Compare commits

...

57 Commits
1.9.2 ... 1.9.4

Author SHA1 Message Date
Owen Schwartz
cd79e77576 Merge pull request #1397 from fosrl/dev
1.9.4
2025-09-01 17:47:01 -07:00
Owen Schwartz
1f1c20d637 Merge pull request #1396 from fosrl/crowdin_dev
New Crowdin updates
2025-09-01 17:46:22 -07:00
Owen Schwartz
e87b3b1b54 New translations en-us.json (Norwegian Bokmal) 2025-09-01 17:46:02 -07:00
Owen Schwartz
a6f7b65625 New translations en-us.json (Chinese Simplified) 2025-09-01 17:46:01 -07:00
Owen Schwartz
722fa47132 New translations en-us.json (Turkish) 2025-09-01 17:46:00 -07:00
Owen Schwartz
f83e290b4c New translations en-us.json (Russian) 2025-09-01 17:45:59 -07:00
Owen Schwartz
11b4047283 New translations en-us.json (Portuguese) 2025-09-01 17:45:57 -07:00
Owen Schwartz
69b2032a86 New translations en-us.json (Polish) 2025-09-01 17:45:56 -07:00
Owen Schwartz
636298569f New translations en-us.json (Dutch) 2025-09-01 17:45:55 -07:00
Owen Schwartz
ed8a282d35 New translations en-us.json (Korean) 2025-09-01 17:45:54 -07:00
Owen Schwartz
3bd5e850e0 New translations en-us.json (Italian) 2025-09-01 17:45:52 -07:00
Owen Schwartz
070f1f9159 New translations en-us.json (German) 2025-09-01 17:45:51 -07:00
Owen Schwartz
195644cca5 New translations en-us.json (Czech) 2025-09-01 17:45:50 -07:00
Owen Schwartz
8092c86ecd New translations en-us.json (Bulgarian) 2025-09-01 17:45:49 -07:00
Owen Schwartz
28f33702da New translations en-us.json (Spanish) 2025-09-01 17:45:48 -07:00
Owen Schwartz
570632b8be New translations en-us.json (French) 2025-09-01 17:45:46 -07:00
Owen
f2881e1b31 Merge branch 'Pallavikumarimdb-enhancement-#906/persist-amount-of-entries' into dev 2025-09-01 17:40:02 -07:00
Owen
dad35e37ef Merge branch 'enhancement-#906/persist-amount-of-entries' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-enhancement-#906/persist-amount-of-entries 2025-09-01 17:39:16 -07:00
Owen
39afabd60e Source maps as js 2025-09-01 14:03:32 -07:00
Owen
dc7e14a34b Limit saas 2025-09-01 11:39:30 -07:00
Owen
1dca71a779 Try to include source maps 2025-09-01 11:29:49 -07:00
Pallavi
e9494efa8e quick fix 2025-09-01 23:06:39 +05:30
Owen Schwartz
8159a0f13d Merge pull request #1394 from Pallavikumarimdb/Fix/hostname-field-reset-port-and-method
Fix/hostname field reset port and method
2025-09-01 10:21:31 -07:00
Pallavi
ee9101e738 Save Amount of Entries 2025-09-01 22:26:12 +05:30
Pallavi
b670e6e3dc update parser to handle h2c 2025-09-01 21:47:50 +05:30
Pallavi
5e5754fa62 preserve port and method on host change 2025-09-01 21:22:18 +05:30
Owen Schwartz
5fcf76066f Merge pull request #1391 from fosrl/dev
1.9.3
2025-08-31 20:58:35 -07:00
Owen
601645fa72 Fix translations
Fix #1355
2025-08-31 20:56:49 -07:00
Owen
12765ad675 Merge branch 'Pallavikumarimdb-Fix/allow-unicode-domain-name' into dev 2025-08-31 19:41:35 -07:00
Owen
ad3383d23d Merge branch 'Fix/allow-unicode-domain-name' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-Fix/allow-unicode-domain-name 2025-08-31 19:40:13 -07:00
Pallavi
7d5961cf50 Support unicode with subdomain sanitized 2025-08-31 22:45:42 +05:30
Owen
864aa052f1 Merge branch 'Hetav21-enhancement-1318' into dev 2025-08-31 09:50:47 -07:00
Hetav21
be16196058 feat: make version numbers link to GitHub releases and add Discord link 2025-08-31 21:19:34 +05:30
Pallavi
8a62f12e8b fix lint 2025-08-31 17:53:36 +05:30
Pallavi
78f464f6ca Show/allow unicode domain name 2025-08-31 17:53:35 +05:30
Owen
f37eda4739 Fix #1376 2025-08-30 22:28:37 -07:00
Owen
4e106e9e5a Make more explicit in config telemetry
Fixes #1374
2025-08-30 22:22:42 -07:00
Owen
ccf8e5e6f4 Dont pull org from api key
Fixes #1361
2025-08-30 22:12:35 -07:00
Owen
9455adf61f Add list invitations to integration api
Fixes #1364
2025-08-30 21:18:22 -07:00
miloschwartz
970ab9818a translate managed page 2025-08-30 16:51:44 -07:00
Owen Schwartz
7848cf7141 Merge pull request #1377 from fosrl/dependabot/npm_and_yarn/dev-patch-updates-f90e31e16c
Bump the dev-patch-updates group across 1 directory with 2 updates
2025-08-30 16:12:29 -07:00
Owen
8e5aa9c195 Merge branch 'Pallavikumarimdb-feature-906/smart-host-parsing' into dev 2025-08-30 15:52:45 -07:00
Owen
a03e9ba7dd Merge branch 'feature-906/smart-host-parsing' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-feature-906/smart-host-parsing 2025-08-30 15:51:15 -07:00
Owen
9e646ba385 Merge branch 'Pallavikumarimdb-Fix/domain-picker-issue' into dev 2025-08-30 15:24:15 -07:00
Owen
d9a4f20fe6 Merge branch 'Fix/domain-picker-issue' of github.com:Pallavikumarimdb/pangolin into Pallavikumarimdb-Fix/domain-picker-issue 2025-08-30 15:22:02 -07:00
Owen
e659f0e75d Fix type 2025-08-29 15:39:06 -07:00
Owen
8891d6239f Handle wildcard certs 2025-08-29 15:35:57 -07:00
Pallavi
e3bd3fb985 consistent full domain 2025-08-30 02:59:23 +05:30
Pallavi
54764dfacd unify subdomain validation schema to handle edge cases 2025-08-30 01:14:03 +05:30
Owen
b156b5ff2d Make /32 to not mess with newt 2025-08-28 22:42:27 -07:00
Owen
d8e547c9a0 Configure if allow raw resources 2025-08-28 22:11:24 -07:00
dependabot[bot]
a0b93377a4 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](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom).


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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-29 01:23:18 +00:00
Pallavi
e8a6efd079 subdomain validation consistent 2025-08-29 03:58:49 +05:30
Pallavi
18bb6caf8f allows typing flow while providing helpful validation 2025-08-29 02:44:39 +05:30
Pallavi
bc335d15c0 preserve subdomain with sanitizeSubdomain and validateSubdomain 2025-08-29 01:12:12 +05:30
Pallavi
fb1481c69c fix lint issue 2025-08-22 19:18:47 +05:30
Pallavi
9557f755a5 Add Smart Host Parsing 2025-08-22 13:07:03 +05:30
65 changed files with 2059 additions and 584 deletions

View File

