Compare commits

...

42 Commits

Author SHA1 Message Date
Owen
40cd8cdec7 Merge branch 'dev' 2025-10-17 16:25:01 -07:00
Owen
6768672a44 Merge branch 'main' of github.com:fosrl/pangolin 2025-10-17 16:24:55 -07:00
Owen
240c5b005b Add more transactions support 2025-10-17 16:22:43 -07:00
Owen
c07abf8ff9 Pass through transaction 2025-10-17 14:05:17 -07:00
Owen
e5a436593f Delete all before migrating 2025-10-17 14:05:17 -07:00
Owen
bb6e093ac6 Priority needs to be def 2025-10-17 14:05:17 -07:00
Milo Schwartz
59a334ce24 Update README.md 2025-10-17 14:05:17 -07:00
Owen
d241dcfb27 Fix typo 2025-10-17 14:05:17 -07:00
Owen
af263e7913 Pass through transaction 2025-10-17 14:04:49 -07:00
Owen Schwartz
6610e7d405 Merge pull request #1673 from fosrl/dependabot/npm_and_yarn/prod-patch-updates-ac45ae572b
Bump the prod-patch-updates group across 1 directory with 2 updates
2025-10-17 14:02:36 -07:00
Owen Schwartz
c476e65cf2 Merge pull request #1677 from fosrl/dependabot/npm_and_yarn/dev-patch-updates-3f2a7d9f8f
Bump the dev-patch-updates group across 1 directory with 2 updates
2025-10-17 14:01:57 -07:00
Owen Schwartz
b69b2eeeb3 Merge pull request #1689 from barnabehvrd/patch-2
FR translation update
2025-10-17 13:57:09 -07:00
Barnabé Havard
89dab0917b Fixed (again ...) indentation issues 2025-10-17 22:42:07 +02:00
Barnabé Havard
73efdb95ae Fixed indentation issues 2025-10-17 22:36:08 +02:00
Barnabé Havard
1bcca88614 Updated several translation 2025-10-17 22:32:51 +02:00
Owen
3af1e0ef56 Delete all before migrating 2025-10-17 11:56:19 -07:00
Owen Schwartz
8387571c1d Merge pull request #1684 from Pallavikumarimdb/fix/make-priority-optional
Make priority optional in schema
2025-10-17 10:14:01 -07:00
Pallavi Kumari
1d017f60b4 make priority optional in schema 2025-10-17 19:51:32 +05:30
dependabot[bot]
81effda9e8 Bump the prod-patch-updates group across 1 directory with 2 updates
Bumps the prod-patch-updates group with 2 updates in the / directory: [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) and [next](https://github.com/vercel/next.js).


Updates `eslint-config-next` from 15.5.4 to 15.5.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.5/packages/eslint-config-next)

Updates `next` from 15.5.4 to 15.5.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.4...v15.5.5)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 15.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: next
  dependency-version: 15.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-17 01:22:32 +00:00
dependabot[bot]
9343906ab1 Bump the dev-patch-updates group across 1 directory with 2 updates
Bumps the dev-patch-updates group with 2 updates in the / directory: [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) and [esbuild](https://github.com/evanw/esbuild).


Updates `@types/react-dom` from 19.2.1 to 19.2.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `esbuild` from 0.25.10 to 0.25.11
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.10...v0.25.11)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: esbuild
  dependency-version: 0.25.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-17 01:20:39 +00:00
Owen
08b7d6735c Priority needs to be def 2025-10-16 14:52:14 -07:00
Milo Schwartz
a91ebd1e91 Update README.md 2025-10-16 17:45:11 -04:00
Owen
312e03b4eb Fix typo 2025-10-16 14:43:11 -07:00
miloschwartz
e8a57e432c hide path match and rewrite in raw resource 2025-10-16 14:30:22 -07:00
Owen
bca2eef2e8 Show ssl toggle 2025-10-16 14:24:36 -07:00
Owen
ec7211a15d Handle updating exit node and fix raw resource issues 2025-10-16 13:55:08 -07:00
Owen
46807c6477 Fix various bugs 2025-10-16 10:23:25 -07:00
miloschwartz
b578786e62 add empty state to sites table cols 2025-10-16 10:11:50 -07:00
miloschwartz
2e0ad8d262 branding only works when licensed 2025-10-15 22:07:33 -07:00
miloschwartz
003f0cfa6d fix target validation on create site 2025-10-15 20:43:59 -07:00
Owen
ee3df081ef Fix docker button and positioning 2025-10-15 20:21:15 -07:00
Owen
08eeb12519 Fix going away when creating target
cd8062ada3
2025-10-15 17:48:31 -07:00
Owen
e66c6b2505 remove volumes for remote nodes 2025-10-15 17:44:03 -07:00
miloschwartz
d2a880d9c8 update docker command in makefile 2025-10-15 17:36:09 -07:00
miloschwartz
edc0b86470 add translation and update url 2025-10-15 17:32:39 -07:00
Owen
aebe6b80b7 Make private file optional 2025-10-15 17:22:43 -07:00
Owen
4d87333b43 Merge branch 'main' into dev 2025-10-15 17:15:48 -07:00
Owen
ef32f3ed5a Load encryption file dynamically 2025-10-15 17:14:24 -07:00
Owen
216ded3034 Merge branch 'main' of github.com:fosrl/pangolin 2025-10-15 17:14:14 -07:00
miloschwartz
cb59fe2cee update readme 2025-10-15 16:34:06 -07:00
miloschwartz
7776f6d09c disable branding 2025-10-15 16:32:16 -07:00
Milo Schwartz
ba96332313 Update README.md 2025-10-15 14:02:28 -04:00
41 changed files with 1605 additions and 822 deletions

View File

@@ -2,13 +2,13 @@
major_tag := $(shell echo $(tag) | cut -d. -f1) major_tag := $(shell echo $(tag) | cut -d. -f1)
minor_tag := $(shell echo $(tag) | cut -d. -f1,2) minor_tag := $(shell echo $(tag) | cut -d. -f1,2)
build-release-arm: build-release:
@if [ -z "$(tag)" ]; then \ @if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \ echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \ exit 1; \
fi fi
docker buildx build \ docker buildx build \
--build-arg BUILD=oss --build-arg BUILD=oss \
--build-arg DATABASE=sqlite \ --build-arg DATABASE=sqlite \
--platform linux/arm64,linux/amd64 \ --platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:latest \ --tag fosrl/pangolin:latest \
@@ -17,7 +17,7 @@ build-release-arm:
--tag fosrl/pangolin:$(tag) \ --tag fosrl/pangolin:$(tag) \
--push . --push .
docker buildx build \ docker buildx build \
--build-arg BUILD=oss --build-arg BUILD=oss \
--build-arg DATABASE=pg \ --build-arg DATABASE=pg \
--platform linux/arm64,linux/amd64 \ --platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:postgresql-latest \ --tag fosrl/pangolin:postgresql-latest \
@@ -26,7 +26,7 @@ build-release-arm:
--tag fosrl/pangolin:postgresql-$(tag) \ --tag fosrl/pangolin:postgresql-$(tag) \
--push . --push .
docker buildx build \ docker buildx build \
--build-arg BUILD=enterprise --build-arg BUILD=enterprise \
--build-arg DATABASE=sqlite \ --build-arg DATABASE=sqlite \
--platform linux/arm64,linux/amd64 \ --platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:ee-latest \ --tag fosrl/pangolin:ee-latest \
@@ -35,7 +35,7 @@ build-release-arm:
--tag fosrl/pangolin:ee-$(tag) \ --tag fosrl/pangolin:ee-$(tag) \
--push . --push .
docker buildx build \ docker buildx build \
--build-arg BUILD=enterprise --build-arg BUILD=enterprise \
--build-arg DATABASE=pg \ --build-arg DATABASE=pg \
--platform linux/arm64,linux/amd64 \ --platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:ee-postgresql-latest \ --tag fosrl/pangolin:ee-postgresql-latest \

View File

@@ -51,7 +51,8 @@ Check out the [quick install guide](https://docs.digpangolin.com/self-host/quick
| <img width=500 /> | Description | | <img width=500 /> | Description |
|-----------------|--------------| |-----------------|--------------|
| **Self-Host** | Free, open source, and AGPL-3 compliant. | | **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.digpangolin.com/manage/remote-node/nodes) and connect to our control plane. | | **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.digpangolin.com/manage/remote-node/nodes) and connect to our control plane. |
## Key Features ## Key Features
@@ -60,10 +61,10 @@ Pangolin packages everything you need for seamless application access and exposu
| <img width=500 /> | <img width=500 /> | | <img width=500 /> | <img width=500 /> |
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------| |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
| **Manage applications in one place**<br /><br /> Pangolin provides a unified dashboard where you can monitor, configure, and secure all of your services regardless of where they are hosted. | <img src="public/screenshots/hero.png" /><tr></tr> | | **Manage applications in one place**<br /><br /> Pangolin provides a unified dashboard where you can monitor, configure, and secure all of your services regardless of where they are hosted. | <img src="public/screenshots/hero.png" width=500 /><tr></tr> |
| **Reverse proxy across networks anywhere**<br /><br />Route traffic via tunnels to any private network. Pangolin works like a reverse proxy that spans multiple networks and handles routing, load balancing, health checking, and more to the right services on the other end. | <img src="public/screenshots/sites.png" /><tr></tr> | | **Reverse proxy across networks anywhere**<br /><br />Route traffic via tunnels to any private network. Pangolin works like a reverse proxy that spans multiple networks and handles routing, load balancing, health checking, and more to the right services on the other end. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> |
| **Enforce identity and context aware rules**<br /><br />Protect your applications with identity and context aware rules such as SSO, OIDC, PIN, password, temporary share links, geolocation, IP, and more. | <img src="public/auth-diagram1.png" /><tr></tr> | | **Enforce identity and context aware rules**<br /><br />Protect your applications with identity and context aware rules such as SSO, OIDC, PIN, password, temporary share links, geolocation, IP, and more. | <img src="public/auth-diagram1.png" width=500 /><tr></tr> |
| **Quickly connect Pangolin sites**<br /><br />Pangolin's lightweight [Newt](https://github.com/fosrl/newt) client runs in userspace and can run anywhere. Use it as a site connector to route traffic to backends across all of your environments. | <img src="public/clip.gif" /><tr></tr> | | **Quickly connect Pangolin sites**<br /><br />Pangolin's lightweight [Newt](https://github.com/fosrl/newt) client runs in userspace and can run anywhere. Use it as a site connector to route traffic to backends across all of your environments. | <img src="public/clip.gif" width=500 /><tr></tr> |
## Get Started ## Get Started

View File

@@ -20,7 +20,7 @@ services:
pangolin: pangolin:
condition: service_healthy condition: service_healthy
command: command:
- --reachableAt=http://gerbil:3003 - --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key - --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/ - --remoteConfig=http://pangolin:3001/api/v1/
volumes: volumes:

View File

@@ -6,8 +6,6 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./config:/app/config - ./config:/app/config
- pangolin-data-certificates:/var/certificates
- pangolin-data-dynamic:/var/dynamic
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"] test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
interval: "10s" interval: "10s"
@@ -22,7 +20,7 @@ services:
pangolin: pangolin:
condition: service_healthy condition: service_healthy
command: command:
- --reachableAt=http://gerbil:3003 - --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key - --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/ - --remoteConfig=http://pangolin:3001/api/v1/
volumes: volumes:
@@ -56,16 +54,9 @@ services:
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs - ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
# Shared volume for certificates and dynamic config in file mode
- pangolin-data-certificates:/var/certificates:ro
- pangolin-data-dynamic:/var/dynamic:ro
networks: networks:
default: default:
driver: bridge driver: bridge
name: pangolin name: pangolin
{{if .EnableIPv6}} enable_ipv6: true{{end}} {{if .EnableIPv6}} enable_ipv6: true{{end}}
volumes:
pangolin-data-dynamic:
pangolin-data-certificates:

View File

@@ -1893,5 +1893,6 @@
"pathRewriteExact": "Exact", "pathRewriteExact": "Exact",
"pathRewriteRegex": "Regex", "pathRewriteRegex": "Regex",
"pathRewriteStrip": "Strip", "pathRewriteStrip": "Strip",
"pathRewriteStripLabel": "strip" "pathRewriteStripLabel": "strip",
"sidebarEnableEnterpriseLicense": "Enable Enterprise License"
} }

View File

