mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-18 10:56:38 +00:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fcf76066f | ||
|
|
601645fa72 | ||
|
|
12765ad675 | ||
|
|
ad3383d23d | ||
|
|
7d5961cf50 | ||
|
|
864aa052f1 | ||
|
|
be16196058 | ||
|
|
8a62f12e8b | ||
|
|
78f464f6ca | ||
|
|
f37eda4739 | ||
|
|
4e106e9e5a | ||
|
|
ccf8e5e6f4 | ||
|
|
9455adf61f | ||
|
|
970ab9818a | ||
|
|
7848cf7141 | ||
|
|
8e5aa9c195 | ||
|
|
a03e9ba7dd | ||
|
|
9e646ba385 | ||
|
|
d9a4f20fe6 | ||
|
|
e659f0e75d | ||
|
|
8891d6239f | ||
|
|
e3bd3fb985 | ||
|
|
54764dfacd | ||
|
|
b156b5ff2d | ||
|
|
d8e547c9a0 | ||
|
|
a0b93377a4 | ||
|
|
e8a6efd079 | ||
|
|
18bb6caf8f | ||
|
|
bc335d15c0 | ||
|
|
2a0d440a34 | ||
|
|
b0500fac29 | ||
|
|
af16e6423a | ||
|
|
ff90471f0f | ||
|
|
bcc501c524 | ||
|
|
c4ef211a3e | ||
|
|
592c0eb7ab | ||
|
|
880a000865 | ||
|
|
a9571f6adf | ||
|
|
ca91f313bc | ||
|
|
76b9753916 | ||
|
|
cd8bbe28bf | ||
|
|
9550c11594 | ||
|
|
7ac21cad25 | ||
|
|
2008a3955a | ||
|
|
f1641c9f3e | ||
|
|
bec5bbd033 | ||
|
|
ae11f72e28 | ||
|
|
6b88cb3920 | ||
|
|
38772111e8 | ||
|
|
14f50c3e66 | ||
|
|
b89a2c9e49 | ||
|
|
2d2eda988c | ||
|
|
432033969b | ||
|
|
1fbf74e1f7 | ||
|
|
93648ff00b | ||
|
|
9513136610 | ||
|
|
43a2a39f8d | ||
|
|
218351de9a | ||
|
|
b92b922eee | ||
|
|
91be4937ee | ||
|
|
19b36a5fae | ||
|
|
bb9ee7dfd2 | ||
|
|
ac0351b525 | ||
|
|
405f5ad7cc | ||
|
|
8cc2712da3 | ||
|
|
c02ac8d1bf | ||
|
|
a1802add19 | ||
|
|
218a6642a2 | ||
|
|
21a83a5755 | ||
|
|
bec75e51f6 | ||
|
|
78d3861382 | ||
|
|
72f19274cd | ||
|
|
fb1481c69c | ||
|
|
9557f755a5 |
@@ -63,7 +63,7 @@ esbuild
|
|||||||
packagePath: getPackagePaths(),
|
packagePath: getPackagePaths(),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
sourcemap: true,
|
sourcemap: "external",
|
||||||
target: "node22",
|
target: "node22",
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ managed:
|
|||||||
app:
|
app:
|
||||||
dashboard_url: "https://{{.DashboardDomain}}"
|
dashboard_url: "https://{{.DashboardDomain}}"
|
||||||
log_level: "info"
|
log_level: "info"
|
||||||
|
telemetry:
|
||||||
|
anonymous_usage: true
|
||||||
|
|
||||||
domains:
|
domains:
|
||||||
domain1:
|
domain1:
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ entryPoints:
|
|||||||
address: ":80"
|
address: ":80"
|
||||||
websecure:
|
websecure:
|
||||||
address: ":443"
|
address: ":443"
|
||||||
|
{{if .HybridMode}} proxyProtocol:
|
||||||
|
trustedIPs:
|
||||||
|
- 0.0.0.0/0
|
||||||
|
- ::1/128{{end}}
|
||||||
transport:
|
transport:
|
||||||
respondingTimeouts:
|
respondingTimeouts:
|
||||||
readTimeout: "30m"
|
readTimeout: "30m"
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ module installer
|
|||||||
go 1.24
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/term v0.33.0
|
golang.org/x/term v0.34.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.34.0 // indirect
|
require golang.org/x/sys v0.35.0 // indirect
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "{resourceName} Settings",
|
"resourceSetting": "{resourceName} Settings",
|
||||||
"alwaysAllow": "Always Allow",
|
"alwaysAllow": "Always Allow",
|
||||||
"alwaysDeny": "Always Deny",
|
"alwaysDeny": "Always Deny",
|
||||||
|
"passToAuth": "Pass to Auth",
|
||||||
"orgSettingsDescription": "Configure your organization's general settings",
|
"orgSettingsDescription": "Configure your organization's general settings",
|
||||||
"orgGeneralSettings": "Organization Settings",
|
"orgGeneralSettings": "Organization Settings",
|
||||||
"orgGeneralSettingsDescription": "Manage your organization details and configuration",
|
"orgGeneralSettingsDescription": "Manage your organization details and configuration",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Actions",
|
"rulesActions": "Actions",
|
||||||
"rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods",
|
"rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods",
|
||||||
"rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted",
|
"rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted",
|
||||||
|
"rulesActionPassToAuth": "Pass to Auth: Allow authentication methods to be attempted",
|
||||||
"rulesMatchCriteria": "Matching Criteria",
|
"rulesMatchCriteria": "Matching Criteria",
|
||||||
"rulesMatchCriteriaIpAddress": "Match a specific IP address",
|
"rulesMatchCriteriaIpAddress": "Match a specific IP address",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation",
|
"rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirecting to login...",
|
"autoLoginRedirecting": "Redirecting to login...",
|
||||||
"autoLoginError": "Auto Login Error",
|
"autoLoginError": "Auto Login Error",
|
||||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
|
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||||
|
"internationaldomaindetected": "International Domain Detected",
|
||||||
|
"willbestoredas": "Will be stored as:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,44 +149,44 @@
|
|||||||
"resourceDescription": "Vytvořte bezpečné proxy služby pro přístup k privátním aplikacím",
|
"resourceDescription": "Vytvořte bezpečné proxy služby pro přístup k privátním aplikacím",
|
||||||
"resourcesSearch": "Prohledat zdroje...",
|
"resourcesSearch": "Prohledat zdroje...",
|
||||||
"resourceAdd": "Přidat zdroj",
|
"resourceAdd": "Přidat zdroj",
|
||||||
"resourceErrorDelte": "Error deleting resource",
|
"resourceErrorDelte": "Chyba při odstraňování zdroje",
|
||||||
"authentication": "Authentication",
|
"authentication": "Autentifikace",
|
||||||
"protected": "Protected",
|
"protected": "Chráněno",
|
||||||
"notProtected": "Not Protected",
|
"notProtected": "Nechráněno",
|
||||||
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
|
"resourceMessageRemove": "Jakmile zdroj odstraníte, nebude dostupný. Všechny související služby a cíle budou také odstraněny.",
|
||||||
"resourceMessageConfirm": "To confirm, please type the name of the resource below.",
|
"resourceMessageConfirm": "Pro potvrzení zadejte prosím název zdroje.",
|
||||||
"resourceQuestionRemove": "Are you sure you want to remove the resource {selectedResource} from the organization?",
|
"resourceQuestionRemove": "Opravdu chcete odstranit zdroj {selectedResource} z organizace?",
|
||||||
"resourceHTTP": "HTTPS Resource",
|
"resourceHTTP": "Zdroj HTTPS",
|
||||||
"resourceHTTPDescription": "Proxy requests to your app over HTTPS using a subdomain or base domain.",
|
"resourceHTTPDescription": "Proxy requests to your app over HTTPS using a subdomain or base domain.",
|
||||||
"resourceRaw": "Raw TCP/UDP Resource",
|
"resourceRaw": "Raw TCP/UDP Resource",
|
||||||
"resourceRawDescription": "Proxy requests to your app over TCP/UDP using a port number.",
|
"resourceRawDescription": "Proxy requests to your app over TCP/UDP using a port number.",
|
||||||
"resourceCreate": "Create Resource",
|
"resourceCreate": "Vytvořit zdroj",
|
||||||
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
"resourceCreateDescription": "Postupujte podle níže uvedených kroků, abyste vytvořili a připojili nový zdroj",
|
||||||
"resourceSeeAll": "See All Resources",
|
"resourceSeeAll": "Zobrazit všechny zdroje",
|
||||||
"resourceInfo": "Resource Information",
|
"resourceInfo": "Informace o zdroji",
|
||||||
"resourceNameDescription": "This is the display name for the resource.",
|
"resourceNameDescription": "Toto je zobrazovaný název zdroje.",
|
||||||
"siteSelect": "Select site",
|
"siteSelect": "Vybrat lokalitu",
|
||||||
"siteSearch": "Search site",
|
"siteSearch": "Hledat lokalitu",
|
||||||
"siteNotFound": "No site found.",
|
"siteNotFound": "Nebyla nalezena žádná lokalita.",
|
||||||
"siteSelectionDescription": "This site will provide connectivity to the target.",
|
"siteSelectionDescription": "Tato lokalita poskytne připojení k cíli.",
|
||||||
"resourceType": "Resource Type",
|
"resourceType": "Typ zdroje",
|
||||||
"resourceTypeDescription": "Determine how you want to access your resource",
|
"resourceTypeDescription": "Určete, jak chcete přistupovat ke svému zdroji",
|
||||||
"resourceHTTPSSettings": "HTTPS Settings",
|
"resourceHTTPSSettings": "Nastavení HTTPS",
|
||||||
"resourceHTTPSSettingsDescription": "Configure how your resource will be accessed over HTTPS",
|
"resourceHTTPSSettingsDescription": "Nakonfigurujte, jak bude váš zdroj přístupný přes HTTPS",
|
||||||
"domainType": "Domain Type",
|
"domainType": "Typ domény",
|
||||||
"subdomain": "Subdomain",
|
"subdomain": "Subdoména",
|
||||||
"baseDomain": "Base Domain",
|
"baseDomain": "Základní doména",
|
||||||
"subdomnainDescription": "The subdomain where your resource will be accessible.",
|
"subdomnainDescription": "Subdoména, kde bude váš zdroj přístupný.",
|
||||||
"resourceRawSettings": "TCP/UDP Settings",
|
"resourceRawSettings": "Nastavení TCP/UDP",
|
||||||
"resourceRawSettingsDescription": "Configure how your resource will be accessed over TCP/UDP",
|
"resourceRawSettingsDescription": "Nakonfigurujte, jak bude váš dokument přístupný přes TCP/UDP",
|
||||||
"protocol": "Protocol",
|
"protocol": "Protokol",
|
||||||
"protocolSelect": "Select a protocol",
|
"protocolSelect": "Vybrat protokol",
|
||||||
"resourcePortNumber": "Port Number",
|
"resourcePortNumber": "Číslo portu",
|
||||||
"resourcePortNumberDescription": "The external port number to proxy requests.",
|
"resourcePortNumberDescription": "Externí port k požadavkům proxy serveru.",
|
||||||
"cancel": "Cancel",
|
"cancel": "Zrušit",
|
||||||
"resourceConfig": "Configuration Snippets",
|
"resourceConfig": "Konfigurační snippety",
|
||||||
"resourceConfigDescription": "Copy and paste these configuration snippets to set up your TCP/UDP resource",
|
"resourceConfigDescription": "Zkopírujte a vložte tyto konfigurační snippety pro nastavení TCP/UDP zdroje",
|
||||||
"resourceAddEntrypoints": "Traefik: Add Entrypoints",
|
"resourceAddEntrypoints": "Traefik: Přidat vstupní body",
|
||||||
"resourceExposePorts": "Gerbil: Expose Ports in Docker Compose",
|
"resourceExposePorts": "Gerbil: Expose Ports in Docker Compose",
|
||||||
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
|
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
|
||||||
"resourceBack": "Back to Resources",
|
"resourceBack": "Back to Resources",
|
||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "{resourceName} Settings",
|
"resourceSetting": "{resourceName} Settings",
|
||||||
"alwaysAllow": "Always Allow",
|
"alwaysAllow": "Always Allow",
|
||||||
"alwaysDeny": "Always Deny",
|
"alwaysDeny": "Always Deny",
|
||||||
|
"passToAuth": "Pass to Auth",
|
||||||
"orgSettingsDescription": "Configure your organization's general settings",
|
"orgSettingsDescription": "Configure your organization's general settings",
|
||||||
"orgGeneralSettings": "Organization Settings",
|
"orgGeneralSettings": "Organization Settings",
|
||||||
"orgGeneralSettingsDescription": "Manage your organization details and configuration",
|
"orgGeneralSettingsDescription": "Manage your organization details and configuration",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Actions",
|
"rulesActions": "Actions",
|
||||||
"rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods",
|
"rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods",
|
||||||
"rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted",
|
"rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted",
|
||||||
|
"rulesActionPassToAuth": "Pass to Auth: Allow authentication methods to be attempted",
|
||||||
"rulesMatchCriteria": "Matching Criteria",
|
"rulesMatchCriteria": "Matching Criteria",
|
||||||
"rulesMatchCriteriaIpAddress": "Match a specific IP address",
|
"rulesMatchCriteriaIpAddress": "Match a specific IP address",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation",
|
"rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirecting to login...",
|
"autoLoginRedirecting": "Redirecting to login...",
|
||||||
"autoLoginError": "Auto Login Error",
|
"autoLoginError": "Auto Login Error",
|
||||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
|
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||||
|
"internationaldomaindetected": "Detekována mezinárodní doména",
|
||||||
|
"willbestoredas": "Bude uloženo jako:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "{resourceName} Einstellungen",
|
"resourceSetting": "{resourceName} Einstellungen",
|
||||||
"alwaysAllow": "Immer erlauben",
|
"alwaysAllow": "Immer erlauben",
|
||||||
"alwaysDeny": "Immer ablehnen",
|
"alwaysDeny": "Immer ablehnen",
|
||||||
|
"passToAuth": "Weiterleiten zur Authentifizierung",
|
||||||
"orgSettingsDescription": "Konfiguriere die allgemeinen Einstellungen deiner Organisation",
|
"orgSettingsDescription": "Konfiguriere die allgemeinen Einstellungen deiner Organisation",
|
||||||
"orgGeneralSettings": "Organisations-Einstellungen",
|
"orgGeneralSettings": "Organisations-Einstellungen",
|
||||||
"orgGeneralSettingsDescription": "Organisationsdetails und Konfiguration verwalten",
|
"orgGeneralSettingsDescription": "Organisationsdetails und Konfiguration verwalten",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Aktionen",
|
"rulesActions": "Aktionen",
|
||||||
"rulesActionAlwaysAllow": "Immer erlauben: Alle Authentifizierungsmethoden umgehen",
|
"rulesActionAlwaysAllow": "Immer erlauben: Alle Authentifizierungsmethoden umgehen",
|
||||||
"rulesActionAlwaysDeny": "Immer verweigern: Alle Anfragen blockieren; keine Authentifizierung möglich",
|
"rulesActionAlwaysDeny": "Immer verweigern: Alle Anfragen blockieren; keine Authentifizierung möglich",
|
||||||
|
"rulesActionPassToAuth": "Weiterleiten zur Authentifizierung: Erlaubt das Versuchen von Authentifizierungsmethoden",
|
||||||
"rulesMatchCriteria": "Übereinstimmungskriterien",
|
"rulesMatchCriteria": "Übereinstimmungskriterien",
|
||||||
"rulesMatchCriteriaIpAddress": "Mit einer bestimmten IP-Adresse übereinstimmen",
|
"rulesMatchCriteriaIpAddress": "Mit einer bestimmten IP-Adresse übereinstimmen",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Mit einem IP-Adressbereich in CIDR-Notation übereinstimmen",
|
"rulesMatchCriteriaIpAddressRange": "Mit einem IP-Adressbereich in CIDR-Notation übereinstimmen",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Weiterleitung zur Anmeldung...",
|
"autoLoginRedirecting": "Weiterleitung zur Anmeldung...",
|
||||||
"autoLoginError": "Fehler bei der automatischen Anmeldung",
|
"autoLoginError": "Fehler bei der automatischen Anmeldung",
|
||||||
"autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.",
|
"autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.",
|
||||||
"autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL."
|
"autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.",
|
||||||
|
"internationaldomaindetected": "Internationale Domäne erkannt",
|
||||||
|
"willbestoredas": "Wird gespeichert als:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "{resourceName} Settings",
|
"resourceSetting": "{resourceName} Settings",
|
||||||
"alwaysAllow": "Always Allow",
|
"alwaysAllow": "Always Allow",
|
||||||
"alwaysDeny": "Always Deny",
|
"alwaysDeny": "Always Deny",
|
||||||
|
"passToAuth": "Pass to Auth",
|
||||||
"orgSettingsDescription": "Configure your organization's general settings",
|
"orgSettingsDescription": "Configure your organization's general settings",
|
||||||
"orgGeneralSettings": "Organization Settings",
|
"orgGeneralSettings": "Organization Settings",
|
||||||
"orgGeneralSettingsDescription": "Manage your organization details and configuration",
|
"orgGeneralSettingsDescription": "Manage your organization details and configuration",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Actions",
|
"rulesActions": "Actions",
|
||||||
"rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods",
|
"rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods",
|
||||||
"rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted",
|
"rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted",
|
||||||
|
"rulesActionPassToAuth": "Pass to Auth: Allow authentication methods to be attempted",
|
||||||
"rulesMatchCriteria": "Matching Criteria",
|
"rulesMatchCriteria": "Matching Criteria",
|
||||||
"rulesMatchCriteriaIpAddress": "Match a specific IP address",
|
"rulesMatchCriteriaIpAddress": "Match a specific IP address",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation",
|
"rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation",
|
||||||
@@ -1057,6 +1059,7 @@
|
|||||||
"actionGetSiteResource": "Get Site Resource",
|
"actionGetSiteResource": "Get Site Resource",
|
||||||
"actionListSiteResources": "List Site Resources",
|
"actionListSiteResources": "List Site Resources",
|
||||||
"actionUpdateSiteResource": "Update Site Resource",
|
"actionUpdateSiteResource": "Update Site Resource",
|
||||||
|
"actionListInvitations": "List Invitations",
|
||||||
"noneSelected": "None selected",
|
"noneSelected": "None selected",
|
||||||
"orgNotFound2": "No organizations found.",
|
"orgNotFound2": "No organizations found.",
|
||||||
"searchProgress": "Search...",
|
"searchProgress": "Search...",
|
||||||
@@ -1455,5 +1458,43 @@
|
|||||||
"autoLoginRedirecting": "Redirecting to login...",
|
"autoLoginRedirecting": "Redirecting to login...",
|
||||||
"autoLoginError": "Auto Login Error",
|
"autoLoginError": "Auto Login Error",
|
||||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
"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:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "Ajustes {resourceName}",
|
"resourceSetting": "Ajustes {resourceName}",
|
||||||
"alwaysAllow": "Permitir siempre",
|
"alwaysAllow": "Permitir siempre",
|
||||||
"alwaysDeny": "Denegar siempre",
|
"alwaysDeny": "Denegar siempre",
|
||||||
|
"passToAuth": "Pasar a Autenticación",
|
||||||
"orgSettingsDescription": "Configurar la configuración general de su organización",
|
"orgSettingsDescription": "Configurar la configuración general de su organización",
|
||||||
"orgGeneralSettings": "Configuración de la organización",
|
"orgGeneralSettings": "Configuración de la organización",
|
||||||
"orgGeneralSettingsDescription": "Administra los detalles y la configuración de tu organización",
|
"orgGeneralSettingsDescription": "Administra los detalles y la configuración de tu organización",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Acciones",
|
"rulesActions": "Acciones",
|
||||||
"rulesActionAlwaysAllow": "Permitir siempre: pasar todos los métodos de autenticación",
|
"rulesActionAlwaysAllow": "Permitir siempre: pasar todos los métodos de autenticación",
|
||||||
"rulesActionAlwaysDeny": "Denegar siempre: Bloquear todas las peticiones; no se puede intentar autenticación",
|
"rulesActionAlwaysDeny": "Denegar siempre: Bloquear todas las peticiones; no se puede intentar autenticación",
|
||||||
|
"rulesActionPassToAuth": "Pasar a Autenticación: Permitir que se intenten los métodos de autenticación",
|
||||||
"rulesMatchCriteria": "Criterios coincidentes",
|
"rulesMatchCriteria": "Criterios coincidentes",
|
||||||
"rulesMatchCriteriaIpAddress": "Coincidir con una dirección IP específica",
|
"rulesMatchCriteriaIpAddress": "Coincidir con una dirección IP específica",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Coincide con un rango de direcciones IP en notación CIDR",
|
"rulesMatchCriteriaIpAddressRange": "Coincide con un rango de direcciones IP en notación CIDR",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirigiendo al inicio de sesión...",
|
"autoLoginRedirecting": "Redirigiendo al inicio de sesión...",
|
||||||
"autoLoginError": "Error de inicio de sesión automático",
|
"autoLoginError": "Error de inicio de sesión automático",
|
||||||
"autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.",
|
"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.",
|
||||||
|
"internationaldomaindetected": "Dominio internacional detectado",
|
||||||
|
"willbestoredas": "Se almacenará como: "
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"inviteLoginUser": "Assurez-vous que vous êtes bien connecté en tant qu'utilisateur correct.",
|
"inviteLoginUser": "Assurez-vous que vous êtes bien connecté en tant qu'utilisateur correct.",
|
||||||
"inviteErrorNoUser": "Nous sommes désolés, mais il semble que l'invitation que vous essayez d'accéder ne soit pas pour un utilisateur qui existe.",
|
"inviteErrorNoUser": "Nous sommes désolés, mais il semble que l'invitation que vous essayez d'accéder ne soit pas pour un utilisateur qui existe.",
|
||||||
"inviteCreateUser": "Veuillez d'abord créer un compte.",
|
"inviteCreateUser": "Veuillez d'abord créer un compte.",
|
||||||
"goHome": "Aller à l’accueil",
|
"goHome": "Retour à la maison",
|
||||||
"inviteLogInOtherUser": "Se connecter en tant qu'utilisateur différent",
|
"inviteLogInOtherUser": "Se connecter en tant qu'utilisateur différent",
|
||||||
"createAnAccount": "Créer un compte",
|
"createAnAccount": "Créer un compte",
|
||||||
"inviteNotAccepted": "Invitation non acceptée",
|
"inviteNotAccepted": "Invitation non acceptée",
|
||||||
@@ -34,16 +34,16 @@
|
|||||||
"confirmPassword": "Confirmer le mot de passe",
|
"confirmPassword": "Confirmer le mot de passe",
|
||||||
"createAccount": "Créer un compte",
|
"createAccount": "Créer un compte",
|
||||||
"viewSettings": "Afficher les paramètres",
|
"viewSettings": "Afficher les paramètres",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimez",
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"online": "En ligne",
|
"online": "En ligne",
|
||||||
"offline": "Hors ligne",
|
"offline": "Hors ligne",
|
||||||
"site": "Site",
|
"site": "Site",
|
||||||
"dataIn": "Données entrantes",
|
"dataIn": "Données dans",
|
||||||
"dataOut": "Données sortantes",
|
"dataOut": "Données épuisées",
|
||||||
"connectionType": "Type de connexion",
|
"connectionType": "Type de connexion",
|
||||||
"tunnelType": "Type de tunnel",
|
"tunnelType": "Type de tunnel",
|
||||||
"local": "Local",
|
"local": "Locale",
|
||||||
"edit": "Editer",
|
"edit": "Editer",
|
||||||
"siteConfirmDelete": "Confirmer la suppression du site",
|
"siteConfirmDelete": "Confirmer la suppression du site",
|
||||||
"siteDelete": "Supprimer le site",
|
"siteDelete": "Supprimer le site",
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
"toggle": "Activer/désactiver",
|
"toggle": "Activer/désactiver",
|
||||||
"dockerCompose": "Composition Docker",
|
"dockerCompose": "Composition Docker",
|
||||||
"dockerRun": "Exécution Docker",
|
"dockerRun": "Exécution Docker",
|
||||||
"siteLearnLocal": "Les sites locaux ne tunnel plus, en savoir plus",
|
"siteLearnLocal": "Les sites locaux ne tunnel, en savoir plus",
|
||||||
"siteConfirmCopy": "J'ai copié la configuration",
|
"siteConfirmCopy": "J'ai copié la configuration",
|
||||||
"searchSitesProgress": "Rechercher des sites...",
|
"searchSitesProgress": "Rechercher des sites...",
|
||||||
"siteAdd": "Ajouter un site",
|
"siteAdd": "Ajouter un site",
|
||||||
@@ -94,9 +94,9 @@
|
|||||||
"siteNewtTunnelDescription": "La façon la plus simple de créer un point d'entrée dans votre réseau. Pas de configuration supplémentaire.",
|
"siteNewtTunnelDescription": "La façon la plus simple de créer un point d'entrée dans votre réseau. Pas de configuration supplémentaire.",
|
||||||
"siteWg": "WireGuard basique",
|
"siteWg": "WireGuard basique",
|
||||||
"siteWgDescription": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise.",
|
"siteWgDescription": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise.",
|
||||||
"siteWgDescriptionSaas": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise. NE FONCTIONNE QUE SUR LES NŒUDS AUTO-HÉBERGÉS",
|
"siteWgDescriptionSaas": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise. FONCTIONNE UNIQUEMENT SUR DES NŒUDS AUTONOMES",
|
||||||
"siteLocalDescription": "Ressources locales seulement. Pas de tunneling.",
|
"siteLocalDescription": "Ressources locales seulement. Pas de tunneling.",
|
||||||
"siteLocalDescriptionSaas": "Ressources locales uniquement. Pas de tunneling. NE FONCTIONNE QUE SUR LES NŒUDS AUTO-HÉBERGÉS",
|
"siteLocalDescriptionSaas": "Ressources locales uniquement. Pas de tunneling. FONCTIONNE UNIQUEMENT SUR DES NŒUDS AUTONOMES",
|
||||||
"siteSeeAll": "Voir tous les sites",
|
"siteSeeAll": "Voir tous les sites",
|
||||||
"siteTunnelDescription": "Déterminez comment vous voulez vous connecter à votre site",
|
"siteTunnelDescription": "Déterminez comment vous voulez vous connecter à votre site",
|
||||||
"siteNewtCredentials": "Identifiants Newt",
|
"siteNewtCredentials": "Identifiants Newt",
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
"expireIn": "Expire dans",
|
"expireIn": "Expire dans",
|
||||||
"neverExpire": "N'expire jamais",
|
"neverExpire": "N'expire jamais",
|
||||||
"shareExpireDescription": "Le temps d'expiration est combien de temps le lien sera utilisable et fournira un accès à la ressource. Après cette période, le lien ne fonctionnera plus et les utilisateurs qui ont utilisé ce lien perdront l'accès à la ressource.",
|
"shareExpireDescription": "Le temps d'expiration est combien de temps le lien sera utilisable et fournira un accès à la ressource. Après cette période, le lien ne fonctionnera plus et les utilisateurs qui ont utilisé ce lien perdront l'accès à la ressource.",
|
||||||
"shareSeeOnce": "Vous ne pourrez voir ce lien qu’une seule fois. Assurez-vous de le copier.",
|
"shareSeeOnce": "Vous ne pourrez voir ce lien. Assurez-vous de le copier.",
|
||||||
"shareAccessHint": "N'importe qui avec ce lien peut accéder à la ressource. Partagez-le avec soin.",
|
"shareAccessHint": "N'importe qui avec ce lien peut accéder à la ressource. Partagez-le avec soin.",
|
||||||
"shareTokenUsage": "Voir Utilisation du jeton d'accès",
|
"shareTokenUsage": "Voir Utilisation du jeton d'accès",
|
||||||
"createLink": "Créer un lien",
|
"createLink": "Créer un lien",
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
"resourceSearch": "Rechercher des ressources",
|
"resourceSearch": "Rechercher des ressources",
|
||||||
"openMenu": "Ouvrir le menu",
|
"openMenu": "Ouvrir le menu",
|
||||||
"resource": "Ressource",
|
"resource": "Ressource",
|
||||||
"title": "Titre",
|
"title": "Titre de la page",
|
||||||
"created": "Créé",
|
"created": "Créé",
|
||||||
"expires": "Expire",
|
"expires": "Expire",
|
||||||
"never": "Jamais",
|
"never": "Jamais",
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
"visibility": "Visibilité",
|
"visibility": "Visibilité",
|
||||||
"enabled": "Activé",
|
"enabled": "Activé",
|
||||||
"disabled": "Désactivé",
|
"disabled": "Désactivé",
|
||||||
"general": "Général",
|
"general": "Généraux",
|
||||||
"generalSettings": "Paramètres généraux",
|
"generalSettings": "Paramètres généraux",
|
||||||
"proxy": "Proxy",
|
"proxy": "Proxy",
|
||||||
"internal": "Interne",
|
"internal": "Interne",
|
||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "Réglages {resourceName}",
|
"resourceSetting": "Réglages {resourceName}",
|
||||||
"alwaysAllow": "Toujours autoriser",
|
"alwaysAllow": "Toujours autoriser",
|
||||||
"alwaysDeny": "Toujours refuser",
|
"alwaysDeny": "Toujours refuser",
|
||||||
|
"passToAuth": "Paser à l'authentification",
|
||||||
"orgSettingsDescription": "Configurer les paramètres généraux de votre organisation",
|
"orgSettingsDescription": "Configurer les paramètres généraux de votre organisation",
|
||||||
"orgGeneralSettings": "Paramètres de l'organisation",
|
"orgGeneralSettings": "Paramètres de l'organisation",
|
||||||
"orgGeneralSettingsDescription": "Gérer les détails et la configuration de votre organisation",
|
"orgGeneralSettingsDescription": "Gérer les détails et la configuration de votre organisation",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Actions",
|
"rulesActions": "Actions",
|
||||||
"rulesActionAlwaysAllow": "Toujours autoriser : Contourner toutes les méthodes d'authentification",
|
"rulesActionAlwaysAllow": "Toujours autoriser : Contourner toutes les méthodes d'authentification",
|
||||||
"rulesActionAlwaysDeny": "Toujours refuser : Bloquer toutes les requêtes ; aucune authentification ne peut être tentée",
|
"rulesActionAlwaysDeny": "Toujours refuser : Bloquer toutes les requêtes ; aucune authentification ne peut être tentée",
|
||||||
|
"rulesActionPassToAuth": "Passer à l'authentification : Autoriser les méthodes d'authentification à être tentées",
|
||||||
"rulesMatchCriteria": "Critères de correspondance",
|
"rulesMatchCriteria": "Critères de correspondance",
|
||||||
"rulesMatchCriteriaIpAddress": "Correspondre à une adresse IP spécifique",
|
"rulesMatchCriteriaIpAddress": "Correspondre à une adresse IP spécifique",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Correspondre à une plage d'adresses IP en notation CIDR",
|
"rulesMatchCriteriaIpAddressRange": "Correspondre à une plage d'adresses IP en notation CIDR",
|
||||||
@@ -593,7 +595,7 @@
|
|||||||
"newtId": "ID Newt",
|
"newtId": "ID Newt",
|
||||||
"newtSecretKey": "Clé secrète Newt",
|
"newtSecretKey": "Clé secrète Newt",
|
||||||
"architecture": "Architecture",
|
"architecture": "Architecture",
|
||||||
"sites": "Sites",
|
"sites": "Espaces",
|
||||||
"siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser vos ressources internes en utilisant l'IP du pair.",
|
"siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser vos ressources internes en utilisant l'IP du pair.",
|
||||||
"siteWgCompatibleAllClients": "Compatible avec tous les clients WireGuard",
|
"siteWgCompatibleAllClients": "Compatible avec tous les clients WireGuard",
|
||||||
"siteWgManualConfigurationRequired": "Configuration manuelle requise",
|
"siteWgManualConfigurationRequired": "Configuration manuelle requise",
|
||||||
@@ -959,7 +961,7 @@
|
|||||||
"supportKetOptionFull": "Support complet",
|
"supportKetOptionFull": "Support complet",
|
||||||
"forWholeServer": "Pour tout le serveur",
|
"forWholeServer": "Pour tout le serveur",
|
||||||
"lifetimePurchase": "Achat à vie",
|
"lifetimePurchase": "Achat à vie",
|
||||||
"supporterStatus": "Statut de supporteur",
|
"supporterStatus": "Statut de supporter",
|
||||||
"buy": "Acheter",
|
"buy": "Acheter",
|
||||||
"supportKeyOptionLimited": "Support limité",
|
"supportKeyOptionLimited": "Support limité",
|
||||||
"forFiveUsers": "Pour 5 utilisateurs ou moins",
|
"forFiveUsers": "Pour 5 utilisateurs ou moins",
|
||||||
@@ -1103,7 +1105,7 @@
|
|||||||
"allowAll": "Tout autoriser",
|
"allowAll": "Tout autoriser",
|
||||||
"permissionsAllowAll": "Autoriser toutes les autorisations",
|
"permissionsAllowAll": "Autoriser toutes les autorisations",
|
||||||
"githubUsernameRequired": "Le nom d'utilisateur GitHub est requis",
|
"githubUsernameRequired": "Le nom d'utilisateur GitHub est requis",
|
||||||
"supportKeyRequired": "La clé de supporteur est requise",
|
"supportKeyRequired": "La clé de supporter est requise",
|
||||||
"passwordRequirementsChars": "Le mot de passe doit comporter au moins 8 caractères",
|
"passwordRequirementsChars": "Le mot de passe doit comporter au moins 8 caractères",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
"verificationCodeRequired": "Le code est requis",
|
"verificationCodeRequired": "Le code est requis",
|
||||||
@@ -1115,14 +1117,14 @@
|
|||||||
"orgErrorNoProvided": "Aucune organisation fournie",
|
"orgErrorNoProvided": "Aucune organisation fournie",
|
||||||
"apiKeysErrorNoUpdate": "Pas de clé API à mettre à jour",
|
"apiKeysErrorNoUpdate": "Pas de clé API à mettre à jour",
|
||||||
"sidebarOverview": "Aperçu",
|
"sidebarOverview": "Aperçu",
|
||||||
"sidebarHome": "Accueil",
|
"sidebarHome": "Domicile",
|
||||||
"sidebarSites": "Sites",
|
"sidebarSites": "Espaces",
|
||||||
"sidebarResources": "Ressources",
|
"sidebarResources": "Ressource",
|
||||||
"sidebarAccessControl": "Contrôle d'accès",
|
"sidebarAccessControl": "Contrôle d'accès",
|
||||||
"sidebarUsers": "Utilisateurs",
|
"sidebarUsers": "Utilisateurs",
|
||||||
"sidebarInvitations": "Invitations",
|
"sidebarInvitations": "Invitations",
|
||||||
"sidebarRoles": "Rôles",
|
"sidebarRoles": "Rôles",
|
||||||
"sidebarShareableLinks": "Liens partageables",
|
"sidebarShareableLinks": "Liens partagables",
|
||||||
"sidebarApiKeys": "Clés API",
|
"sidebarApiKeys": "Clés API",
|
||||||
"sidebarSettings": "Réglages",
|
"sidebarSettings": "Réglages",
|
||||||
"sidebarAllUsers": "Tous les utilisateurs",
|
"sidebarAllUsers": "Tous les utilisateurs",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirection vers la connexion...",
|
"autoLoginRedirecting": "Redirection vers la connexion...",
|
||||||
"autoLoginError": "Erreur de connexion automatique",
|
"autoLoginError": "Erreur de connexion automatique",
|
||||||
"autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.",
|
"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.",
|
||||||
|
"internationaldomaindetected": "Domaine international détecté",
|
||||||
|
"willbestoredas": "Sera stocké comme:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "Impostazioni {resourceName}",
|
"resourceSetting": "Impostazioni {resourceName}",
|
||||||
"alwaysAllow": "Consenti Sempre",
|
"alwaysAllow": "Consenti Sempre",
|
||||||
"alwaysDeny": "Nega Sempre",
|
"alwaysDeny": "Nega Sempre",
|
||||||
|
"passToAuth": "Passa all'autenticazione",
|
||||||
"orgSettingsDescription": "Configura le impostazioni generali della tua organizzazione",
|
"orgSettingsDescription": "Configura le impostazioni generali della tua organizzazione",
|
||||||
"orgGeneralSettings": "Impostazioni Organizzazione",
|
"orgGeneralSettings": "Impostazioni Organizzazione",
|
||||||
"orgGeneralSettingsDescription": "Gestisci i dettagli dell'organizzazione e la configurazione",
|
"orgGeneralSettingsDescription": "Gestisci i dettagli dell'organizzazione e la configurazione",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Azioni",
|
"rulesActions": "Azioni",
|
||||||
"rulesActionAlwaysAllow": "Consenti Sempre: Ignora tutti i metodi di autenticazione",
|
"rulesActionAlwaysAllow": "Consenti Sempre: Ignora tutti i metodi di autenticazione",
|
||||||
"rulesActionAlwaysDeny": "Nega Sempre: Blocca tutte le richieste; nessuna autenticazione può essere tentata",
|
"rulesActionAlwaysDeny": "Nega Sempre: Blocca tutte le richieste; nessuna autenticazione può essere tentata",
|
||||||
|
"rulesActionPassToAuth": "Passa all'autenticazione: Consenti di tentare i metodi di autenticazione",
|
||||||
"rulesMatchCriteria": "Criteri di Corrispondenza",
|
"rulesMatchCriteria": "Criteri di Corrispondenza",
|
||||||
"rulesMatchCriteriaIpAddress": "Corrisponde a un indirizzo IP specifico",
|
"rulesMatchCriteriaIpAddress": "Corrisponde a un indirizzo IP specifico",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Corrisponde a un intervallo di indirizzi IP in notazione CIDR",
|
"rulesMatchCriteriaIpAddressRange": "Corrisponde a un intervallo di indirizzi IP in notazione CIDR",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Reindirizzamento al login...",
|
"autoLoginRedirecting": "Reindirizzamento al login...",
|
||||||
"autoLoginError": "Errore di Accesso Automatico",
|
"autoLoginError": "Errore di Accesso Automatico",
|
||||||
"autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.",
|
"autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.",
|
||||||
"autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione."
|
"autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione.",
|
||||||
|
"internationaldomaindetected": "Rilevato dominio internazionale",
|
||||||
|
"willbestoredas": "Verrà archiviato come:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "{resourceName} 설정",
|
"resourceSetting": "{resourceName} 설정",
|
||||||
"alwaysAllow": "항상 허용",
|
"alwaysAllow": "항상 허용",
|
||||||
"alwaysDeny": "항상 거부",
|
"alwaysDeny": "항상 거부",
|
||||||
|
"passToAuth": "인증으로 전달",
|
||||||
"orgSettingsDescription": "조직의 일반 설정을 구성하세요",
|
"orgSettingsDescription": "조직의 일반 설정을 구성하세요",
|
||||||
"orgGeneralSettings": "조직 설정",
|
"orgGeneralSettings": "조직 설정",
|
||||||
"orgGeneralSettingsDescription": "조직 세부정보 및 구성을 관리하세요.",
|
"orgGeneralSettingsDescription": "조직 세부정보 및 구성을 관리하세요.",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "작업",
|
"rulesActions": "작업",
|
||||||
"rulesActionAlwaysAllow": "항상 허용: 모든 인증 방법 우회",
|
"rulesActionAlwaysAllow": "항상 허용: 모든 인증 방법 우회",
|
||||||
"rulesActionAlwaysDeny": "항상 거부: 모든 요청을 차단합니다. 인증을 시도할 수 없습니다.",
|
"rulesActionAlwaysDeny": "항상 거부: 모든 요청을 차단합니다. 인증을 시도할 수 없습니다.",
|
||||||
|
"rulesActionPassToAuth": "인증으로 전달: 인증 방법 시도를 허용합니다",
|
||||||
"rulesMatchCriteria": "일치 기준",
|
"rulesMatchCriteria": "일치 기준",
|
||||||
"rulesMatchCriteriaIpAddress": "특정 IP 주소와 일치",
|
"rulesMatchCriteriaIpAddress": "특정 IP 주소와 일치",
|
||||||
"rulesMatchCriteriaIpAddressRange": "CIDR 표기법으로 IP 주소 범위를 일치시킵니다",
|
"rulesMatchCriteriaIpAddressRange": "CIDR 표기법으로 IP 주소 범위를 일치시킵니다",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "로그인으로 리디렉션 중...",
|
"autoLoginRedirecting": "로그인으로 리디렉션 중...",
|
||||||
"autoLoginError": "자동 로그인 오류",
|
"autoLoginError": "자동 로그인 오류",
|
||||||
"autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.",
|
"autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.",
|
||||||
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패."
|
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.",
|
||||||
|
"internationaldomaindetected": "국제 도메인 감지됨",
|
||||||
|
"willbestoredas": "다음과 같이 저장됩니다."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "{resourceName} Innstillinger",
|
"resourceSetting": "{resourceName} Innstillinger",
|
||||||
"alwaysAllow": "Alltid tillat",
|
"alwaysAllow": "Alltid tillat",
|
||||||
"alwaysDeny": "Alltid avslå",
|
"alwaysDeny": "Alltid avslå",
|
||||||
|
"passToAuth": "Pass til Autentisering",
|
||||||
"orgSettingsDescription": "Konfigurer organisasjonens generelle innstillinger",
|
"orgSettingsDescription": "Konfigurer organisasjonens generelle innstillinger",
|
||||||
"orgGeneralSettings": "Organisasjonsinnstillinger",
|
"orgGeneralSettings": "Organisasjonsinnstillinger",
|
||||||
"orgGeneralSettingsDescription": "Administrer dine organisasjonsdetaljer og konfigurasjon",
|
"orgGeneralSettingsDescription": "Administrer dine organisasjonsdetaljer og konfigurasjon",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Handlinger",
|
"rulesActions": "Handlinger",
|
||||||
"rulesActionAlwaysAllow": "Alltid Tillat: Omgå alle autentiserings metoder",
|
"rulesActionAlwaysAllow": "Alltid Tillat: Omgå alle autentiserings metoder",
|
||||||
"rulesActionAlwaysDeny": "Alltid Nekt: Blokker alle forespørsler; ingen autentisering kan forsøkes",
|
"rulesActionAlwaysDeny": "Alltid Nekt: Blokker alle forespørsler; ingen autentisering kan forsøkes",
|
||||||
|
"rulesActionPassToAuth": "Pass til Autentisering: Tillat at autentiseringsmetoder forsøkes",
|
||||||
"rulesMatchCriteria": "Samsvarende kriterier",
|
"rulesMatchCriteria": "Samsvarende kriterier",
|
||||||
"rulesMatchCriteriaIpAddress": "Samsvar med en spesifikk IP-adresse",
|
"rulesMatchCriteriaIpAddress": "Samsvar med en spesifikk IP-adresse",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Samsvar et IP-adresseområde i CIDR-notasjon",
|
"rulesMatchCriteriaIpAddressRange": "Samsvar et IP-adresseområde i CIDR-notasjon",
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "{resourceName} instellingen",
|
"resourceSetting": "{resourceName} instellingen",
|
||||||
"alwaysAllow": "Altijd toestaan",
|
"alwaysAllow": "Altijd toestaan",
|
||||||
"alwaysDeny": "Altijd weigeren",
|
"alwaysDeny": "Altijd weigeren",
|
||||||
|
"passToAuth": "Passeren naar Auth",
|
||||||
"orgSettingsDescription": "Configureer de algemene instellingen van je organisatie",
|
"orgSettingsDescription": "Configureer de algemene instellingen van je organisatie",
|
||||||
"orgGeneralSettings": "Organisatie Instellingen",
|
"orgGeneralSettings": "Organisatie Instellingen",
|
||||||
"orgGeneralSettingsDescription": "Beheer de details en configuratie van uw organisatie",
|
"orgGeneralSettingsDescription": "Beheer de details en configuratie van uw organisatie",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "acties",
|
"rulesActions": "acties",
|
||||||
"rulesActionAlwaysAllow": "Altijd toegestaan: Omzeil alle authenticatiemethoden",
|
"rulesActionAlwaysAllow": "Altijd toegestaan: Omzeil alle authenticatiemethoden",
|
||||||
"rulesActionAlwaysDeny": "Altijd weigeren: Blokkeer alle aanvragen, er kan geen verificatie worden geprobeerd",
|
"rulesActionAlwaysDeny": "Altijd weigeren: Blokkeer alle aanvragen, er kan geen verificatie worden geprobeerd",
|
||||||
|
"rulesActionPassToAuth": "Doorgeven aan Auth: Toestaan dat authenticatiemethoden worden geprobeerd",
|
||||||
"rulesMatchCriteria": "Overeenkomende criteria",
|
"rulesMatchCriteria": "Overeenkomende criteria",
|
||||||
"rulesMatchCriteriaIpAddress": "Overeenkomen met een specifiek IP-adres",
|
"rulesMatchCriteriaIpAddress": "Overeenkomen met een specifiek IP-adres",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Overeenkomen met een bereik van IP-adressen in de CIDR-notatie",
|
"rulesMatchCriteriaIpAddressRange": "Overeenkomen met een bereik van IP-adressen in de CIDR-notatie",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirecting naar inloggen...",
|
"autoLoginRedirecting": "Redirecting naar inloggen...",
|
||||||
"autoLoginError": "Auto Login Fout",
|
"autoLoginError": "Auto Login Fout",
|
||||||
"autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.",
|
"autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.",
|
||||||
"autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt."
|
"autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt.",
|
||||||
|
"internationaldomaindetected": "Internationaal Domein Gedetecteerd",
|
||||||
|
"willbestoredas": "Wordt opgeslagen als:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "Ustawienia {resourceName}",
|
"resourceSetting": "Ustawienia {resourceName}",
|
||||||
"alwaysAllow": "Zawsze zezwalaj",
|
"alwaysAllow": "Zawsze zezwalaj",
|
||||||
"alwaysDeny": "Zawsze odmawiaj",
|
"alwaysDeny": "Zawsze odmawiaj",
|
||||||
|
"passToAuth": "Przekaż do Autoryzacji",
|
||||||
"orgSettingsDescription": "Skonfiguruj ustawienia ogólne swojej organizacji",
|
"orgSettingsDescription": "Skonfiguruj ustawienia ogólne swojej organizacji",
|
||||||
"orgGeneralSettings": "Ustawienia organizacji",
|
"orgGeneralSettings": "Ustawienia organizacji",
|
||||||
"orgGeneralSettingsDescription": "Zarządzaj szczegółami swojej organizacji i konfiguracją",
|
"orgGeneralSettingsDescription": "Zarządzaj szczegółami swojej organizacji i konfiguracją",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Akcje",
|
"rulesActions": "Akcje",
|
||||||
"rulesActionAlwaysAllow": "Zawsze zezwalaj: Pomiń wszystkie metody uwierzytelniania",
|
"rulesActionAlwaysAllow": "Zawsze zezwalaj: Pomiń wszystkie metody uwierzytelniania",
|
||||||
"rulesActionAlwaysDeny": "Zawsze odmawiaj: Blokuj wszystkie żądania; nie można próbować uwierzytelniania",
|
"rulesActionAlwaysDeny": "Zawsze odmawiaj: Blokuj wszystkie żądania; nie można próbować uwierzytelniania",
|
||||||
|
"rulesActionPassToAuth": "Przekaż do Autoryzacji: Zezwól na próby metod uwierzytelniania",
|
||||||
"rulesMatchCriteria": "Kryteria dopasowania",
|
"rulesMatchCriteria": "Kryteria dopasowania",
|
||||||
"rulesMatchCriteriaIpAddress": "Dopasuj konkretny adres IP",
|
"rulesMatchCriteriaIpAddress": "Dopasuj konkretny adres IP",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Dopasuj zakres adresów IP w notacji CIDR",
|
"rulesMatchCriteriaIpAddressRange": "Dopasuj zakres adresów IP w notacji CIDR",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Przekierowanie do logowania...",
|
"autoLoginRedirecting": "Przekierowanie do logowania...",
|
||||||
"autoLoginError": "Błąd automatycznego logowania",
|
"autoLoginError": "Błąd automatycznego logowania",
|
||||||
"autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.",
|
"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.",
|
||||||
|
"internationaldomaindetected": "Wykryto domenę międzynarodową",
|
||||||
|
"willbestoredas": "Będzie przechowywane jako:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "Configurações do {resourceName}",
|
"resourceSetting": "Configurações do {resourceName}",
|
||||||
"alwaysAllow": "Sempre permitir",
|
"alwaysAllow": "Sempre permitir",
|
||||||
"alwaysDeny": "Sempre negar",
|
"alwaysDeny": "Sempre negar",
|
||||||
|
"passToAuth": "Passar para Autenticação",
|
||||||
"orgSettingsDescription": "Configurar as configurações gerais da sua organização",
|
"orgSettingsDescription": "Configurar as configurações gerais da sua organização",
|
||||||
"orgGeneralSettings": "Configurações da organização",
|
"orgGeneralSettings": "Configurações da organização",
|
||||||
"orgGeneralSettingsDescription": "Gerencie os detalhes e a configuração da sua organização",
|
"orgGeneralSettingsDescription": "Gerencie os detalhes e a configuração da sua organização",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Ações",
|
"rulesActions": "Ações",
|
||||||
"rulesActionAlwaysAllow": "Sempre Permitir: Ignorar todos os métodos de autenticação",
|
"rulesActionAlwaysAllow": "Sempre Permitir: Ignorar todos os métodos de autenticação",
|
||||||
"rulesActionAlwaysDeny": "Sempre Negar: Bloquear todas as requisições; nenhuma autenticação pode ser tentada",
|
"rulesActionAlwaysDeny": "Sempre Negar: Bloquear todas as requisições; nenhuma autenticação pode ser tentada",
|
||||||
|
"rulesActionPassToAuth": "Passar para Autenticação: Permitir que métodos de autenticação sejam tentados",
|
||||||
"rulesMatchCriteria": "Critérios de Correspondência",
|
"rulesMatchCriteria": "Critérios de Correspondência",
|
||||||
"rulesMatchCriteriaIpAddress": "Corresponder a um endereço IP específico",
|
"rulesMatchCriteriaIpAddress": "Corresponder a um endereço IP específico",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Corresponder a uma faixa de endereços IP em notação CIDR",
|
"rulesMatchCriteriaIpAddressRange": "Corresponder a uma faixa de endereços IP em notação CIDR",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirecionando para login...",
|
"autoLoginRedirecting": "Redirecionando para login...",
|
||||||
"autoLoginError": "Erro de Login Automático",
|
"autoLoginError": "Erro de Login Automático",
|
||||||
"autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.",
|
"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.",
|
||||||
|
"internationaldomaindetected": "Domínio internacional detetado",
|
||||||
|
"willbestoredas": "Será armazenado como:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "Настройки {resourceName}",
|
"resourceSetting": "Настройки {resourceName}",
|
||||||
"alwaysAllow": "Всегда разрешать",
|
"alwaysAllow": "Всегда разрешать",
|
||||||
"alwaysDeny": "Всегда запрещать",
|
"alwaysDeny": "Всегда запрещать",
|
||||||
|
"passToAuth": "Переход к аутентификации",
|
||||||
"orgSettingsDescription": "Настройте общие параметры вашей организации",
|
"orgSettingsDescription": "Настройте общие параметры вашей организации",
|
||||||
"orgGeneralSettings": "Настройки организации",
|
"orgGeneralSettings": "Настройки организации",
|
||||||
"orgGeneralSettingsDescription": "Управляйте данными и конфигурацией вашей организации",
|
"orgGeneralSettingsDescription": "Управляйте данными и конфигурацией вашей организации",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Действия",
|
"rulesActions": "Действия",
|
||||||
"rulesActionAlwaysAllow": "Всегда разрешать: Обойти все методы аутентификации",
|
"rulesActionAlwaysAllow": "Всегда разрешать: Обойти все методы аутентификации",
|
||||||
"rulesActionAlwaysDeny": "Всегда запрещать: Блокировать все запросы; аутентификация не может быть выполнена",
|
"rulesActionAlwaysDeny": "Всегда запрещать: Блокировать все запросы; аутентификация не может быть выполнена",
|
||||||
|
"rulesActionPassToAuth": "Переход к аутентификации: Разрешить попытки методов аутентификации",
|
||||||
"rulesMatchCriteria": "Критерии совпадения",
|
"rulesMatchCriteria": "Критерии совпадения",
|
||||||
"rulesMatchCriteriaIpAddress": "Совпадение с конкретным IP адресом",
|
"rulesMatchCriteriaIpAddress": "Совпадение с конкретным IP адресом",
|
||||||
"rulesMatchCriteriaIpAddressRange": "Совпадение с диапазоном IP адресов в нотации CIDR",
|
"rulesMatchCriteriaIpAddressRange": "Совпадение с диапазоном IP адресов в нотации CIDR",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Перенаправление к входу...",
|
"autoLoginRedirecting": "Перенаправление к входу...",
|
||||||
"autoLoginError": "Ошибка автоматического входа",
|
"autoLoginError": "Ошибка автоматического входа",
|
||||||
"autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.",
|
"autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.",
|
||||||
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации."
|
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.",
|
||||||
|
"internationaldomaindetected": "Обнаружен международный домен",
|
||||||
|
"willbestoredas": "Будет сохранен как:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "{resourceName} Ayarları",
|
"resourceSetting": "{resourceName} Ayarları",
|
||||||
"alwaysAllow": "Her Zaman İzin Ver",
|
"alwaysAllow": "Her Zaman İzin Ver",
|
||||||
"alwaysDeny": "Her Zaman Reddet",
|
"alwaysDeny": "Her Zaman Reddet",
|
||||||
|
"passToAuth": "Kimlik Doğrulamasına Geç",
|
||||||
"orgSettingsDescription": "Organizasyonunuzun genel ayarlarını yapılandırın",
|
"orgSettingsDescription": "Organizasyonunuzun genel ayarlarını yapılandırın",
|
||||||
"orgGeneralSettings": "Organizasyon Ayarları",
|
"orgGeneralSettings": "Organizasyon Ayarları",
|
||||||
"orgGeneralSettingsDescription": "Organizasyon detaylarınızı ve yapılandırmanızı yönetin",
|
"orgGeneralSettingsDescription": "Organizasyon detaylarınızı ve yapılandırmanızı yönetin",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "Aksiyonlar",
|
"rulesActions": "Aksiyonlar",
|
||||||
"rulesActionAlwaysAllow": "Her Zaman İzin Ver: Tüm kimlik doğrulama yöntemlerini atlayın",
|
"rulesActionAlwaysAllow": "Her Zaman İzin Ver: Tüm kimlik doğrulama yöntemlerini atlayın",
|
||||||
"rulesActionAlwaysDeny": "Her Zaman Reddedin: Tüm istekleri engelleyin; kimlik doğrulaması yapılamaz",
|
"rulesActionAlwaysDeny": "Her Zaman Reddedin: Tüm istekleri engelleyin; kimlik doğrulaması yapılamaz",
|
||||||
|
"rulesActionPassToAuth": "Kimlik Doğrulamasına Geç: Kimlik doğrulama yöntemlerinin denenmesine izin ver",
|
||||||
"rulesMatchCriteria": "Eşleşme Kriterleri",
|
"rulesMatchCriteria": "Eşleşme Kriterleri",
|
||||||
"rulesMatchCriteriaIpAddress": "Belirli bir IP adresi ile eşleşme",
|
"rulesMatchCriteriaIpAddress": "Belirli bir IP adresi ile eşleşme",
|
||||||
"rulesMatchCriteriaIpAddressRange": "CIDR gösteriminde bir IP adresi aralığı ile eşleşme",
|
"rulesMatchCriteriaIpAddressRange": "CIDR gösteriminde bir IP adresi aralığı ile eşleşme",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...",
|
"autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...",
|
||||||
"autoLoginError": "Otomatik Giriş Hatası",
|
"autoLoginError": "Otomatik Giriş Hatası",
|
||||||
"autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.",
|
"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ı.",
|
||||||
|
"internationaldomaindetected": "Uluslararası Etki Alanı Algılandı",
|
||||||
|
"willbestoredas": "Şu şekilde saklanacaktır:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"resourceSetting": "{resourceName} 设置",
|
"resourceSetting": "{resourceName} 设置",
|
||||||
"alwaysAllow": "一律允许",
|
"alwaysAllow": "一律允许",
|
||||||
"alwaysDeny": "一律拒绝",
|
"alwaysDeny": "一律拒绝",
|
||||||
|
"passToAuth": "传递至认证",
|
||||||
"orgSettingsDescription": "配置您组织的一般设置",
|
"orgSettingsDescription": "配置您组织的一般设置",
|
||||||
"orgGeneralSettings": "组织设置",
|
"orgGeneralSettings": "组织设置",
|
||||||
"orgGeneralSettingsDescription": "管理您的机构详细信息和配置",
|
"orgGeneralSettingsDescription": "管理您的机构详细信息和配置",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
"rulesActions": "行动",
|
"rulesActions": "行动",
|
||||||
"rulesActionAlwaysAllow": "总是允许:绕过所有身份验证方法",
|
"rulesActionAlwaysAllow": "总是允许:绕过所有身份验证方法",
|
||||||
"rulesActionAlwaysDeny": "总是拒绝:阻止所有请求;无法尝试验证",
|
"rulesActionAlwaysDeny": "总是拒绝:阻止所有请求;无法尝试验证",
|
||||||
|
"rulesActionPassToAuth": "传递至认证:允许尝试身份验证方法",
|
||||||
"rulesMatchCriteria": "匹配条件",
|
"rulesMatchCriteria": "匹配条件",
|
||||||
"rulesMatchCriteriaIpAddress": "匹配一个指定的 IP 地址",
|
"rulesMatchCriteriaIpAddress": "匹配一个指定的 IP 地址",
|
||||||
"rulesMatchCriteriaIpAddressRange": "在 CIDR 符号中匹配一系列IP地址",
|
"rulesMatchCriteriaIpAddressRange": "在 CIDR 符号中匹配一系列IP地址",
|
||||||
@@ -1455,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "重定向到登录...",
|
"autoLoginRedirecting": "重定向到登录...",
|
||||||
"autoLoginError": "自动登录错误",
|
"autoLoginError": "自动登录错误",
|
||||||
"autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。",
|
"autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。",
|
||||||
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。"
|
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。",
|
||||||
|
"internationaldomaindetected": "检测到国际域名",
|
||||||
|
"willbestoredas": "将存储为:"
|
||||||
}
|
}
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -112,8 +112,8 @@
|
|||||||
"@types/node": "^24",
|
"@types/node": "^24",
|
||||||
"@types/nodemailer": "6.4.17",
|
"@types/nodemailer": "6.4.17",
|
||||||
"@types/pg": "8.15.5",
|
"@types/pg": "8.15.5",
|
||||||
"@types/react": "19.1.10",
|
"@types/react": "19.1.12",
|
||||||
"@types/react-dom": "19.1.7",
|
"@types/react-dom": "19.1.9",
|
||||||
"@types/semver": "^7.7.0",
|
"@types/semver": "^7.7.0",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
"react-email": "4.2.8",
|
"react-email": "4.2.8",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.16",
|
||||||
"tsx": "4.20.4",
|
"tsx": "4.20.5",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"typescript-eslint": "^8.40.0"
|
"typescript-eslint": "^8.40.0"
|
||||||
}
|
}
|
||||||
@@ -5026,9 +5026,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.1.10",
|
"version": "19.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
"integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5036,9 +5036,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.1.7",
|
"version": "19.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz",
|
||||||
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
|
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -16268,9 +16268,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.20.4",
|
"version": "4.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz",
|
||||||
"integrity": "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==",
|
"integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -129,8 +129,8 @@
|
|||||||
"@types/node": "^24",
|
"@types/node": "^24",
|
||||||
"@types/nodemailer": "6.4.17",
|
"@types/nodemailer": "6.4.17",
|
||||||
"@types/pg": "8.15.5",
|
"@types/pg": "8.15.5",
|
||||||
"@types/react": "19.1.10",
|
"@types/react": "19.1.12",
|
||||||
"@types/react-dom": "19.1.7",
|
"@types/react-dom": "19.1.9",
|
||||||
"@types/semver": "^7.7.0",
|
"@types/semver": "^7.7.0",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
"react-email": "4.2.8",
|
"react-email": "4.2.8",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.16",
|
||||||
"tsx": "4.20.4",
|
"tsx": "4.20.5",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"typescript-eslint": "^8.40.0"
|
"typescript-eslint": "^8.40.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ export const resourceRules = pgTable("resourceRules", {
|
|||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: varchar("action").notNull(), // ACCEPT, DROP
|
action: varchar("action").notNull(), // ACCEPT, DROP, PASS
|
||||||
match: varchar("match").notNull(), // CIDR, PATH, IP
|
match: varchar("match").notNull(), // CIDR, PATH, IP
|
||||||
value: varchar("value").notNull()
|
value: varchar("value").notNull()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -570,7 +570,7 @@ export const resourceRules = sqliteTable("resourceRules", {
|
|||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").notNull(), // ACCEPT, DROP
|
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
||||||
match: text("match").notNull(), // CIDR, PATH, IP
|
match: text("match").notNull(), // CIDR, PATH, IP
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
});
|
});
|
||||||
|
|||||||
32
server/lib/geoip.ts
Normal file
32
server/lib/geoip.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import config from "./config";
|
||||||
|
import { tokenManager } from "./tokenManager";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
export async function getCountryCodeForIp(
|
||||||
|
ip: string
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/geoip/${ip}`,
|
||||||
|
await tokenManager.getAuthHeader()
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.data.countryCode;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
logger.error("Error fetching config in verify session:", {
|
||||||
|
message: error.message,
|
||||||
|
code: error.code,
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
url: error.config?.url,
|
||||||
|
method: error.config?.method
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error("Error fetching config in verify session:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./response";
|
export * from "./response";
|
||||||
export { tokenManager, TokenManager } from "./tokenManager";
|
export { tokenManager, TokenManager } from "./tokenManager";
|
||||||
|
export * from "./geoip";
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export const configSchema = z
|
|||||||
.default("/var/dynamic/router_config.yml"),
|
.default("/var/dynamic/router_config.yml"),
|
||||||
static_domains: z.array(z.string()).optional().default([]),
|
static_domains: z.array(z.string()).optional().default([]),
|
||||||
site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]),
|
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)
|
file_mode: z.boolean().optional().default(false)
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export async function getValidCertificatesForDomainsHybrid(domains: Set<string>)
|
|||||||
Array<{
|
Array<{
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
wildcard: boolean | null;
|
||||||
certFile: string | null;
|
certFile: string | null;
|
||||||
keyFile: string | null;
|
keyFile: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
@@ -68,6 +69,7 @@ export async function getValidCertificatesForDomains(domains: Set<string>): Prom
|
|||||||
Array<{
|
Array<{
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
wildcard: boolean | null;
|
||||||
certFile: string | null;
|
certFile: string | null;
|
||||||
keyFile: string | null;
|
keyFile: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
export const subdomainSchema = z
|
export const subdomainSchema = z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.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"
|
"Invalid subdomain format"
|
||||||
)
|
)
|
||||||
.min(1, "Subdomain must be at least 1 character long")
|
.min(1, "Subdomain must be at least 1 character long")
|
||||||
@@ -12,7 +12,8 @@ export const subdomainSchema = z
|
|||||||
export const tlsNameSchema = z
|
export const tlsNameSchema = z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.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"
|
"Invalid subdomain format"
|
||||||
)
|
)
|
||||||
.transform((val) => val.toLowerCase());
|
.transform((val) => val.toLowerCase());
|
||||||
|
|
||||||
|
|||||||
235
server/lib/traefikConfig.test.ts
Normal file
235
server/lib/traefikConfig.test.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export class TraefikConfigManager {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
lastModified: Date | null;
|
lastModified: Date | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
|
wildcard: boolean | null;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ export class TraefikConfigManager {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
lastModified: Date | null;
|
lastModified: Date | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
|
wildcard: boolean;
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
> {
|
> {
|
||||||
@@ -136,13 +138,16 @@ export class TraefikConfigManager {
|
|||||||
const certPath = path.join(domainDir, "cert.pem");
|
const certPath = path.join(domainDir, "cert.pem");
|
||||||
const keyPath = path.join(domainDir, "key.pem");
|
const keyPath = path.join(domainDir, "key.pem");
|
||||||
const lastUpdatePath = path.join(domainDir, ".last_update");
|
const lastUpdatePath = path.join(domainDir, ".last_update");
|
||||||
|
const wildcardPath = path.join(domainDir, ".wildcard");
|
||||||
|
|
||||||
const certExists = await this.fileExists(certPath);
|
const certExists = await this.fileExists(certPath);
|
||||||
const keyExists = await this.fileExists(keyPath);
|
const keyExists = await this.fileExists(keyPath);
|
||||||
const lastUpdateExists = await this.fileExists(lastUpdatePath);
|
const lastUpdateExists = await this.fileExists(lastUpdatePath);
|
||||||
|
const wildcardExists = await this.fileExists(wildcardPath);
|
||||||
|
|
||||||
let lastModified: Date | null = null;
|
let lastModified: Date | null = null;
|
||||||
const expiresAt: Date | null = null;
|
const expiresAt: Date | null = null;
|
||||||
|
let wildcard = false;
|
||||||
|
|
||||||
if (lastUpdateExists) {
|
if (lastUpdateExists) {
|
||||||
try {
|
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, {
|
state.set(domain, {
|
||||||
exists: certExists && keyExists,
|
exists: certExists && keyExists,
|
||||||
lastModified,
|
lastModified,
|
||||||
expiresAt
|
expiresAt,
|
||||||
|
wildcard
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -192,19 +213,36 @@ export class TraefikConfigManager {
|
|||||||
return true;
|
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 (
|
if (
|
||||||
this.lastKnownDomains.size !== currentDomains.size ||
|
domainsNeedingCerts.size !== lastDomainsNeedingCerts.size ||
|
||||||
!Array.from(this.lastKnownDomains).every((domain) =>
|
!Array.from(domainsNeedingCerts).every((domain) =>
|
||||||
currentDomains.has(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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any local certificates are missing or appear to be outdated
|
// 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);
|
const localState = this.lastLocalCertificateState.get(domain);
|
||||||
if (!localState || !localState.exists) {
|
if (!localState || !localState.exists) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -273,6 +311,7 @@ export class TraefikConfigManager {
|
|||||||
let validCertificates: Array<{
|
let validCertificates: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
wildcard: boolean | null;
|
||||||
certFile: string | null;
|
certFile: string | null;
|
||||||
keyFile: string | null;
|
keyFile: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
@@ -280,23 +319,50 @@ export class TraefikConfigManager {
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (this.shouldFetchCertificates(domains)) {
|
if (this.shouldFetchCertificates(domains)) {
|
||||||
// Get valid certificates for active domains
|
// Filter out domains that are already covered by wildcard certificates
|
||||||
if (config.isManagedMode()) {
|
const domainsToFetch = new Set<string>();
|
||||||
validCertificates =
|
for (const domain of domains) {
|
||||||
await getValidCertificatesForDomainsHybrid(domains);
|
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||||
} else {
|
domainsToFetch.add(domain);
|
||||||
validCertificates =
|
} else {
|
||||||
await getValidCertificatesForDomains(domains);
|
logger.debug(
|
||||||
|
`Domain ${domain} is covered by existing wildcard certificate, skipping fetch`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.lastCertificateFetch = new Date();
|
|
||||||
this.lastKnownDomains = new Set(domains);
|
|
||||||
|
|
||||||
logger.info(
|
if (domainsToFetch.size > 0) {
|
||||||
`Fetched ${validCertificates.length} certificates from remote`
|
// 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
|
logger.info(
|
||||||
await this.processValidCertificates(validCertificates);
|
`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 {
|
} else {
|
||||||
const timeSinceLastFetch = this.lastCertificateFetch
|
const timeSinceLastFetch = this.lastCertificateFetch
|
||||||
? Math.round(
|
? Math.round(
|
||||||
@@ -544,7 +610,11 @@ export class TraefikConfigManager {
|
|||||||
// Clear existing certificates and rebuild from local state
|
// Clear existing certificates and rebuild from local state
|
||||||
dynamicConfig.tls.certificates = [];
|
dynamicConfig.tls.certificates = [];
|
||||||
|
|
||||||
|
// Keep track of certificates we've already added to avoid duplicates
|
||||||
|
const addedCertPaths = new Set<string>();
|
||||||
|
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
|
// First, try to find an exact match certificate
|
||||||
const localState = this.lastLocalCertificateState.get(domain);
|
const localState = this.lastLocalCertificateState.get(domain);
|
||||||
if (localState && localState.exists) {
|
if (localState && localState.exists) {
|
||||||
const domainDir = path.join(
|
const domainDir = path.join(
|
||||||
@@ -554,11 +624,47 @@ export class TraefikConfigManager {
|
|||||||
const certPath = path.join(domainDir, "cert.pem");
|
const certPath = path.join(domainDir, "cert.pem");
|
||||||
const keyPath = path.join(domainDir, "key.pem");
|
const keyPath = path.join(domainDir, "key.pem");
|
||||||
|
|
||||||
const certEntry = {
|
if (!addedCertPaths.has(certPath)) {
|
||||||
certFile: certPath,
|
const certEntry = {
|
||||||
keyFile: keyPath
|
certFile: certPath,
|
||||||
};
|
keyFile: keyPath
|
||||||
dynamicConfig.tls.certificates.push(certEntry);
|
};
|
||||||
|
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<{
|
validCertificates: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
wildcard: boolean | null;
|
||||||
certFile: string | null;
|
certFile: string | null;
|
||||||
keyFile: string | null;
|
keyFile: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
@@ -651,15 +758,24 @@ export class TraefikConfigManager {
|
|||||||
"utf8"
|
"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(
|
logger.info(
|
||||||
`Certificate updated for domain: ${cert.domain}`
|
`Certificate updated for domain: ${cert.domain}${cert.wildcard ? " (wildcard)" : ""}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update local state tracking
|
// Update local state tracking
|
||||||
this.lastLocalCertificateState.set(cert.domain, {
|
this.lastLocalCertificateState.set(cert.domain, {
|
||||||
exists: true,
|
exists: true,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
expiresAt: cert.expiresAt
|
expiresAt: cert.expiresAt,
|
||||||
|
wildcard: cert.wildcard
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,14 +926,8 @@ export class TraefikConfigManager {
|
|||||||
this.lastLocalCertificateState.delete(dirName);
|
this.lastLocalCertificateState.delete(dirName);
|
||||||
|
|
||||||
// Remove from dynamic config
|
// Remove from dynamic config
|
||||||
const certFilePath = path.join(
|
const certFilePath = path.join(domainDir, "cert.pem");
|
||||||
domainDir,
|
const keyFilePath = path.join(domainDir, "key.pem");
|
||||||
"cert.pem"
|
|
||||||
);
|
|
||||||
const keyFilePath = path.join(
|
|
||||||
domainDir,
|
|
||||||
"key.pem"
|
|
||||||
);
|
|
||||||
const before = dynamicConfig.tls.certificates.length;
|
const before = dynamicConfig.tls.certificates.length;
|
||||||
dynamicConfig.tls.certificates =
|
dynamicConfig.tls.certificates =
|
||||||
dynamicConfig.tls.certificates.filter(
|
dynamicConfig.tls.certificates.filter(
|
||||||
@@ -894,14 +1004,58 @@ export class TraefikConfigManager {
|
|||||||
monitorInterval: number;
|
monitorInterval: number;
|
||||||
lastCertificateFetch: Date | null;
|
lastCertificateFetch: Date | null;
|
||||||
localCertificateCount: number;
|
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 {
|
return {
|
||||||
isRunning: this.isRunning,
|
isRunning: this.isRunning,
|
||||||
activeDomains: Array.from(this.activeDomains),
|
activeDomains: Array.from(this.activeDomains),
|
||||||
monitorInterval:
|
monitorInterval:
|
||||||
config.getRawConfig().traefik.monitor_interval || 5000,
|
config.getRawConfig().traefik.monitor_interval || 5000,
|
||||||
lastCertificateFetch: this.lastCertificateFetch,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export async function verifyRoleAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { roleIds } = req.body;
|
const roleIds = req.body?.roleIds;
|
||||||
const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
|
const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
|
||||||
|
|
||||||
if (allRoleIds.length === 0) {
|
if (allRoleIds.length === 0) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
validateResourceSessionToken
|
validateResourceSessionToken
|
||||||
} from "@server/auth/sessions/resource";
|
} from "@server/auth/sessions/resource";
|
||||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
import { db } from "@server/db";
|
|
||||||
import {
|
import {
|
||||||
getResourceByDomain,
|
getResourceByDomain,
|
||||||
getUserSessionWithUser,
|
getUserSessionWithUser,
|
||||||
@@ -33,6 +32,7 @@ import createHttpError from "http-errors";
|
|||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { getCountryCodeForIp } from "@server/lib";
|
||||||
|
|
||||||
// We'll see if this speeds anything up
|
// We'll see if this speeds anything up
|
||||||
const cache = new NodeCache({
|
const cache = new NodeCache({
|
||||||
@@ -123,8 +123,8 @@ export async function verifyResourceSession(
|
|||||||
let cleanHost = host;
|
let cleanHost = host;
|
||||||
// if the host ends with :port, strip it
|
// if the host ends with :port, strip it
|
||||||
if (cleanHost.match(/:[0-9]{1,5}$/)) {
|
if (cleanHost.match(/:[0-9]{1,5}$/)) {
|
||||||
const matched = ''+cleanHost.match(/:[0-9]{1,5}$/);
|
const matched = "" + cleanHost.match(/:[0-9]{1,5}$/);
|
||||||
cleanHost = cleanHost.slice(0, -1*matched.length);
|
cleanHost = cleanHost.slice(0, -1 * matched.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceCacheKey = `resource:${cleanHost}`;
|
const resourceCacheKey = `resource:${cleanHost}`;
|
||||||
@@ -176,6 +176,11 @@ export async function verifyResourceSession(
|
|||||||
} else if (action == "DROP") {
|
} else if (action == "DROP") {
|
||||||
logger.debug("Resource denied by rule");
|
logger.debug("Resource denied by rule");
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
|
} else if (action == "PASS") {
|
||||||
|
logger.debug(
|
||||||
|
"Resource passed by rule, continuing to auth checks"
|
||||||
|
);
|
||||||
|
// Continue to authentication checks below
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise its undefined and we pass
|
// otherwise its undefined and we pass
|
||||||
@@ -193,7 +198,10 @@ export async function verifyResourceSession(
|
|||||||
|
|
||||||
let endpoint: string;
|
let endpoint: string;
|
||||||
if (config.isManagedMode()) {
|
if (config.isManagedMode()) {
|
||||||
endpoint = config.getRawConfig().managed?.redirect_endpoint || config.getRawConfig().managed?.endpoint || "";
|
endpoint =
|
||||||
|
config.getRawConfig().managed?.redirect_endpoint ||
|
||||||
|
config.getRawConfig().managed?.endpoint ||
|
||||||
|
"";
|
||||||
} else {
|
} else {
|
||||||
endpoint = config.getRawConfig().app.dashboard_url!;
|
endpoint = config.getRawConfig().app.dashboard_url!;
|
||||||
}
|
}
|
||||||
@@ -576,7 +584,7 @@ async function checkRules(
|
|||||||
resourceId: number,
|
resourceId: number,
|
||||||
clientIp: string | undefined,
|
clientIp: string | undefined,
|
||||||
path: string | undefined
|
path: string | undefined
|
||||||
): Promise<"ACCEPT" | "DROP" | undefined> {
|
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
|
||||||
const ruleCacheKey = `rules:${resourceId}`;
|
const ruleCacheKey = `rules:${resourceId}`;
|
||||||
|
|
||||||
let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
|
let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
|
||||||
@@ -613,6 +621,12 @@ async function checkRules(
|
|||||||
isPathAllowed(rule.value, path)
|
isPathAllowed(rule.value, path)
|
||||||
) {
|
) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
|
} else if (
|
||||||
|
clientIp &&
|
||||||
|
rule.match == "GEOIP" &&
|
||||||
|
(await isIpInGeoIP(clientIp, rule.value))
|
||||||
|
) {
|
||||||
|
return rule.action as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,3 +751,23 @@ export function isPathAllowed(pattern: string, path: string): boolean {
|
|||||||
logger.debug(`Final result: ${result}`);
|
logger.debug(`Final result: ${result}`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isIpInGeoIP(ip: string, countryCode: string): Promise<boolean> {
|
||||||
|
if (countryCode == "ALL") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geoIpCacheKey = `geoip:${ip}`;
|
||||||
|
|
||||||
|
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
|
||||||
|
|
||||||
|
if (!cachedCountryCode) {
|
||||||
|
cachedCountryCode = await getCountryCodeForIp(ip);
|
||||||
|
// Cache for longer since IP geolocation doesn't change frequently
|
||||||
|
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`IP ${ip} is in country: ${cachedCountryCode}`);
|
||||||
|
|
||||||
|
return cachedCountryCode?.toUpperCase() === countryCode.toUpperCase();
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ authenticated.delete(
|
|||||||
"/org/:orgId",
|
"/org/:orgId",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserIsOrgOwner,
|
verifyUserIsOrgOwner,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteOrg),
|
||||||
org.deleteOrg
|
org.deleteOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
58
server/routers/gerbil/createExitNode.ts
Normal file
58
server/routers/gerbil/createExitNode.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { db, ExitNode, exitNodes } from "@server/db";
|
||||||
|
import { getUniqueExitNodeEndpointName } from "@server/db/names";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function createExitNode(publicKey: string, reachableAt: string | undefined) {
|
||||||
|
// Fetch exit node
|
||||||
|
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
|
||||||
|
let exitNode: ExitNode;
|
||||||
|
if (!exitNodeQuery) {
|
||||||
|
const address = await getNextAvailableSubnet();
|
||||||
|
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||||
|
// const listenPort = await getNextAvailablePort();
|
||||||
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
|
let subEndpoint = "";
|
||||||
|
if (config.getRawConfig().gerbil.use_subdomain) {
|
||||||
|
subEndpoint = await getUniqueExitNodeEndpointName();
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitNodeName =
|
||||||
|
config.getRawConfig().gerbil.exit_node_name ||
|
||||||
|
`Exit Node ${publicKey.slice(0, 8)}`;
|
||||||
|
|
||||||
|
// create a new exit node
|
||||||
|
[exitNode] = await db
|
||||||
|
.insert(exitNodes)
|
||||||
|
.values({
|
||||||
|
publicKey,
|
||||||
|
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
||||||
|
address,
|
||||||
|
listenPort,
|
||||||
|
reachableAt,
|
||||||
|
name: exitNodeName
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// update the existing exit node
|
||||||
|
[exitNode] = await db
|
||||||
|
.update(exitNodes)
|
||||||
|
.set({
|
||||||
|
reachableAt,
|
||||||
|
publicKey
|
||||||
|
})
|
||||||
|
.where(eq(exitNodes.publicKey, publicKey))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
logger.info(`Updated exit node`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return exitNode;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { getAllowedIps } from "../target/helpers";
|
import { getAllowedIps } from "../target/helpers";
|
||||||
import { proxyToRemote } from "@server/lib/remoteProxy";
|
import { proxyToRemote } from "@server/lib/remoteProxy";
|
||||||
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
|
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
|
||||||
|
import { createExitNode } from "./createExitNode";
|
||||||
|
|
||||||
// Define Zod schema for request validation
|
// Define Zod schema for request validation
|
||||||
const getConfigSchema = z.object({
|
const getConfigSchema = z.object({
|
||||||
publicKey: z.string(),
|
publicKey: z.string(),
|
||||||
@@ -53,46 +55,7 @@ export async function getConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch exit node
|
const exitNode = await createExitNode(publicKey, reachableAt);
|
||||||
const exitNodeQuery = await db
|
|
||||||
.select()
|
|
||||||
.from(exitNodes)
|
|
||||||
.where(eq(exitNodes.publicKey, publicKey));
|
|
||||||
let exitNode;
|
|
||||||
if (exitNodeQuery.length === 0) {
|
|
||||||
const address = await getNextAvailableSubnet();
|
|
||||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
|
||||||
// const listenPort = await getNextAvailablePort();
|
|
||||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
|
||||||
let subEndpoint = "";
|
|
||||||
if (config.getRawConfig().gerbil.use_subdomain) {
|
|
||||||
subEndpoint = await getUniqueExitNodeEndpointName();
|
|
||||||
}
|
|
||||||
|
|
||||||
const exitNodeName =
|
|
||||||
config.getRawConfig().gerbil.exit_node_name ||
|
|
||||||
`Exit Node ${publicKey.slice(0, 8)}`;
|
|
||||||
|
|
||||||
// create a new exit node
|
|
||||||
exitNode = await db
|
|
||||||
.insert(exitNodes)
|
|
||||||
.values({
|
|
||||||
publicKey,
|
|
||||||
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
|
|
||||||
address,
|
|
||||||
listenPort,
|
|
||||||
reachableAt,
|
|
||||||
name: exitNodeName
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Created new exit node ${exitNode[0].name} with address ${exitNode[0].address} and port ${exitNode[0].listenPort}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
exitNode = exitNodeQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!exitNode) {
|
if (!exitNode) {
|
||||||
return next(
|
return next(
|
||||||
@@ -107,13 +70,13 @@ export async function getConfig(
|
|||||||
if (config.isManagedMode()) {
|
if (config.isManagedMode()) {
|
||||||
req.body = {
|
req.body = {
|
||||||
...req.body,
|
...req.body,
|
||||||
endpoint: exitNode[0].endpoint,
|
endpoint: exitNode.endpoint,
|
||||||
listenPort: exitNode[0].listenPort
|
listenPort: exitNode.listenPort
|
||||||
};
|
};
|
||||||
return proxyToRemote(req, res, next, "hybrid/gerbil/get-config");
|
return proxyToRemote(req, res, next, "hybrid/gerbil/get-config");
|
||||||
}
|
}
|
||||||
|
|
||||||
const configResponse = await generateGerbilConfig(exitNode[0]);
|
const configResponse = await generateGerbilConfig(exitNode);
|
||||||
|
|
||||||
logger.debug("Sending config: ", configResponse);
|
logger.debug("Sending config: ", configResponse);
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,13 @@ authenticated.get(
|
|||||||
domain.listDomains
|
domain.listDomains
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/invitations",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listInvitations),
|
||||||
|
user.listInvitations
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/create-invite",
|
"/org/:orgId/create-invite",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -49,19 +49,7 @@ export async function deleteOrg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
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
|
const [org] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(orgs)
|
.from(orgs)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
|
|
||||||
const createResourceRuleSchema = z
|
const createResourceRuleSchema = z
|
||||||
.object({
|
.object({
|
||||||
action: z.enum(["ACCEPT", "DROP"]),
|
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||||
match: z.enum(["CIDR", "IP", "PATH"]),
|
match: z.enum(["CIDR", "IP", "PATH"]),
|
||||||
value: z.string().min(1),
|
value: z.string().min(1),
|
||||||
priority: z.number().int(),
|
priority: z.number().int(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { apiKeys, roleResources, roles } from "@server/db";
|
import { apiKeys, roleResources, roles } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -74,13 +74,18 @@ export async function setResourceRoles(
|
|||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
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(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"Organization not found"
|
"Resource not found"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,7 +97,7 @@ export async function setResourceRoles(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roles.name, "Admin"),
|
eq(roles.name, "Admin"),
|
||||||
eq(roles.orgId, orgId)
|
eq(roles.orgId, resource.orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const updateResourceRuleParamsSchema = z
|
|||||||
// Define Zod schema for request body validation
|
// Define Zod schema for request body validation
|
||||||
const updateResourceRuleSchema = z
|
const updateResourceRuleSchema = z
|
||||||
.object({
|
.object({
|
||||||
action: z.enum(["ACCEPT", "DROP"]).optional(),
|
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
|
||||||
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
|
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
|
||||||
value: z.string().min(1).optional(),
|
value: z.string().min(1).optional(),
|
||||||
priority: z.number().int(),
|
priority: z.number().int(),
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ export async function createSite(
|
|||||||
type,
|
type,
|
||||||
dockerSocketEnabled: false,
|
dockerSocketEnabled: false,
|
||||||
online: true,
|
online: true,
|
||||||
subnet: "0.0.0.0/0"
|
subnet: "0.0.0.0/32"
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { db, exitNodes } from "@server/db";
|
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 logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
@@ -149,7 +149,10 @@ export async function getTraefikConfig(
|
|||||||
eq(sites.exitNodeId, exitNodeId),
|
eq(sites.exitNodeId, exitNodeId),
|
||||||
isNull(sites.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),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await db
|
const existingUser = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.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);
|
.limit(1);
|
||||||
|
|
||||||
if (existingUser.length === 0) {
|
if (existingUser.length === 0) {
|
||||||
@@ -93,7 +98,7 @@ export async function addUserRole(
|
|||||||
const roleExists = await db
|
const roleExists = await db
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.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);
|
.limit(1);
|
||||||
|
|
||||||
if (roleExists.length === 0) {
|
if (roleExists.length === 0) {
|
||||||
@@ -108,7 +113,7 @@ export async function addUserRole(
|
|||||||
const newUserRole = await db
|
const newUserRole = await db
|
||||||
.update(userOrgs)
|
.update(userOrgs)
|
||||||
.set({ roleId })
|
.set({ roleId })
|
||||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage,
|
||||||
|
FormDescription
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +34,7 @@ import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
|
|||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
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 CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
@@ -43,9 +44,58 @@ import {
|
|||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { build } from "@server/build";
|
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({
|
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"])
|
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 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 = [];
|
let domainOptions: any = [];
|
||||||
if (build == "enterprise" || build == "saas") {
|
if (build == "enterprise" || build == "saas") {
|
||||||
@@ -182,10 +238,23 @@ export default function CreateDomainForm({
|
|||||||
<FormLabel>{t("domain")}</FormLabel>
|
<FormLabel>{t("domain")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="example.com"
|
placeholder="example.com, café.com, 日本.com"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</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 />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -206,66 +275,73 @@ export default function CreateDomainForm({
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{createdDomain.nsRecords &&
|
{createdDomain.nsRecords &&
|
||||||
createdDomain.nsRecords.length > 0 && (
|
createdDomain.nsRecords.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium mb-3">
|
<h3 className="font-medium mb-3">
|
||||||
{t("createDomainNsRecords")}
|
{t("createDomainNsRecords")}
|
||||||
</h3>
|
</h3>
|
||||||
<InfoSections cols={1}>
|
<InfoSections cols={1}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("createDomainRecord")}
|
{t("createDomainRecord")}
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
"createDomainType"
|
"createDomainType"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-mono">
|
<span className="text-sm font-mono">
|
||||||
NS
|
NS
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
"createDomainName"
|
"createDomainName"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-mono">
|
<div className="text-right">
|
||||||
{baseDomain}
|
<span className="text-sm font-mono block">
|
||||||
</span>
|
{fromPunycode(baseDomain)}
|
||||||
</div>
|
</span>
|
||||||
<span className="text-sm font-medium">
|
{fromPunycode(baseDomain) !== baseDomain && (
|
||||||
{t(
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
"createDomainValue"
|
({baseDomain})
|
||||||
)}
|
</span>
|
||||||
</span>
|
)}
|
||||||
{createdDomain.nsRecords.map(
|
|
||||||
(
|
|
||||||
nsRecord,
|
|
||||||
index
|
|
||||||
) => (
|
|
||||||
<div
|
|
||||||
className="flex justify-between items-center"
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<CopyToClipboard
|
|
||||||
text={
|
|
||||||
nsRecord
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
)}
|
<span className="text-sm font-medium">
|
||||||
</div>
|
{t(
|
||||||
</InfoSectionContent>
|
"createDomainValue"
|
||||||
</InfoSection>
|
)}
|
||||||
</InfoSections>
|
</span>
|
||||||
</div>
|
{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 &&
|
||||||
createdDomain.cnameRecords.length > 0 && (
|
createdDomain.cnameRecords.length > 0 && (
|
||||||
@@ -307,11 +383,16 @@ export default function CreateDomainForm({
|
|||||||
"createDomainName"
|
"createDomainName"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-mono">
|
<div className="text-right">
|
||||||
{
|
<span className="text-sm font-mono block">
|
||||||
cnameRecord.baseDomain
|
{fromPunycode(cnameRecord.baseDomain)}
|
||||||
}
|
</span>
|
||||||
</span>
|
{fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
({cnameRecord.baseDomain})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
@@ -374,11 +455,16 @@ export default function CreateDomainForm({
|
|||||||
"createDomainName"
|
"createDomainName"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-mono">
|
<div className="text-right">
|
||||||
{
|
<span className="text-sm font-mono block">
|
||||||
aRecord.baseDomain
|
{fromPunycode(aRecord.baseDomain)}
|
||||||
}
|
</span>
|
||||||
</span>
|
{fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
({aRecord.baseDomain})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
@@ -390,7 +476,7 @@ export default function CreateDomainForm({
|
|||||||
{
|
{
|
||||||
aRecord.value
|
aRecord.value
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -440,11 +526,16 @@ export default function CreateDomainForm({
|
|||||||
"createDomainName"
|
"createDomainName"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-mono">
|
<div className="text-right">
|
||||||
{
|
<span className="text-sm font-mono block">
|
||||||
txtRecord.baseDomain
|
{fromPunycode(txtRecord.baseDomain)}
|
||||||
}
|
</span>
|
||||||
</span>
|
{fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
({txtRecord.baseDomain})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
@@ -513,4 +604,4 @@ export default function CreateDomainForm({
|
|||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { ListDomainsResponse } from "@server/routers/domain";
|
import { ListDomainsResponse } from "@server/routers/domain";
|
||||||
|
import { toUnicode } from 'punycode';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
@@ -22,7 +23,13 @@ export default async function DomainsPage(props: Props) {
|
|||||||
const res = await internal.get<
|
const res = await internal.get<
|
||||||
AxiosResponse<ListDomainsResponse>
|
AxiosResponse<ListDomainsResponse>
|
||||||
>(`/org/${params.orgId}/domains`, await authCookieHeader());
|
>(`/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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { toUnicode } from "punycode";
|
||||||
|
|
||||||
interface DomainOption {
|
interface DomainOption {
|
||||||
baseDomain: string;
|
baseDomain: string;
|
||||||
@@ -91,7 +92,7 @@ export default function CustomDomainInput({
|
|||||||
key={option.domainId}
|
key={option.domainId}
|
||||||
value={option.domainId}
|
value={option.domainId}
|
||||||
>
|
>
|
||||||
.{option.baseDomain}
|
.{toUnicode(option.baseDomain)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ import {
|
|||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { toUnicode } from 'punycode';
|
||||||
|
|
||||||
type ResourceInfoBoxType = {};
|
type ResourceInfoBoxType = {};
|
||||||
|
|
||||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||||
const { resource, authInfo } = useResourceContext();
|
const { resource, authInfo } = useResourceContext();
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
|
||||||
|
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
@@ -34,9 +38,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{authInfo.password ||
|
{authInfo.password ||
|
||||||
authInfo.pincode ||
|
authInfo.pincode ||
|
||||||
authInfo.sso ||
|
authInfo.sso ||
|
||||||
authInfo.whitelist ? (
|
authInfo.whitelist ? (
|
||||||
<div className="flex items-start space-x-2 text-green-500">
|
<div className="flex items-start space-x-2 text-green-500">
|
||||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||||
<span>{t("protected")}</span>
|
<span>{t("protected")}</span>
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ import {
|
|||||||
import DomainPicker from "@app/components/DomainPicker";
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
import { Globe } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
import { build } from "@server/build";
|
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() {
|
export default function GeneralForm() {
|
||||||
const [formKey, setFormKey] = useState(0);
|
const [formKey, setFormKey] = useState(0);
|
||||||
@@ -79,12 +82,13 @@ export default function GeneralForm() {
|
|||||||
|
|
||||||
const [loadingPage, setLoadingPage] = useState(true);
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
const [resourceFullDomain, setResourceFullDomain] = useState(
|
const [resourceFullDomain, setResourceFullDomain] = useState(
|
||||||
`${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
|
||||||
);
|
);
|
||||||
const [selectedDomain, setSelectedDomain] = useState<{
|
const [selectedDomain, setSelectedDomain] = useState<{
|
||||||
domainId: string;
|
domainId: string;
|
||||||
subdomain?: string;
|
subdomain?: string;
|
||||||
fullDomain: string;
|
fullDomain: string;
|
||||||
|
baseDomain: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const GeneralFormSchema = z
|
const GeneralFormSchema = z
|
||||||
@@ -153,7 +157,11 @@ export default function GeneralForm() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res?.status === 200) {
|
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);
|
setBaseDomains(domains);
|
||||||
setFormKey((key) => key + 1);
|
setFormKey((key) => key + 1);
|
||||||
}
|
}
|
||||||
@@ -178,7 +186,7 @@ export default function GeneralForm() {
|
|||||||
{
|
{
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
subdomain: data.subdomain,
|
subdomain: data.subdomain ? toASCII(data.subdomain) : undefined,
|
||||||
domainId: data.domainId,
|
domainId: data.domainId,
|
||||||
proxyPort: data.proxyPort,
|
proxyPort: data.proxyPort,
|
||||||
// ...(!resource.http && {
|
// ...(!resource.http && {
|
||||||
@@ -317,10 +325,10 @@ export default function GeneralForm() {
|
|||||||
.target
|
.target
|
||||||
.value
|
.value
|
||||||
? parseInt(
|
? parseInt(
|
||||||
e
|
e
|
||||||
.target
|
.target
|
||||||
.value
|
.value
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -441,7 +449,8 @@ export default function GeneralForm() {
|
|||||||
const selected = {
|
const selected = {
|
||||||
domainId: res.domainId,
|
domainId: res.domainId,
|
||||||
subdomain: res.subdomain,
|
subdomain: res.subdomain,
|
||||||
fullDomain: res.fullDomain
|
fullDomain: res.fullDomain,
|
||||||
|
baseDomain: res.baseDomain
|
||||||
};
|
};
|
||||||
setSelectedDomain(selected);
|
setSelectedDomain(selected);
|
||||||
}}
|
}}
|
||||||
@@ -454,18 +463,23 @@ export default function GeneralForm() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectedDomain) {
|
if (selectedDomain) {
|
||||||
setResourceFullDomain(
|
const sanitizedSubdomain = selectedDomain.subdomain
|
||||||
selectedDomain.fullDomain
|
? finalizeSubdomainSanitize(selectedDomain.subdomain)
|
||||||
);
|
: "";
|
||||||
form.setValue(
|
|
||||||
"domainId",
|
const sanitizedFullDomain = sanitizedSubdomain
|
||||||
selectedDomain.domainId
|
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
|
||||||
);
|
: selectedDomain.baseDomain;
|
||||||
form.setValue(
|
|
||||||
"subdomain",
|
setResourceFullDomain(sanitizedFullDomain);
|
||||||
selectedDomain.subdomain
|
form.setValue("domainId", selectedDomain.domainId);
|
||||||
);
|
form.setValue("subdomain", sanitizedSubdomain);
|
||||||
|
|
||||||
setEditDomainOpen(false);
|
setEditDomainOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: `Final domain: ${sanitizedFullDomain}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ import {
|
|||||||
CommandList
|
CommandList
|
||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||||
|
|
||||||
const addTargetSchema = z.object({
|
const addTargetSchema = z.object({
|
||||||
ip: z.string().refine(isTargetValid),
|
ip: z.string().refine(isTargetValid),
|
||||||
@@ -417,11 +418,11 @@ export default function ReverseProxyTargets(props: {
|
|||||||
targets.map((target) =>
|
targets.map((target) =>
|
||||||
target.targetId === targetId
|
target.targetId === targetId
|
||||||
? {
|
? {
|
||||||
...target,
|
...target,
|
||||||
...data,
|
...data,
|
||||||
updated: true,
|
updated: true,
|
||||||
siteType: site?.type || null
|
siteType: site?.type || null
|
||||||
}
|
}
|
||||||
: target
|
: target
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -545,7 +546,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!row.original.siteId &&
|
!row.original.siteId &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.original.siteId
|
{row.original.siteId
|
||||||
@@ -614,31 +615,31 @@ export default function ReverseProxyTargets(props: {
|
|||||||
},
|
},
|
||||||
...(resource.http
|
...(resource.http
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
accessorKey: "method",
|
accessorKey: "method",
|
||||||
header: t("method"),
|
header: t("method"),
|
||||||
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.method ?? ""}
|
defaultValue={row.original.method ?? ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateTarget(row.original.targetId, {
|
updateTarget(row.original.targetId, {
|
||||||
...row.original,
|
...row.original,
|
||||||
method: value
|
method: value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
{row.original.method}
|
{row.original.method}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
<SelectItem value="http">http</SelectItem>
|
||||||
<SelectItem value="https">https</SelectItem>
|
<SelectItem value="https">https</SelectItem>
|
||||||
<SelectItem value="h2c">h2c</SelectItem>
|
<SelectItem value="h2c">h2c</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
@@ -647,13 +648,25 @@ export default function ReverseProxyTargets(props: {
|
|||||||
<Input
|
<Input
|
||||||
defaultValue={row.original.ip}
|
defaultValue={row.original.ip}
|
||||||
className="min-w-[150px]"
|
className="min-w-[150px]"
|
||||||
onBlur={(e) =>
|
onBlur={(e) => {
|
||||||
updateTarget(row.original.targetId, {
|
const parsed = parseHostTarget(e.target.value);
|
||||||
...row.original,
|
if (parsed) {
|
||||||
ip: e.target.value
|
updateTarget(row.original.targetId, {
|
||||||
})
|
...row.original,
|
||||||
}
|
method: parsed.protocol,
|
||||||
|
ip: parsed.host,
|
||||||
|
port: parsed.port
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateTarget(row.original.targetId, {
|
||||||
|
...row.original,
|
||||||
|
ip: e.target.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -785,21 +798,21 @@ export default function ReverseProxyTargets(props: {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!field.value &&
|
!field.value &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value
|
{field.value
|
||||||
? sites.find(
|
? sites.find(
|
||||||
(
|
(
|
||||||
site
|
site
|
||||||
) =>
|
) =>
|
||||||
site.siteId ===
|
site.siteId ===
|
||||||
field.value
|
field.value
|
||||||
)
|
)
|
||||||
?.name
|
?.name
|
||||||
: t(
|
: t(
|
||||||
"siteSelect"
|
"siteSelect"
|
||||||
)}
|
)}
|
||||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -865,18 +878,18 @@ export default function ReverseProxyTargets(props: {
|
|||||||
);
|
);
|
||||||
return selectedSite &&
|
return selectedSite &&
|
||||||
selectedSite.type ===
|
selectedSite.type ===
|
||||||
"newt" ? (() => {
|
"newt" ? (() => {
|
||||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
||||||
return (
|
return (
|
||||||
<ContainersSelector
|
<ContainersSelector
|
||||||
site={selectedSite}
|
site={selectedSite}
|
||||||
containers={dockerState.containers}
|
containers={dockerState.containers}
|
||||||
isAvailable={dockerState.isAvailable}
|
isAvailable={dockerState.isAvailable}
|
||||||
onContainerSelect={handleContainerSelect}
|
onContainerSelect={handleContainerSelect}
|
||||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})() : null;
|
})() : null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -942,11 +955,22 @@ export default function ReverseProxyTargets(props: {
|
|||||||
name="ip"
|
name="ip"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="relative">
|
<FormItem className="relative">
|
||||||
<FormLabel>
|
<FormLabel>{t("targetAddr")}</FormLabel>
|
||||||
{t("targetAddr")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input id="ip" {...field} />
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -1048,12 +1072,12 @@ export default function ReverseProxyTargets(props: {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header
|
header
|
||||||
.column
|
.column
|
||||||
.columnDef
|
.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
// Schema for rule validation
|
// Schema for rule validation
|
||||||
const addRuleSchema = z.object({
|
const addRuleSchema = z.object({
|
||||||
action: z.string(),
|
action: z.enum(["ACCEPT", "DROP", "PASS"]),
|
||||||
match: z.string(),
|
match: z.string(),
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
priority: z.coerce.number().int().optional()
|
priority: z.coerce.number().int().optional()
|
||||||
@@ -104,7 +104,8 @@ export default function ResourceRules(props: {
|
|||||||
|
|
||||||
const RuleAction = {
|
const RuleAction = {
|
||||||
ACCEPT: t('alwaysAllow'),
|
ACCEPT: t('alwaysAllow'),
|
||||||
DROP: t('alwaysDeny')
|
DROP: t('alwaysDeny'),
|
||||||
|
PASS: t('passToAuth')
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const RuleMatch = {
|
const RuleMatch = {
|
||||||
@@ -113,7 +114,7 @@ export default function ResourceRules(props: {
|
|||||||
CIDR: t('ipAddressRange')
|
CIDR: t('ipAddressRange')
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const addRuleForm = useForm({
|
const addRuleForm = useForm<z.infer<typeof addRuleSchema>>({
|
||||||
resolver: zodResolver(addRuleSchema),
|
resolver: zodResolver(addRuleSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
action: "ACCEPT",
|
action: "ACCEPT",
|
||||||
@@ -437,7 +438,7 @@ export default function ResourceRules(props: {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.action}
|
defaultValue={row.original.action}
|
||||||
onValueChange={(value: "ACCEPT" | "DROP") =>
|
onValueChange={(value: "ACCEPT" | "DROP" | "PASS") =>
|
||||||
updateRule(row.original.ruleId, { action: value })
|
updateRule(row.original.ruleId, { action: value })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -449,6 +450,7 @@ export default function ResourceRules(props: {
|
|||||||
{RuleAction.ACCEPT}
|
{RuleAction.ACCEPT}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
|
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
|
||||||
|
<SelectItem value="PASS">{RuleAction.PASS}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
@@ -629,6 +631,9 @@ export default function ResourceRules(props: {
|
|||||||
<SelectItem value="DROP">
|
<SelectItem value="DROP">
|
||||||
{RuleAction.DROP}
|
{RuleAction.DROP}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="PASS">
|
||||||
|
{RuleAction.PASS}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ import { ArrayElement } from "@server/types/ArrayElement";
|
|||||||
import { isTargetValid } from "@server/lib/validators";
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
import { ListTargetsResponse } from "@server/routers/target";
|
import { ListTargetsResponse } from "@server/routers/target";
|
||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||||
|
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||||
|
import { toASCII, toUnicode } from 'punycode';
|
||||||
|
import { DomainRow } from "../../domains/DomainsTable";
|
||||||
|
|
||||||
const baseResourceFormSchema = z.object({
|
const baseResourceFormSchema = z.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
@@ -164,12 +167,12 @@ export default function Page() {
|
|||||||
...(!env.flags.allowRawResources
|
...(!env.flags.allowRawResources
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
id: "raw" as ResourceType,
|
id: "raw" as ResourceType,
|
||||||
title: t("resourceRaw"),
|
title: t("resourceRaw"),
|
||||||
description: t("resourceRawDescription")
|
description: t("resourceRawDescription")
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseForm = useForm<BaseResourceFormValues>({
|
const baseForm = useForm<BaseResourceFormValues>({
|
||||||
@@ -301,11 +304,11 @@ export default function Page() {
|
|||||||
targets.map((target) =>
|
targets.map((target) =>
|
||||||
target.targetId === targetId
|
target.targetId === targetId
|
||||||
? {
|
? {
|
||||||
...target,
|
...target,
|
||||||
...data,
|
...data,
|
||||||
updated: true,
|
updated: true,
|
||||||
siteType: site?.type || null
|
siteType: site?.type || null
|
||||||
}
|
}
|
||||||
: target
|
: target
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -326,7 +329,7 @@ export default function Page() {
|
|||||||
if (isHttp) {
|
if (isHttp) {
|
||||||
const httpData = httpForm.getValues();
|
const httpData = httpForm.getValues();
|
||||||
Object.assign(payload, {
|
Object.assign(payload, {
|
||||||
subdomain: httpData.subdomain,
|
subdomain: httpData.subdomain ? toASCII(httpData.subdomain) : undefined,
|
||||||
domainId: httpData.domainId,
|
domainId: httpData.domainId,
|
||||||
protocol: "tcp"
|
protocol: "tcp"
|
||||||
});
|
});
|
||||||
@@ -468,7 +471,11 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res?.status === 200) {
|
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);
|
setBaseDomains(domains);
|
||||||
// if (domains.length) {
|
// if (domains.length) {
|
||||||
// httpForm.setValue("domainId", domains[0].domainId);
|
// httpForm.setValue("domainId", domains[0].domainId);
|
||||||
@@ -520,7 +527,7 @@ export default function Page() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!row.original.siteId &&
|
!row.original.siteId &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.original.siteId
|
{row.original.siteId
|
||||||
@@ -589,31 +596,31 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
...(baseForm.watch("http")
|
...(baseForm.watch("http")
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
accessorKey: "method",
|
accessorKey: "method",
|
||||||
header: t("method"),
|
header: t("method"),
|
||||||
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.method ?? ""}
|
defaultValue={row.original.method ?? ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateTarget(row.original.targetId, {
|
updateTarget(row.original.targetId, {
|
||||||
...row.original,
|
...row.original,
|
||||||
method: value
|
method: value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
{row.original.method}
|
{row.original.method}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
<SelectItem value="http">http</SelectItem>
|
||||||
<SelectItem value="https">https</SelectItem>
|
<SelectItem value="https">https</SelectItem>
|
||||||
<SelectItem value="h2c">h2c</SelectItem>
|
<SelectItem value="h2c">h2c</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
@@ -622,12 +629,23 @@ export default function Page() {
|
|||||||
<Input
|
<Input
|
||||||
defaultValue={row.original.ip}
|
defaultValue={row.original.ip}
|
||||||
className="min-w-[150px]"
|
className="min-w-[150px]"
|
||||||
onBlur={(e) =>
|
onBlur={(e) => {
|
||||||
updateTarget(row.original.targetId, {
|
const parsed = parseHostTarget(e.target.value);
|
||||||
...row.original,
|
|
||||||
ip: 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
|
.target
|
||||||
.value
|
.value
|
||||||
? parseInt(
|
? parseInt(
|
||||||
e
|
e
|
||||||
.target
|
.target
|
||||||
.value
|
.value
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1015,21 +1033,21 @@ export default function Page() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!field.value &&
|
!field.value &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value
|
{field.value
|
||||||
? sites.find(
|
? sites.find(
|
||||||
(
|
(
|
||||||
site
|
site
|
||||||
) =>
|
) =>
|
||||||
site.siteId ===
|
site.siteId ===
|
||||||
field.value
|
field.value
|
||||||
)
|
)
|
||||||
?.name
|
?.name
|
||||||
: t(
|
: t(
|
||||||
"siteSelect"
|
"siteSelect"
|
||||||
)}
|
)}
|
||||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -1097,18 +1115,18 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
return selectedSite &&
|
return selectedSite &&
|
||||||
selectedSite.type ===
|
selectedSite.type ===
|
||||||
"newt" ? (() => {
|
"newt" ? (() => {
|
||||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
||||||
return (
|
return (
|
||||||
<ContainersSelector
|
<ContainersSelector
|
||||||
site={selectedSite}
|
site={selectedSite}
|
||||||
containers={dockerState.containers}
|
containers={dockerState.containers}
|
||||||
isAvailable={dockerState.isAvailable}
|
isAvailable={dockerState.isAvailable}
|
||||||
onContainerSelect={handleContainerSelect}
|
onContainerSelect={handleContainerSelect}
|
||||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})() : null;
|
})() : null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -1176,21 +1194,25 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={
|
control={addTargetForm.control}
|
||||||
addTargetForm.control
|
|
||||||
}
|
|
||||||
name="ip"
|
name="ip"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="relative">
|
<FormItem className="relative">
|
||||||
<FormLabel>
|
<FormLabel>{t("targetAddr")}</FormLabel>
|
||||||
{t(
|
|
||||||
"targetAddr"
|
|
||||||
)}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
id="ip"
|
id="ip"
|
||||||
{...field}
|
{...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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -1270,12 +1292,12 @@ export default function Page() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header
|
header
|
||||||
.column
|
.column
|
||||||
.columnDef
|
.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
|||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
|
import { toUnicode } from "punycode";
|
||||||
|
|
||||||
type ResourcesPageProps = {
|
type ResourcesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
@@ -75,7 +76,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
id: resource.resourceId,
|
id: resource.resourceId,
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
|
||||||
|
|
||||||
|
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
||||||
protocol: resource.protocol,
|
protocol: resource.protocol,
|
||||||
proxyPort: resource.proxyPort,
|
proxyPort: resource.proxyPort,
|
||||||
http: resource.http,
|
http: resource.http,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import {
|
|||||||
} from "@app/components/ui/collapsible";
|
} from "@app/components/ui/collapsible";
|
||||||
import AccessTokenSection from "./AccessTokenUsage";
|
import AccessTokenSection from "./AccessTokenUsage";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toUnicode } from 'punycode';
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -159,7 +160,7 @@ export default function CreateShareLinkForm({
|
|||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
resourceId: r.resourceId,
|
resourceId: r.resourceId,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
|
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
@@ -18,30 +20,27 @@ import {
|
|||||||
ExternalLink
|
ExternalLink
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function ManagedPage() {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
export default async function ManagedPage() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title="Managed Self-Hosted"
|
title={t("managedSelfHosted.title")}
|
||||||
description="More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles"
|
description={t("managedSelfHosted.description")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
<strong>Managed Self-Hosted Pangolin</strong> is a
|
<strong>{t("managedSelfHosted.introTitle")}</strong>{" "}
|
||||||
deployment option designed for people who want
|
{t("managedSelfHosted.introDescription")}
|
||||||
simplicity and extra reliability while still keeping
|
|
||||||
their data private and self-hosted.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
With this option, you still run your own Pangolin
|
{t("managedSelfHosted.introDetail")}
|
||||||
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:
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 py-4">
|
<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" />
|
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
Simpler operations
|
{t(
|
||||||
|
"managedSelfHosted.benefitSimplerOperations.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No need to run your own mail server
|
{t(
|
||||||
or set up complex alerting. You'll
|
"managedSelfHosted.benefitSimplerOperations.description"
|
||||||
get health checks and downtime
|
)}
|
||||||
alerts out of the box.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<RefreshCw className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
Automatic updates
|
{t(
|
||||||
|
"managedSelfHosted.benefitAutomaticUpdates.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The cloud dashboard evolves quickly,
|
{t(
|
||||||
so you get new features and bug
|
"managedSelfHosted.benefitAutomaticUpdates.description"
|
||||||
fixes without having to manually
|
)}
|
||||||
pull new containers every time.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<Wrench className="w-5 h-5 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
Less maintenance
|
{t(
|
||||||
|
"managedSelfHosted.benefitLessMaintenance.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No database migrations, backups, or
|
{t(
|
||||||
extra infrastructure to manage. We
|
"managedSelfHosted.benefitLessMaintenance.description"
|
||||||
handle that in the cloud.
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<Activity className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
Cloud failover
|
{t(
|
||||||
|
"managedSelfHosted.benefitCloudFailover.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
If your node goes down, your tunnels
|
{t(
|
||||||
can temporarily fail over to our
|
"managedSelfHosted.benefitCloudFailover.description"
|
||||||
cloud points of presence until you
|
)}
|
||||||
bring it back online.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<Shield className="w-5 h-5 text-indigo-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
High availability (PoPs)
|
{t(
|
||||||
|
"managedSelfHosted.benefitHighAvailability.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
You can also attach multiple nodes
|
{t(
|
||||||
to your account for redundancy and
|
"managedSelfHosted.benefitHighAvailability.description"
|
||||||
better performance.
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<Zap className="w-5 h-5 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
Future enhancements
|
{t(
|
||||||
|
"managedSelfHosted.benefitFutureEnhancements.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
We're planning to add more
|
{t(
|
||||||
analytics, alerting, and management
|
"managedSelfHosted.benefitFutureEnhancements.description"
|
||||||
tools to make your deployment even
|
)}
|
||||||
more robust.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,15 +148,14 @@ export default async function ManagedPage() {
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Read the docs to learn more about the Managed
|
{t("managedSelfHosted.docsAlert.text")}{" "}
|
||||||
Self-Hosted option in our{" "}
|
|
||||||
<Link
|
<Link
|
||||||
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
|
href="https://docs.digpangolin.com/manage/managed"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:underline text-primary flex items-center gap-1"
|
className="hover:underline text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
documentation
|
{t("managedSelfHosted.docsAlert.documentation")}
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
@@ -157,13 +163,13 @@ export default async function ManagedPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Link
|
<Link
|
||||||
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
|
href="https://docs.digpangolin.com/self-host/convert-managed"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:underline text-primary flex items-center gap-1"
|
className="hover:underline text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Button>
|
<Button>
|
||||||
Convert This Node to Managed Self-Hosted
|
{t("managedSelfHosted.convertButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default async function InvitePage(props: {
|
|||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
error = formatAxiosError(e);
|
error = formatAxiosError(e);
|
||||||
|
console.error(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res && res.status === 200) {
|
if (res && res.status === 200) {
|
||||||
@@ -55,13 +56,13 @@ export default async function InvitePage(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cardType() {
|
function cardType() {
|
||||||
if (error.includes(t('inviteErrorWrongUser'))) {
|
if (error.includes("Invite is not for this user")) {
|
||||||
return "wrong_user";
|
return "wrong_user";
|
||||||
} else if (
|
} else if (
|
||||||
error.includes(t('inviteErrorUserNotExists'))
|
error.includes("User does not exist. Please create an account first.")
|
||||||
) {
|
) {
|
||||||
return "user_does_not_exist";
|
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";
|
return "not_logged_in";
|
||||||
} else {
|
} else {
|
||||||
return "rejected";
|
return "rejected";
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ import { cn } from "@/lib/cn";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import {
|
||||||
|
sanitizeInputRaw,
|
||||||
|
finalizeSubdomainSanitize,
|
||||||
|
validateByDomainType,
|
||||||
|
isValidSubdomainStructure
|
||||||
|
} from "@/lib/subdomain-utils";
|
||||||
|
import { toUnicode } from "punycode";
|
||||||
|
|
||||||
type OrganizationDomain = {
|
type OrganizationDomain = {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
@@ -120,6 +127,7 @@ export default function DomainPicker2({
|
|||||||
)
|
)
|
||||||
.map((domain) => ({
|
.map((domain) => ({
|
||||||
...domain,
|
...domain,
|
||||||
|
baseDomain: toUnicode(domain.baseDomain),
|
||||||
type: domain.type as "ns" | "cname" | "wildcard"
|
type: domain.type as "ns" | "cname" | "wildcard"
|
||||||
}));
|
}));
|
||||||
setOrganizationDomains(domains);
|
setOrganizationDomains(domains);
|
||||||
@@ -255,108 +263,64 @@ export default function DomainPicker2({
|
|||||||
|
|
||||||
const dropdownOptions = generateDropdownOptions();
|
const dropdownOptions = generateDropdownOptions();
|
||||||
|
|
||||||
const validateSubdomain = (
|
const finalizeSubdomain = (sub: string, base: DomainOption): string => {
|
||||||
subdomain: string,
|
const sanitized = finalizeSubdomainSanitize(sub);
|
||||||
baseDomain: DomainOption
|
|
||||||
): boolean => {
|
|
||||||
if (!baseDomain) return false;
|
|
||||||
|
|
||||||
if (baseDomain.type === "provided-search") {
|
if (!sanitized) {
|
||||||
return /^[a-zA-Z0-9-]+$/.test(subdomain);
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid subdomain",
|
||||||
|
description: `The input "${sub}" was removed because it's not valid.`,
|
||||||
|
});
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (baseDomain.type === "organization") {
|
const ok = validateByDomainType(sanitized, {
|
||||||
if (baseDomain.domainType === "cname") {
|
type: base.type === "provided-search" ? "provided-search" : "organization",
|
||||||
return subdomain === "";
|
domainType: base.domainType
|
||||||
} else if (baseDomain.domainType === "ns") {
|
});
|
||||||
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
|
|
||||||
} else if (baseDomain.domainType === "wildcard") {
|
if (!ok) {
|
||||||
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
|
toast({
|
||||||
}
|
variant: "destructive",
|
||||||
|
title: "Invalid subdomain",
|
||||||
|
description: `"${sub}" could not be made valid for ${base.domain}.`,
|
||||||
|
});
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if (sub !== sanitized) {
|
||||||
};
|
toast({
|
||||||
|
title: "Subdomain sanitized",
|
||||||
// Handle base domain selection
|
description: `"${sub}" was corrected to "${sanitized}"`,
|
||||||
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 (option.type === "organization") {
|
return sanitized;
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubdomainChange = (value: string) => {
|
const handleSubdomainChange = (value: string) => {
|
||||||
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, "");
|
const raw = sanitizeInputRaw(value);
|
||||||
setSubdomainInput(validInput);
|
setSubdomainInput(raw);
|
||||||
|
|
||||||
setSelectedProvidedDomain(null);
|
setSelectedProvidedDomain(null);
|
||||||
|
|
||||||
if (selectedBaseDomain && selectedBaseDomain.type === "organization") {
|
if (selectedBaseDomain?.type === "organization") {
|
||||||
const isValid = validateSubdomain(validInput, selectedBaseDomain);
|
const fullDomain = raw
|
||||||
if (isValid) {
|
? `${raw}.${selectedBaseDomain.domain}`
|
||||||
const fullDomain = validInput
|
: selectedBaseDomain.domain;
|
||||||
? `${validInput}.${selectedBaseDomain.domain}`
|
|
||||||
: selectedBaseDomain.domain;
|
onDomainChange?.({
|
||||||
onDomainChange?.({
|
domainId: selectedBaseDomain.domainId!,
|
||||||
domainId: selectedBaseDomain.domainId!,
|
type: "organization",
|
||||||
type: "organization",
|
subdomain: raw || undefined,
|
||||||
subdomain: validInput || undefined,
|
fullDomain,
|
||||||
fullDomain: fullDomain,
|
baseDomain: selectedBaseDomain.domain
|
||||||
baseDomain: selectedBaseDomain.domain
|
});
|
||||||
});
|
|
||||||
} else if (validInput === "") {
|
|
||||||
onDomainChange?.({
|
|
||||||
domainId: selectedBaseDomain.domainId!,
|
|
||||||
type: "organization",
|
|
||||||
subdomain: undefined,
|
|
||||||
fullDomain: selectedBaseDomain.domain,
|
|
||||||
baseDomain: selectedBaseDomain.domain
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProvidedDomainInputChange = (value: string) => {
|
const handleProvidedDomainInputChange = (value: string) => {
|
||||||
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, "");
|
setUserInput(value);
|
||||||
setUserInput(validInput);
|
|
||||||
|
|
||||||
// Clear selected domain when user types
|
|
||||||
if (selectedProvidedDomain) {
|
if (selectedProvidedDomain) {
|
||||||
setSelectedProvidedDomain(null);
|
setSelectedProvidedDomain(null);
|
||||||
onDomainChange?.({
|
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) => {
|
const handleProvidedDomainSelect = (option: AvailableOption) => {
|
||||||
setSelectedProvidedDomain(option);
|
setSelectedProvidedDomain(option);
|
||||||
|
|
||||||
@@ -380,15 +381,19 @@ export default function DomainPicker2({
|
|||||||
domainId: option.domainId,
|
domainId: option.domainId,
|
||||||
domainNamespaceId: option.domainNamespaceId,
|
domainNamespaceId: option.domainNamespaceId,
|
||||||
type: "provided",
|
type: "provided",
|
||||||
subdomain: subdomain,
|
subdomain,
|
||||||
fullDomain: option.fullDomain,
|
fullDomain: option.fullDomain,
|
||||||
baseDomain: baseDomain
|
baseDomain
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSubdomainValid = selectedBaseDomain
|
const isSubdomainValid = selectedBaseDomain && subdomainInput
|
||||||
? validateSubdomain(subdomainInput, selectedBaseDomain)
|
? validateByDomainType(subdomainInput, {
|
||||||
|
type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization",
|
||||||
|
domainType: selectedBaseDomain.domainType
|
||||||
|
})
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
const showSubdomainInput =
|
const showSubdomainInput =
|
||||||
selectedBaseDomain &&
|
selectedBaseDomain &&
|
||||||
selectedBaseDomain.type === "organization" &&
|
selectedBaseDomain.type === "organization" &&
|
||||||
@@ -396,7 +401,7 @@ export default function DomainPicker2({
|
|||||||
const showProvidedDomainSearch =
|
const showProvidedDomainSearch =
|
||||||
selectedBaseDomain?.type === "provided-search";
|
selectedBaseDomain?.type === "provided-search";
|
||||||
|
|
||||||
const sortedAvailableOptions = availableOptions.sort((a, b) => {
|
const sortedAvailableOptions = [...availableOptions].sort((a, b) => {
|
||||||
const comparison = a.fullDomain.localeCompare(b.fullDomain);
|
const comparison = a.fullDomain.localeCompare(b.fullDomain);
|
||||||
return sortOrder === "asc" ? comparison : -comparison;
|
return sortOrder === "asc" ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
@@ -408,6 +413,7 @@ export default function DomainPicker2({
|
|||||||
const hasMoreProvided =
|
const hasMoreProvided =
|
||||||
sortedAvailableOptions.length > providedDomainsShown;
|
sortedAvailableOptions.length > providedDomainsShown;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
@@ -426,16 +432,16 @@ export default function DomainPicker2({
|
|||||||
showProvidedDomainSearch
|
showProvidedDomainSearch
|
||||||
? ""
|
? ""
|
||||||
: showSubdomainInput
|
: showSubdomainInput
|
||||||
? ""
|
? ""
|
||||||
: t("domainPickerNotAvailableForCname")
|
: t("domainPickerNotAvailableForCname")
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
!showSubdomainInput && !showProvidedDomainSearch
|
!showSubdomainInput && !showProvidedDomainSearch
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
!isSubdomainValid &&
|
!isSubdomainValid &&
|
||||||
subdomainInput &&
|
subdomainInput &&
|
||||||
"border-red-500"
|
"border-red-500 focus:border-red-500"
|
||||||
)}
|
)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (showProvidedDomainSearch) {
|
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 && (
|
{showSubdomainInput && !subdomainInput && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("domainPickerEnterSubdomainOrLeaveBlank")}
|
{t("domainPickerEnterSubdomainOrLeaveBlank")}
|
||||||
@@ -470,7 +481,7 @@ export default function DomainPicker2({
|
|||||||
{selectedBaseDomain ? (
|
{selectedBaseDomain ? (
|
||||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||||
{selectedBaseDomain.type ===
|
{selectedBaseDomain.type ===
|
||||||
"organization" ? null : (
|
"organization" ? null : (
|
||||||
<Zap className="h-4 w-4 flex-shrink-0" />
|
<Zap className="h-4 w-4 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
@@ -564,67 +575,67 @@ export default function DomainPicker2({
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
{(build === "saas" ||
|
{(build === "saas" ||
|
||||||
build === "enterprise") && (
|
build === "enterprise") && (
|
||||||
<CommandSeparator className="my-2" />
|
<CommandSeparator className="my-2" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(build === "saas" ||
|
{(build === "saas" ||
|
||||||
build === "enterprise") && (
|
build === "enterprise") && (
|
||||||
<CommandGroup
|
<CommandGroup
|
||||||
heading={
|
heading={
|
||||||
build === "enterprise"
|
build === "enterprise"
|
||||||
? t(
|
? t(
|
||||||
"domainPickerProvidedDomains"
|
"domainPickerProvidedDomains"
|
||||||
)
|
)
|
||||||
: t("domainPickerFreeDomains")
|
: t("domainPickerFreeDomains")
|
||||||
}
|
}
|
||||||
className="py-2"
|
className="py-2"
|
||||||
>
|
>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key="provided-search"
|
key="provided-search"
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleBaseDomainSelect({
|
handleBaseDomainSelect({
|
||||||
id: "provided-search",
|
id: "provided-search",
|
||||||
domain:
|
domain:
|
||||||
build ===
|
build ===
|
||||||
"enterprise"
|
"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"
|
? "Provided Domain"
|
||||||
: "Free Provided Domain",
|
: "Free Provided Domain"}
|
||||||
type: "provided-search"
|
</span>
|
||||||
})
|
<span className="text-xs text-muted-foreground">
|
||||||
}
|
{t(
|
||||||
className="mx-2 rounded-md"
|
"domainPickerSearchForAvailableDomains"
|
||||||
>
|
)}
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
|
</span>
|
||||||
<Zap className="h-4 w-4 text-primary" />
|
</div>
|
||||||
</div>
|
<Check
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
className={cn(
|
||||||
<span className="font-medium truncate">
|
"h-4 w-4 text-primary",
|
||||||
{build === "enterprise"
|
selectedBaseDomain?.id ===
|
||||||
? "Provided Domain"
|
"provided-search"
|
||||||
: "Free Provided Domain"}
|
? "opacity-100"
|
||||||
</span>
|
: "opacity-0"
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"domainPickerSearchForAvailableDomains"
|
|
||||||
)}
|
)}
|
||||||
</span>
|
/>
|
||||||
</div>
|
</CommandItem>
|
||||||
<Check
|
</CommandList>
|
||||||
className={cn(
|
</CommandGroup>
|
||||||
"h-4 w-4 text-primary",
|
)}
|
||||||
selectedBaseDomain?.id ===
|
|
||||||
"provided-search"
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
</CommandList>
|
|
||||||
</CommandGroup>
|
|
||||||
)}
|
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -680,7 +691,7 @@ export default function DomainPicker2({
|
|||||||
htmlFor={option.domainNamespaceId}
|
htmlFor={option.domainNamespaceId}
|
||||||
data-state={
|
data-state={
|
||||||
selectedProvidedDomain?.domainNamespaceId ===
|
selectedProvidedDomain?.domainNamespaceId ===
|
||||||
option.domainNamespaceId
|
option.domainNamespaceId
|
||||||
? "checked"
|
? "checked"
|
||||||
: "unchecked"
|
: "unchecked"
|
||||||
}
|
}
|
||||||
@@ -760,4 +771,4 @@ function debounce<T extends (...args: any[]) => any>(
|
|||||||
func(...args);
|
func(...args);
|
||||||
}, wait);
|
}, wait);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import { cn } from "@app/lib/cn";
|
|||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import SupporterStatus from "@app/components/SupporterStatus";
|
import SupporterStatus from "@app/components/SupporterStatus";
|
||||||
import { Button } from "@app/components/ui/button";
|
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 Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
@@ -117,7 +117,15 @@ export function LayoutMobileMenu({
|
|||||||
<SupporterStatus />
|
<SupporterStatus />
|
||||||
{env?.app?.version && (
|
{env?.app?.version && (
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn";
|
|||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import SupporterStatus from "@app/components/SupporterStatus";
|
import SupporterStatus from "@app/components/SupporterStatus";
|
||||||
import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react";
|
import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react";
|
||||||
|
import { FaDiscord, FaGithub } from "react-icons/fa";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
@@ -151,7 +152,7 @@ export function LayoutSidebar({
|
|||||||
{!isUnlocked()
|
{!isUnlocked()
|
||||||
? t("communityEdition")
|
? t("communityEdition")
|
||||||
: t("commercialEdition")}
|
: t("commercialEdition")}
|
||||||
<ExternalLink size={12} />
|
<FaGithub size={12} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground ">
|
<div className="text-xs text-muted-foreground ">
|
||||||
@@ -165,9 +166,28 @@ export function LayoutSidebar({
|
|||||||
<BookOpenText size={12} />
|
<BookOpenText size={12} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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 && (
|
{env?.app?.version && (
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function getActionsCategories(root: boolean) {
|
|||||||
[t('actionUpdateOrg')]: "updateOrg",
|
[t('actionUpdateOrg')]: "updateOrg",
|
||||||
[t('actionGetOrgUser')]: "getOrgUser",
|
[t('actionGetOrgUser')]: "getOrgUser",
|
||||||
[t('actionInviteUser')]: "inviteUser",
|
[t('actionInviteUser')]: "inviteUser",
|
||||||
|
[t('actionListInvitations')]: "listInvitations",
|
||||||
[t('actionRemoveUser')]: "removeUser",
|
[t('actionRemoveUser')]: "removeUser",
|
||||||
[t('actionListUsers')]: "listUsers",
|
[t('actionListUsers')]: "listUsers",
|
||||||
[t('actionListOrgDomains')]: "listOrgDomains"
|
[t('actionListOrgDomains')]: "listOrgDomains"
|
||||||
|
|||||||
15
src/lib/parseHostTarget.ts
Normal file
15
src/lib/parseHostTarget.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function parseHostTarget(input: string) {
|
||||||
|
try {
|
||||||
|
const normalized = input.match(/^https?:\/\//) ? input : `http://${input}`;
|
||||||
|
const url = new URL(normalized);
|
||||||
|
|
||||||
|
const protocol = url.protocol.replace(":", ""); // http | https
|
||||||
|
const host = url.hostname;
|
||||||
|
const port = url.port ? parseInt(url.port, 10) : protocol === "https" ? 443 : 80;
|
||||||
|
|
||||||
|
return { protocol, host, port };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
63
src/lib/subdomain-utils.ts
Normal file
63
src/lib/subdomain-utils.ts
Normal 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));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user