@@ -13,6 +13,8 @@ managed:
app:
dashboard_url: "https://{{.DashboardDomain}}"
log_level: "info"
telemetry:
anonymous_usage: true
domains:
domain1:

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Get Site Resource",
"actionListSiteResources": "List Site Resources",
"actionUpdateSiteResource": "Update Site Resource",
"actionListInvitations": "List Invitations",
"noneSelected": "None selected",
"orgNotFound2": "No organizations found.",
"searchProgress": "Search...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Redirecting to login...",
"autoLoginError": "Auto Login Error",
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
"managedSelfHosted": {
"title": "Managed Self-Hosted",
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
"introTitle": "Managed Self-Hosted Pangolin",
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
"benefitSimplerOperations": {
"title": "Simpler operations",
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
},
"benefitAutomaticUpdates": {
"title": "Automatic updates",
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
},
"benefitLessMaintenance": {
"title": "Less maintenance",
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
},
"benefitCloudFailover": {
"title": "Cloud failover",
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
},
"benefitHighAvailability": {
"title": "High availability (PoPs)",
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
},
"benefitFutureEnhancements": {
"title": "Future enhancements",
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
},
"docsAlert": {
"text": "Learn more about the Managed Self-Hosted option in our",
"documentation": "documentation"
},
"convertButton": "Convert This Node to Managed Self-Hosted"
},
"internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Get Site Resource",
"actionListSiteResources": "List Site Resources",
"actionUpdateSiteResource": "Update Site Resource",
"actionListInvitations": "List Invitations",
"noneSelected": "None selected",
"orgNotFound2": "No organizations found.",
"searchProgress": "Search...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Redirecting to login...",
"autoLoginError": "Auto Login Error",
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
"managedSelfHosted": {
"title": "Managed Self-Hosted",
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
"introTitle": "Managed Self-Hosted Pangolin",
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
"benefitSimplerOperations": {
"title": "Simpler operations",
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
},
"benefitAutomaticUpdates": {
"title": "Automatic updates",
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
},
"benefitLessMaintenance": {
"title": "Less maintenance",
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
},
"benefitCloudFailover": {
"title": "Cloud failover",
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
},
"benefitHighAvailability": {
"title": "High availability (PoPs)",
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
},
"benefitFutureEnhancements": {
"title": "Future enhancements",
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
},
"docsAlert": {
"text": "Learn more about the Managed Self-Hosted option in our",
"documentation": "documentation"
},
"convertButton": "Convert This Node to Managed Self-Hosted"
},
"internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Site-Ressource abrufen",
"actionListSiteResources": "Site-Ressourcen auflisten",
"actionUpdateSiteResource": "Site-Ressource aktualisieren",
"actionListInvitations": "Einladungen auflisten",
"noneSelected": "Keine ausgewählt",
"orgNotFound2": "Keine Organisationen gefunden.",
"searchProgress": "Suche...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Weiterleitung zur Anmeldung...",
"autoLoginError": "Fehler bei der automatischen Anmeldung",
"autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.",
"autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL."
"autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.",
"managedSelfHosted": {
"title": "Verwaltetes Selbsthosted",
"description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen",
"introTitle": "Verwalteter selbstgehosteter Pangolin",
"introDescription": "ist eine Deployment-Option, die für Personen konzipiert wurde, die Einfachheit und zusätzliche Zuverlässigkeit wünschen, während sie ihre Daten privat und selbstgehostet halten.",
"introDetail": "Mit dieser Option haben Sie immer noch Ihren eigenen Pangolin-Knoten Ihre Tunnel, SSL-Terminierung und Traffic bleiben auf Ihrem Server. Der Unterschied besteht darin, dass Verwaltung und Überwachung über unser Cloud-Dashboard abgewickelt werden, das eine Reihe von Vorteilen freischaltet:",
"benefitSimplerOperations": {
"title": "Einfachere Operationen",
"description": "Sie brauchen keinen eigenen Mail-Server auszuführen oder komplexe Warnungen einzurichten. Sie erhalten Gesundheitschecks und Ausfallwarnungen aus dem Box."
},
"benefitAutomaticUpdates": {
"title": "Automatische Updates",
"description": "Das Cloud-Dashboard entwickelt sich schnell, so dass Sie neue Funktionen und Fehlerbehebungen erhalten, ohne jedes Mal neue Container manuell ziehen zu müssen."
},
"benefitLessMaintenance": {
"title": "Weniger Wartung",
"description": "Keine Datenbankmigrationen, Sicherungen oder zusätzliche Infrastruktur zum Verwalten. Wir kümmern uns um das in der Cloud."
},
"benefitCloudFailover": {
"title": "Cloud-Ausfall",
"description": "Wenn Ihr Knoten runtergeht, können Ihre Tunnel vorübergehend an unsere Cloud-Punkte scheitern, bis Sie ihn wieder online bringen."
},
"benefitHighAvailability": {
"title": "Hohe Verfügbarkeit (PoPs)",
"description": "Sie können auch mehrere Knoten an Ihr Konto anhängen, um Redundanz und bessere Leistung zu erzielen."
},
"benefitFutureEnhancements": {
"title": "Zukünftige Verbesserungen",
"description": "Wir planen weitere Analyse-, Alarm- und Management-Tools hinzuzufügen, um Ihren Einsatz noch robuster zu machen."
},
"docsAlert": {
"text": "Erfahren Sie mehr über die Managed Self-Hosted Option in unserer",
"documentation": "dokumentation"
},
"convertButton": "Diesen Knoten in Managed Self-Hosted umwandeln"
},
"internationaldomaindetected": "Internationale Domain erkannt",
"willbestoredas": "Wird gespeichert als:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Get Site Resource",
"actionListSiteResources": "List Site Resources",
"actionUpdateSiteResource": "Update Site Resource",
"actionListInvitations": "List Invitations",
"noneSelected": "None selected",
"orgNotFound2": "No organizations found.",
"searchProgress": "Search...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Redirecting to login...",
"autoLoginError": "Auto Login Error",
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
"managedSelfHosted": {
"title": "Managed Self-Hosted",
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
"introTitle": "Managed Self-Hosted Pangolin",
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
"benefitSimplerOperations": {
"title": "Simpler operations",
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
},
"benefitAutomaticUpdates": {
"title": "Automatic updates",
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
},
"benefitLessMaintenance": {
"title": "Less maintenance",
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
},
"benefitCloudFailover": {
"title": "Cloud failover",
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
},
"benefitHighAvailability": {
"title": "High availability (PoPs)",
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
},
"benefitFutureEnhancements": {
"title": "Future enhancements",
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
},
"docsAlert": {
"text": "Learn more about the Managed Self-Hosted option in our",
"documentation": "documentation"
},
"convertButton": "Convert This Node to Managed Self-Hosted"
},
"internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Obtener recurso del sitio",
"actionListSiteResources": "Listar recursos del sitio",
"actionUpdateSiteResource": "Actualizar recurso del sitio",
"actionListInvitations": "Listar invitaciones",
"noneSelected": "Ninguno seleccionado",
"orgNotFound2": "No se encontraron organizaciones.",
"searchProgress": "Buscar...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Redirigiendo al inicio de sesión...",
"autoLoginError": "Error de inicio de sesión automático",
"autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.",
"autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación."
"autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación.",
"managedSelfHosted": {
"title": "Autogestionado",
"description": "Servidor Pangolin autoalojado más fiable y de bajo mantenimiento con campanas y silbidos extra",
"introTitle": "Pangolin autogestionado",
"introDescription": "es una opción de despliegue diseñada para personas que quieren simplicidad y fiabilidad extra mientras mantienen sus datos privados y autoalojados.",
"introDetail": "Con esta opción, todavía ejecuta su propio nodo Pangolin, sus túneles, terminación SSL y tráfico permanecen en su servidor. La diferencia es que la gestión y el control se gestionan a través de nuestro panel de control en la nube, que desbloquea una serie de ventajas:",
"benefitSimplerOperations": {
"title": "Operaciones simples",
"description": "No necesitas ejecutar tu propio servidor de correo o configurar alertas complejas. Recibirás cheques de salud y alertas de tiempo de inactividad."
},
"benefitAutomaticUpdates": {
"title": "Actualizaciones automáticas",
"description": "El tablero de la nube evolucionará rápidamente, por lo que obtendrá nuevas características y correcciones de errores sin tener que extraer manualmente nuevos contenedores cada vez."
},
"benefitLessMaintenance": {
"title": "Menos mantenimiento",
"description": "No hay migraciones de base de datos, copias de seguridad o infraestructura extra para administrar. Lo manejamos en la nube."
},
"benefitCloudFailover": {
"title": "Fallo en la nube",
"description": "Si tu nodo cae, tus túneles pueden fallar temporalmente a nuestros puntos de presencia en la nube hasta que lo vuelvas a conectar."
},
"benefitHighAvailability": {
"title": "Alta disponibilidad (PoPs)",
"description": "También puede adjuntar múltiples nodos a su cuenta para redundancia y mejor rendimiento."
},
"benefitFutureEnhancements": {
"title": "Mejoras futuras",
"description": "Estamos planeando añadir más herramientas analíticas, alertas y de administración para hacer su despliegue aún más robusto."
},
"docsAlert": {
"text": "Aprenda más acerca de la opción de autoalojamiento administrado en nuestra",
"documentation": "documentación"
},
"convertButton": "Convierte este nodo a autoalojado administrado"
},
"internationaldomaindetected": "Dominio Internacional detectado",
"willbestoredas": "Se almacenará como:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Obtenir une ressource de site",
"actionListSiteResources": "Lister les ressources de site",
"actionUpdateSiteResource": "Mettre à jour une ressource de site",
"actionListInvitations": "Lister les invitations",
"noneSelected": "Aucune sélection",
"orgNotFound2": "Aucune organisation trouvée.",
"searchProgress": "Rechercher...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Redirection vers la connexion...",
"autoLoginError": "Erreur de connexion automatique",
"autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.",
"autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification."
"autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification.",
"managedSelfHosted": {
"title": "Gestion autonome",
"description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires",
"introTitle": "Pangolin auto-hébergé géré",
"introDescription": "est une option de déploiement conçue pour les personnes qui veulent de la simplicité et de la fiabilité tout en gardant leurs données privées et auto-hébergées.",
"introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin — vos tunnels, la terminaison SSL et le trafic restent sur votre serveur. La différence est que la gestion et la surveillance sont gérées via notre tableau de bord du cloud, qui déverrouille un certain nombre d'avantages :",
"benefitSimplerOperations": {
"title": "Opérations plus simples",
"description": "Pas besoin de faire tourner votre propre serveur de messagerie ou de configurer des alertes complexes. Vous obtiendrez des contrôles de santé et des alertes de temps d'arrêt par la suite."
},
"benefitAutomaticUpdates": {
"title": "Mises à jour automatiques",
"description": "Le tableau de bord du cloud évolue rapidement, de sorte que vous obtenez de nouvelles fonctionnalités et des corrections de bugs sans avoir à extraire manuellement de nouveaux conteneurs à chaque fois."
},
"benefitLessMaintenance": {
"title": "Moins de maintenance",
"description": "Aucune migration de base de données, sauvegarde ou infrastructure supplémentaire à gérer. Nous gérons cela dans le cloud."
},
"benefitCloudFailover": {
"title": "Basculement du Cloud",
"description": "Si votre nœud descend, vos tunnels peuvent temporairement échouer jusqu'à ce que vous le rapatriez en ligne."
},
"benefitHighAvailability": {
"title": "Haute disponibilité (PoPs)",
"description": "Vous pouvez également attacher plusieurs nœuds à votre compte pour une redondance et de meilleures performances."
},
"benefitFutureEnhancements": {
"title": "Améliorations futures",
"description": "Nous prévoyons d'ajouter plus d'outils d'analyse, d'alerte et de gestion pour rendre votre déploiement encore plus robuste."
},
"docsAlert": {
"text": "En savoir plus sur l'option Auto-Hébergement géré dans notre",
"documentation": "documentation"
},
"convertButton": "Convertir ce noeud en auto-hébergé géré"
},
"internationaldomaindetected": "Domaine international détecté",
"willbestoredas": "Sera stocké comme :"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Ottieni Risorsa del Sito",
"actionListSiteResources": "Elenca Risorse del Sito",
"actionUpdateSiteResource": "Aggiorna Risorsa del Sito",
"actionListInvitations": "Elenco Inviti",
"noneSelected": "Nessuna selezione",
"orgNotFound2": "Nessuna organizzazione trovata.",
"searchProgress": "Ricerca...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Reindirizzamento al login...",
"autoLoginError": "Errore di Accesso Automatico",
"autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.",
"autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione."
"autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione.",
"managedSelfHosted": {
"title": "Gestito Auto-Ospitato",
"description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra",
"introTitle": "Managed Self-Hosted Pangolin",
"introDescription": "è un'opzione di distribuzione progettata per le persone che vogliono la semplicità e l'affidabilità extra mantenendo i loro dati privati e self-hosted.",
"introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin — i tunnel, la terminazione SSL e il traffico rimangono tutti sul tuo server. La differenza è che la gestione e il monitoraggio sono gestiti attraverso il nostro cruscotto cloud, che sblocca una serie di vantaggi:",
"benefitSimplerOperations": {
"title": "Operazioni più semplici",
"description": "Non è necessario eseguire il proprio server di posta o impostare un avviso complesso. Otterrai controlli di salute e avvisi di inattività fuori dalla casella."
},
"benefitAutomaticUpdates": {
"title": "Aggiornamenti automatici",
"description": "Il cruscotto cloud si evolve rapidamente, in modo da ottenere nuove funzionalità e correzioni di bug senza dover tirare manualmente nuovi contenitori ogni volta."
},
"benefitLessMaintenance": {
"title": "Meno manutenzione",
"description": "Nessuna migrazione di database, backup o infrastruttura extra da gestire. Gestiamo questo problema nel cloud."
},
"benefitCloudFailover": {
"title": "failover del cloud",
"description": "Se il tuo nodo scende, i tuoi tunnel possono temporaneamente fallire nei nostri punti di presenza cloud fino a quando non lo riporti online."
},
"benefitHighAvailability": {
"title": "Alta disponibilità (PoPs)",
"description": "Puoi anche allegare più nodi al tuo account per ridondanza e prestazioni migliori."
},
"benefitFutureEnhancements": {
"title": "Miglioramenti futuri",
"description": "Stiamo pianificando di aggiungere più strumenti di analisi, allerta e gestione per rendere la tua distribuzione ancora più robusta."
},
"docsAlert": {
"text": "Scopri di più sull'opzione Managed Self-Hosted nella nostra",
"documentation": "documentazione"
},
"convertButton": "Converti questo nodo in auto-ospitato gestito"
},
"internationaldomaindetected": "Dominio Internazionale Rilevato",
"willbestoredas": "Verrà conservato come:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "사이트 리소스 가져오기",
"actionListSiteResources": "사이트 리소스 목록",
"actionUpdateSiteResource": "사이트 리소스 업데이트",
"actionListInvitations": "초대 목록",
"noneSelected": "선택된 항목 없음",
"orgNotFound2": "조직이 없습니다.",
"searchProgress": "검색...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "로그인으로 리디렉션 중...",
"autoLoginError": "자동 로그인 오류",
"autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.",
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패."
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.",
"managedSelfHosted": {
"title": "관리 자체 호스팅",
"description": "더 신뢰할 수 있고 낮은 유지보수의 자체 호스팅 팡골린 서버, 추가 기능 포함",
"introTitle": "관리 자체 호스팅 팡골린",
"introDescription": "는 자신의 데이터를 프라이빗하고 자체 호스팅을 유지하면서 더 간단하고 추가적인 신뢰성을 원하는 사람들을 위한 배포 옵션입니다.",
"introDetail": "이 옵션을 사용하면 여전히 자신의 팡골린 노드를 운영하고 - 터널, SSL 종료 및 트래픽 모두 서버에 유지됩니다. 차이점은 관리 및 모니터링이 클라우드 대시보드를 통해 처리되어 여러 혜택을 제공합니다.",
"benefitSimplerOperations": {
"title": "더 간단한 운영",
"description": "자체 메일 서버를 운영하거나 복잡한 경고를 설정할 필요가 없습니다. 기본적으로 상태 점검 및 다운타임 경고를 받을 수 있습니다."
},
"benefitAutomaticUpdates": {
"title": "자동 업데이트",
"description": "클라우드 대시보드는 빠르게 발전하므로 새로운 기능과 버그 수정 사항을 수동으로 새로운 컨테이너를 가져오지 않고도 받을 수 있습니다."
},
"benefitLessMaintenance": {
"title": "유지보수 감소",
"description": "데이터베이스 마이그레이션, 백업 또는 추가 인프라를 관리할 필요가 없습니다. 저희가 클라우드에서 처리합니다."
},
"benefitCloudFailover": {
"title": "클라우드 장애 조치",
"description": "노드가 다운되면 터널이 클라우드의 프레즌스 포인트로 임시 전환되어 노드를 다시 온라인으로 가져올 때까지 유지됩니다."
},
"benefitHighAvailability": {
"title": "고가용성 (PoPs)",
"description": "계정에 여러 노드를 연결하여 이중성과 성능을 향상시킬 수 있습니다."
},
"benefitFutureEnhancements": {
"title": "향후 개선",
"description": "배포를 더욱 견고하게 만들기 위해 더 많은 분석, 경고, 및 관리 도구를 추가할 계획입니다."
},
"docsAlert": {
"text": "관리 자체 호스팅 옵션에 대해 더 알아보세요",
"documentation": "문서"
},
"convertButton": "이 노드를 관리 자체 호스팅으로 변환"
},
"internationaldomaindetected": "국제 도메인 감지됨",
"willbestoredas": "다음으로 저장됩니다:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Hent Stedsressurs",
"actionListSiteResources": "List opp Stedsressurser",
"actionUpdateSiteResource": "Oppdater Stedsressurs",
"actionListInvitations": "Liste invitasjoner",
"noneSelected": "Ingen valgt",
"orgNotFound2": "Ingen organisasjoner funnet.",
"searchProgress": "Søker...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Omdirigerer til innlogging...",
"autoLoginError": "Feil ved automatisk innlogging",
"autoLoginErrorNoRedirectUrl": "Ingen omdirigerings-URL mottatt fra identitetsleverandøren.",
"autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL."
"autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL.",
"managedSelfHosted": {
"title": "Administrert selv-hostet",
"description": "Sikre og lavvedlikeholdsservere, selvbetjente Pangolin med ekstra klokker, og understell",
"introTitle": "Administrert Self-Hosted Pangolin",
"introDescription": "er et alternativ for bruk utviklet for personer som ønsker enkel og ekstra pålitelighet mens de fortsatt holder sine data privat og selvdrevne.",
"introDetail": "Med dette valget kjører du fortsatt din egen Pangolin-node - tunneler, SSL-terminering og trafikken ligger på serveren din. Forskjellen er at behandling og overvåking håndteres gjennom vårt skydashbord, som låser opp en rekke fordeler:",
"benefitSimplerOperations": {
"title": "Enklere operasjoner",
"description": "Ingen grunn til å kjøre din egen e-postserver eller sette opp kompleks varsling. Du vil få helsesjekk og nedetid varsler ut av boksen."
},
"benefitAutomaticUpdates": {
"title": "Automatiske oppdateringer",
"description": "Cloud dashbordet utvikler seg raskt, så du får nye funksjoner og feilrettinger uten at du trenger å trekke nye beholdere manuelt hver gang."
},
"benefitLessMaintenance": {
"title": "Mindre vedlikehold",
"description": "Ingen databasestyrer, sikkerhetskopier eller ekstra infrastruktur for å forvalte. Vi håndterer det i skyen."
},
"benefitCloudFailover": {
"title": "Sky feilslått",
"description": "Hvis EK-gruppen din går ned, kan tunnlene midlertidig mislykkes i å nå våre sky-punkter til du tar den tilbake på nett."
},
"benefitHighAvailability": {
"title": "Høy tilgjengelighet (PoPs)",
"description": "Du kan også legge ved flere noder til kontoen din for redundans og bedre ytelse."
},
"benefitFutureEnhancements": {
"title": "Fremtidige forbedringer",
"description": "Vi planlegger å legge inn mer analyser, varsle og styringsverktøy for å gjøre din distribusjon enda mer robust."
},
"docsAlert": {
"text": "Lær mer om Managed Self-Hosted alternativet i vår",
"documentation": "dokumentasjon"
},
"convertButton": "Konverter denne noden til manuelt bruk"
},
"internationaldomaindetected": "Internasjonalt domene oppdaget",
"willbestoredas": "Vil bli lagret som:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Bron van site ophalen",
"actionListSiteResources": "Bronnen van site weergeven",
"actionUpdateSiteResource": "Document bijwerken van site",
"actionListInvitations": "Toon uitnodigingen",
"noneSelected": "Niet geselecteerd",
"orgNotFound2": "Geen organisaties gevonden.",
"searchProgress": "Zoeken...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Redirecting naar inloggen...",
"autoLoginError": "Auto Login Fout",
"autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.",
"autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt."
"autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt.",
"managedSelfHosted": {
"title": "Beheerde Self-Hosted",
"description": "betrouwbaardere en slecht onderhouden Pangolin server met extra klokken en klokkenluiders",
"introTitle": "Beheerde zelfgehoste pangolin",
"introDescription": "is een implementatieoptie ontworpen voor mensen die eenvoud en extra betrouwbaarheid willen, terwijl hun gegevens privé en zelf georganiseerd blijven.",
"introDetail": "Met deze optie beheert u nog steeds uw eigen Pangolin node - uw tunnels, SSL-verbinding en verkeer alles op uw server. Het verschil is dat beheer en monitoring worden behandeld via onze cloud dashboard, wat een aantal voordelen oplevert:",
"benefitSimplerOperations": {
"title": "Simpler operaties",
"description": "Je hoeft geen eigen mailserver te draaien of complexe waarschuwingen in te stellen. Je krijgt gezondheidscontroles en downtime meldingen uit de box."
},
"benefitAutomaticUpdates": {
"title": "Automatische updates",
"description": "Het cloud dashboard evolueert snel, zodat u nieuwe functies en bug fixes krijgt zonder elke keer handmatig nieuwe containers te moeten trekken."
},
"benefitLessMaintenance": {
"title": "Minder onderhoud",
"description": "Geen database migratie, back-ups of extra infrastructuur om te beheren. Dat behandelen we in de cloud."
},
"benefitCloudFailover": {
"title": "Cloud fout",
"description": "Als uw node omlaag gaat, kunnen uw tunnels tijdelijk niet meer naar onze aanwezigheidspunten gaan totdat u hem weer online brengt."
},
"benefitHighAvailability": {
"title": "Hoge beschikbaarheid (PoPs)",
"description": "U kunt ook meerdere nodes koppelen aan uw account voor ontslag en betere prestaties."
},
"benefitFutureEnhancements": {
"title": "Toekomstige verbeteringen",
"description": "We zijn van plan om meer analytica, waarschuwing en beheerhulpmiddelen toe te voegen om uw implementatie nog steviger te maken."
},
"docsAlert": {
"text": "Meer informatie over de optie voor zelf-verzorging in onze",
"documentation": "documentatie"
},
"convertButton": "Converteer deze node naar Beheerde Zelf-Hosted"
},
"internationaldomaindetected": "Internationaal Domein Gedetecteerd",
"willbestoredas": "Zal worden opgeslagen als:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Pobierz zasób strony",
"actionListSiteResources": "Lista zasobów strony",
"actionUpdateSiteResource": "Aktualizuj zasób strony",
"actionListInvitations": "Lista zaproszeń",
"noneSelected": "Nie wybrano",
"orgNotFound2": "Nie znaleziono organizacji.",
"searchProgress": "Szukaj...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Przekierowanie do logowania...",
"autoLoginError": "Błąd automatycznego logowania",
"autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.",
"autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania."
"autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania.",
"managedSelfHosted": {
"title": "Zarządzane Samodzielnie-Hostingowane",
"description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami",
"introTitle": "Zarządzany samowystarczalny Pangolin",
"introDescription": "jest opcją wdrażania zaprojektowaną dla osób, które chcą prostoty i dodatkowej niezawodności, przy jednoczesnym utrzymaniu swoich danych prywatnych i samodzielnych.",
"introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin — tunele, zakończenie SSL i ruch na Twoim serwerze. Różnica polega na tym, że zarządzanie i monitorowanie odbywa się za pomocą naszej tablicy rozdzielczej, która odblokowuje szereg korzyści:",
"benefitSimplerOperations": {
"title": "Uproszczone operacje",
"description": "Nie ma potrzeby uruchamiania własnego serwera pocztowego lub ustawiania skomplikowanych powiadomień. Będziesz mieć kontrolę zdrowia i powiadomienia o przestoju."
},
"benefitAutomaticUpdates": {
"title": "Automatyczne aktualizacje",
"description": "Panel chmury rozwija się szybko, więc otrzymujesz nowe funkcje i poprawki błędów bez konieczności ręcznego ciągnięcia nowych kontenerów za każdym razem."
},
"benefitLessMaintenance": {
"title": "Mniej konserwacji",
"description": "Brak migracji bazy danych, kopii zapasowych lub dodatkowej infrastruktury do zarządzania. Obsługujemy to w chmurze."
},
"benefitCloudFailover": {
"title": "Przegrywanie w chmurze",
"description": "Jeśli Twój węzeł zostanie wyłączony, tunele mogą tymczasowo zawieść do naszych punktów w chmurze, dopóki nie przyniesiesz go z powrotem do trybu online."
},
"benefitHighAvailability": {
"title": "Wysoka dostępność (PoPs)",
"description": "Możesz również dołączyć wiele węzłów do swojego konta w celu nadmiarowości i lepszej wydajności."
},
"benefitFutureEnhancements": {
"title": "Przyszłe ulepszenia",
"description": "Planujemy dodać więcej narzędzi analitycznych, ostrzegawczych i zarządzania, aby zwiększyć odporność wdrożenia."
},
"docsAlert": {
"text": "Dowiedz się więcej o opcji zarządzania samodzielnym hostingiem w naszym",
"documentation": "dokumentacja"
},
"convertButton": "Konwertuj ten węzeł do zarządzanego samodzielnie"
},
"internationaldomaindetected": "Wykryto międzynarodową domenę",
"willbestoredas": "Będą przechowywane jako:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Obter Recurso do Site",
"actionListSiteResources": "Listar Recursos do Site",
"actionUpdateSiteResource": "Atualizar Recurso do Site",
"actionListInvitations": "Listar Convites",
"noneSelected": "Nenhum selecionado",
"orgNotFound2": "Nenhuma organização encontrada.",
"searchProgress": "Pesquisar...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Redirecionando para login...",
"autoLoginError": "Erro de Login Automático",
"autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.",
"autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação."
"autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação.",
"managedSelfHosted": {
"title": "Gerenciado Auto-Hospedado",
"description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos",
"introTitle": "Pangolin Auto-Hospedado Gerenciado",
"introDescription": "é uma opção de implantação projetada para pessoas que querem simplicidade e confiança adicional, mantendo os seus dados privados e auto-hospedados.",
"introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin — seus túneis, terminação SSL e tráfego todos permanecem no seu servidor. A diferença é que a gestão e a monitorização são geridos através do nosso painel de nuvem, que desbloqueia vários benefícios:",
"benefitSimplerOperations": {
"title": "Operações simples",
"description": "Não é necessário executar o seu próprio servidor de e-mail ou configurar um alerta complexo. Você receberá fora de caixa verificações de saúde e alertas de tempo de inatividade."
},
"benefitAutomaticUpdates": {
"title": "Atualizações automáticas",
"description": "O painel em nuvem evolui rapidamente, para que você obtenha novos recursos e correções de bugs sem ter de puxar manualmente novos contêineres toda vez."
},
"benefitLessMaintenance": {
"title": "Menos manutenção",
"description": "Sem migrações, backups ou infraestrutura extra para gerenciar. Lidamos com isso na nuvem."
},
"benefitCloudFailover": {
"title": "Falha na nuvem",
"description": "Se o seu nó descer, seus túneis podem falhar temporariamente nos nossos pontos de presença na nuvem até que você o traga online."
},
"benefitHighAvailability": {
"title": "Alta disponibilidade (Ppos)",
"description": "Você também pode anexar vários nós à sua conta para um melhor desempenho."
},
"benefitFutureEnhancements": {
"title": "Aprimoramentos futuros",
"description": "Estamos planejando adicionar mais análises, alertas e ferramentas de gerenciamento para tornar sua implantação ainda mais robusta."
},
"docsAlert": {
"text": "Saiba mais sobre a opção Hospedagem Auto-Gerenciada no nosso",
"documentation": "documentação"
},
"convertButton": "Converter este nó para Auto-Hospedado Gerenciado"
},
"internationaldomaindetected": "Domínio Internacional Detectado",
"willbestoredas": "Será armazenado como:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Получить ресурс сайта",
"actionListSiteResources": "Список ресурсов сайта",
"actionUpdateSiteResource": "Обновить ресурс сайта",
"actionListInvitations": "Список приглашений",
"noneSelected": "Ничего не выбрано",
"orgNotFound2": "Организации не найдены.",
"searchProgress": "Поиск...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Перенаправление к входу...",
"autoLoginError": "Ошибка автоматического входа",
"autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.",
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации."
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.",
"managedSelfHosted": {
"title": "Управляемый с самовывоза",
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
"introTitle": "Управляемый Само-Хост Панголина",
"introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.",
"introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin — туннели, SSL, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:",
"benefitSimplerOperations": {
"title": "Более простые операции",
"description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки."
},
"benefitAutomaticUpdates": {
"title": "Автоматическое обновление",
"description": "Панель управления в облаке развивается быстро, так что вы получаете новые функции и исправления ошибок, без необходимости каждый раз получать новые контейнеры."
},
"benefitLessMaintenance": {
"title": "Меньше обслуживания",
"description": "Нет миграции баз данных, резервных копий или дополнительной инфраструктуры для управления. Мы обрабатываем это в облаке."
},
"benefitCloudFailover": {
"title": "Облачное срабатывание",
"description": "Если ваш узел исчезнет, ваши туннели могут временно прерваться до наших облачных точек присутствия, пока вы не вернете его в сети."
},
"benefitHighAvailability": {
"title": "Высокая доступность (PoP)",
"description": "Вы также можете прикрепить несколько узлов к вашему аккаунту для избыточности и лучшей производительности."
},
"benefitFutureEnhancements": {
"title": "Будущие улучшения",
"description": "Мы планируем добавить дополнительные инструменты аналитики, оповещения и управления, чтобы сделать установку еще более надежной."
},
"docsAlert": {
"text": "Узнайте больше о опции Managed Self-Hosted в нашей",
"documentation": "документация"
},
"convertButton": "Конвертировать этот узел в управляемый себе-хост"
},
"internationaldomaindetected": "Обнаружен международный домен",
"willbestoredas": "Будет храниться как:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "Site Kaynağını Al",
"actionListSiteResources": "Site Kaynaklarını Listele",
"actionUpdateSiteResource": "Site Kaynağını Güncelle",
"actionListInvitations": "Davetiyeleri Listele",
"noneSelected": "Hiçbiri seçili değil",
"orgNotFound2": "Hiçbir organizasyon bulunamadı.",
"searchProgress": "Ara...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...",
"autoLoginError": "Otomatik Giriş Hatası",
"autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.",
"autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı."
"autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı.",
"managedSelfHosted": {
"title": "Yönetilen Self-Hosted",
"description": "Daha güvenilir ve düşük bakım gerektiren, ekstra özelliklere sahip kendi kendine barındırabileceğiniz Pangolin sunucusu",
"introTitle": "Yönetilen Kendi Kendine Barındırılan Pangolin",
"introDescription": "Bu, basitlik ve ekstra güvenilirlik arayan, ancak verilerini gizli tutmak ve kendi sunucularında barındırmak isteyen kişiler için tasarlanmış bir dağıtım seçeneğidir.",
"introDetail": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz — tünelleriniz, SSL bitişiniz ve trafiğiniz tamamen sunucunuzda kalır. Fark, yönetim ve izlemeyi bulut panomuz üzerinden gerçekleştiririz, bu da bir dizi avantaj sağlar:",
"benefitSimplerOperations": {
"title": "Daha basit işlemler",
"description": "Kendi e-posta sunucunuzu çalıştırmanıza veya karmaşık uyarılar kurmanıza gerek yok. Sağlık kontrolleri ve kesinti uyarılarını kutudan çıktığı gibi alırsınız."
},
"benefitAutomaticUpdates": {
"title": "Otomatik güncellemeler",
"description": "Bulut panosu hızla gelişir, böylece her seferinde yeni konteynerler manuel olarak çekmeden yeni özellikler ve hata düzeltmeleri alırsınız."
},
"benefitLessMaintenance": {
"title": "Daha az bakım",
"description": "Veritabanı geçişleri, yedeklemeler veya ekstra altyapı yönetimi yok. Biz bunu bulutta hallederiz."
},
"benefitCloudFailover": {
"title": "Bulut yedekleme",
"description": "Düğümünüz kapandığında, tünelleriniz geçici olarak bulut bağlantı noktalarımıza geçebilir, böylece tekrar çevrimiçi hale getirene kadar tünelleriniz kesintiye uğramaz."
},
"benefitHighAvailability": {
"title": "Yüksek kullanılabilirlik (Bağlantı Noktaları)",
"description": "Yedeklilik ve daha iyi performans için hesabınıza birden fazla düğüm bağlayabilirsiniz."
},
"benefitFutureEnhancements": {
"title": "Gelecek iyileştirmeler",
"description": "Dağıtımınızı daha sağlam hale getirmek amacıyla daha fazla analiz, uyarı ve yönetim aracı eklemeyi planlıyoruz."
},
"docsAlert": {
"text": "Yönetilen Kendi Kendine Barındırılan seçeneği hakkında daha fazla bilgi edinin",
"documentation": "dokümantasyon"
},
"convertButton": "Bu Düğümü Yönetilen Kendi Kendine Barındırma Dönüştürün"
},
"internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi",
"willbestoredas": "Şu şekilde depolanacak:"
}