@@ -6,45 +6,45 @@
"setupOrgName": "Nom de l'organisation", "setupOrgName": "Nom de l'organisation",
"orgDisplayName": "Ceci est le nom d'affichage de votre organisation.", "orgDisplayName": "Ceci est le nom d'affichage de votre organisation.",
"orgId": "ID de l'organisation", "orgId": "ID de l'organisation",
"setupIdentifierMessage": "Ceci est l'identifiant unique pour votre organisation. Il est séparé du nom affiché.", "setupIdentifierMessage": "Ceci est l'identifiant de votre organisation. Il est différent du nom affiché.",
"setupErrorIdentifier": "L'ID de l'organisation est déjà pris. Veuillez en choisir un autre.", "setupErrorIdentifier": "Cet identifiant est déjà pris. Veuillez en choisir un autre.",
"componentsErrorNoMemberCreate": "Vous n'êtes actuellement membre d'aucune organisation. Créez une organisation pour commencer.", "componentsErrorNoMemberCreate": "Vous n'êtes actuellement membre d'aucune organisation. Créez une organisation pour commencer.",
"componentsErrorNoMember": "Vous n'êtes actuellement membre d'aucune organisation.", "componentsErrorNoMember": "Vous n'êtes actuellement membre d'aucune organisation.",
"welcome": "Bienvenue à Pangolin", "welcome": "Bienvenue sur Pangolin",
"welcomeTo": "Bienvenue chez", "welcomeTo": "Bienvenue chez",
"componentsCreateOrg": "Créer une organisation", "componentsCreateOrg": "Créer une organisation",
"componentsMember": "Vous êtes membre de {count, plural, =0 {aucune organisation} one {une organisation} other {# organisations}}.", "componentsMember": "Vous {count, plural, =0 {n'} other {}}êtes membre {count, plural, =0 {d'aucune organisation} one {d'une organisation} other {de # organisations}}.",
"componentsInvalidKey": "Clés de licence invalides ou expirées détectées. Suivez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "componentsInvalidKey": "Clés de licence invalides ou expirées détectées. Suivez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.",
"dismiss": "Refuser", "dismiss": "Refuser",
"componentsLicenseViolation": "Violation de licence : Ce serveur utilise des sites {usedSites} qui dépassent la limite autorisée des sites {maxSites} . Suivez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "componentsLicenseViolation": "Violation de licence : Ce serveur utilise des sites {usedSites} qui dépassent la limite autorisée des sites {maxSites} . Suivez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.",
"componentsSupporterMessage": "Merci de soutenir Pangolin en tant que {tier}!", "componentsSupporterMessage": "Merci de soutenir Pangolin en tant que {tier}!",
"inviteErrorNotValid": "Nous sommes désolés, mais il semble que l'invitation que vous essayez d'accéder n'ait pas été acceptée ou n'est plus valide.", "inviteErrorNotValid": "Nous sommes désolés, mais il semble que l'invitation via laquelle vous essayez d'accéder n'ait pas été acceptée ou n'est plus valide.",
"inviteErrorUser": "Nous sommes désolés, mais il semble que l'invitation que vous essayez d'accéder ne soit pas pour cet utilisateur.", "inviteErrorUser": "Nous sommes désolés, mais il semble que l'invitation via laquelle vous essayez d'accéder ne soit pas pour cet utilisateur.",
"inviteLoginUser": "Assurez-vous que vous êtes bien connecté en tant qu'utilisateur correct.", "inviteLoginUser": "Assurez-vous d'etre bien connecté au bon compte.",
"inviteErrorNoUser": "Nous sommes désolés, mais il semble que l'invitation que vous essayez d'accéder ne soit pas pour un utilisateur qui existe.", "inviteErrorNoUser": "Nous sommes désolés, mais il semble que l'invitation via laquelle vous essayez d'accéder ne soit pas pour un utilisateur qui existe.",
"inviteCreateUser": "Veuillez d'abord créer un compte.", "inviteCreateUser": "Vous n'avez aucun compte, veuillez en créer un.",
"goHome": "Retour à la maison", "goHome": "Retour à l'accueil",
"inviteLogInOtherUser": "Se connecter en tant qu'utilisateur différent", "inviteLogInOtherUser": "Se connecter en tant qu'utilisateur différent",
"createAnAccount": "Créer un compte", "createAnAccount": "Créer un compte",
"inviteNotAccepted": "Invitation non acceptée", "inviteNotAccepted": "Invitation non acceptée",
"authCreateAccount": "Créez un compte pour commencer", "authCreateAccount": "Créez un compte pour commencer",
"authNoAccount": "Vous n'avez pas de compte ?", "authNoAccount": "Vous n'avez pas de compte ?",
"email": "Courriel", "email": "Adresse email",
"password": "Mot de passe", "password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe", "confirmPassword": "Confirmer le mot de passe",
"createAccount": "Créer un compte", "createAccount": "Créer un compte",
"viewSettings": "Afficher les paramètres", "viewSettings": "Afficher les paramètres",
"delete": "Supprimez", "delete": "Supprimer",
"name": "Nom", "name": "Nom",
"online": "En ligne", "online": "En ligne",
"offline": "Hors ligne", "offline": "Hors ligne",
"site": "Site", "site": "Site",
"dataIn": "Données dans", "dataIn": "Données reçues",
"dataOut": "Données épuisées", "dataOut": "Données émises",
"connectionType": "Type de connexion", "connectionType": "Type de connexion",
"tunnelType": "Type de tunnel", "tunnelType": "Type de tunnel",
"local": "Locale", "local": "Locale",
"edit": "Editer", "edit": "Modifier",
"siteConfirmDelete": "Confirmer la suppression du site", "siteConfirmDelete": "Confirmer la suppression du site",
"siteDelete": "Supprimer le site", "siteDelete": "Supprimer le site",
"siteMessageRemove": "Une fois supprimé, le site ne sera plus accessible. Toutes les ressources et cibles associées au site seront également supprimées.", "siteMessageRemove": "Une fois supprimé, le site ne sera plus accessible. Toutes les ressources et cibles associées au site seront également supprimées.",
@@ -64,11 +64,11 @@
"siteLearnNewt": "Apprenez à installer Newt sur votre système", "siteLearnNewt": "Apprenez à installer Newt sur votre système",
"siteSeeConfigOnce": "Vous ne pourrez voir la configuration qu'une seule fois.", "siteSeeConfigOnce": "Vous ne pourrez voir la configuration qu'une seule fois.",
"siteLoadWGConfig": "Chargement de la configuration WireGuard...", "siteLoadWGConfig": "Chargement de la configuration WireGuard...",
"siteDocker": "Développer les détails du déploiement Docker", "siteDocker": "Afficher les détails du déploiement Docker",
"toggle": "Activer/désactiver", "toggle": "Activer/désactiver",
"dockerCompose": "Composition Docker", "dockerCompose": "Docker Compose",
"dockerRun": "Exécution Docker", "dockerRun": "Docker Run",
"siteLearnLocal": "Les sites locaux ne tunnel, en savoir plus", "siteLearnLocal": "Les sites locaux ne permettent pas d'utiliser les tunnel, en savoir plus",
"siteConfirmCopy": "J'ai copié la configuration", "siteConfirmCopy": "J'ai copié la configuration",
"searchSitesProgress": "Rechercher des sites...", "searchSitesProgress": "Rechercher des sites...",
"siteAdd": "Ajouter un site", "siteAdd": "Ajouter un site",
@@ -79,9 +79,9 @@
"operatingSystem": "Système d'exploitation", "operatingSystem": "Système d'exploitation",
"commands": "Commandes", "commands": "Commandes",
"recommended": "Recommandé", "recommended": "Recommandé",
"siteNewtDescription": "Pour une meilleure expérience d'utilisateur, utilisez Newt. Il utilise WireGuard sous le capot et vous permet d'adresser vos ressources privées par leur adresse LAN sur votre réseau privé à partir du tableau de bord Pangolin.", "siteNewtDescription": "Pour une meilleure expérience d'utilisateur, utilisez Newt. Newt se base sur WireGuard et vous permet d'adresser vos ressources privées par leur adresse LAN sur votre réseau privé à partir du tableau de bord Pangolin.",
"siteRunsInDocker": "Exécute dans Docker", "siteRunsInDocker": "S'exécute dans Docker",
"siteRunsInShell": "Exécute en shell sur macOS, Linux et Windows", "siteRunsInShell": "S'exécute en shell sur macOS, Linux et Windows",
"siteErrorDelete": "Erreur lors de la suppression du site", "siteErrorDelete": "Erreur lors de la suppression du site",
"siteErrorUpdate": "Impossible de mettre à jour le site", "siteErrorUpdate": "Impossible de mettre à jour le site",
"siteErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour du site.", "siteErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour du site.",
@@ -89,18 +89,18 @@
"siteUpdatedDescription": "Le site a été mis à jour.", "siteUpdatedDescription": "Le site a été mis à jour.",
"siteGeneralDescription": "Configurer les paramètres généraux de ce site", "siteGeneralDescription": "Configurer les paramètres généraux de ce site",
"siteSettingDescription": "Configurer les paramètres de votre site", "siteSettingDescription": "Configurer les paramètres de votre site",
"siteSetting": "Réglages {siteName}", "siteSetting": "Réglages de {siteName}",
"siteNewtTunnel": "Tunnel Newt (Recommandé)", "siteNewtTunnel": "Tunnel Newt (Recommandé)",
"siteNewtTunnelDescription": "La façon la plus simple de créer un point d'entrée dans votre réseau. Pas de configuration supplémentaire.", "siteNewtTunnelDescription": "La façon la plus simple de créer un point d'entrée dans votre réseau. Pas de configuration supplémentaire.",
"siteWg": "WireGuard basique", "siteWg": "WireGuard basique",
"siteWgDescription": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise.", "siteWgDescription": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise.",
"siteWgDescriptionSaas": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise. FONCTIONNE UNIQUEMENT SUR DES NŒUDS AUTONOMES", "siteWgDescriptionSaas": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise. FONCTIONNE UNIQUEMENT SUR DES NŒUDS AUTONOMES",
"siteLocalDescription": "Ressources locales seulement. Pas de tunneling.", "siteLocalDescription": "Ressources locales seulement. Pas de tunneling.",
"siteLocalDescriptionSaas": "Local resources only. No tunneling. Only available on remote nodes.", "siteLocalDescriptionSaas": "Ressources locales seulement. Pas de tunneling. Seulement disponible sur les noeuds distants",
"siteSeeAll": "Voir tous les sites", "siteSeeAll": "Voir tous les sites",
"siteTunnelDescription": "Déterminez comment vous voulez vous connecter à votre site", "siteTunnelDescription": "Déterminez comment vous voulez vous connecter à votre site",
"siteNewtCredentials": "Identifiants Newt", "siteNewtCredentials": "Identifiants Newt",
"siteNewtCredentialsDescription": "C'est ainsi que Newt s'authentifiera avec le serveur", "siteNewtCredentialsDescription": "C'est comme cela que Newt s'authentifiera avec le serveur",
"siteCredentialsSave": "Enregistrez vos identifiants", "siteCredentialsSave": "Enregistrez vos identifiants",
"siteCredentialsSaveDescription": "Vous ne pourrez voir cela qu'une seule fois. Assurez-vous de le copier dans un endroit sécurisé.", "siteCredentialsSaveDescription": "Vous ne pourrez voir cela qu'une seule fois. Assurez-vous de le copier dans un endroit sécurisé.",
"siteInfo": "Informations sur le site", "siteInfo": "Informations sur le site",
@@ -112,7 +112,7 @@
"shareErrorDelete": "Impossible de supprimer le lien", "shareErrorDelete": "Impossible de supprimer le lien",
"shareErrorDeleteMessage": "Une erreur s'est produite lors de la suppression du lien", "shareErrorDeleteMessage": "Une erreur s'est produite lors de la suppression du lien",
"shareDeleted": "Lien supprimé", "shareDeleted": "Lien supprimé",
"shareDeletedDescription": "Le lien a été supprimé", "shareDeletedDescription": "Le lien de partage a été supprimé",
"shareTokenDescription": "Votre jeton d'accès peut être passé de deux façons : en tant que paramètre de requête ou dans les en-têtes de la requête. Elles doivent être transmises par le client à chaque demande d'accès authentifié.", "shareTokenDescription": "Votre jeton d'accès peut être passé de deux façons : en tant que paramètre de requête ou dans les en-têtes de la requête. Elles doivent être transmises par le client à chaque demande d'accès authentifié.",
"accessToken": "Jeton d'accès", "accessToken": "Jeton d'accès",
"usageExamples": "Exemples d'utilisation", "usageExamples": "Exemples d'utilisation",
@@ -134,7 +134,7 @@
"shareExpireDescription": "Le temps d'expiration est combien de temps le lien sera utilisable et fournira un accès à la ressource. Après cette période, le lien ne fonctionnera plus et les utilisateurs qui ont utilisé ce lien perdront l'accès à la ressource.", "shareExpireDescription": "Le temps d'expiration est combien de temps le lien sera utilisable et fournira un accès à la ressource. Après cette période, le lien ne fonctionnera plus et les utilisateurs qui ont utilisé ce lien perdront l'accès à la ressource.",
"shareSeeOnce": "Vous ne pourrez voir ce lien. Assurez-vous de le copier.", "shareSeeOnce": "Vous ne pourrez voir ce lien. Assurez-vous de le copier.",
"shareAccessHint": "N'importe qui avec ce lien peut accéder à la ressource. Partagez-le avec soin.", "shareAccessHint": "N'importe qui avec ce lien peut accéder à la ressource. Partagez-le avec soin.",
"shareTokenUsage": "Voir Utilisation du jeton d'accès", "shareTokenUsage": "Voir l'utilisation du jeton d'accès",
"createLink": "Créer un lien", "createLink": "Créer un lien",
"resourcesNotFound": "Aucune ressource trouvée", "resourcesNotFound": "Aucune ressource trouvée",
"resourceSearch": "Rechercher des ressources", "resourceSearch": "Rechercher des ressources",
@@ -1234,7 +1234,7 @@
"billing": "Facturation", "billing": "Facturation",
"orgBillingDescription": "Gérez vos informations de facturation et vos abonnements", "orgBillingDescription": "Gérez vos informations de facturation et vos abonnements",
"github": "GitHub", "github": "GitHub",
"pangolinHosted": "Pangolin Hébergement", "pangolinHosted": "Hebergé par Pangolin",
"fossorial": "Fossorial", "fossorial": "Fossorial",
"completeAccountSetup": "Complétez la configuration du compte", "completeAccountSetup": "Complétez la configuration du compte",
"completeAccountSetupDescription": "Définissez votre mot de passe pour commencer", "completeAccountSetupDescription": "Définissez votre mot de passe pour commencer",
@@ -1316,7 +1316,7 @@
"billingRemoteExitNodesInfo": "Vous êtes facturé pour chaque nœud géré dans votre organisation. La facturation est calculée quotidiennement en fonction du nombre de nœuds gérés actifs dans votre organisation.", "billingRemoteExitNodesInfo": "Vous êtes facturé pour chaque nœud géré dans votre organisation. La facturation est calculée quotidiennement en fonction du nombre de nœuds gérés actifs dans votre organisation.",
"domainNotFound": "Domaine introuvable", "domainNotFound": "Domaine introuvable",
"domainNotFoundDescription": "Cette ressource est désactivée car le domaine n'existe plus dans notre système. Veuillez définir un nouveau domaine pour cette ressource.", "domainNotFoundDescription": "Cette ressource est désactivée car le domaine n'existe plus dans notre système. Veuillez définir un nouveau domaine pour cette ressource.",
"failed": "Échec", "failed": "Erreur",
"createNewOrgDescription": "Créer une nouvelle organisation", "createNewOrgDescription": "Créer une nouvelle organisation",
"organization": "Organisation", "organization": "Organisation",
"port": "Port", "port": "Port",
@@ -1370,7 +1370,7 @@
"createDomainARecords": "Enregistrements A", "createDomainARecords": "Enregistrements A",
"createDomainRecordNumber": "Enregistrement {number}", "createDomainRecordNumber": "Enregistrement {number}",
"createDomainTxtRecords": "Enregistrements TXT", "createDomainTxtRecords": "Enregistrements TXT",
"createDomainSaveTheseRecords": "Enregistrez ces enregistrements", "createDomainSaveTheseRecords": "Sauvegardez ces enregistrements",
"createDomainSaveTheseRecordsDescription": "Assurez-vous de sauvegarder ces enregistrements DNS car vous ne les reverrez pas.", "createDomainSaveTheseRecordsDescription": "Assurez-vous de sauvegarder ces enregistrements DNS car vous ne les reverrez pas.",
"createDomainDnsPropagation": "Propagation DNS", "createDomainDnsPropagation": "Propagation DNS",
"createDomainDnsPropagationDescription": "Les modifications DNS peuvent mettre du temps à se propager sur internet. Cela peut prendre de quelques minutes à 48 heures selon votre fournisseur DNS et les réglages TTL.", "createDomainDnsPropagationDescription": "Les modifications DNS peuvent mettre du temps à se propager sur internet. Cela peut prendre de quelques minutes à 48 heures selon votre fournisseur DNS et les réglages TTL.",
@@ -1445,7 +1445,7 @@
"IntervalSeconds": "Intervalle sain", "IntervalSeconds": "Intervalle sain",
"timeoutSeconds": "Délai", "timeoutSeconds": "Délai",
"timeIsInSeconds": "Le temps est exprimé en secondes", "timeIsInSeconds": "Le temps est exprimé en secondes",
"retryAttempts": "Tentatives de réessai", "retryAttempts": "Tentatives",
"expectedResponseCodes": "Codes de réponse attendus", "expectedResponseCodes": "Codes de réponse attendus",
"expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.", "expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.",
"customHeaders": "En-têtes personnalisés", "customHeaders": "En-têtes personnalisés",
@@ -1760,9 +1760,9 @@
"enterpriseEdition": "Enterprise Edition", "enterpriseEdition": "Enterprise Edition",
"unlicensed": "Unlicensed", "unlicensed": "Unlicensed",
"beta": "Beta", "beta": "Beta",
"manageClients": "Manage Clients", "manageClients": "Gérer les clients",
"manageClientsDescription": "Clients are devices that can connect to your sites", "manageClientsDescription": "Clients are devices that can connect to your sites",
"licenseTableValidUntil": "Valid Until", "licenseTableValidUntil": "Valide jusqu'au",
"saasLicenseKeysSettingsTitle": "Enterprise Licenses", "saasLicenseKeysSettingsTitle": "Enterprise Licenses",
"saasLicenseKeysSettingsDescription": "Generate and manage Enterprise license keys for self-hosted Pangolin instances", "saasLicenseKeysSettingsDescription": "Generate and manage Enterprise license keys for self-hosted Pangolin instances",
"sidebarEnterpriseLicenses": "Licenses", "sidebarEnterpriseLicenses": "Licenses",
@@ -1771,8 +1771,8 @@
"validation": { "validation": {
"emailRequired": "Please enter a valid email address", "emailRequired": "Please enter a valid email address",
"useCaseTypeRequired": "Please select a use case type", "useCaseTypeRequired": "Please select a use case type",
"firstNameRequired": "First name is required", "firstNameRequired": "Le prénom est requis",
"lastNameRequired": "Last name is required", "lastNameRequired": "Le nom est requis",
"primaryUseRequired": "Please describe your primary use", "primaryUseRequired": "Please describe your primary use",
"jobTitleRequiredBusiness": "Job title is required for business use", "jobTitleRequiredBusiness": "Job title is required for business use",
"industryRequiredBusiness": "Industry is required for business use", "industryRequiredBusiness": "Industry is required for business use",
@@ -1786,7 +1786,7 @@
}, },
"useCaseOptions": { "useCaseOptions": {
"personal": { "personal": {
"title": "Personal Use", "title": "Utilisation personelle",
"description": "For individual, non-commercial use such as learning, personal projects, or experimentation." "description": "For individual, non-commercial use such as learning, personal projects, or experimentation."
}, },
"business": { "business": {
@@ -1824,27 +1824,27 @@
}, },
"form": { "form": {
"useCaseQuestion": "Are you using Pangolin for personal or business use?", "useCaseQuestion": "Are you using Pangolin for personal or business use?",
"firstName": "First Name", "firstName": "Prénom",
"lastName": "Last Name", "lastName": "Nom",
"jobTitle": "Job Title", "jobTitle": "profession",
"primaryUseQuestion": "What do you primarily plan to use Pangolin for?", "primaryUseQuestion": "What do you primarily plan to use Pangolin for?",
"industryQuestion": "What is your industry?", "industryQuestion": "What is your industry?",
"prospectiveUsersQuestion": "How many prospective users do you expect to have?", "prospectiveUsersQuestion": "How many prospective users do you expect to have?",
"prospectiveSitesQuestion": "How many prospective sites (tunnels) do you expect to have?", "prospectiveSitesQuestion": "How many prospective sites (tunnels) do you expect to have?",
"companyName": "Company name", "companyName": "Entreprise",
"countryOfResidence": "Country of residence", "countryOfResidence": "Pays de résidence",
"stateProvinceRegion": "State / Province / Region", "stateProvinceRegion": "État / Province / Région",
"postalZipCode": "Postal / ZIP Code", "postalZipCode": "Code postal",
"companyWebsite": "Company website", "companyWebsite": "Site de l'entreprise",
"companyPhoneNumber": "Company phone number", "companyPhoneNumber": "Numéro de téléphone professionnel",
"country": "Country", "country": "Pays",
"phoneNumberOptional": "Phone number (optional)", "phoneNumberOptional": "Numéro de téléphone (optionnel)",
"complianceConfirmation": "I confirm that I am in compliance with the Fossorial Commercial License and that reporting inaccurate information or misidentifying use of the product is a violation of the license." "complianceConfirmation": "I confirm that I am in compliance with the Fossorial Commercial License and that reporting inaccurate information or misidentifying use of the product is a violation of the license."
}, },
"buttons": { "buttons": {
"close": "Close", "close": "Fermer",
"previous": "Previous", "previous": "Précédent",
"next": "Next", "next": "Suivant",
"generateLicenseKey": "Generate License Key" "generateLicenseKey": "Generate License Key"
}, },
"toasts": { "toasts": {
@@ -1860,16 +1860,16 @@
}, },
"priority": "Priorité", "priority": "Priorité",
"priorityDescription": "Les routes de haute priorité sont évaluées en premier. La priorité = 100 signifie l'ordre automatique (décision du système). Utilisez un autre nombre pour imposer la priorité manuelle.", "priorityDescription": "Les routes de haute priorité sont évaluées en premier. La priorité = 100 signifie l'ordre automatique (décision du système). Utilisez un autre nombre pour imposer la priorité manuelle.",
"instanceName": "Instance Name", "instanceName": "Nom de l'instance",
"pathMatchModalTitle": "Configure Path Matching", "pathMatchModalTitle": "Configure Path Matching",
"pathMatchModalDescription": "Set up how incoming requests should be matched based on their path.", "pathMatchModalDescription": "Set up how incoming requests should be matched based on their path.",
"pathMatchType": "Match Type", "pathMatchType": "Match Type",
"pathMatchPrefix": "Prefix", "pathMatchPrefix": "Préfix",
"pathMatchExact": "Exact", "pathMatchExact": "Exact",
"pathMatchRegex": "Regex", "pathMatchRegex": "Regex",
"pathMatchValue": "Path Value", "pathMatchValue": "Path Value",
"clear": "Clear", "clear": "Clear",
"saveChanges": "Save Changes", "saveChanges": "Sauvegarder",
"pathMatchRegexPlaceholder": "^/api/.*", "pathMatchRegexPlaceholder": "^/api/.*",
"pathMatchDefaultPlaceholder": "/path", "pathMatchDefaultPlaceholder": "/path",
"pathMatchPrefixHelp": "Example: /api matches /api, /api/users, etc.", "pathMatchPrefixHelp": "Example: /api matches /api, /api/users, etc.",
@@ -1889,7 +1889,7 @@
"pathRewriteExactHelp": "Replace the entire path with this value when the path matches exactly", "pathRewriteExactHelp": "Replace the entire path with this value when the path matches exactly",
"pathRewriteRegexHelp": "Use capture groups like $1, $2 for replacement", "pathRewriteRegexHelp": "Use capture groups like $1, $2 for replacement",
"pathRewriteStripPrefixHelp": "Leave empty to strip prefix or provide new prefix", "pathRewriteStripPrefixHelp": "Leave empty to strip prefix or provide new prefix",
"pathRewritePrefix": "Prefix", "pathRewritePrefix": "Préfix",
"pathRewriteExact": "Exact", "pathRewriteExact": "Exact",
"pathRewriteRegex": "Regex", "pathRewriteRegex": "Regex",
"pathRewriteStrip": "Strip", "pathRewriteStrip": "Strip",

873
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -77,7 +77,7 @@
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"drizzle-orm": "0.44.6", "drizzle-orm": "0.44.6",
"eslint": "9.37.0", "eslint": "9.37.0",
"eslint-config-next": "15.5.4", "eslint-config-next": "15.5.5",
"express": "5.1.0", "express": "5.1.0",
"express-rate-limit": "8.1.0", "express-rate-limit": "8.1.0",
"glob": "11.0.3", "glob": "11.0.3",
@@ -92,7 +92,7 @@
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"maxmind": "5.0.0", "maxmind": "5.0.0",
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.5.4", "next": "15.5.5",
"next-intl": "^4.3.12", "next-intl": "^4.3.12",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"node-cache": "5.1.2", "node-cache": "5.1.2",
@@ -143,13 +143,13 @@
"@types/nodemailer": "7.0.2", "@types/nodemailer": "7.0.2",
"@types/pg": "8.15.5", "@types/pg": "8.15.5",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.1", "@types/react-dom": "19.2.2",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.1",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"drizzle-kit": "0.31.5", "drizzle-kit": "0.31.5",
"esbuild": "0.25.10", "esbuild": "0.25.11",
"esbuild-node-externals": "1.18.0", "esbuild-node-externals": "1.18.0",
"postcss": "^8", "postcss": "^8",
"react-email": "4.3.0", "react-email": "4.3.0",

View File

@@ -126,7 +126,7 @@ export const targets = pgTable("targets", {
pathMatchType: text("pathMatchType"), // exact, prefix, regex pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100) priority: integer("priority").default(100)
}); });
export const targetHealthCheck = pgTable("targetHealthCheck", { export const targetHealthCheck = pgTable("targetHealthCheck", {

View File

@@ -138,7 +138,7 @@ export const targets = sqliteTable("targets", {
pathMatchType: text("pathMatchType"), // exact, prefix, regex pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100) priority: integer("priority").default(100)
}); });
export const targetHealthCheck = sqliteTable("targetHealthCheck", { export const targetHealthCheck = sqliteTable("targetHealthCheck", {

View File

@@ -19,11 +19,16 @@ import { setHostMeta } from "@server/lib/hostMeta";
import { initTelemetryClient } from "./lib/telemetry.js"; import { initTelemetryClient } from "./lib/telemetry.js";
import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js"; import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js";
import { initCleanup } from "#dynamic/cleanup"; import { initCleanup } from "#dynamic/cleanup";
import license from "#dynamic/license/license";
async function startServers() { async function startServers() {
await setHostMeta(); await setHostMeta();
await config.initServer(); await config.initServer();
license.setServerSecret(config.getRawConfig().server.secret!);
await license.check();
await runSetupFunctions(); await runSetupFunctions();
initTelemetryClient(); initTelemetryClient();

View File

@@ -612,7 +612,8 @@ export class UsageService {
public async getUsage( public async getUsage(
orgId: string, orgId: string,
featureId: FeatureId featureId: FeatureId,
trx: Transaction | typeof db = db
): Promise<Usage | null> { ): Promise<Usage | null> {
if (noop()) { if (noop()) {
return null; return null;
@@ -621,7 +622,7 @@ export class UsageService {
const usageId = `${orgId}-${featureId}`; const usageId = `${orgId}-${featureId}`;
try { try {
const [result] = await db const [result] = await trx
.select() .select()
.from(usage) .from(usage)
.where(eq(usage.usageId, usageId)) .where(eq(usage.usageId, usageId))
@@ -635,7 +636,7 @@ export class UsageService {
const meterId = getFeatureMeterId(featureId); const meterId = getFeatureMeterId(featureId);
try { try {
const [newUsage] = await db const [newUsage] = await trx
.insert(usage) .insert(usage)
.values({ .values({
usageId, usageId,
@@ -652,7 +653,7 @@ export class UsageService {
return newUsage; return newUsage;
} else { } else {
// Record was created by another process, fetch it // Record was created by another process, fetch it
const [existingUsage] = await db const [existingUsage] = await trx
.select() .select()
.from(usage) .from(usage)
.where(eq(usage.usageId, usageId)) .where(eq(usage.usageId, usageId))
@@ -665,7 +666,7 @@ export class UsageService {
`Insert failed for ${orgId}/${featureId}, attempting to fetch existing record:`, `Insert failed for ${orgId}/${featureId}, attempting to fetch existing record:`,
insertError insertError
); );
const [existingUsage] = await db const [existingUsage] = await trx
.select() .select()
.from(usage) .from(usage)
.where(eq(usage.usageId, usageId)) .where(eq(usage.usageId, usageId))
@@ -812,7 +813,8 @@ export class UsageService {
orgId: string, orgId: string,
kickSites = false, kickSites = false,
featureId?: FeatureId, featureId?: FeatureId,
usage?: Usage usage?: Usage,
trx: Transaction | typeof db = db
): Promise<boolean> { ): Promise<boolean> {
if (noop()) { if (noop()) {
return false; return false;
@@ -825,7 +827,7 @@ export class UsageService {
let orgLimits: Limit[] = []; let orgLimits: Limit[] = [];
if (featureId) { if (featureId) {
// Get all limits set for this organization // Get all limits set for this organization
orgLimits = await db orgLimits = await trx
.select() .select()
.from(limits) .from(limits)
.where( .where(
@@ -836,7 +838,7 @@ export class UsageService {
); );
} else { } else {
// Get all limits set for this organization // Get all limits set for this organization
orgLimits = await db orgLimits = await trx
.select() .select()
.from(limits) .from(limits)
.where(eq(limits.orgId, orgId)); .where(eq(limits.orgId, orgId));
@@ -855,7 +857,8 @@ export class UsageService {
} else { } else {
currentUsage = await this.getUsage( currentUsage = await this.getUsage(
orgId, orgId,
limit.featureId as FeatureId limit.featureId as FeatureId,
trx
); );
} }
@@ -890,7 +893,7 @@ export class UsageService {
); );
// Get all sites for this organization // Get all sites for this organization
const orgSites = await db const orgSites = await trx
.select() .select()
.from(sites) .from(sites)
.where(eq(sites.orgId, orgId)); .where(eq(sites.orgId, orgId));
@@ -902,7 +905,7 @@ export class UsageService {
// Send termination messages to newt sites // Send termination messages to newt sites
for (const site of orgSites) { for (const site of orgSites) {
if (site.type === "newt") { if (site.type === "newt") {
const [newt] = await db const [newt] = await trx
.select() .select()
.from(newts) .from(newts)
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
@@ -917,7 +920,7 @@ export class UsageService {
}; };
// Don't await to prevent blocking // Don't await to prevent blocking
sendToClient(newt.newtId, payload).catch( await sendToClient(newt.newtId, payload).catch(
(error: any) => { (error: any) => {
logger.error( logger.error(
`Failed to send termination message to newt ${newt.newtId}:`, `Failed to send termination message to newt ${newt.newtId}:`,

View File

@@ -3,11 +3,9 @@ import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
import { db } from "@server/db"; import { db } from "@server/db";
import { SupporterKey, supporterKey } from "@server/db"; import { SupporterKey, supporterKey } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { license } from "#dynamic/license/license";
import { configSchema, readConfigFile } from "./readConfigFile"; import { configSchema, readConfigFile } from "./readConfigFile";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { build } from "@server/build"; import { build } from "@server/build";
import logger from "@server/logger";
export class Config { export class Config {
private rawConfig!: z.infer<typeof configSchema>; private rawConfig!: z.infer<typeof configSchema>;
@@ -103,16 +101,10 @@ export class Config {
throw new Error("Config not loaded. Call load() first."); throw new Error("Config not loaded. Call load() first.");
} }
license.setServerSecret(this.rawConfig.server.secret!);
await this.checkKeyStatus(); await this.checkKeyStatus();
} }
private async checkKeyStatus() { private async checkKeyStatus() {
if (build === "enterprise") {
await license.check();
}
if (build == "oss") { if (build == "oss") {
this.checkSupporterKey(); this.checkSupporterKey();
} }

View File

@@ -1,4 +1,4 @@
import { db, exitNodes } from "@server/db"; import { db, exitNodes, Transaction } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt"; import { ExitNodePingResult } from "@server/routers/newt";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -59,7 +59,11 @@ export function selectBestExitNode(
return pingResults[0]; return pingResults[0];
} }
export async function checkExitNodeOrg(exitNodeId: number, orgId: string) { export async function checkExitNodeOrg(
exitNodeId: number,
orgId: string,
trx?: Transaction | typeof db
): Promise<boolean> {
return false; return false;
} }

View File

@@ -1,5 +1,15 @@
import { db, targetHealthCheck } from "@server/db"; import { db, targetHealthCheck } from "@server/db";
import { and, eq, inArray, or, isNull, ne, isNotNull, desc } from "drizzle-orm"; import {
and,
eq,
inArray,
or,
isNull,
ne,
isNotNull,
desc,
sql
} from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { resources, sites, Target, targets } from "@server/db"; import { resources, sites, Target, targets } from "@server/db";
@@ -78,7 +88,13 @@ export async function getTraefikConfig(
and( and(
eq(targets.enabled, true), eq(targets.enabled, true),
eq(resources.enabled, true), eq(resources.enabled, true),
or(eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId)), or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)` // only allow local sites if "local" is in siteTypes
)
),
or( or(
ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record isNull(targetHealthCheck.hcHealth) // Include targets with no health check record

View File

@@ -19,18 +19,26 @@ import * as fs from "fs";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import logger from "@server/logger"; import logger from "@server/logger";
const encryptionKeyPath = let encryptionKeyPath = "";
config.getRawPrivateConfig().server.encryption_key_path; let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
if (!fs.existsSync(encryptionKeyPath)) { encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
throw new Error(
"Encryption key file not found. Please generate one first." if (!fs.existsSync(encryptionKeyPath)) {
); throw new Error(
"Encryption key file not found. Please generate one first."
);
}
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
} }
const encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
const encryptionKey = Buffer.from(encryptionKeyHex, "hex");
// Define the return type for clarity and type safety // Define the return type for clarity and type safety
export type CertificateResult = { export type CertificateResult = {
id: number; id: number;
@@ -50,6 +58,9 @@ export async function getValidCertificatesForDomains(
domains: Set<string>, domains: Set<string>,
useCache: boolean = true useCache: boolean = true
): Promise<Array<CertificateResult>> { ): Promise<Array<CertificateResult>> {
loadEncryptData(); // Ensure encryption key is loaded
const finalResults: CertificateResult[] = []; const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>(); const domainsToQuery = new Set<string>();
@@ -151,7 +162,9 @@ export async function getValidCertificatesForDomains(
// If a certificate was found, format it, add to results, and cache it // If a certificate was found, format it, add to results, and cache it
if (foundCert) { if (foundCert) {
logger.debug(`Creating result cert for ${domain} using cert from ${foundCert.domain}`); logger.debug(
`Creating result cert for ${domain} using cert from ${foundCert.domain}`
);
const resultCert: CertificateResult = { const resultCert: CertificateResult = {
id: foundCert.certId, id: foundCert.certId,
domain: foundCert.domain, // The actual domain of the cert record domain: foundCert.domain, // The actual domain of the cert record
@@ -172,7 +185,6 @@ export async function getValidCertificatesForDomains(
} }
} }
const decryptedResults = decryptFinalResults(finalResults); const decryptedResults = decryptFinalResults(finalResults);
return decryptedResults; return decryptedResults;
} }

View File

@@ -19,7 +19,6 @@ import {
privateConfigSchema, privateConfigSchema,
readPrivateConfigFile readPrivateConfigFile
} from "#private/lib/readConfigFile"; } from "#private/lib/readConfigFile";
import { build } from "@server/build";
export class PrivateConfig { export class PrivateConfig {
private rawPrivateConfig!: z.infer<typeof privateConfigSchema>; private rawPrivateConfig!: z.infer<typeof privateConfigSchema>;
@@ -44,115 +43,104 @@ export class PrivateConfig {
throw new Error(`Invalid private configuration file: ${errors}`); throw new Error(`Invalid private configuration file: ${errors}`);
} }
if (parsedPrivateConfig.branding?.colors) { this.rawPrivateConfig = parsedPrivateConfig;
if (this.rawPrivateConfig.branding?.colors) {
process.env.BRANDING_COLORS = JSON.stringify( process.env.BRANDING_COLORS = JSON.stringify(
parsedPrivateConfig.branding?.colors this.rawPrivateConfig.branding?.colors
); );
} }
if (parsedPrivateConfig.branding?.logo?.light_path) { if (this.rawPrivateConfig.branding?.logo?.light_path) {
process.env.BRANDING_LOGO_LIGHT_PATH = process.env.BRANDING_LOGO_LIGHT_PATH =
parsedPrivateConfig.branding?.logo?.light_path; this.rawPrivateConfig.branding?.logo?.light_path;
} }
if (parsedPrivateConfig.branding?.logo?.dark_path) { if (this.rawPrivateConfig.branding?.logo?.dark_path) {
process.env.BRANDING_LOGO_DARK_PATH = process.env.BRANDING_LOGO_DARK_PATH =
parsedPrivateConfig.branding?.logo?.dark_path || undefined; this.rawPrivateConfig.branding?.logo?.dark_path || undefined;
} }
if (build != "oss") { process.env.BRANDING_LOGO_AUTH_WIDTH = this.rawPrivateConfig.branding
if (parsedPrivateConfig.branding?.logo?.light_path) { ?.logo?.auth_page?.width
process.env.BRANDING_LOGO_LIGHT_PATH = ? this.rawPrivateConfig.branding?.logo?.auth_page?.width.toString()
parsedPrivateConfig.branding?.logo?.light_path; : undefined;
} process.env.BRANDING_LOGO_AUTH_HEIGHT = this.rawPrivateConfig.branding
if (parsedPrivateConfig.branding?.logo?.dark_path) { ?.logo?.auth_page?.height
process.env.BRANDING_LOGO_DARK_PATH = ? this.rawPrivateConfig.branding?.logo?.auth_page?.height.toString()
parsedPrivateConfig.branding?.logo?.dark_path || undefined; : undefined;
}
process.env.BRANDING_LOGO_AUTH_WIDTH = parsedPrivateConfig.branding process.env.BRANDING_LOGO_NAVBAR_WIDTH = this.rawPrivateConfig.branding
?.logo?.auth_page?.width ?.logo?.navbar?.width
? parsedPrivateConfig.branding?.logo?.auth_page?.width.toString() ? this.rawPrivateConfig.branding?.logo?.navbar?.width.toString()
: undefined; : undefined;
process.env.BRANDING_LOGO_AUTH_HEIGHT = parsedPrivateConfig.branding process.env.BRANDING_LOGO_NAVBAR_HEIGHT = this.rawPrivateConfig.branding
?.logo?.auth_page?.height ?.logo?.navbar?.height
? parsedPrivateConfig.branding?.logo?.auth_page?.height.toString() ? this.rawPrivateConfig.branding?.logo?.navbar?.height.toString()
: undefined; : undefined;
process.env.BRANDING_LOGO_NAVBAR_WIDTH = parsedPrivateConfig process.env.BRANDING_FAVICON_PATH =
.branding?.logo?.navbar?.width this.rawPrivateConfig.branding?.favicon_path;
? parsedPrivateConfig.branding?.logo?.navbar?.width.toString()
: undefined;
process.env.BRANDING_LOGO_NAVBAR_HEIGHT = parsedPrivateConfig
.branding?.logo?.navbar?.height
? parsedPrivateConfig.branding?.logo?.navbar?.height.toString()
: undefined;
process.env.BRANDING_FAVICON_PATH = process.env.BRANDING_APP_NAME =
parsedPrivateConfig.branding?.favicon_path; this.rawPrivateConfig.branding?.app_name || "Pangolin";
process.env.BRANDING_APP_NAME = if (this.rawPrivateConfig.branding?.footer) {
parsedPrivateConfig.branding?.app_name || "Pangolin"; process.env.BRANDING_FOOTER = JSON.stringify(
this.rawPrivateConfig.branding?.footer
if (parsedPrivateConfig.branding?.footer) { );
process.env.BRANDING_FOOTER = JSON.stringify(
parsedPrivateConfig.branding?.footer
);
}
process.env.LOGIN_PAGE_TITLE_TEXT =
parsedPrivateConfig.branding?.login_page?.title_text || "";
process.env.LOGIN_PAGE_SUBTITLE_TEXT =
parsedPrivateConfig.branding?.login_page?.subtitle_text || "";
process.env.SIGNUP_PAGE_TITLE_TEXT =
parsedPrivateConfig.branding?.signup_page?.title_text || "";
process.env.SIGNUP_PAGE_SUBTITLE_TEXT =
parsedPrivateConfig.branding?.signup_page?.subtitle_text || "";
process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY =
parsedPrivateConfig.branding?.resource_auth_page
?.hide_powered_by === true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO =
parsedPrivateConfig.branding?.resource_auth_page?.show_logo ===
true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT =
parsedPrivateConfig.branding?.resource_auth_page?.title_text ||
"";
process.env.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT =
parsedPrivateConfig.branding?.resource_auth_page
?.subtitle_text || "";
if (parsedPrivateConfig.branding?.background_image_path) {
process.env.BACKGROUND_IMAGE_PATH =
parsedPrivateConfig.branding?.background_image_path;
}
if (parsedPrivateConfig.server.reo_client_id) {
process.env.REO_CLIENT_ID =
parsedPrivateConfig.server.reo_client_id;
}
if (parsedPrivateConfig.stripe?.s3Bucket) {
process.env.S3_BUCKET = parsedPrivateConfig.stripe.s3Bucket;
}
if (parsedPrivateConfig.stripe?.localFilePath) {
process.env.LOCAL_FILE_PATH =
parsedPrivateConfig.stripe.localFilePath;
}
if (parsedPrivateConfig.stripe?.s3Region) {
process.env.S3_REGION = parsedPrivateConfig.stripe.s3Region;
}
if (parsedPrivateConfig.flags.use_pangolin_dns) {
process.env.USE_PANGOLIN_DNS =
parsedPrivateConfig.flags.use_pangolin_dns.toString();
}
} }
this.rawPrivateConfig = parsedPrivateConfig; process.env.LOGIN_PAGE_TITLE_TEXT =
this.rawPrivateConfig.branding?.login_page?.title_text || "";
process.env.LOGIN_PAGE_SUBTITLE_TEXT =
this.rawPrivateConfig.branding?.login_page?.subtitle_text || "";
process.env.SIGNUP_PAGE_TITLE_TEXT =
this.rawPrivateConfig.branding?.signup_page?.title_text || "";
process.env.SIGNUP_PAGE_SUBTITLE_TEXT =
this.rawPrivateConfig.branding?.signup_page?.subtitle_text || "";
process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY =
this.rawPrivateConfig.branding?.resource_auth_page
?.hide_powered_by === true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO =
this.rawPrivateConfig.branding?.resource_auth_page?.show_logo ===
true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT =
this.rawPrivateConfig.branding?.resource_auth_page?.title_text ||
"";
process.env.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT =
this.rawPrivateConfig.branding?.resource_auth_page?.subtitle_text ||
"";
if (this.rawPrivateConfig.branding?.background_image_path) {
process.env.BACKGROUND_IMAGE_PATH =
this.rawPrivateConfig.branding?.background_image_path;
}
if (this.rawPrivateConfig.server.reo_client_id) {
process.env.REO_CLIENT_ID =
this.rawPrivateConfig.server.reo_client_id;
}
if (this.rawPrivateConfig.stripe?.s3Bucket) {
process.env.S3_BUCKET = this.rawPrivateConfig.stripe.s3Bucket;
}
if (this.rawPrivateConfig.stripe?.localFilePath) {
process.env.LOCAL_FILE_PATH =
this.rawPrivateConfig.stripe.localFilePath;
}
if (this.rawPrivateConfig.stripe?.s3Region) {
process.env.S3_REGION = this.rawPrivateConfig.stripe.s3Region;
}
if (this.rawPrivateConfig.flags.use_pangolin_dns) {
process.env.USE_PANGOLIN_DNS =
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
}
} }
public getRawPrivateConfig() { public getRawPrivateConfig() {

View File

@@ -18,7 +18,8 @@ import {
resources, resources,
targets, targets,
sites, sites,
targetHealthCheck targetHealthCheck,
Transaction
} from "@server/db"; } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt"; import { ExitNodePingResult } from "@server/routers/newt";
@@ -333,8 +334,8 @@ export function selectBestExitNode(
return fallbackNode; return fallbackNode;
} }
export async function checkExitNodeOrg(exitNodeId: number, orgId: string) { export async function checkExitNodeOrg(exitNodeId: number, orgId: string, trx: Transaction | typeof db = db) {
const [exitNodeOrg] = await db const [exitNodeOrg] = await trx
.select() .select()
.from(exitNodeOrgs) .from(exitNodeOrgs)
.where( .where(

View File

@@ -172,6 +172,15 @@ export function readPrivateConfigFile() {
return {}; return {};
} }
// test if the config file is there
if (!fs.existsSync(privateConfigFilePath1)) {
// console.warn(
// `Private configuration file not found at ${privateConfigFilePath1}. Using default configuration.`
// );
// load the default values of the zod schema and return those
return privateConfigSchema.parse({});
}
const loadConfig = (configPath: string) => { const loadConfig = (configPath: string) => {
try { try {
const yamlContent = fs.readFileSync(configPath, "utf8"); const yamlContent = fs.readFileSync(configPath, "utf8");

View File

@@ -19,7 +19,17 @@ import {
loginPage, loginPage,
targetHealthCheck targetHealthCheck
} from "@server/db"; } from "@server/db";
import { and, eq, inArray, or, isNull, ne, isNotNull, desc } from "drizzle-orm"; import {
and,
eq,
inArray,
or,
isNull,
ne,
isNotNull,
desc,
sql
} from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { orgs, resources, sites, Target, targets } from "@server/db"; import { orgs, resources, sites, Target, targets } from "@server/db";
@@ -110,15 +120,19 @@ export async function getTraefikConfig(
and( and(
eq(targets.enabled, true), eq(targets.enabled, true),
eq(resources.enabled, true), eq(resources.enabled, true),
// or( or(
eq(sites.exitNodeId, exitNodeId), eq(sites.exitNodeId, exitNodeId),
// isNull(sites.exitNodeId) and(
// ), isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)` // only allow local sites if "local" is in siteTypes
)
),
or( or(
ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record isNull(targetHealthCheck.hcHealth) // Include targets with no health check record
), ),
inArray(sites.type, siteTypes), inArray(sites.type, siteTypes),
// lets rewrite this using sql
config.getRawConfig().traefik.allow_raw_resources config.getRawConfig().traefik.allow_raw_resources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true) : eq(resources.http, true)
@@ -224,7 +238,7 @@ export async function getTraefikConfig(
} }
// get the valid certs for these domains // get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
} }
const config_output: any = { const config_output: any = {

View File

@@ -61,7 +61,15 @@ export async function createExitNode(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
); );
} else { } else {
exitNode = exitNodeQuery; // update the reachable at
[exitNode] = await db
.update(exitNodes)
.set({
reachableAt
})
.where(eq(exitNodes.exitNodeId, exitNodeQuery.exitNodeId))
.returning();
logger.info(`Updated exit node reachableAt to ${reachableAt}`);
} }
return exitNode; return exitNode;

View File

@@ -292,11 +292,33 @@ hybridRouter.get(
} }
); );
let encryptionKeyPath = "";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
encryptionKeyPath = privateConfig.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
}
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
// Get valid certificates for given domains (supports wildcard certs) // Get valid certificates for given domains (supports wildcard certs)
hybridRouter.get( hybridRouter.get(
"/certificates/domains", "/certificates/domains",
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
loadEncryptData(); // Ensure encryption key is loaded
const parsed = getCertificatesByDomainsQuerySchema.safeParse( const parsed = getCertificatesByDomainsQuerySchema.safeParse(
req.query req.query
); );
@@ -425,20 +447,6 @@ hybridRouter.get(
filtered.push(cert); filtered.push(cert);
} }
const encryptionKeyPath =
privateConfig.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
}
const encryptionKeyHex = fs
.readFileSync(encryptionKeyPath, "utf8")
.trim();
const encryptionKey = Buffer.from(encryptionKeyHex, "hex");
const result = filtered.map((cert) => { const result = filtered.map((cert) => {
// Decrypt and save certificate file // Decrypt and save certificate file
const decryptedCert = decryptData( const decryptedCert = decryptData(

View File

@@ -52,7 +52,7 @@ export async function createExitNode(publicKey: string, reachableAt: string | un
.where(eq(exitNodes.publicKey, publicKey)) .where(eq(exitNodes.publicKey, publicKey))
.returning(); .returning();
logger.info(`Updated exit node`); logger.info(`Updated exit node with reachableAt to ${reachableAt}`);
} }
return exitNode; return exitNode;

View File

@@ -117,27 +117,4 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
}; };
return configResponse; return configResponse;
} }
async function getNextAvailablePort(): Promise<number> {
// Get all existing ports from exitNodes table
const existingPorts = await db
.select({
listenPort: exitNodes.listenPort
})
.from(exitNodes);
// Find the first available port between 1024 and 65535
let nextPort = config.getRawConfig().gerbil.start_port;
for (const port of existingPorts) {
if (port.listenPort > nextPort) {
break;
}
nextPort++;
if (nextPort > 65535) {
throw new Error("No available ports remaining in space");
}
}
return nextPort;
}

View File

@@ -98,7 +98,8 @@ export async function updateSiteBandwidth(
if ( if (
await checkExitNodeOrg( await checkExitNodeOrg(
exitNodeId, exitNodeId,
updatedSite.orgId updatedSite.orgId,
trx
) )
) { ) {
// not allowed // not allowed
@@ -148,7 +149,8 @@ export async function updateSiteBandwidth(
orgId, orgId,
true, true,
FeatureId.EGRESS_DATA_MB, FeatureId.EGRESS_DATA_MB,
bandwidthUsage bandwidthUsage,
trx
) )
.catch((error: any) => { .catch((error: any) => {
logger.error( logger.error(
@@ -174,7 +176,8 @@ export async function updateSiteBandwidth(
orgId, orgId,
true, true,
FeatureId.SITE_UPTIME, FeatureId.SITE_UPTIME,
uptimeUsage uptimeUsage,
trx
) )
.catch((error: any) => { .catch((error: any) => {
logger.error( logger.error(
@@ -242,7 +245,8 @@ export async function updateSiteBandwidth(
if ( if (
await checkExitNodeOrg( await checkExitNodeOrg(
exitNodeId, exitNodeId,
updatedSite.orgId updatedSite.orgId,
trx
) )
) { ) {
// not allowed // not allowed

View File

@@ -46,15 +46,24 @@ const createTargetSchema = z
.optional() .optional()
.nullable(), .nullable(),
hcTimeout: z.number().int().positive().min(1).optional().nullable(), hcTimeout: z.number().int().positive().min(1).optional().nullable(),
hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(), hcHeaders: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable()
.optional(),
hcFollowRedirects: z.boolean().optional().nullable(), hcFollowRedirects: z.boolean().optional().nullable(),
hcMethod: z.string().min(1).optional().nullable(), hcMethod: z.string().min(1).optional().nullable(),
hcStatus: z.number().int().optional().nullable(), hcStatus: z.number().int().optional().nullable(),
path: z.string().optional().nullable(), path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
.nullable(),
rewritePath: z.string().optional().nullable(), rewritePath: z.string().optional().nullable(),
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(), rewritePathType: z
priority: z.number().int().min(1).max(1000) .enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z.number().int().min(1).max(1000).optional().nullable()
}) })
.strict(); .strict();
@@ -164,12 +173,14 @@ export async function createTarget(
let newTarget: Target[] = []; let newTarget: Target[] = [];
let healthCheck: TargetHealthCheck[] = []; let healthCheck: TargetHealthCheck[] = [];
let targetIps: string[] = [];
if (site.type == "local") { if (site.type == "local") {
newTarget = await db newTarget = await db
.insert(targets) .insert(targets)
.values({ .values({
resourceId, resourceId,
...targetData ...targetData,
priority: targetData.priority || 100
}) })
.returning(); .returning();
} else { } else {
@@ -186,7 +197,7 @@ export async function createTarget(
); );
} }
const { internalPort, targetIps } = await pickPort( const { internalPort, targetIps: newTargetIps } = await pickPort(
site.siteId!, site.siteId!,
db db
); );
@@ -214,61 +225,63 @@ export async function createTarget(
pathMatchType: targetData.pathMatchType, pathMatchType: targetData.pathMatchType,
rewritePath: targetData.rewritePath, rewritePath: targetData.rewritePath,
rewritePathType: targetData.rewritePathType, rewritePathType: targetData.rewritePathType,
priority: targetData.priority priority: targetData.priority || 100
})
.returning();
let hcHeaders = null;
if (targetData.hcHeaders) {
hcHeaders = JSON.stringify(targetData.hcHeaders);
}
healthCheck = await db
.insert(targetHealthCheck)
.values({
targetId: newTarget[0].targetId,
hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null,
hcMode: targetData.hcMode ?? null,
hcHostname: targetData.hcHostname ?? null,
hcPort: targetData.hcPort ?? null,
hcInterval: targetData.hcInterval ?? null,
hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null,
hcTimeout: targetData.hcTimeout ?? null,
hcHeaders: hcHeaders,
hcFollowRedirects: targetData.hcFollowRedirects ?? null,
hcMethod: targetData.hcMethod ?? null,
hcStatus: targetData.hcStatus ?? null,
hcHealth: "unknown"
}) })
.returning(); .returning();
// add the new target to the targetIps array // add the new target to the targetIps array
targetIps.push(`${targetData.ip}/32`); newTargetIps.push(`${targetData.ip}/32`);
if (site.pubKey) { targetIps = newTargetIps;
if (site.type == "wireguard") { }
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
});
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
await addTargets( let hcHeaders = null;
newt.newtId, if (targetData.hcHeaders) {
newTarget, hcHeaders = JSON.stringify(targetData.hcHeaders);
healthCheck, }
resource.protocol,
resource.proxyPort healthCheck = await db
); .insert(targetHealthCheck)
} .values({
targetId: newTarget[0].targetId,
hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null,
hcMode: targetData.hcMode ?? null,
hcHostname: targetData.hcHostname ?? null,
hcPort: targetData.hcPort ?? null,
hcInterval: targetData.hcInterval ?? null,
hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null,
hcTimeout: targetData.hcTimeout ?? null,
hcHeaders: hcHeaders,
hcFollowRedirects: targetData.hcFollowRedirects ?? null,
hcMethod: targetData.hcMethod ?? null,
hcStatus: targetData.hcStatus ?? null,
hcHealth: "unknown"
})
.returning();
if (site.pubKey) {
if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
});
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
await addTargets(
newt.newtId,
newTarget,
healthCheck,
resource.protocol,
resource.proxyPort
);
} }
} }

View File

@@ -313,6 +313,11 @@ export default async function migration() {
dateCreated: string; dateCreated: string;
}[]; }[];
// Delete the old record
await db.execute(sql`
DELETE FROM "webauthnCredentials";
`);
for (const webauthnCredential of webauthnCredentials) { for (const webauthnCredential of webauthnCredentials) {
const newCredentialId = isoBase64URL.fromBuffer( const newCredentialId = isoBase64URL.fromBuffer(
new Uint8Array( new Uint8Array(
@@ -325,12 +330,6 @@ export default async function migration() {
) )
); );
// Delete the old record
await db.execute(sql`
DELETE FROM "webauthnCredentials"
WHERE "credentialId" = ${webauthnCredential.credentialId}
`);
// Insert the updated record with converted values // Insert the updated record with converted values
await db.execute(sql` await db.execute(sql`
INSERT INTO "webauthnCredentials" ("credentialId", "publicKey", "userId", "signCount", "transports", "name", "lastUsed", "dateCreated") INSERT INTO "webauthnCredentials" ("credentialId", "publicKey", "userId", "signCount", "transports", "name", "lastUsed", "dateCreated")

View File

@@ -269,6 +269,8 @@ export default async function migration() {
dateCreated: string; dateCreated: string;
}[]; }[];
db.prepare(`DELETE FROM 'webauthnCredentials';`).run();
for (const webauthnCredential of webauthnCredentials) { for (const webauthnCredential of webauthnCredentials) {
const newCredentialId = isoBase64URL.fromBuffer( const newCredentialId = isoBase64URL.fromBuffer(
new Uint8Array( new Uint8Array(
@@ -281,11 +283,6 @@ export default async function migration() {
) )
); );
// Delete the old record
db.prepare(
`DELETE FROM 'webauthnCredentials' WHERE 'credentialId' = ?`
).run(webauthnCredential.credentialId);
// Insert the updated record with converted values // Insert the updated record with converted values
db.prepare( db.prepare(
`INSERT INTO 'webauthnCredentials' (credentialId, publicKey, userId, signCount, transports, name, lastUsed, dateCreated) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` `INSERT INTO 'webauthnCredentials' (credentialId, publicKey, userId, signCount, transports, name, lastUsed, dateCreated) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`

View File

@@ -135,7 +135,7 @@ const addTargetSchema = z
.enum(["exact", "prefix", "regex", "stripPrefix"]) .enum(["exact", "prefix", "regex", "stripPrefix"])
.optional() .optional()
.nullable(), .nullable(),
priority: z.number().int().min(1).max(1000) priority: z.number().int().min(1).max(1000).optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -205,6 +205,7 @@ export default function ReverseProxyTargets(props: {
}) { }) {
const params = use(props.params); const params = use(props.params);
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext();
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
@@ -428,17 +429,19 @@ export default function ReverseProxyTargets(props: {
}, [isAdvancedMode]); }, [isAdvancedMode]);
function addNewTarget() { function addNewTarget() {
const isHttp = resource.http;
const newTarget: LocalTarget = { const newTarget: LocalTarget = {
targetId: -Date.now(), // Use negative timestamp as temporary ID targetId: -Date.now(), // Use negative timestamp as temporary ID
ip: "", ip: "",
method: resource.http ? "http" : null, method: isHttp ? "http" : null,
port: 0, port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0, siteId: sites.length > 0 ? sites[0].siteId : 0,
path: null, path: isHttp ? null : null,
pathMatchType: null, pathMatchType: isHttp ? null : null,
rewritePath: null, rewritePath: isHttp ? null : null,
rewritePathType: null, rewritePathType: isHttp ? null : null,
priority: 100, priority: isHttp ? 100 : 100,
enabled: true, enabled: true,
resourceId: resource.resourceId, resourceId: resource.resourceId,
hcEnabled: false, hcEnabled: false,
@@ -514,25 +517,31 @@ export default function ReverseProxyTargets(props: {
try { try {
setTargetsLoading(true); setTargetsLoading(true);
const response = await api.post< const data: any = {
AxiosResponse<CreateTargetResponse>
>(`/target`, {
resourceId: resource.resourceId, resourceId: resource.resourceId,
siteId: target.siteId, siteId: target.siteId,
ip: target.ip, ip: target.ip,
method: target.method, method: target.method,
port: target.port, port: target.port,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType,
priority: target.priority,
enabled: target.enabled, enabled: target.enabled,
hcEnabled: target.hcEnabled, hcEnabled: target.hcEnabled,
hcPath: target.hcPath, hcPath: target.hcPath,
hcInterval: target.hcInterval, hcInterval: target.hcInterval,
hcTimeout: target.hcTimeout hcTimeout: target.hcTimeout
}); };
// Only include path-related fields for HTTP resources
if (resource.http) {
data.path = target.path;
data.pathMatchType = target.pathMatchType;
data.rewritePath = target.rewritePath;
data.rewritePathType = target.rewritePathType;
data.priority = target.priority;
}
const response = await api.post<
AxiosResponse<CreateTargetResponse>
>(`/target`, data);
if (response.status === 200) { if (response.status === 200) {
// Update the target with the new ID and remove the new flag // Update the target with the new ID and remove the new flag
@@ -615,19 +624,20 @@ export default function ReverseProxyTargets(props: {
// } // }
const site = sites.find((site) => site.siteId === data.siteId); const site = sites.find((site) => site.siteId === data.siteId);
const isHttp = resource.http;
const newTarget: LocalTarget = { const newTarget: LocalTarget = {
...data, ...data,
path: data.path || null, path: isHttp ? (data.path || null) : null,
pathMatchType: data.pathMatchType || null, pathMatchType: isHttp ? (data.pathMatchType || null) : null,
rewritePath: data.rewritePath || null, rewritePath: isHttp ? (data.rewritePath || null) : null,
rewritePathType: data.rewritePathType || null, rewritePathType: isHttp ? (data.rewritePathType || null) : null,
siteType: site?.type || null, siteType: site?.type || null,
enabled: true, enabled: true,
targetId: new Date().getTime(), targetId: new Date().getTime(),
new: true, new: true,
resourceId: resource.resourceId, resourceId: resource.resourceId,
priority: 100, priority: isHttp ? (data.priority || 100) : 100,
hcEnabled: false, hcEnabled: false,
hcPath: null, hcPath: null,
hcMethod: null, hcMethod: null,
@@ -666,7 +676,7 @@ export default function ReverseProxyTargets(props: {
...target, ...target,
...data, ...data,
updated: true, updated: true,
// siteType: site?.type || null siteType: site ? site.type : target.siteType
} }
: target : target
) )
@@ -719,7 +729,7 @@ export default function ReverseProxyTargets(props: {
// Save targets // Save targets
for (const target of targets) { for (const target of targets) {
const data = { const data: any = {
ip: target.ip, ip: target.ip,
port: target.port, port: target.port,
method: target.method, method: target.method,
@@ -735,14 +745,18 @@ export default function ReverseProxyTargets(props: {
hcHeaders: target.hcHeaders || null, hcHeaders: target.hcHeaders || null,
hcFollowRedirects: target.hcFollowRedirects || null, hcFollowRedirects: target.hcFollowRedirects || null,
hcMethod: target.hcMethod || null, hcMethod: target.hcMethod || null,
hcStatus: target.hcStatus || null, hcStatus: target.hcStatus || null
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType,
priority: target.priority
}; };
// Only include path-related fields for HTTP resources
if (resource.http) {
data.path = target.path;
data.pathMatchType = target.pathMatchType;
data.rewritePath = target.rewritePath;
data.rewritePathType = target.rewritePathType;
data.priority = target.priority;
}
if (target.new) { if (target.new) {
const res = await api.put< const res = await api.put<
AxiosResponse<CreateTargetResponse> AxiosResponse<CreateTargetResponse>
@@ -814,6 +828,7 @@ export default function ReverseProxyTargets(props: {
const getColumns = (): ColumnDef<LocalTarget>[] => { const getColumns = (): ColumnDef<LocalTarget>[] => {
const baseColumns: ColumnDef<LocalTarget>[] = []; const baseColumns: ColumnDef<LocalTarget>[] = [];
const isHttp = resource.http;
const priorityColumn: ColumnDef<LocalTarget> = { const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority", id: "priority",
@@ -1007,14 +1022,9 @@ export default function ReverseProxyTargets(props: {
) => { ) => {
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
...row.original, ...row.original,
ip: hostname ip: hostname,
...(port && { port: port })
}); });
if (port) {
updateTarget(row.original.targetId, {
...row.original,
port: port
});
}
}; };
return ( return (
@@ -1051,12 +1061,12 @@ export default function ReverseProxyTargets(props: {
variant="ghost" variant="ghost"
role="combobox" role="combobox"
className={cn( className={cn(
"w-[180px] justify-between text-sm font-medium border-r pr-4 rounded-none h-8 hover:bg-transparent", "w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId && !row.original.siteId &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
<span className="truncate max-w-[90px]"> <span className="truncate max-w-[150px]">
{row.original.siteId {row.original.siteId
? selectedSite?.name ? selectedSite?.name
: t("siteSelect")} : t("siteSelect")}
@@ -1133,7 +1143,7 @@ export default function ReverseProxyTargets(props: {
<Input <Input
defaultValue={row.original.ip} defaultValue={row.original.ip}
placeholder="IP / Hostname" placeholder="IP / Hostname"
className="flex-1 min-w-[120px] border-none placeholder-gray-400" className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => { onBlur={(e) => {
const input = e.target.value.trim(); const input = e.target.value.trim();
const hasProtocol = const hasProtocol =
@@ -1317,15 +1327,20 @@ export default function ReverseProxyTargets(props: {
}; };
if (isAdvancedMode) { if (isAdvancedMode) {
return [ const columns = [
matchPathColumn,
addressColumn, addressColumn,
rewritePathColumn,
priorityColumn,
healthCheckColumn, healthCheckColumn,
enabledColumn, enabledColumn,
actionsColumn actionsColumn
]; ];
// Only include path-related columns for HTTP resources
if (isHttp) {
columns.unshift(matchPathColumn);
columns.splice(3, 0, rewritePathColumn, priorityColumn);
}
return columns;
} else { } else {
return [ return [
addressColumn, addressColumn,
@@ -1454,7 +1469,7 @@ export default function ReverseProxyTargets(props: {
/> />
<label <label
htmlFor="advanced-mode-toggle" htmlFor="advanced-mode-toggle"
className="text-sm font-medium" className="text-sm"
> >
{t("advancedMode")} {t("advancedMode")}
</label> </label>
@@ -1496,7 +1511,7 @@ export default function ReverseProxyTargets(props: {
className="space-y-4" className="space-y-4"
id="tls-settings-form" id="tls-settings-form"
> >
{build == "oss" && ( {!env.flags.usePangolinDns && (
<FormField <FormField
control={tlsSettingsForm.control} control={tlsSettingsForm.control}
name="ssl" name="ssl"

View File

@@ -25,7 +25,6 @@ import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
@@ -58,7 +57,16 @@ import {
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ArrowRight, CircleCheck, CircleX, Info, MoveRight, Plus, Settings, SquareArrowOutUpRight } from "lucide-react"; import {
ArrowRight,
CircleCheck,
CircleX,
Info,
MoveRight,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -89,16 +97,25 @@ import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target"; import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker"; import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget"; import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from 'punycode'; import { toASCII, toUnicode } from "punycode";
import { DomainRow } from "../../../../../components/DomainsTable"; import { DomainRow } from "../../../../../components/DomainsTable";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; import {
import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import HealthCheckDialog from "@app/components/HealthCheckDialog"; import HealthCheckDialog from "@app/components/HealthCheckDialog";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
const baseResourceFormSchema = z.object({ const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
http: z.boolean() http: z.boolean()
@@ -115,54 +132,57 @@ const tcpUdpResourceFormSchema = z.object({
// enableProxy: z.boolean().default(false) // enableProxy: z.boolean().default(false)
}); });
const targetsSettingsSchema = z.object({ const addTargetSchema = z
stickySession: z.boolean() .object({
}); ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number().int().positive(),
const addTargetSchema = z.object({ siteId: z.number().int().positive(),
ip: z.string().refine(isTargetValid), path: z.string().optional().nullable(),
method: z.string().nullable(), pathMatchType: z
port: z.coerce.number().int().positive(), .enum(["exact", "prefix", "regex"])
siteId: z.number().int().positive(), .optional()
path: z.string().optional().nullable(), .nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), rewritePath: z.string().optional().nullable(),
rewritePath: z.string().optional().nullable(), rewritePathType: z
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(), .enum(["exact", "prefix", "regex", "stripPrefix"])
priority: z.number().int().min(1).max(1000) .optional()
}).refine( .nullable(),
(data) => { priority: z.number().int().min(1).max(1000).optional()
// If path is provided, pathMatchType must be provided })
if (data.path && !data.pathMatchType) { .refine(
return false; (data) => {
} // If path is provided, pathMatchType must be provided
// If pathMatchType is provided, path must be provided if (data.path && !data.pathMatchType) {
if (data.pathMatchType && !data.path) { return false;
return false;
}
// Validate path based on pathMatchType
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
// Path should start with /
return data.path.startsWith("/");
case "regex":
// Validate regex
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
} }
// If pathMatchType is provided, path must be provided
if (data.pathMatchType && !data.path) {
return false;
}
// Validate path based on pathMatchType
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
// Path should start with /
return data.path.startsWith("/");
case "regex":
// Validate regex
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
}
}
return true;
},
{
message: "Invalid path configuration"
} }
return true; )
},
{
message: "Invalid path configuration"
}
)
.refine( .refine(
(data) => { (data) => {
// If rewritePath is provided, rewritePathType must be provided // If rewritePath is provided, rewritePathType must be provided
@@ -216,12 +236,14 @@ export default function Page() {
>([]); >([]);
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false); const [showSnippets, setShowSnippets] = useState(false);
const [resourceId, setResourceId] = useState<number | null>(null); const [niceId, setNiceId] = useState<string>("");
// Target management state // Target management state
const [targets, setTargets] = useState<LocalTarget[]>([]); const [targets, setTargets] = useState<LocalTarget[]>([]);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]); const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(new Map()); const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
new Map()
);
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null); useState<LocalTarget | null>(null);
@@ -246,17 +268,19 @@ export default function Page() {
}, [isAdvancedMode]); }, [isAdvancedMode]);
function addNewTarget() { function addNewTarget() {
const isHttp = baseForm.watch("http");
const newTarget: LocalTarget = { const newTarget: LocalTarget = {
targetId: -Date.now(), // Use negative timestamp as temporary ID targetId: -Date.now(), // Use negative timestamp as temporary ID
ip: "", ip: "",
method: baseForm.watch("http") ? "http" : null, method: isHttp ? "http" : null,
port: 0, port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0, siteId: sites.length > 0 ? sites[0].siteId : 0,
path: null, path: isHttp ? null : null,
pathMatchType: null, pathMatchType: isHttp ? null : null,
rewritePath: null, rewritePath: isHttp ? null : null,
rewritePathType: null, rewritePathType: isHttp ? null : null,
priority: 100, priority: isHttp ? 100 : 100,
enabled: true, enabled: true,
resourceId: 0, resourceId: 0,
hcEnabled: false, hcEnabled: false,
@@ -290,12 +314,12 @@ export default function Page() {
...(!env.flags.allowRawResources ...(!env.flags.allowRawResources
? [] ? []
: [ : [
{ {
id: "raw" as ResourceType, id: "raw" as ResourceType,
title: t("resourceRaw"), title: t("resourceRaw"),
description: t("resourceRawDescription") description: t("resourceRawDescription")
} }
]) ])
]; ];
const baseForm = useForm({ const baseForm = useForm({
@@ -330,26 +354,39 @@ export default function Page() {
pathMatchType: null, pathMatchType: null,
rewritePath: null, rewritePath: null,
rewritePathType: null, rewritePathType: null,
priority: 100, priority: baseForm.watch("http") ? 100 : undefined
} as z.infer<typeof addTargetSchema> } as z.infer<typeof addTargetSchema>
}); });
const targetsSettingsForm = useForm({ // Helper function to check if all targets have required fields using schema validation
resolver: zodResolver(targetsSettingsSchema), const areAllTargetsValid = () => {
defaultValues: { if (targets.length === 0) return true; // No targets is valid
stickySession: false
}
});
const watchedIp = addTargetForm.watch("ip"); return targets.every((target) => {
const watchedPort = addTargetForm.watch("port"); try {
const watchedSiteId = addTargetForm.watch("siteId"); const isHttp = baseForm.watch("http");
const targetData: any = {
ip: target.ip,
method: target.method,
port: target.port,
siteId: target.siteId,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType
};
const handleContainerSelect = (hostname: string, port?: number) => { // Only include priority for HTTP resources
addTargetForm.setValue("ip", hostname); if (isHttp) {
if (port) { targetData.priority = target.priority;
addTargetForm.setValue("port", port); }
}
addTargetSchema.parse(targetData);
return true;
} catch {
return false;
}
});
}; };
const initializeDockerForSite = async (siteId: number) => { const initializeDockerForSite = async (siteId: number) => {
@@ -360,14 +397,14 @@ export default function Page() {
const dockerManager = new DockerManager(api, siteId); const dockerManager = new DockerManager(api, siteId);
const dockerState = await dockerManager.initializeDocker(); const dockerState = await dockerManager.initializeDocker();
setDockerStates(prev => new Map(prev.set(siteId, dockerState))); setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
}; };
const refreshContainersForSite = async (siteId: number) => { const refreshContainersForSite = async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId); const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers(); const containers = await dockerManager.fetchContainers();
setDockerStates(prev => { setDockerStates((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
const existingState = newMap.get(siteId); const existingState = newMap.get(siteId);
if (existingState) { if (existingState) {
@@ -378,11 +415,13 @@ export default function Page() {
}; };
const getDockerStateForSite = (siteId: number): DockerState => { const getDockerStateForSite = (siteId: number): DockerState => {
return dockerStates.get(siteId) || { return (
isEnabled: false, dockerStates.get(siteId) || {
isAvailable: false, isEnabled: false,
containers: [] isAvailable: false,
}; containers: []
}
);
}; };
async function addTarget(data: z.infer<typeof addTargetSchema>) { async function addTarget(data: z.infer<typeof addTargetSchema>) {
@@ -406,18 +445,20 @@ export default function Page() {
const site = sites.find((site) => site.siteId === data.siteId); const site = sites.find((site) => site.siteId === data.siteId);
const isHttp = baseForm.watch("http");
const newTarget: LocalTarget = { const newTarget: LocalTarget = {
...data, ...data,
path: data.path || null, path: isHttp ? (data.path || null) : null,
pathMatchType: data.pathMatchType || null, pathMatchType: isHttp ? (data.pathMatchType || null) : null,
rewritePath: data.rewritePath || null, rewritePath: isHttp ? (data.rewritePath || null) : null,
rewritePathType: data.rewritePathType || null, rewritePathType: isHttp ? (data.rewritePathType || null) : null,
siteType: site?.type || null, siteType: site?.type || null,
enabled: true, enabled: true,
targetId: new Date().getTime(), targetId: new Date().getTime(),
new: true, new: true,
resourceId: 0, // Will be set when resource is created resourceId: 0, // Will be set when resource is created
priority: 100, // Default priority priority: isHttp ? (data.priority || 100) : 100, // Default priority
hcEnabled: false, hcEnabled: false,
hcPath: null, hcPath: null,
hcMethod: null, hcMethod: null,
@@ -443,7 +484,7 @@ export default function Page() {
pathMatchType: null, pathMatchType: null,
rewritePath: null, rewritePath: null,
rewritePathType: null, rewritePathType: null,
priority: 100, priority: isHttp ? 100 : undefined
}); });
} }
@@ -463,11 +504,11 @@ export default function Page() {
targets.map((target) => targets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ? {
...target, ...target,
...data, ...data,
updated: true, updated: true,
siteType: site?.type || null siteType: site ? site.type : target.siteType
} }
: target : target
) )
); );
@@ -478,13 +519,11 @@ export default function Page() {
const baseData = baseForm.getValues(); const baseData = baseForm.getValues();
const isHttp = baseData.http; const isHttp = baseData.http;
const stickySessionData = targetsSettingsForm.getValues();
try { try {
const payload = { const payload = {
name: baseData.name, name: baseData.name,
http: baseData.http, http: baseData.http,
stickySession: stickySessionData.stickySession
}; };
let sanitizedSubdomain: string | undefined; let sanitizedSubdomain: string | undefined;
@@ -497,7 +536,9 @@ export default function Page() {
: undefined; : undefined;
Object.assign(payload, { Object.assign(payload, {
subdomain: sanitizedSubdomain ? toASCII(sanitizedSubdomain) : undefined, subdomain: sanitizedSubdomain
? toASCII(sanitizedSubdomain)
: undefined,
domainId: httpData.domainId, domainId: httpData.domainId,
protocol: "tcp" protocol: "tcp"
}); });
@@ -528,13 +569,13 @@ export default function Page() {
if (res && res.status === 201) { if (res && res.status === 201) {
const id = res.data.data.resourceId; const id = res.data.data.resourceId;
const niceId = res.data.data.niceId; const niceId = res.data.data.niceId;
setResourceId(id); setNiceId(niceId);
// Create targets if any exist // Create targets if any exist
if (targets.length > 0) { if (targets.length > 0) {
try { try {
for (const target of targets) { for (const target of targets) {
const data = { const data: any = {
ip: target.ip, ip: target.ip,
port: target.port, port: target.port,
method: target.method, method: target.method,
@@ -551,14 +592,18 @@ export default function Page() {
hcPort: target.hcPort || null, hcPort: target.hcPort || null,
hcFollowRedirects: hcFollowRedirects:
target.hcFollowRedirects || null, target.hcFollowRedirects || null,
hcStatus: target.hcStatus || null, hcStatus: target.hcStatus || null
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType,
priority: target.priority
}; };
// Only include path-related fields for HTTP resources
if (isHttp) {
data.path = target.path;
data.pathMatchType = target.pathMatchType;
data.rewritePath = target.rewritePath;
data.rewritePathType = target.rewritePathType;
data.priority = target.priority;
}
await api.put(`/resource/${id}/target`, data); await api.put(`/resource/${id}/target`, data);
} }
} catch (targetError) { } catch (targetError) {
@@ -660,7 +705,7 @@ export default function Page() {
const rawDomains = res.data.data.domains as DomainRow[]; const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({ const domains = rawDomains.map((domain) => ({
...domain, ...domain,
baseDomain: toUnicode(domain.baseDomain), baseDomain: toUnicode(domain.baseDomain)
})); }));
setBaseDomains(domains); setBaseDomains(domains);
// if (domains.length) { // if (domains.length) {
@@ -683,10 +728,10 @@ export default function Page() {
targets.map((target) => targets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ? {
...target, ...target,
...config, ...config,
updated: true updated: true
} }
: target : target
) )
); );
@@ -700,6 +745,7 @@ export default function Page() {
const getColumns = (): ColumnDef<LocalTarget>[] => { const getColumns = (): ColumnDef<LocalTarget>[] => {
const baseColumns: ColumnDef<LocalTarget>[] = []; const baseColumns: ColumnDef<LocalTarget>[] = [];
const isHttp = baseForm.watch("http");
const priorityColumn: ColumnDef<LocalTarget> = { const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority", id: "priority",
@@ -712,9 +758,7 @@ export default function Page() {
<Info className="h-4 w-4 text-muted-foreground" /> <Info className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-xs"> <TooltipContent className="max-w-xs">
<p> <p>{t("priorityDescription")}</p>
{t("priorityDescription")}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -895,31 +939,51 @@ export default function Page() {
) => { ) => {
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
...row.original, ...row.original,
ip: hostname ip: hostname,
...(port && { port: port })
}); });
if (port) {
updateTarget(row.original.targetId, {
...row.original,
port: port
});
}
}; };
return ( return (
<div className="flex items-center w-full"> <div className="flex items-center w-full">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input shadow-2xs rounded-md"> <div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input shadow-2xs rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
role="combobox" role="combobox"
className={cn( className={cn(
"w-[180px] justify-between text-sm font-medium border-r pr-4 rounded-none h-8 hover:bg-transparent", "w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId && !row.original.siteId &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
<span className="truncate max-w-[90px]"> <span className="truncate max-w-[150px]">
{row.original.siteId {row.original.siteId
? selectedSite?.name ? selectedSite?.name
: t("siteSelect")} : t("siteSelect")}
@@ -969,30 +1033,6 @@ export default function Page() {
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Select <Select
defaultValue={row.original.method ?? "http"} defaultValue={row.original.method ?? "http"}
@@ -1003,7 +1043,7 @@ export default function Page() {
}) })
} }
> >
<SelectTrigger className="h-8 px-2 w-[70px] text-sm font-normal border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent"> <SelectTrigger className="h-8 px-2 w-[70px] border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
{row.original.method || "http"} {row.original.method || "http"}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1020,7 +1060,7 @@ export default function Page() {
<Input <Input
defaultValue={row.original.ip} defaultValue={row.original.ip}
placeholder="IP / Hostname" placeholder="IP / Hostname"
className="flex-1 min-w-[120px] border-none placeholder-gray-400" className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => { onBlur={(e) => {
const input = e.target.value.trim(); const input = e.target.value.trim();
const hasProtocol = const hasProtocol =
@@ -1204,15 +1244,20 @@ export default function Page() {
}; };
if (isAdvancedMode) { if (isAdvancedMode) {
return [ const columns = [
matchPathColumn,
addressColumn, addressColumn,
rewritePathColumn,
priorityColumn,
healthCheckColumn, healthCheckColumn,
enabledColumn, enabledColumn,
actionsColumn actionsColumn
]; ];
// Only include path-related columns for HTTP resources
if (isHttp) {
columns.unshift(matchPathColumn);
columns.splice(3, 0, rewritePathColumn, priorityColumn);
}
return columns;
} else { } else {
return [ return [
addressColumn, addressColumn,
@@ -1464,10 +1509,10 @@ export default function Page() {
.target .target
.value .value
? parseInt( ? parseInt(
e e
.target .target
.value .value
) )
: undefined : undefined
) )
} }
@@ -1546,60 +1591,87 @@ export default function Page() {
<TableHeader> <TableHeader>
{table {table
.getHeaderGroups() .getHeaderGroups()
.map((headerGroup) => ( .map(
<TableRow key={headerGroup.id}> (
{headerGroup.headers.map( headerGroup
(header) => ( ) => (
<TableHead <TableRow
key={header.id} key={
> headerGroup.id
{header.isPlaceholder }
? null >
: flexRender( {headerGroup.headers.map(
header (
.column header
.columnDef ) => (
.header, <TableHead
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={ key={
cell.id header.id
} }
> >
{flexRender( {header.isPlaceholder
cell ? null
.column : flexRender(
.columnDef header
.cell, .column
cell.getContext() .columnDef
)} .header,
</TableCell> header.getContext()
))} )}
</TableHead>
)
)}
</TableRow> </TableRow>
)) )
)}
</TableHeader>
<TableBody>
{table.getRowModel()
.rows?.length ? (
table
.getRowModel()
.rows.map(
(row) => (
<TableRow
key={
row.id
}
>
{row
.getVisibleCells()
.map(
(
cell
) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
)
)}
</TableRow>
)
)
) : ( ) : (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={columns.length} colSpan={
columns.length
}
className="h-24 text-center" className="h-24 text-center"
> >
{t("targetNoOne")} {t(
"targetNoOne"
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -1621,12 +1693,16 @@ export default function Page() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="advanced-mode-toggle" id="advanced-mode-toggle"
checked={isAdvancedMode} checked={
onCheckedChange={setIsAdvancedMode} isAdvancedMode
}
onCheckedChange={
setIsAdvancedMode
}
/> />
<label <label
htmlFor="advanced-mode-toggle" htmlFor="advanced-mode-toggle"
className="text-sm font-medium" className="text-sm"
> >
{t("advancedMode")} {t("advancedMode")}
</label> </label>
@@ -1639,7 +1715,10 @@ export default function Page() {
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
{t("targetNoOne")} {t("targetNoOne")}
</p> </p>
<Button onClick={addNewTarget} variant="outline"> <Button
onClick={addNewTarget}
variant="outline"
>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{t("addTarget")} {t("addTarget")}
</Button> </Button>
@@ -1677,6 +1756,7 @@ export default function Page() {
} }
}} }}
loading={createLoading} loading={createLoading}
disabled={!areAllTargetsValid()}
> >
{t("resourceCreate")} {t("resourceCreate")}
</Button> </Button>
@@ -1685,24 +1765,36 @@ export default function Page() {
<HealthCheckDialog <HealthCheckDialog
open={healthCheckDialogOpen} open={healthCheckDialogOpen}
setOpen={setHealthCheckDialogOpen} setOpen={setHealthCheckDialogOpen}
targetId={selectedTargetForHealthCheck.targetId} targetId={
selectedTargetForHealthCheck.targetId
}
targetAddress={`${selectedTargetForHealthCheck.ip}:${selectedTargetForHealthCheck.port}`} targetAddress={`${selectedTargetForHealthCheck.ip}:${selectedTargetForHealthCheck.port}`}
targetMethod={ targetMethod={
selectedTargetForHealthCheck.method || undefined selectedTargetForHealthCheck.method ||
undefined
} }
initialConfig={{ initialConfig={{
hcEnabled: hcEnabled:
selectedTargetForHealthCheck.hcEnabled || false, selectedTargetForHealthCheck.hcEnabled ||
hcPath: selectedTargetForHealthCheck.hcPath || "/", false,
hcPath:
selectedTargetForHealthCheck.hcPath ||
"/",
hcMethod: hcMethod:
selectedTargetForHealthCheck.hcMethod || "GET", selectedTargetForHealthCheck.hcMethod ||
"GET",
hcInterval: hcInterval:
selectedTargetForHealthCheck.hcInterval || 5, selectedTargetForHealthCheck.hcInterval ||
hcTimeout: selectedTargetForHealthCheck.hcTimeout || 5, 5,
hcTimeout:
selectedTargetForHealthCheck.hcTimeout ||
5,
hcHeaders: hcHeaders:
selectedTargetForHealthCheck.hcHeaders || undefined, selectedTargetForHealthCheck.hcHeaders ||
undefined,
hcScheme: hcScheme:
selectedTargetForHealthCheck.hcScheme || undefined, selectedTargetForHealthCheck.hcScheme ||
undefined,
hcHostname: hcHostname:
selectedTargetForHealthCheck.hcHostname || selectedTargetForHealthCheck.hcHostname ||
selectedTargetForHealthCheck.ip, selectedTargetForHealthCheck.ip,
@@ -1713,8 +1805,11 @@ export default function Page() {
selectedTargetForHealthCheck.hcFollowRedirects || selectedTargetForHealthCheck.hcFollowRedirects ||
true, true,
hcStatus: hcStatus:
selectedTargetForHealthCheck.hcStatus || undefined, selectedTargetForHealthCheck.hcStatus ||
hcMode: selectedTargetForHealthCheck.hcMode || "http", undefined,
hcMode:
selectedTargetForHealthCheck.hcMode ||
"http",
hcUnhealthyInterval: hcUnhealthyInterval:
selectedTargetForHealthCheck.hcUnhealthyInterval || selectedTargetForHealthCheck.hcUnhealthyInterval ||
30 30
@@ -1749,7 +1844,9 @@ export default function Page() {
{t("resourceAddEntrypoints")} {t("resourceAddEntrypoints")}
</h3> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("resourceAddEntrypointsEditFile")} {t(
"resourceAddEntrypointsEditFile"
)}
</p> </p>
<CopyTextBox <CopyTextBox
text={`entryPoints: text={`entryPoints:
@@ -1764,7 +1861,9 @@ export default function Page() {
{t("resourceExposePorts")} {t("resourceExposePorts")}
</h3> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("resourceExposePortsEditFile")} {t(
"resourceExposePortsEditFile"
)}
</p> </p>
<CopyTextBox <CopyTextBox
text={`ports: text={`ports:
@@ -1802,7 +1901,7 @@ export default function Page() {
type="button" type="button"
onClick={() => onClick={() =>
router.push( router.push(
`/${orgId}/settings/resources/${resourceId}/proxy` `/${orgId}/settings/resources/${niceId}/proxy`
) )
} }
> >

View File

@@ -67,6 +67,12 @@ export default async function RootLayout({
) )
)(); )();
licenseStatus = licenseStatusRes.data.data; licenseStatus = licenseStatusRes.data.data;
} else if (build === "saas") {
licenseStatus = {
isHostLicensed: true,
isLicenseValid: true,
hostId: "saas"
};
} else { } else {
licenseStatus = { licenseStatus = {
isHostLicensed: false, isHostLicensed: false,

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -13,6 +14,7 @@ type BrandingLogoProps = {
export default function BrandingLogo(props: BrandingLogoProps) { export default function BrandingLogo(props: BrandingLogoProps) {
const { env } = useEnvContext(); const { env } = useEnvContext();
const { theme } = useTheme(); const { theme } = useTheme();
const { isUnlocked } = useLicenseStatusContext();
const [path, setPath] = useState<string>(""); // Default logo path const [path, setPath] = useState<string>(""); // Default logo path
useEffect(() => { useEffect(() => {
@@ -27,12 +29,16 @@ export default function BrandingLogo(props: BrandingLogoProps) {
} }
if (lightOrDark === "light") { if (lightOrDark === "light") {
return ( if (isUnlocked() && env.branding.logo?.lightPath) {
env.branding.logo?.lightPath || "/logo/word_mark_black.png" return env.branding.logo.lightPath;
); }
return "/logo/word_mark_black.png";
} }
return env.branding.logo?.darkPath || "/logo/word_mark_white.png"; if (isUnlocked() && env.branding.logo?.darkPath) {
return env.branding.logo.darkPath;
}
return "/logo/word_mark_white.png";
} }
const path = getPath(); const path = getPath();

View File

@@ -16,6 +16,7 @@ import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect"; import { cleanRedirect } from "@app/lib/cleanRedirect";
import BrandingLogo from "@app/components/BrandingLogo"; import BrandingLogo from "@app/components/BrandingLogo";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
type DashboardLoginFormProps = { type DashboardLoginFormProps = {
redirect?: string; redirect?: string;
@@ -29,18 +30,22 @@ export default function DashboardLoginForm({
const router = useRouter(); const router = useRouter();
const { env } = useEnvContext(); const { env } = useEnvContext();
const t = useTranslations(); const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
function getSubtitle() { function getSubtitle() {
return t("loginStart"); return t("loginStart");
} }
const logoWidth = isUnlocked() ? env.branding.logo?.authPage?.width || 175 : 175;
const logoHeight = isUnlocked() ? env.branding.logo?.authPage?.height || 58 : 58;
return ( return (
<Card className="shadow-md w-full max-w-md"> <Card className="shadow-md w-full max-w-md">
<CardHeader className="border-b"> <CardHeader className="border-b">
<div className="flex flex-row items-center justify-center"> <div className="flex flex-row items-center justify-center">
<BrandingLogo <BrandingLogo
height={env.branding.logo?.authPage?.height || 58} height={logoHeight}
width={env.branding.logo?.authPage?.width || 175} width={logoWidth}
/> />
</div> </div>
<div className="text-center space-y-1 pt-3"> <div className="text-center space-y-1 pt-3">

View File

@@ -1,15 +1,13 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import ProfileIcon from "@app/components/ProfileIcon"; import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher"; import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import BrandingLogo from "./BrandingLogo"; import BrandingLogo from "./BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "./ui/badge"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
interface LayoutHeaderProps { interface LayoutHeaderProps {
showTopBar: boolean; showTopBar: boolean;
@@ -19,6 +17,14 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const [path, setPath] = useState<string>(""); const [path, setPath] = useState<string>("");
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const logoWidth = isUnlocked()
? env.branding.logo?.navbar?.width || 98
: 98;
const logoHeight = isUnlocked()
? env.branding.logo?.navbar?.height || 32
: 32;
useEffect(() => { useEffect(() => {
function getPath() { function getPath() {
@@ -50,12 +56,8 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/" className="flex items-center"> <Link href="/" className="flex items-center">
<BrandingLogo <BrandingLogo
width={ width={logoWidth}
env.branding.logo?.navbar?.width || 98 height={logoHeight}
}
height={
env.branding.logo?.navbar?.height || 32
}
/> />
</Link> </Link>
{/* {build === "saas" && ( {/* {build === "saas" && (

View File

@@ -67,6 +67,9 @@ export function LayoutSidebar({
}, [isSidebarCollapsed]); }, [isSidebarCollapsed]);
function loadFooterLinks(): { text: string; href?: string }[] | undefined { function loadFooterLinks(): { text: string; href?: string }[] | undefined {
if (!isUnlocked()) {
return undefined;
}
if (env.branding.footer) { if (env.branding.footer) {
try { try {
return JSON.parse(env.branding.footer); return JSON.parse(env.branding.footer);

View File

@@ -48,6 +48,7 @@ import BrandingLogo from "@app/components/BrandingLogo";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { build } from "@server/build"; import { build } from "@server/build";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
const pinSchema = z.object({ const pinSchema = z.object({
pin: z pin: z
@@ -92,6 +93,7 @@ type ResourceAuthPortalProps = {
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
const getNumMethods = () => { const getNumMethods = () => {
let colLength = 0; let colLength = 0;
@@ -308,14 +310,22 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
} }
function getTitle() { function getTitle() {
if (build !== "oss" && env.branding.resourceAuthPage?.titleText) { if (
isUnlocked() &&
build !== "oss" &&
env.branding.resourceAuthPage?.titleText
) {
return env.branding.resourceAuthPage.titleText; return env.branding.resourceAuthPage.titleText;
} }
return t("authenticationRequired"); return t("authenticationRequired");
} }
function getSubtitle(resourceName: string) { function getSubtitle(resourceName: string) {
if (build !== "oss" && env.branding.resourceAuthPage?.subtitleText) { if (
isUnlocked() &&
build !== "oss" &&
env.branding.resourceAuthPage?.subtitleText
) {
return env.branding.resourceAuthPage.subtitleText return env.branding.resourceAuthPage.subtitleText
.split("{{resourceName}}") .split("{{resourceName}}")
.join(resourceName); .join(resourceName);
@@ -325,11 +335,14 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
: t("authenticationRequest", { name: resourceName }); : t("authenticationRequest", { name: resourceName });
} }
const logoWidth = isUnlocked() ? env.branding.logo?.authPage?.width || 100 : 100;
const logoHeight = isUnlocked() ? env.branding.logo?.authPage?.height || 100 : 100;
return ( return (
<div> <div>
{!accessDenied ? ( {!accessDenied ? (
<div> <div>
{build === "enterprise" ? ( {isUnlocked() && build === "enterprise" ? (
!env.branding.resourceAuthPage?.hidePoweredBy && ( !env.branding.resourceAuthPage?.hidePoweredBy && (
<div className="text-center mb-2"> <div className="text-center mb-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
@@ -362,18 +375,13 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
)} )}
<Card> <Card>
<CardHeader> <CardHeader>
{build !== "oss" && {isUnlocked() &&
build !== "oss" &&
env.branding?.resourceAuthPage?.showLogo && ( env.branding?.resourceAuthPage?.showLogo && (
<div className="flex flex-row items-center justify-center mb-3"> <div className="flex flex-row items-center justify-center mb-3">
<BrandingLogo <BrandingLogo
height={ height={logoHeight}
env.branding.logo?.authPage width={logoWidth}
?.height || 100
}
width={
env.branding.logo?.authPage
?.width || 100
}
/> />
</div> </div>
)} )}

View File

@@ -21,6 +21,8 @@ export default function SidebarLicenseButton({
}: SidebarLicenseButtonProps) { }: SidebarLicenseButtonProps) {
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext(); const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const url = "https://docs.digpangolin.com/self-host/enterprise-edition";
const t = useTranslations(); const t = useTranslations();
return ( return (
@@ -30,21 +32,21 @@ export default function SidebarLicenseButton({
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link href="https://docs.digpangolin.com/"> <Link href={url}>
<Button size="icon" className="w-8 h-8"> <Button size="icon" className="w-8 h-8">
<TicketCheck className="h-4 w-4" /> <TicketCheck className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={8}> <TooltipContent side="right" sideOffset={8}>
Enable Enterprise License {t("sidebarEnableEnterpriseLicense")}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : ( ) : (
<Link href="https://docs.digpangolin.com/"> <Link href={url}>
<Button size="sm" className="gap-2 w-full"> <Button size="sm" className="gap-2 w-full">
Enable Enterprise License {t("sidebarEnableEnterpriseLicense")}
</Button> </Button>
</Link> </Link>
) )

View File

@@ -18,9 +18,7 @@ import {
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
@@ -31,13 +29,13 @@ import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect"; import { cleanRedirect } from "@app/lib/cleanRedirect";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import BrandingLogo from "@app/components/BrandingLogo"; import BrandingLogo from "@app/components/BrandingLogo";
import { build } from "@server/build"; import { build } from "@server/build";
import { Check, X } from "lucide-react"; import { Check, X } from "lucide-react";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
// Password strength calculation // Password strength calculation
const calculatePasswordStrength = (password: string) => { const calculatePasswordStrength = (password: string) => {
@@ -111,6 +109,7 @@ export default function SignupForm({
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const t = useTranslations(); const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -192,14 +191,18 @@ export default function SignupForm({
} }
}; };
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
return ( return (
<Card className="w-full max-w-md shadow-md"> <Card className="w-full max-w-md shadow-md">
<CardHeader className="border-b"> <CardHeader className="border-b">
<div className="flex flex-row items-center justify-center"> <div className="flex flex-row items-center justify-center">
<BrandingLogo <BrandingLogo height={logoHeight} width={logoWidth} />
height={env.branding.logo?.authPage?.height || 58}
width={env.branding.logo?.authPage?.width || 175}
/>
</div> </div>
<div className="text-center space-y-1 pt-3"> <div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p> <p className="text-muted-foreground">{getSubtitle()}</p>

View File

@@ -300,33 +300,65 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}, },
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
return ( return originalRow.exitNodeName ? (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span>{originalRow.exitNodeName}</span> <span>{originalRow.exitNodeName}</span>
{build == "saas" && originalRow.exitNodeName && {build == "saas" &&
['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'].includes(originalRow.exitNodeName.toLowerCase()) && ( originalRow.exitNodeName &&
<Badge variant="secondary">Cloud</Badge> [
)} "mercury",
"venus",
"earth",
"mars",
"jupiter",
"saturn",
"uranus",
"neptune"
].includes(
originalRow.exitNodeName.toLowerCase()
) && <Badge variant="secondary">Cloud</Badge>}
</div> </div>
); ) : (
}, "-"
},
...(env.flags.enableClients ? [{
accessorKey: "address",
header: ({ column }: { column: Column<SiteRow, unknown> }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
); );
} }
}] : []), },
...(env.flags.enableClients
? [
{
accessorKey: "address",
header: ({
column
}: {
column: Column<SiteRow, unknown>;
}) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }: { row: any }) => {
const originalRow = row.original;
return originalRow.address ? (
<div className="flex items-center space-x-2">
<span>{originalRow.address}</span>
</div>
) : (
"-"
);
}
}
]
: []),
{ {
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {

View File

@@ -3,6 +3,7 @@
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
type SplashImageProps = { type SplashImageProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -11,8 +12,12 @@ type SplashImageProps = {
export default function SplashImage({ children }: SplashImageProps) { export default function SplashImage({ children }: SplashImageProps) {
const pathname = usePathname(); const pathname = usePathname();
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
function showBackgroundImage() { function showBackgroundImage() {
if (!isUnlocked()) {
return false;
}
if (!env.branding.background_image_path) { if (!env.branding.background_image_path) {
return false; return false;
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import LicenseStatusContext from "@app/contexts/licenseStatusContext"; import LicenseStatusContext from "@app/contexts/licenseStatusContext";
import { build } from "@server/build";
import { LicenseStatus } from "@server/license/license"; import { LicenseStatus } from "@server/license/license";
import { useState } from "react"; import { useState } from "react";