View File

@@ -1059,6 +1059,7 @@
"actionGetSiteResource": "获取站点资源",
"actionListSiteResources": "列出站点资源",
"actionUpdateSiteResource": "更新站点资源",
"actionListInvitations": "邀请列表",
"noneSelected": "未选择",
"orgNotFound2": "未找到组织。",
"searchProgress": "搜索中...",
@@ -1457,5 +1458,43 @@
"autoLoginRedirecting": "重定向到登录...",
"autoLoginError": "自动登录错误",
"autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。",
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。"
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。",
"managedSelfHosted": {
"title": "托管自托管",
"description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器",
"introTitle": "托管自托管的潘戈林公司",
"introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。",
"introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 — — 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:",
"benefitSimplerOperations": {
"title": "简单的操作",
"description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。"
},
"benefitAutomaticUpdates": {
"title": "自动更新",
"description": "云仪表盘快速演化,所以您可以获得新的功能和错误修复,而不必每次手动拉取新的容器。"
},
"benefitLessMaintenance": {
"title": "减少维护时间",
"description": "没有要管理的数据库迁移、备份或额外的基础设施。我们在云端处理这个问题。"
},
"benefitCloudFailover": {
"title": "云失败",
"description": "如果您的节点被关闭,您的隧道可能暂时无法连接到我们的云端,直到您将其重新连接上线。"
},
"benefitHighAvailability": {
"title": "高可用率(PoPs)",
"description": "您还可以将多个节点添加到您的帐户中以获取冗余和更好的性能。"
},
"benefitFutureEnhancements": {
"title": "将来的改进",
"description": "我们正在计划添加更多的分析、警报和管理工具,使你的部署更加有力。"
},
"docsAlert": {
"text": "在我们中更多地了解管理下的自托管选项",
"documentation": "文档"
},
"convertButton": "将此节点转换为管理自托管的"
},
"internationaldomaindetected": "检测到国际域",
"willbestoredas": "储存为:"
}

20
package-lock.json generated
View File

@@ -84,6 +84,7 @@
"react-icons": "^5.5.0",
"rebuild": "0.1.2",
"semver": "^7.7.2",
"source-map-support": "0.5.21",
"swagger-ui-express": "^5.0.1",
"tailwind-merge": "3.3.1",
"tw-animate-css": "^1.3.7",
@@ -112,8 +113,8 @@
"@types/node": "^24",
"@types/nodemailer": "6.4.17",
"@types/pg": "8.15.5",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.8",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"@types/semver": "^7.7.0",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.18.1",
@@ -5026,9 +5027,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
"version": "19.1.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -5036,9 +5037,9 @@
}
},
"node_modules/@types/react-dom": {
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.8.tgz",
"integrity": "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ==",
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz",
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
@@ -6199,7 +6200,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/bytes": {
@@ -15590,7 +15590,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -15609,7 +15608,6 @@
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",

View File

@@ -101,6 +101,7 @@
"react-icons": "^5.5.0",
"rebuild": "0.1.2",
"semver": "^7.7.2",
"source-map-support": "0.5.21",
"swagger-ui-express": "^5.0.1",
"tailwind-merge": "3.3.1",
"tw-animate-css": "^1.3.7",
@@ -129,8 +130,8 @@
"@types/node": "^24",
"@types/nodemailer": "6.4.17",
"@types/pg": "8.15.5",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.8",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"@types/semver": "^7.7.0",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.18.1",

View File

@@ -1,5 +1,6 @@
#! /usr/bin/env node
import "./extendZod.ts";
import 'source-map-support/register.js'
import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";

View File

@@ -179,6 +179,7 @@ export const configSchema = z
.default("/var/dynamic/router_config.yml"),
static_domains: z.array(z.string()).optional().default([]),
site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]),
allow_raw_resources: z.boolean().optional().default(true),
file_mode: z.boolean().optional().default(false)
})
.optional()

View File

@@ -10,6 +10,7 @@ export async function getValidCertificatesForDomainsHybrid(domains: Set<string>)
Array<{
id: number;
domain: string;
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: Date | null;
@@ -68,6 +69,7 @@ export async function getValidCertificatesForDomains(domains: Set<string>): Prom
Array<{
id: number;
domain: string;
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: Date | null;

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
export const subdomainSchema = z
.string()
.regex(
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
/^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/,
"Invalid subdomain format"
)
.min(1, "Subdomain must be at least 1 character long")
@@ -12,7 +12,8 @@ export const subdomainSchema = z
export const tlsNameSchema = z
.string()
.regex(
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$|^$/,
/^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$|^$/,
"Invalid subdomain format"
)
.transform((val) => val.toLowerCase());
.transform((val) => val.toLowerCase());

View File

@@ -0,0 +1,235 @@
import { assertEquals } from "@test/assert";
import { isDomainCoveredByWildcard } from "./traefikConfig";
function runTests() {
console.log('Running wildcard domain coverage tests...');
// Test case 1: Basic wildcard certificate at example.com
const basicWildcardCerts = new Map([
['example.com', { exists: true, wildcard: true }]
]);
// Should match first-level subdomains
assertEquals(
isDomainCoveredByWildcard('level1.example.com', basicWildcardCerts),
true,
'Wildcard cert at example.com should match level1.example.com'
);
assertEquals(
isDomainCoveredByWildcard('api.example.com', basicWildcardCerts),
true,
'Wildcard cert at example.com should match api.example.com'
);
assertEquals(
isDomainCoveredByWildcard('www.example.com', basicWildcardCerts),
true,
'Wildcard cert at example.com should match www.example.com'
);
// Should match the root domain (exact match)
assertEquals(
isDomainCoveredByWildcard('example.com', basicWildcardCerts),
true,
'Wildcard cert at example.com should match example.com itself'
);
// Should NOT match second-level subdomains
assertEquals(
isDomainCoveredByWildcard('level2.level1.example.com', basicWildcardCerts),
false,
'Wildcard cert at example.com should NOT match level2.level1.example.com'
);
assertEquals(
isDomainCoveredByWildcard('deep.nested.subdomain.example.com', basicWildcardCerts),
false,
'Wildcard cert at example.com should NOT match deep.nested.subdomain.example.com'
);
// Should NOT match different domains
assertEquals(
isDomainCoveredByWildcard('test.otherdomain.com', basicWildcardCerts),
false,
'Wildcard cert at example.com should NOT match test.otherdomain.com'
);
assertEquals(
isDomainCoveredByWildcard('notexample.com', basicWildcardCerts),
false,
'Wildcard cert at example.com should NOT match notexample.com'
);
// Test case 2: Multiple wildcard certificates
const multipleWildcardCerts = new Map([
['example.com', { exists: true, wildcard: true }],
['test.org', { exists: true, wildcard: true }],
['api.service.net', { exists: true, wildcard: true }]
]);
assertEquals(
isDomainCoveredByWildcard('app.example.com', multipleWildcardCerts),
true,
'Should match subdomain of first wildcard cert'
);
assertEquals(
isDomainCoveredByWildcard('staging.test.org', multipleWildcardCerts),
true,
'Should match subdomain of second wildcard cert'
);
assertEquals(
isDomainCoveredByWildcard('v1.api.service.net', multipleWildcardCerts),
true,
'Should match subdomain of third wildcard cert'
);
assertEquals(
isDomainCoveredByWildcard('deep.nested.api.service.net', multipleWildcardCerts),
false,
'Should NOT match multi-level subdomain of third wildcard cert'
);
// Test exact domain matches for multiple certs
assertEquals(
isDomainCoveredByWildcard('example.com', multipleWildcardCerts),
true,
'Should match exact domain of first wildcard cert'
);
assertEquals(
isDomainCoveredByWildcard('test.org', multipleWildcardCerts),
true,
'Should match exact domain of second wildcard cert'
);
assertEquals(
isDomainCoveredByWildcard('api.service.net', multipleWildcardCerts),
true,
'Should match exact domain of third wildcard cert'
);
// Test case 3: Non-wildcard certificates (should not match anything)
const nonWildcardCerts = new Map([
['example.com', { exists: true, wildcard: false }],
['specific.domain.com', { exists: true, wildcard: false }]
]);
assertEquals(
isDomainCoveredByWildcard('sub.example.com', nonWildcardCerts),
false,
'Non-wildcard cert should not match subdomains'
);
assertEquals(
isDomainCoveredByWildcard('example.com', nonWildcardCerts),
false,
'Non-wildcard cert should not match even exact domain via this function'
);
// Test case 4: Non-existent certificates (should not match)
const nonExistentCerts = new Map([
['example.com', { exists: false, wildcard: true }],
['missing.com', { exists: false, wildcard: true }]
]);
assertEquals(
isDomainCoveredByWildcard('sub.example.com', nonExistentCerts),
false,
'Non-existent wildcard cert should not match'
);
// Test case 5: Edge cases with special domain names
const specialDomainCerts = new Map([
['localhost', { exists: true, wildcard: true }],
['127-0-0-1.nip.io', { exists: true, wildcard: true }],
['xn--e1afmkfd.xn--p1ai', { exists: true, wildcard: true }] // IDN domain
]);
assertEquals(
isDomainCoveredByWildcard('app.localhost', specialDomainCerts),
true,
'Should match subdomain of localhost wildcard'
);
assertEquals(
isDomainCoveredByWildcard('test.127-0-0-1.nip.io', specialDomainCerts),
true,
'Should match subdomain of nip.io wildcard'
);
assertEquals(
isDomainCoveredByWildcard('sub.xn--e1afmkfd.xn--p1ai', specialDomainCerts),
true,
'Should match subdomain of IDN wildcard'
);
// Test case 6: Empty input and edge cases
const emptyCerts = new Map();
assertEquals(
isDomainCoveredByWildcard('any.domain.com', emptyCerts),
false,
'Empty certificate map should not match any domain'
);
// Test case 7: Domains with single character components
const singleCharCerts = new Map([
['a.com', { exists: true, wildcard: true }],
['x.y.z', { exists: true, wildcard: true }]
]);
assertEquals(
isDomainCoveredByWildcard('b.a.com', singleCharCerts),
true,
'Should match single character subdomain'
);
assertEquals(
isDomainCoveredByWildcard('w.x.y.z', singleCharCerts),
true,
'Should match single character subdomain of multi-part domain'
);
assertEquals(
isDomainCoveredByWildcard('v.w.x.y.z', singleCharCerts),
false,
'Should NOT match multi-level subdomain of single char domain'
);
// Test case 8: Domains with numbers and hyphens
const numericCerts = new Map([
['api-v2.service-1.com', { exists: true, wildcard: true }],
['123.456.net', { exists: true, wildcard: true }]
]);
assertEquals(
isDomainCoveredByWildcard('staging.api-v2.service-1.com', numericCerts),
true,
'Should match subdomain with hyphens and numbers'
);
assertEquals(
isDomainCoveredByWildcard('test.123.456.net', numericCerts),
true,
'Should match subdomain with numeric components'
);
assertEquals(
isDomainCoveredByWildcard('deep.staging.api-v2.service-1.com', numericCerts),
false,
'Should NOT match multi-level subdomain with hyphens and numbers'
);
console.log('All wildcard domain coverage tests passed!');
}
// Run all tests
try {
runTests();
} catch (error) {
console.error('Test failed:', error);
process.exit(1);
}

View File

@@ -29,6 +29,7 @@ export class TraefikConfigManager {
exists: boolean;
lastModified: Date | null;
expiresAt: Date | null;
wildcard: boolean | null;
}
>();
@@ -115,6 +116,7 @@ export class TraefikConfigManager {
exists: boolean;
lastModified: Date | null;
expiresAt: Date | null;
wildcard: boolean;
}
>
> {
@@ -136,13 +138,16 @@ export class TraefikConfigManager {
const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem");
const lastUpdatePath = path.join(domainDir, ".last_update");
const wildcardPath = path.join(domainDir, ".wildcard");
const certExists = await this.fileExists(certPath);
const keyExists = await this.fileExists(keyPath);
const lastUpdateExists = await this.fileExists(lastUpdatePath);
const wildcardExists = await this.fileExists(wildcardPath);
let lastModified: Date | null = null;
const expiresAt: Date | null = null;
let wildcard = false;
if (lastUpdateExists) {
try {
@@ -161,10 +166,26 @@ export class TraefikConfigManager {
}
}
// Check if this is a wildcard certificate
if (wildcardExists) {
try {
const wildcardContent = fs
.readFileSync(wildcardPath, "utf8")
.trim();
wildcard = wildcardContent === "true";
} catch (error) {
logger.warn(
`Could not read wildcard file for ${domain}:`,
error
);
}
}
state.set(domain, {
exists: certExists && keyExists,
lastModified,
expiresAt
expiresAt,
wildcard
});
}
} catch (error) {
@@ -192,19 +213,36 @@ export class TraefikConfigManager {
return true;
}
// Fetch if domains have changed
// Filter out domains covered by wildcard certificates
const domainsNeedingCerts = new Set<string>();
for (const domain of currentDomains) {
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
domainsNeedingCerts.add(domain);
}
}
// Fetch if domains needing certificates have changed
const lastDomainsNeedingCerts = new Set<string>();
for (const domain of this.lastKnownDomains) {
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
lastDomainsNeedingCerts.add(domain);
}
}
if (
this.lastKnownDomains.size !== currentDomains.size ||
!Array.from(this.lastKnownDomains).every((domain) =>
currentDomains.has(domain)
domainsNeedingCerts.size !== lastDomainsNeedingCerts.size ||
!Array.from(domainsNeedingCerts).every((domain) =>
lastDomainsNeedingCerts.has(domain)
)
) {
logger.info("Fetching certificates due to domain changes");
logger.info(
"Fetching certificates due to domain changes (after wildcard filtering)"
);
return true;
}
// Check if any local certificates are missing or appear to be outdated
for (const domain of currentDomains) {
for (const domain of domainsNeedingCerts) {
const localState = this.lastLocalCertificateState.get(domain);
if (!localState || !localState.exists) {
logger.info(
@@ -273,6 +311,7 @@ export class TraefikConfigManager {
let validCertificates: Array<{
id: number;
domain: string;
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: Date | null;
@@ -280,23 +319,50 @@ export class TraefikConfigManager {
}> = [];
if (this.shouldFetchCertificates(domains)) {
// Get valid certificates for active domains
if (config.isManagedMode()) {
validCertificates =
await getValidCertificatesForDomainsHybrid(domains);
} else {
validCertificates =
await getValidCertificatesForDomains(domains);
// Filter out domains that are already covered by wildcard certificates
const domainsToFetch = new Set<string>();
for (const domain of domains) {
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
domainsToFetch.add(domain);
} else {
logger.debug(
`Domain ${domain} is covered by existing wildcard certificate, skipping fetch`
);
}
}
this.lastCertificateFetch = new Date();
this.lastKnownDomains = new Set(domains);
logger.info(
`Fetched ${validCertificates.length} certificates from remote`
);
if (domainsToFetch.size > 0) {
// Get valid certificates for domains not covered by wildcards
if (config.isManagedMode()) {
validCertificates =
await getValidCertificatesForDomainsHybrid(
domainsToFetch
);
} else {
validCertificates =
await getValidCertificatesForDomains(
domainsToFetch
);
}
this.lastCertificateFetch = new Date();
this.lastKnownDomains = new Set(domains);
// Download and decrypt new certificates
await this.processValidCertificates(validCertificates);
logger.info(
`Fetched ${validCertificates.length} certificates from remote (${domains.size - domainsToFetch.size} domains covered by wildcards)`
);
// Download and decrypt new certificates
await this.processValidCertificates(validCertificates);
} else {
logger.info(
"All domains are covered by existing wildcard certificates, no fetch needed"
);
this.lastCertificateFetch = new Date();
this.lastKnownDomains = new Set(domains);
}
// Always ensure all existing certificates (including wildcards) are in the config
await this.updateDynamicConfigFromLocalCerts(domains);
} else {
const timeSinceLastFetch = this.lastCertificateFetch
? Math.round(
@@ -544,7 +610,11 @@ export class TraefikConfigManager {
// Clear existing certificates and rebuild from local state
dynamicConfig.tls.certificates = [];
// Keep track of certificates we've already added to avoid duplicates
const addedCertPaths = new Set<string>();
for (const domain of domains) {
// First, try to find an exact match certificate
const localState = this.lastLocalCertificateState.get(domain);
if (localState && localState.exists) {
const domainDir = path.join(
@@ -554,11 +624,47 @@ export class TraefikConfigManager {
const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem");
const certEntry = {
certFile: certPath,
keyFile: keyPath
};
dynamicConfig.tls.certificates.push(certEntry);
if (!addedCertPaths.has(certPath)) {
const certEntry = {
certFile: certPath,
keyFile: keyPath
};
dynamicConfig.tls.certificates.push(certEntry);
addedCertPaths.add(certPath);
}
continue;
}
// If no exact match, check for wildcard certificates that cover this domain
for (const [certDomain, certState] of this.lastLocalCertificateState) {
if (certState.exists && certState.wildcard) {
// Check if this wildcard certificate covers the domain
if (domain.endsWith("." + certDomain)) {
// Verify it's only one level deep (wildcard only covers one level)
const prefix = domain.substring(
0,
domain.length - ("." + certDomain).length
);
if (!prefix.includes(".")) {
const domainDir = path.join(
config.getRawConfig().traefik.certificates_path,
certDomain
);
const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem");
if (!addedCertPaths.has(certPath)) {
const certEntry = {
certFile: certPath,
keyFile: keyPath
};
dynamicConfig.tls.certificates.push(certEntry);
addedCertPaths.add(certPath);
}
break; // Found a wildcard that covers this domain
}
}
}
}
}
@@ -577,6 +683,7 @@ export class TraefikConfigManager {
validCertificates: Array<{
id: number;
domain: string;
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: Date | null;
@@ -651,15 +758,24 @@ export class TraefikConfigManager {
"utf8"
);
// Check if this is a wildcard certificate and store it
const wildcardPath = path.join(domainDir, ".wildcard");
fs.writeFileSync(
wildcardPath,
cert.wildcard ? "true" : "false",
"utf8"
);
logger.info(
`Certificate updated for domain: ${cert.domain}`
`Certificate updated for domain: ${cert.domain}${cert.wildcard ? " (wildcard)" : ""}`
);
// Update local state tracking
this.lastLocalCertificateState.set(cert.domain, {
exists: true,
lastModified: new Date(),
expiresAt: cert.expiresAt
expiresAt: cert.expiresAt,
wildcard: cert.wildcard
});
}
@@ -810,14 +926,8 @@ export class TraefikConfigManager {
this.lastLocalCertificateState.delete(dirName);
// Remove from dynamic config
const certFilePath = path.join(
domainDir,
"cert.pem"
);
const keyFilePath = path.join(
domainDir,
"key.pem"
);
const certFilePath = path.join(domainDir, "cert.pem");
const keyFilePath = path.join(domainDir, "key.pem");
const before = dynamicConfig.tls.certificates.length;
dynamicConfig.tls.certificates =
dynamicConfig.tls.certificates.filter(
@@ -894,14 +1004,58 @@ export class TraefikConfigManager {
monitorInterval: number;
lastCertificateFetch: Date | null;
localCertificateCount: number;
wildcardCertificates: string[];
domainsCoveredByWildcards: string[];
} {
const wildcardCertificates: string[] = [];
const domainsCoveredByWildcards: string[] = [];
// Find wildcard certificates
for (const [domain, state] of this.lastLocalCertificateState) {
if (state.exists && state.wildcard) {
wildcardCertificates.push(domain);
}
}
// Find domains covered by wildcards
for (const domain of this.activeDomains) {
if (isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
domainsCoveredByWildcards.push(domain);
}
}
return {
isRunning: this.isRunning,
activeDomains: Array.from(this.activeDomains),
monitorInterval:
config.getRawConfig().traefik.monitor_interval || 5000,
lastCertificateFetch: this.lastCertificateFetch,
localCertificateCount: this.lastLocalCertificateState.size
localCertificateCount: this.lastLocalCertificateState.size,
wildcardCertificates,
domainsCoveredByWildcards
};
}
}
/**
* Check if a domain is covered by existing wildcard certificates
*/
export function isDomainCoveredByWildcard(domain: string, lastLocalCertificateState: Map<string, { exists: boolean; wildcard: boolean | null }>): boolean {
for (const [certDomain, state] of lastLocalCertificateState) {
if (state.exists && state.wildcard) {
// If stored as example.com but is wildcard, check subdomains
if (domain.endsWith("." + certDomain)) {
// Check that it's only one level deep (wildcard only covers one level)
const prefix = domain.substring(
0,
domain.length - ("." + certDomain).length
);
// If prefix contains a dot, it's more than one level deep
if (!prefix.includes(".")) {
return true;
}
}
}
}
return false;
}

View File

@@ -22,7 +22,7 @@ export async function verifyRoleAccess(
);
}
const { roleIds } = req.body;
const roleIds = req.body?.roleIds;
const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
if (allRoleIds.length === 0) {

View File

@@ -78,12 +78,16 @@ authenticated.post(
verifyUserHasAction(ActionsEnum.updateOrg),
org.updateOrg
);
authenticated.delete(
"/org/:orgId",
verifyOrgAccess,
verifyUserIsOrgOwner,
org.deleteOrg
);
if (build !== "saas") {
authenticated.delete(
"/org/:orgId",
verifyOrgAccess,
verifyUserIsOrgOwner,
verifyUserHasAction(ActionsEnum.deleteOrg),
org.deleteOrg
);
}
authenticated.put(
"/org/:orgId/site",

View File

@@ -221,6 +221,13 @@ authenticated.get(
domain.listDomains
);
authenticated.get(
"/org/:orgId/invitations",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listInvitations),
user.listInvitations
);
authenticated.post(
"/org/:orgId/create-invite",
verifyApiKeyOrgAccess,

View File

@@ -49,19 +49,7 @@ export async function deleteOrg(
}
const { orgId } = parsedParams.data;
// Check if the user has permission to list sites
const hasPermission = await checkUserActionPermission(
ActionsEnum.deleteOrg,
req
);
if (!hasPermission) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission to perform this action"
)
);
}
const [org] = await db
.select()
.from(orgs)

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, resources } from "@server/db";
import { apiKeys, roleResources, roles } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -74,13 +74,18 @@ export async function setResourceRoles(
const { resourceId } = parsedParams.data;
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId;
// get the resource
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!orgId) {
if (!resource) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Organization not found"
"Resource not found"
)
);
}
@@ -92,7 +97,7 @@ export async function setResourceRoles(
.where(
and(
eq(roles.name, "Admin"),
eq(roles.orgId, orgId)
eq(roles.orgId, resource.orgId)
)
)
.limit(1);

View File

@@ -272,7 +272,7 @@ export async function createSite(
type,
dockerSocketEnabled: false,
online: true,
subnet: "0.0.0.0/0"
subnet: "0.0.0.0/32"
})
.returning();
}

View File

@@ -1,6 +1,6 @@
import { Request, Response } from "express";
import { db, exitNodes } from "@server/db";
import { and, eq, inArray, or, isNull, ne } from "drizzle-orm";
import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config";
@@ -149,7 +149,10 @@ export async function getTraefikConfig(
eq(sites.exitNodeId, exitNodeId),
isNull(sites.exitNodeId)
),
inArray(sites.type, siteTypes)
inArray(sites.type, siteTypes),
config.getRawConfig().traefik.allow_raw_resources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true),
)
);

View File

@@ -58,18 +58,23 @@ export async function addUserRole(
);
}
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId;
// get the role
const [role] = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId))
.limit(1);
if (!orgId) {
if (!role) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID")
);
}
const existingUser = await db
.select()
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)))
.limit(1);
if (existingUser.length === 0) {
@@ -93,7 +98,7 @@ export async function addUserRole(
const roleExists = await db
.select()
.from(roles)
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId)))
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId)))
.limit(1);
if (roleExists.length === 0) {
@@ -108,7 +113,7 @@ export async function addUserRole(
const newUserRole = await db
.update(userOrgs)
.set({ roleId })
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)))
.returning();
return response(res, {

View File

@@ -22,6 +22,7 @@ export function InvitationsDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="invitations-table"
title={t('invite')}
searchPlaceholder={t('inviteSearch')}
searchColumn="email"

View File

@@ -24,6 +24,7 @@ export function RolesDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="roles-table"
title={t('roles')}
searchPlaceholder={t('accessRolesSearch')}
searchColumn="name"

View File

@@ -24,6 +24,7 @@ export function UsersDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="users-table"
title={t('users')}
searchPlaceholder={t('accessUsersSearch')}
searchColumn="email"

View File

@@ -22,6 +22,7 @@ export function OrgApiKeysDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="Org-apikeys-table"
title={t('apiKeys')}
searchPlaceholder={t('searchApiKeys')}
searchColumn="name"

View File

@@ -20,6 +20,7 @@ export function ClientsDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="clients-table"
title="Clients"
searchPlaceholder="Search clients..."
searchColumn="name"

View File

@@ -7,12 +7,13 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage
FormMessage,
FormDescription
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
@@ -33,7 +34,7 @@ import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
import { StrategySelect } from "@app/components/StrategySelect";
import { AxiosResponse } from "axios";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, AlertTriangle } from "lucide-react";
import { InfoIcon, AlertTriangle, Globe } from "lucide-react";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
@@ -43,9 +44,58 @@ import {
} from "@app/components/InfoSection";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { build } from "@server/build";
import { toASCII, toUnicode } from 'punycode';
// Helper functions for Unicode domain handling
function toPunycode(domain: string): string {
try {
const parts = toASCII(domain);
return parts;
} catch (error) {
return domain.toLowerCase();
}
}
function fromPunycode(domain: string): string {
try {
const parts = toUnicode(domain);
return parts;
} catch (error) {
return domain;
}
}
function isValidDomainFormat(domain: string): boolean {
const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/;
if (!unicodeRegex.test(domain)) {
return false;
}
const parts = domain.split('.');
for (const part of parts) {
if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) {
return false;
}
if (part.length > 63) {
return false;
}
}
if (domain.length > 253) {
return false;
}
return true;
}
const formSchema = z.object({
baseDomain: z.string().min(1, "Domain is required"),
baseDomain: z
.string()
.min(1, "Domain is required")
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
.transform((val) => toPunycode(val)),
type: z.enum(["ns", "cname", "wildcard"])
});
@@ -109,8 +159,14 @@ export default function CreateDomainForm({
}
}
const domainType = form.watch("type");
const baseDomain = form.watch("baseDomain");
const domainInputValue = form.watch("baseDomain") || "";
const punycodePreview = useMemo(() => {
if (!domainInputValue) return "";
const punycode = toPunycode(domainInputValue);
return punycode !== domainInputValue.toLowerCase() ? punycode : "";
}, [domainInputValue]);
let domainOptions: any = [];
if (build == "enterprise" || build == "saas") {
@@ -182,10 +238,23 @@ export default function CreateDomainForm({
<FormLabel>{t("domain")}</FormLabel>
<FormControl>
<Input
placeholder="example.com"
placeholder="example.com, café.com, 日本.com"
{...field}
/>
</FormControl>
{punycodePreview && (
<FormDescription className="flex items-center gap-2 text-xs">
<Alert>
<Globe className="h-4 w-4" />
<AlertTitle>{t("internationaldomaindetected")}</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-1">
<p>{t("willbestoredas")} <code className="font-mono px-1 py-0.5 rounded">{punycodePreview}</code></p>
</div>
</AlertDescription>
</Alert>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
@@ -206,66 +275,73 @@ export default function CreateDomainForm({
<div className="space-y-4">
{createdDomain.nsRecords &&
createdDomain.nsRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainNsRecords")}
</h3>
<InfoSections cols={1}>
<InfoSection>
<InfoSectionTitle>
{t("createDomainRecord")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
NS
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<span className="text-sm font-mono">
{baseDomain}
</span>
</div>
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
{createdDomain.nsRecords.map(
(
nsRecord,
index
) => (
<div
className="flex justify-between items-center"
key={index}
>
<CopyToClipboard
text={
nsRecord
}
/>
createdDomain.nsRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainNsRecords")}
</h3>
<InfoSections cols={1}>
<InfoSection>
<InfoSectionTitle>
{t("createDomainRecord")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
NS
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(baseDomain)}
</span>
{fromPunycode(baseDomain) !== baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({baseDomain})
</span>
)}
</div>
)
)}
</div>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</div>
)}
</div>
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
{createdDomain.nsRecords.map(
(
nsRecord,
index
) => (
<div
className="flex justify-between items-center"
key={index}
>
<CopyToClipboard
text={
nsRecord
}
/>
</div>
)
)}
</div>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</div>
)}
{createdDomain.cnameRecords &&
createdDomain.cnameRecords.length > 0 && (
@@ -307,11 +383,16 @@ export default function CreateDomainForm({
"createDomainName"
)}
</span>
<span className="text-sm font-mono">
{
cnameRecord.baseDomain
}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(cnameRecord.baseDomain)}
</span>
{fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({cnameRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
@@ -374,11 +455,16 @@ export default function CreateDomainForm({
"createDomainName"
)}
</span>
<span className="text-sm font-mono">
{
aRecord.baseDomain
}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(aRecord.baseDomain)}
</span>
{fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({aRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
@@ -390,7 +476,7 @@ export default function CreateDomainForm({
{
aRecord.value
}
</span>
</span>
</div>
</div>
</InfoSectionContent>
@@ -440,11 +526,16 @@ export default function CreateDomainForm({
"createDomainName"
)}
</span>
<span className="text-sm font-mono">
{
txtRecord.baseDomain
}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(txtRecord.baseDomain)}
</span>
{fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({txtRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
@@ -513,4 +604,4 @@ export default function CreateDomainForm({
</CredenzaContent>
</Credenza>
);
}
}

View File

@@ -25,6 +25,7 @@ export function DomainsDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="domains-table"
title={t("domains")}
searchPlaceholder={t("domainsSearch")}
searchColumn="baseDomain"

View File

@@ -9,6 +9,7 @@ import { GetOrgResponse } from "@server/routers/org";
import { redirect } from "next/navigation";
import OrgProvider from "@app/providers/OrgProvider";
import { ListDomainsResponse } from "@server/routers/domain";
import { toUnicode } from 'punycode';
type Props = {
params: Promise<{ orgId: string }>;
@@ -22,7 +23,13 @@ export default async function DomainsPage(props: Props) {
const res = await internal.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${params.orgId}/domains`, await authCookieHeader());
domains = res.data.data.domains as DomainRow[];
const rawDomains = res.data.data.domains as DomainRow[];
domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
}));
} catch (e) {
console.error(e);
}

View File

@@ -99,6 +99,43 @@ type ResourcesTableProps = {
defaultView?: "proxy" | "internal";
};
const STORAGE_KEYS = {
PAGE_SIZE: 'datatable-page-size',
getTablePageSize: (tableId?: string) =>
tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE
};
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
if (typeof window === 'undefined') return defaultSize;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = parseInt(stored, 10);
if (parsed > 0 && parsed <= 1000) {
return parsed;
}
}
} catch (error) {
console.warn('Failed to read page size from localStorage:', error);
}
return defaultSize;
};
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
if (typeof window === 'undefined') return;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
localStorage.setItem(key, pageSize.toString());
} catch (error) {
console.warn('Failed to save page size to localStorage:', error);
}
};
export default function ResourcesTable({
resources,
internalResources,
@@ -113,6 +150,13 @@ export default function ResourcesTable({
const api = createApiClient({ env });
const [proxyPageSize, setProxyPageSize] = useState<number>(() =>
getStoredPageSize('proxy-resources', 20)
);
const [internalPageSize, setInternalPageSize] = useState<number>(() =>
getStoredPageSize('internal-resources', 20)
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
@@ -559,7 +603,7 @@ export default function ResourcesTable({
onGlobalFilterChange: setProxyGlobalFilter,
initialState: {
pagination: {
pageSize: 20,
pageSize: proxyPageSize,
pageIndex: 0
}
},
@@ -582,7 +626,7 @@ export default function ResourcesTable({
onGlobalFilterChange: setInternalGlobalFilter,
initialState: {
pagination: {
pageSize: 20,
pageSize: internalPageSize,
pageIndex: 0
}
},
@@ -593,6 +637,16 @@ export default function ResourcesTable({
}
});
const handleProxyPageSizeChange = (newPageSize: number) => {
setProxyPageSize(newPageSize);
setStoredPageSize(newPageSize, 'proxy-resources');
};
const handleInternalPageSizeChange = (newPageSize: number) => {
setInternalPageSize(newPageSize);
setStoredPageSize(newPageSize, 'internal-resources');
};
return (
<>
{selectedResource && (
@@ -761,7 +815,10 @@ export default function ResourcesTable({
</TableBody>
</Table>
<div className="mt-4">
<DataTablePagination table={proxyTable} />
<DataTablePagination
table={proxyTable}
onPageSizeChange={handleProxyPageSizeChange}
/>
</div>
</TabsContent>
<TabsContent value="internal">
@@ -861,6 +918,7 @@ export default function ResourcesTable({
<div className="mt-4">
<DataTablePagination
table={internalTable}
onPageSizeChange={handleInternalPageSizeChange}
/>
</div>
</TabsContent>

View File

@@ -9,6 +9,7 @@ import {
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { toUnicode } from "punycode";
interface DomainOption {
baseDomain: string;
@@ -91,7 +92,7 @@ export default function CustomDomainInput({
key={option.domainId}
value={option.domainId}
>
.{option.baseDomain}
.{toUnicode(option.baseDomain)}
</SelectItem>
))}
</SelectContent>

View File

@@ -12,15 +12,19 @@ import {
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { toUnicode } from 'punycode';
type ResourceInfoBoxType = {};
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
const { resource, authInfo } = useResourceContext();
const t = useTranslations();
const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
return (
<Alert>
@@ -34,9 +38,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ? (
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>{t("protected")}</span>

View File

@@ -53,6 +53,9 @@ import {
import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react";
import { build } from "@server/build";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { DomainRow } from "../../../domains/DomainsTable";
import { toASCII, toUnicode } from "punycode";
export default function GeneralForm() {
const [formKey, setFormKey] = useState(0);
@@ -79,12 +82,13 @@ export default function GeneralForm() {
const [loadingPage, setLoadingPage] = useState(true);
const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
} | null>(null);
const GeneralFormSchema = z
@@ -153,7 +157,11 @@ export default function GeneralForm() {
});
if (res?.status === 200) {
const domains = res.data.data.domains;
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
}));
setBaseDomains(domains);
setFormKey((key) => key + 1);
}
@@ -178,7 +186,7 @@ export default function GeneralForm() {
{
enabled: data.enabled,
name: data.name,
subdomain: data.subdomain,
subdomain: data.subdomain ? toASCII(data.subdomain) : undefined,
domainId: data.domainId,
proxyPort: data.proxyPort,
// ...(!resource.http && {
@@ -317,10 +325,10 @@ export default function GeneralForm() {
.target
.value
? parseInt(
e
.target
.value
)
e
.target
.value
)
: undefined
)
}
@@ -441,7 +449,8 @@ export default function GeneralForm() {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
setSelectedDomain(selected);
}}
@@ -454,18 +463,23 @@ export default function GeneralForm() {
<Button
onClick={() => {
if (selectedDomain) {
setResourceFullDomain(
selectedDomain.fullDomain
);
form.setValue(
"domainId",
selectedDomain.domainId
);
form.setValue(
"subdomain",
selectedDomain.subdomain
);
const sanitizedSubdomain = selectedDomain.subdomain
? finalizeSubdomainSanitize(selectedDomain.subdomain)
: "";
const sanitizedFullDomain = sanitizedSubdomain
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
: selectedDomain.baseDomain;
setResourceFullDomain(sanitizedFullDomain);
form.setValue("domainId", selectedDomain.domainId);
form.setValue("subdomain", sanitizedSubdomain);
setEditDomainOpen(false);
toast({
description: `Final domain: ${sanitizedFullDomain}`,
});
}
}}
>

View File

@@ -94,6 +94,7 @@ import {
CommandList
} from "@app/components/ui/command";
import { Badge } from "@app/components/ui/badge";
import { parseHostTarget } from "@app/lib/parseHostTarget";
const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid),
@@ -417,11 +418,11 @@ export default function ReverseProxyTargets(props: {
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site?.type || null
}
...target,
...data,
updated: true,
siteType: site?.type || null
}
: target
)
);
@@ -545,7 +546,7 @@ export default function ReverseProxyTargets(props: {
className={cn(
"justify-between flex-1",
!row.original.siteId &&
"text-muted-foreground"
"text-muted-foreground"
)}
>
{row.original.siteId
@@ -614,31 +615,31 @@ export default function ReverseProxyTargets(props: {
},
...(resource.http
? [
{
accessorKey: "method",
header: t("method"),
cell: ({ row }: { row: Row<LocalTarget> }) => (
<Select
defaultValue={row.original.method ?? ""}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger>
{row.original.method}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)
}
]
{
accessorKey: "method",
header: t("method"),
cell: ({ row }: { row: Row<LocalTarget> }) => (
<Select
defaultValue={row.original.method ?? ""}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger>
{row.original.method}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)
}
]
: []),
{
accessorKey: "ip",
@@ -647,12 +648,33 @@ export default function ReverseProxyTargets(props: {
<Input
defaultValue={row.original.ip}
className="min-w-[150px]"
onBlur={(e) =>
updateTarget(row.original.targetId, {
...row.original,
ip: e.target.value
})
}
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol = /^(https?|h2c):\/\//.test(input);
const hasPort = /:\d+(?:\/|$)/.test(input);
if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
updateTarget(row.original.targetId, {
...row.original,
method: hasProtocol ? parsed.protocol : row.original.method,
ip: parsed.host,
port: hasPort ? parsed.port : row.original.port
});
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: input
});
}
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: input
});
}
}}
/>
)
},
@@ -785,21 +807,21 @@ export default function ReverseProxyTargets(props: {
className={cn(
"justify-between flex-1",
!field.value &&
"text-muted-foreground"
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)
?.name
(
site
) =>
site.siteId ===
field.value
)
?.name
: t(
"siteSelect"
)}
"siteSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
@@ -865,18 +887,18 @@ export default function ReverseProxyTargets(props: {
);
return selectedSite &&
selectedSite.type ===
"newt" ? (() => {
const dockerState = getDockerStateForSite(selectedSite.siteId);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={dockerState.isAvailable}
onContainerSelect={handleContainerSelect}
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
/>
);
})() : null;
"newt" ? (() => {
const dockerState = getDockerStateForSite(selectedSite.siteId);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={dockerState.isAvailable}
onContainerSelect={handleContainerSelect}
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
/>
);
})() : null;
})()}
</div>
<FormMessage />
@@ -942,11 +964,32 @@ export default function ReverseProxyTargets(props: {
name="ip"
render={({ field }) => (
<FormItem className="relative">
<FormLabel>
{t("targetAddr")}
</FormLabel>
<FormLabel>{t("targetAddr")}</FormLabel>
<FormControl>
<Input id="ip" {...field} />
<Input
id="ip"
{...field}
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol = /^(https?|h2c):\/\//.test(input);
const hasPort = /:\d+(?:\/|$)/.test(input);
if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
if (hasProtocol || !addTargetForm.getValues("method")) {
addTargetForm.setValue("method", parsed.protocol);
}
addTargetForm.setValue("ip", parsed.host);
if (hasPort || !addTargetForm.getValues("port")) {
addTargetForm.setValue("port", parsed.port);
}
}
} else {
field.onBlur();
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -1048,12 +1091,12 @@ export default function ReverseProxyTargets(props: {
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}

View File

@@ -88,6 +88,9 @@ import { ArrayElement } from "@server/types/ArrayElement";
import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from 'punycode';
import { DomainRow } from "../../domains/DomainsTable";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
@@ -164,12 +167,12 @@ export default function Page() {
...(!env.flags.allowRawResources
? []
: [
{
id: "raw" as ResourceType,
title: t("resourceRaw"),
description: t("resourceRawDescription")
}
])
{
id: "raw" as ResourceType,
title: t("resourceRaw"),
description: t("resourceRawDescription")
}
])
];
const baseForm = useForm<BaseResourceFormValues>({
@@ -301,11 +304,11 @@ export default function Page() {
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site?.type || null
}
...target,
...data,
updated: true,
siteType: site?.type || null
}
: target
)
);
@@ -326,7 +329,7 @@ export default function Page() {
if (isHttp) {
const httpData = httpForm.getValues();
Object.assign(payload, {
subdomain: httpData.subdomain,
subdomain: httpData.subdomain ? toASCII(httpData.subdomain) : undefined,
domainId: httpData.domainId,
protocol: "tcp"
});
@@ -468,7 +471,11 @@ export default function Page() {
});
if (res?.status === 200) {
const domains = res.data.data.domains;
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
}));
setBaseDomains(domains);
// if (domains.length) {
// httpForm.setValue("domainId", domains[0].domainId);
@@ -520,7 +527,7 @@ export default function Page() {
className={cn(
"justify-between flex-1",
!row.original.siteId &&
"text-muted-foreground"
"text-muted-foreground"
)}
>
{row.original.siteId
@@ -589,31 +596,31 @@ export default function Page() {
},
...(baseForm.watch("http")
? [
{
accessorKey: "method",
header: t("method"),
cell: ({ row }: { row: Row<LocalTarget> }) => (
<Select
defaultValue={row.original.method ?? ""}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger>
{row.original.method}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)
}
]
{
accessorKey: "method",
header: t("method"),
cell: ({ row }: { row: Row<LocalTarget> }) => (
<Select
defaultValue={row.original.method ?? ""}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger>
{row.original.method}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)
}
]
: []),
{
accessorKey: "ip",
@@ -622,12 +629,23 @@ export default function Page() {
<Input
defaultValue={row.original.ip}
className="min-w-[150px]"
onBlur={(e) =>
updateTarget(row.original.targetId, {
...row.original,
ip: e.target.value
})
}
onBlur={(e) => {
const parsed = parseHostTarget(e.target.value);
if (parsed) {
updateTarget(row.original.targetId, {
...row.original,
method: parsed.protocol,
ip: parsed.host,
port: parsed.port ? Number(parsed.port) : undefined,
});
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: e.target.value,
});
}
}}
/>
)
},
@@ -909,10 +927,10 @@ export default function Page() {
.target
.value
? parseInt(
e
.target
.value
)
e
.target
.value
)
: undefined
)
}
@@ -1015,21 +1033,21 @@ export default function Page() {
className={cn(
"justify-between flex-1",
!field.value &&
"text-muted-foreground"
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)
?.name
(
site
) =>
site.siteId ===
field.value
)
?.name
: t(
"siteSelect"
)}
"siteSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
@@ -1097,18 +1115,18 @@ export default function Page() {
);
return selectedSite &&
selectedSite.type ===
"newt" ? (() => {
const dockerState = getDockerStateForSite(selectedSite.siteId);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={dockerState.isAvailable}
onContainerSelect={handleContainerSelect}
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
/>
);
})() : null;
"newt" ? (() => {
const dockerState = getDockerStateForSite(selectedSite.siteId);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={dockerState.isAvailable}
onContainerSelect={handleContainerSelect}
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
/>
);
})() : null;
})()}
</div>
<FormMessage />
@@ -1176,21 +1194,25 @@ export default function Page() {
)}
<FormField
control={
addTargetForm.control
}
control={addTargetForm.control}
name="ip"
render={({ field }) => (
<FormItem className="relative">
<FormLabel>
{t(
"targetAddr"
)}
</FormLabel>
<FormLabel>{t("targetAddr")}</FormLabel>
<FormControl>
<Input
id="ip"
{...field}
onBlur={(e) => {
const parsed = parseHostTarget(e.target.value);
if (parsed) {
addTargetForm.setValue("method", parsed.protocol);
addTargetForm.setValue("ip", parsed.host);
addTargetForm.setValue("port", parsed.port);
} else {
field.onBlur();
}
}}
/>
</FormControl>
<FormMessage />
@@ -1270,12 +1292,12 @@ export default function Page() {
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}

View File

@@ -14,6 +14,7 @@ import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { toUnicode } from "punycode";
type ResourcesPageProps = {
params: Promise<{ orgId: string }>;
@@ -75,7 +76,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
id: resource.resourceId,
name: resource.name,
orgId: params.orgId,
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
protocol: resource.protocol,
proxyPort: resource.proxyPort,
http: resource.http,

View File

@@ -67,6 +67,7 @@ import {
} from "@app/components/ui/collapsible";
import AccessTokenSection from "./AccessTokenUsage";
import { useTranslations } from "next-intl";
import { toUnicode } from 'punycode';
type FormProps = {
open: boolean;
@@ -159,7 +160,7 @@ export default function CreateShareLinkForm({
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
}))
);
}

View File

@@ -24,6 +24,7 @@ export function ShareLinksDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="shareLinks-table"
title={t('shareLinks')}
searchPlaceholder={t('shareSearch')}
searchColumn="name"

View File

@@ -26,6 +26,7 @@ export function SitesDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="sites-table"
title={t('sites')}
searchPlaceholder={t('searchSitesProgress')}
searchColumn="name"

View File

@@ -47,6 +47,7 @@ export function ApiKeysDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="apiKeys-table"
title={t('apiKeys')}
searchPlaceholder={t('searchApiKeys')}
searchColumn="name"

View File

@@ -21,6 +21,7 @@ export function IdpDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="idp-table"
title={t('idp')}
searchPlaceholder={t('idpSearch')}
searchColumn="name"

View File

@@ -22,6 +22,7 @@ export function PolicyDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="orgPolicies-table"
title={t('orgPolicies')}
searchPlaceholder={t('orgPoliciesSearch')}
searchColumn="orgId"

View File

@@ -136,6 +136,7 @@ export function LicenseKeysDataTable({
<DataTable
columns={columns}
data={licenseKeys}
persistPageSize="licenseKeys-table"
title={t('licenseKeys')}
searchPlaceholder={t('licenseKeySearch')}
searchColumn="licenseKey"

View File

@@ -1,3 +1,5 @@
"use client";
import {
SettingsContainer,
SettingsSection,
@@ -18,30 +20,27 @@ import {
ExternalLink
} from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
export default function ManagedPage() {
const t = useTranslations();
export default async function ManagedPage() {
return (
<>
<SettingsSectionTitle
title="Managed Self-Hosted"
description="More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles"
title={t("managedSelfHosted.title")}
description={t("managedSelfHosted.description")}
/>
<SettingsContainer>
<SettingsSection>
<SettingsSectionBody>
<p className="text-muted-foreground mb-4">
<strong>Managed Self-Hosted Pangolin</strong> is a
deployment option designed for people who want
simplicity and extra reliability while still keeping
their data private and self-hosted.
<strong>{t("managedSelfHosted.introTitle")}</strong>{" "}
{t("managedSelfHosted.introDescription")}
</p>
<p className="text-muted-foreground mb-6">
With this option, you still run your own Pangolin
node your tunnels, SSL termination, and traffic
all stay on your server. The difference is that
management and monitoring are handled through our
cloud dashboard, which unlocks a number of benefits:
{t("managedSelfHosted.introDetail")}
</p>
<div className="grid gap-4 md:grid-cols-2 py-4">
@@ -50,13 +49,14 @@ export default async function ManagedPage() {
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
Simpler operations
{t(
"managedSelfHosted.benefitSimplerOperations.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
No need to run your own mail server
or set up complex alerting. You'll
get health checks and downtime
alerts out of the box.
{t(
"managedSelfHosted.benefitSimplerOperations.description"
)}
</p>
</div>
</div>
@@ -65,13 +65,14 @@ export default async function ManagedPage() {
<RefreshCw className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
Automatic updates
{t(
"managedSelfHosted.benefitAutomaticUpdates.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
The cloud dashboard evolves quickly,
so you get new features and bug
fixes without having to manually
pull new containers every time.
{t(
"managedSelfHosted.benefitAutomaticUpdates.description"
)}
</p>
</div>
</div>
@@ -80,12 +81,14 @@ export default async function ManagedPage() {
<Wrench className="w-5 h-5 text-orange-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
Less maintenance
{t(
"managedSelfHosted.benefitLessMaintenance.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
No database migrations, backups, or
extra infrastructure to manage. We
handle that in the cloud.
{t(
"managedSelfHosted.benefitLessMaintenance.description"
)}
</p>
</div>
</div>
@@ -96,13 +99,14 @@ export default async function ManagedPage() {
<Activity className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
Cloud failover
{t(
"managedSelfHosted.benefitCloudFailover.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
If your node goes down, your tunnels
can temporarily fail over to our
cloud points of presence until you
bring it back online.
{t(
"managedSelfHosted.benefitCloudFailover.description"
)}
</p>
</div>
</div>
@@ -110,12 +114,14 @@ export default async function ManagedPage() {
<Shield className="w-5 h-5 text-indigo-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
High availability (PoPs)
{t(
"managedSelfHosted.benefitHighAvailability.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
You can also attach multiple nodes
to your account for redundancy and
better performance.
{t(
"managedSelfHosted.benefitHighAvailability.description"
)}
</p>
</div>
</div>
@@ -124,13 +130,14 @@ export default async function ManagedPage() {
<Zap className="w-5 h-5 text-yellow-500 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium">
Future enhancements
{t(
"managedSelfHosted.benefitFutureEnhancements.title"
)}
</h4>
<p className="text-sm text-muted-foreground">
We're planning to add more
analytics, alerting, and management
tools to make your deployment even
more robust.
{t(
"managedSelfHosted.benefitFutureEnhancements.description"
)}
</p>
</div>
</div>
@@ -141,15 +148,14 @@ export default async function ManagedPage() {
variant="neutral"
className="flex items-center gap-1"
>
Read the docs to learn more about the Managed
Self-Hosted option in our{" "}
{t("managedSelfHosted.docsAlert.text")}{" "}
<Link
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
href="https://docs.digpangolin.com/manage/managed"
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-primary flex items-center gap-1"
>
documentation
{t("managedSelfHosted.docsAlert.documentation")}
<ExternalLink className="w-4 h-4" />
</Link>
.
@@ -157,13 +163,13 @@ export default async function ManagedPage() {
</SettingsSectionBody>
<SettingsSectionFooter>
<Link
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
href="https://docs.digpangolin.com/self-host/convert-managed"
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-primary flex items-center gap-1"
>
<Button>
Convert This Node to Managed Self-Hosted
{t("managedSelfHosted.convertButton")}
</Button>
</Link>
</SettingsSectionFooter>

View File

@@ -22,6 +22,7 @@ export function UsersDataTable<TData, TValue>({
<DataTable
columns={columns}
data={data}
persistPageSize="userServer-table"
title={t('userServer')}
searchPlaceholder={t('userSearch')}
searchColumn="email"

View File

@@ -48,6 +48,7 @@ export default async function InvitePage(props: {
)
.catch((e) => {
error = formatAxiosError(e);
console.error(error);
});
if (res && res.status === 200) {
@@ -55,13 +56,13 @@ export default async function InvitePage(props: {
}
function cardType() {
if (error.includes(t('inviteErrorWrongUser'))) {
if (error.includes("Invite is not for this user")) {
return "wrong_user";
} else if (
error.includes(t('inviteErrorUserNotExists'))
error.includes("User does not exist. Please create an account first.")
) {
return "user_does_not_exist";
} else if (error.includes(t('inviteErrorLoginRequired'))) {
} else if (error.includes("You must be logged in to accept an invite")) {
return "not_logged_in";
} else {
return "rejected";

View File

@@ -18,28 +18,38 @@ import { useTranslations } from "next-intl";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
onPageSizeChange?: (pageSize: number) => void;
}
export function DataTablePagination<TData>({
table
table,
onPageSizeChange
}: DataTablePaginationProps<TData>) {
const t = useTranslations();
const handlePageSizeChange = (value: string) => {
const newPageSize = Number(value);
table.setPageSize(newPageSize);
// Call the callback if provided (for persistence)
if (onPageSizeChange) {
onPageSizeChange(newPageSize);
}
};
return (
<div className="flex items-center justify-between text-muted-foreground">
<div className="flex items-center space-x-2">
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
onValueChange={handlePageSizeChange}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
<SelectContent side="bottom">
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}

View File

@@ -37,6 +37,13 @@ import { cn } from "@/lib/cn";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
sanitizeInputRaw,
finalizeSubdomainSanitize,
validateByDomainType,
isValidSubdomainStructure
} from "@/lib/subdomain-utils";
import { toUnicode } from "punycode";
type OrganizationDomain = {
domainId: string;
@@ -120,6 +127,7 @@ export default function DomainPicker2({
)
.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
type: domain.type as "ns" | "cname" | "wildcard"
}));
setOrganizationDomains(domains);
@@ -255,108 +263,64 @@ export default function DomainPicker2({
const dropdownOptions = generateDropdownOptions();
const validateSubdomain = (
subdomain: string,
baseDomain: DomainOption
): boolean => {
if (!baseDomain) return false;
const finalizeSubdomain = (sub: string, base: DomainOption): string => {
const sanitized = finalizeSubdomainSanitize(sub);
if (baseDomain.type === "provided-search") {
return /^[a-zA-Z0-9-]+$/.test(subdomain);
if (!sanitized) {
toast({
variant: "destructive",
title: "Invalid subdomain",
description: `The input "${sub}" was removed because it's not valid.`,
});
return "";
}
if (baseDomain.type === "organization") {
if (baseDomain.domainType === "cname") {
return subdomain === "";
} else if (baseDomain.domainType === "ns") {
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
} else if (baseDomain.domainType === "wildcard") {
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
}
const ok = validateByDomainType(sanitized, {
type: base.type === "provided-search" ? "provided-search" : "organization",
domainType: base.domainType
});
if (!ok) {
toast({
variant: "destructive",
title: "Invalid subdomain",
description: `"${sub}" could not be made valid for ${base.domain}.`,
});
return "";
}
return false;
};
// Handle base domain selection
const handleBaseDomainSelect = (option: DomainOption) => {
setSelectedBaseDomain(option);
setOpen(false);
if (option.domainType === "cname") {
setSubdomainInput("");
}
if (option.type === "provided-search") {
setUserInput("");
setAvailableOptions([]);
setSelectedProvidedDomain(null);
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
if (sub !== sanitized) {
toast({
title: "Subdomain sanitized",
description: `"${sub}" was corrected to "${sanitized}"`,
});
}
if (option.type === "organization") {
if (option.domainType === "cname") {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
} else {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
}
}
return sanitized;
};
const handleSubdomainChange = (value: string) => {
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, "");
setSubdomainInput(validInput);
const raw = sanitizeInputRaw(value);
setSubdomainInput(raw);
setSelectedProvidedDomain(null);
if (selectedBaseDomain && selectedBaseDomain.type === "organization") {
const isValid = validateSubdomain(validInput, selectedBaseDomain);
if (isValid) {
const fullDomain = validInput
? `${validInput}.${selectedBaseDomain.domain}`
: selectedBaseDomain.domain;
onDomainChange?.({
domainId: selectedBaseDomain.domainId!,
type: "organization",
subdomain: validInput || undefined,
fullDomain: fullDomain,
baseDomain: selectedBaseDomain.domain
});
} else if (validInput === "") {
onDomainChange?.({
domainId: selectedBaseDomain.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: selectedBaseDomain.domain,
baseDomain: selectedBaseDomain.domain
});
}
if (selectedBaseDomain?.type === "organization") {
const fullDomain = raw
? `${raw}.${selectedBaseDomain.domain}`
: selectedBaseDomain.domain;
onDomainChange?.({
domainId: selectedBaseDomain.domainId!,
type: "organization",
subdomain: raw || undefined,
fullDomain,
baseDomain: selectedBaseDomain.domain
});
}
};
const handleProvidedDomainInputChange = (value: string) => {
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, "");
setUserInput(validInput);
// Clear selected domain when user types
setUserInput(value);
if (selectedProvidedDomain) {
setSelectedProvidedDomain(null);
onDomainChange?.({
@@ -369,6 +333,43 @@ export default function DomainPicker2({
}
};
const handleBaseDomainSelect = (option: DomainOption) => {
let sub = subdomainInput;
if (sub && sub.trim() !== "") {
sub = finalizeSubdomain(sub, option) || "";
setSubdomainInput(sub);
} else {
sub = "";
setSubdomainInput("");
}
if (option.type === "provided-search") {
setUserInput("");
setAvailableOptions([]);
setSelectedProvidedDomain(null);
}
setSelectedBaseDomain(option);
setOpen(false);
if (option.domainType === "cname") {
sub = "";
setSubdomainInput("");
}
const fullDomain = sub ? `${sub}.${option.domain}` : option.domain;
onDomainChange?.({
domainId: option.domainId || "",
domainNamespaceId: option.domainNamespaceId,
type: option.type === "provided-search" ? "provided" : "organization",
subdomain: sub || undefined,
fullDomain,
baseDomain: option.domain
});
};
const handleProvidedDomainSelect = (option: AvailableOption) => {
setSelectedProvidedDomain(option);
@@ -380,15 +381,19 @@ export default function DomainPicker2({
domainId: option.domainId,
domainNamespaceId: option.domainNamespaceId,
type: "provided",
subdomain: subdomain,
subdomain,
fullDomain: option.fullDomain,
baseDomain: baseDomain
baseDomain
});
};
const isSubdomainValid = selectedBaseDomain
? validateSubdomain(subdomainInput, selectedBaseDomain)
const isSubdomainValid = selectedBaseDomain && subdomainInput
? validateByDomainType(subdomainInput, {
type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization",
domainType: selectedBaseDomain.domainType
})
: true;
const showSubdomainInput =
selectedBaseDomain &&
selectedBaseDomain.type === "organization" &&
@@ -396,7 +401,7 @@ export default function DomainPicker2({
const showProvidedDomainSearch =
selectedBaseDomain?.type === "provided-search";
const sortedAvailableOptions = availableOptions.sort((a, b) => {
const sortedAvailableOptions = [...availableOptions].sort((a, b) => {
const comparison = a.fullDomain.localeCompare(b.fullDomain);
return sortOrder === "asc" ? comparison : -comparison;
});
@@ -408,6 +413,7 @@ export default function DomainPicker2({
const hasMoreProvided =
sortedAvailableOptions.length > providedDomainsShown;
return (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -426,16 +432,16 @@ export default function DomainPicker2({
showProvidedDomainSearch
? ""
: showSubdomainInput
? ""
: t("domainPickerNotAvailableForCname")
? ""
: t("domainPickerNotAvailableForCname")
}
disabled={
!showSubdomainInput && !showProvidedDomainSearch
}
className={cn(
!isSubdomainValid &&
subdomainInput &&
"border-red-500"
subdomainInput &&
"border-red-500 focus:border-red-500"
)}
onChange={(e) => {
if (showProvidedDomainSearch) {
@@ -445,6 +451,11 @@ export default function DomainPicker2({
}
}}
/>
{showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && (
<p className="text-sm text-red-500">
This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.
</p>
)}
{showSubdomainInput && !subdomainInput && (
<p className="text-sm text-muted-foreground">
{t("domainPickerEnterSubdomainOrLeaveBlank")}
@@ -470,7 +481,7 @@ export default function DomainPicker2({
{selectedBaseDomain ? (
<div className="flex items-center space-x-2 min-w-0 flex-1">
{selectedBaseDomain.type ===
"organization" ? null : (
"organization" ? null : (
<Zap className="h-4 w-4 flex-shrink-0" />
)}
<span className="truncate">
@@ -564,67 +575,67 @@ export default function DomainPicker2({
</CommandGroup>
{(build === "saas" ||
build === "enterprise") && (
<CommandSeparator className="my-2" />
)}
<CommandSeparator className="my-2" />
)}
</>
)}
{(build === "saas" ||
build === "enterprise") && (
<CommandGroup
heading={
build === "enterprise"
? t(
"domainPickerProvidedDomains"
)
: t("domainPickerFreeDomains")
}
className="py-2"
>
<CommandList>
<CommandItem
key="provided-search"
onSelect={() =>
handleBaseDomainSelect({
id: "provided-search",
domain:
build ===
"enterprise"
<CommandGroup
heading={
build === "enterprise"
? t(
"domainPickerProvidedDomains"
)
: t("domainPickerFreeDomains")
}
className="py-2"
>
<CommandList>
<CommandItem
key="provided-search"
onSelect={() =>
handleBaseDomainSelect({
id: "provided-search",
domain:
build ===
"enterprise"
? "Provided Domain"
: "Free Provided Domain",
type: "provided-search"
})
}
className="mx-2 rounded-md"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Zap className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{build === "enterprise"
? "Provided Domain"
: "Free Provided Domain",
type: "provided-search"
})
}
className="mx-2 rounded-md"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Zap className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{build === "enterprise"
? "Provided Domain"
: "Free Provided Domain"}
</span>
<span className="text-xs text-muted-foreground">
{t(
"domainPickerSearchForAvailableDomains"
: "Free Provided Domain"}
</span>
<span className="text-xs text-muted-foreground">
{t(
"domainPickerSearchForAvailableDomains"
)}
</span>
</div>
<Check
className={cn(
"h-4 w-4 text-primary",
selectedBaseDomain?.id ===
"provided-search"
? "opacity-100"
: "opacity-0"
)}
</span>
</div>
<Check
className={cn(
"h-4 w-4 text-primary",
selectedBaseDomain?.id ===
"provided-search"
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
</CommandList>
</CommandGroup>
)}
/>
</CommandItem>
</CommandList>
</CommandGroup>
)}
</Command>
</PopoverContent>
</Popover>
@@ -680,7 +691,7 @@ export default function DomainPicker2({
htmlFor={option.domainNamespaceId}
data-state={
selectedProvidedDomain?.domainNamespaceId ===
option.domainNamespaceId
option.domainNamespaceId
? "checked"
: "unchecked"
}
@@ -760,4 +771,4 @@ function debounce<T extends (...args: any[]) => any>(
func(...args);
}, wait);
};
}
}

View File

@@ -7,7 +7,7 @@ import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { Button } from "@app/components/ui/button";
import { Menu, Server } from "lucide-react";
import { ExternalLink, Menu, Server } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
@@ -117,7 +117,15 @@ export function LayoutMobileMenu({
<SupporterStatus />
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
<Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
v{env.app.version}
<ExternalLink size={12} />
</Link>
</div>
)}
</div>

View File

@@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react";
import { FaDiscord, FaGithub } from "react-icons/fa";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
@@ -151,7 +152,7 @@ export function LayoutSidebar({
{!isUnlocked()
? t("communityEdition")
: t("commercialEdition")}
<ExternalLink size={12} />
<FaGithub size={12} />
</Link>
</div>
<div className="text-xs text-muted-foreground ">
@@ -165,9 +166,28 @@ export function LayoutSidebar({
<BookOpenText size={12} />
</Link>
</div>
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://discord.gg/HCJR8Xhme4"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
Discord
<FaDiscord size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
<Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
v{env.app.version}
<ExternalLink size={12} />
</Link>
</div>
)}
</div>

View File

@@ -24,6 +24,7 @@ function getActionsCategories(root: boolean) {
[t('actionUpdateOrg')]: "updateOrg",
[t('actionGetOrgUser')]: "getOrgUser",
[t('actionInviteUser')]: "inviteUser",
[t('actionListInvitations')]: "listInvitations",
[t('actionRemoveUser')]: "removeUser",
[t('actionListUsers')]: "listUsers",
[t('actionListOrgDomains')]: "listOrgDomains"

View File

@@ -20,7 +20,7 @@ import {
TableRow
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search, RefreshCw } from "lucide-react";
@@ -32,7 +32,42 @@ import {
} from "@app/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
const STORAGE_KEYS = {
PAGE_SIZE: 'datatable-page-size',
getTablePageSize: (tableId?: string) =>
tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE
};
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
if (typeof window === 'undefined') return defaultSize;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = parseInt(stored, 10);
// Validate that it's a reasonable page size
if (parsed > 0 && parsed <= 1000) {
return parsed;
}
}
} catch (error) {
console.warn('Failed to read page size from localStorage:', error);
}
return defaultSize;
};
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
if (typeof window === 'undefined') return;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
localStorage.setItem(key, pageSize.toString());
} catch (error) {
console.warn('Failed to save page size to localStorage:', error);
}
};
type TabFilter = {
id: string;
@@ -56,6 +91,8 @@ type DataTableProps<TData, TValue> = {
};
tabs?: TabFilter[];
defaultTab?: string;
persistPageSize?: boolean | string;
defaultPageSize?: number;
};
export function DataTable<TData, TValue>({
@@ -70,8 +107,23 @@ export function DataTable<TData, TValue>({
searchColumn = "name",
defaultSort,
tabs,
defaultTab
defaultTab,
persistPageSize = false,
defaultPageSize = 20
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
// Determine table identifier for storage
const tableId = typeof persistPageSize === 'string' ? persistPageSize : undefined;
// Initialize page size from storage or default
const [pageSize, setPageSize] = useState<number>(() => {
if (persistPageSize) {
return getStoredPageSize(tableId, defaultPageSize);
}
return defaultPageSize;
});
const [sorting, setSorting] = useState<SortingState>(
defaultSort ? [defaultSort] : []
);
@@ -80,7 +132,6 @@ export function DataTable<TData, TValue>({
const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || ""
);
const t = useTranslations();
// Apply tab filter to data
const filteredData = useMemo(() => {
@@ -108,7 +159,7 @@ export function DataTable<TData, TValue>({
onGlobalFilterChange: setGlobalFilter,
initialState: {
pagination: {
pageSize: 20,
pageSize: pageSize,
pageIndex: 0
}
},
@@ -119,12 +170,35 @@ export function DataTable<TData, TValue>({
}
});
useEffect(() => {
const currentPageSize = table.getState().pagination.pageSize;
if (currentPageSize !== pageSize) {
table.setPageSize(pageSize);
// Persist to localStorage if enabled
if (persistPageSize) {
setStoredPageSize(pageSize, tableId);
}
}
}, [pageSize, table, persistPageSize, tableId]);
const handleTabChange = (value: string) => {
setActiveTab(value);
// Reset to first page when changing tabs
table.setPageIndex(0);
};
// Enhanced pagination component that updates our local state
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
table.setPageSize(newPageSize);
// Persist immediately when changed
if (persistPageSize) {
setStoredPageSize(newPageSize, tableId);
}
};
return (
<div className="container mx-auto max-w-12xl">
<Card>
@@ -235,7 +309,10 @@ export function DataTable<TData, TValue>({
</TableBody>
</Table>
<div className="mt-4">
<DataTablePagination table={table} />
<DataTablePagination
table={table}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,29 @@
export function parseHostTarget(input: string) {
try {
const normalized = input.match(/^(https?|h2c):\/\//) ? input : `http://${input}`;
const url = new URL(normalized);
const protocol = url.protocol.replace(":", ""); // http | https | h2c
const host = url.hostname;
let defaultPort: number;
switch (protocol) {
case "https":
defaultPort = 443;
break;
case "h2c":
defaultPort = 80;
break;
default: // http
defaultPort = 80;
break;
}
const port = url.port ? parseInt(url.port, 10) : defaultPort;
return { protocol, host, port };
} catch {
return null;
}
}

View File

@@ -0,0 +1,63 @@
export type DomainType = "organization" | "provided" | "provided-search";
export const SINGLE_LABEL_RE = /^[\p{L}\p{N}-]+$/u; // provided-search (no dots)
export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wildcard
export const SINGLE_LABEL_STRICT_RE = /^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum
export function sanitizeInputRaw(input: string): string {
if (!input) return "";
return input
.toLowerCase()
.normalize("NFC") // normalize Unicode
.replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen
}
export function finalizeSubdomainSanitize(input: string): string {
if (!input) return "";
return input
.toLowerCase()
.normalize("NFC")
.replace(/[^\p{L}\p{N}.-]/gu, "") // allow Unicode
.replace(/\.{2,}/g, ".") // collapse multiple dots
.replace(/^-+|-+$/g, "") // strip leading/trailing hyphens
.replace(/^\.+|\.+$/g, "") // strip leading/trailing dots
.replace(/(\.-)|(-\.)/g, "."); // fix illegal dot-hyphen combos
}
export function validateByDomainType(subdomain: string, domainType: { type: "provided-search" | "organization"; domainType?: "ns" | "cname" | "wildcard" } ): boolean {
if (!domainType) return false;
if (domainType.type === "provided-search") {
return SINGLE_LABEL_RE.test(subdomain);
}
if (domainType.type === "organization") {
if (domainType.domainType === "cname") {
return subdomain === "";
} else if (domainType.domainType === "ns" || domainType.domainType === "wildcard") {
if (subdomain === "") return true;
if (!MULTI_LABEL_RE.test(subdomain)) return false;
const labels = subdomain.split(".");
return labels.every(l => l.length >= 1 && l.length <= 63 && SINGLE_LABEL_RE.test(l));
}
}
return false;
}
export const isValidSubdomainStructure = (input: string): boolean => {
const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u;
if (!input) return false;
if (input.includes("..")) return false;
return input.split(".").every(label => regex.test(label));
};