Merge branch 'rdp-ssh' into dev

This commit is contained in:
Owen
2026-05-28 13:59:14 -07:00
104 changed files with 8491 additions and 3528 deletions
+110
View File
@@ -0,0 +1,110 @@
git push origin -d 1.11.0-s.0
git push origin -d 1.11.0-s.1
git push origin -d 1.11.0-s.2
git push origin -d 1.11.0-s.3
git push origin -d 1.11.0-s.4
git push origin -d 1.11.0-s.5
git push origin -d 1.11.1-s.0
git push origin -d 1.12.0-s.0
git push origin -d 1.12.2-s.0
git push origin -d 1.12.2-s.1
git push origin -d 1.12.2-s.2
git push origin -d 1.12.2-s.3
git push origin -d 1.12.2-s.4
git push origin -d 1.12.2-s.5
git push origin -d 1.13.0.s.0
git push origin -d 1.13.1-s.0
git push origin -d 1.14.0-s.2
git push origin -d 1.14.1-s.0
git push origin -d 1.14.1-s.1
git push origin -d 1.14.1-s.2
git push origin -d 1.14.1-s.3
git push origin -d 1.15.0-s.0
git push origin -d 1.15.0-s.1
git push origin -d 1.15.0-s.2
git push origin -d 1.15.0-s.3
git push origin -d 1.15.0-s.4
git push origin -d 1.15.0-s.5
git push origin -d 1.15.1-s.0
git push origin -d 1.15.1-s.1
git push origin -d 1.15.3-s.0
git push origin -d 1.15.3-s.1
git push origin -d 1.15.4-s.0
git push origin -d 1.15.4-s.1
git push origin -d 1.15.4-s.10
git push origin -d 1.15.4-s.2
git push origin -d 1.15.4-s.3
git push origin -d 1.15.4-s.4
git push origin -d 1.15.4-s.5
git push origin -d 1.15.4-s.6
git push origin -d 1.15.4-s.7
git push origin -d 1.15.4-s.8
git push origin -d 1.15.4-s.9
git push origin -d 1.16.0-s.0
git push origin -d 1.16.0-s.1
git push origin -d 1.16.1-s.0
git push origin -d 1.16.1-s.1
git push origin -d 1.16.2-s.0
git push origin -d 1.16.2-s.1
git push origin -d 1.16.2-s.10
git push origin -d 1.16.2-s.11
git push origin -d 1.16.2-s.12
git push origin -d 1.16.2-s.13
git push origin -d 1.16.2-s.14
git push origin -d 1.16.2-s.15
git push origin -d 1.16.2-s.16
git push origin -d 1.16.2-s.17
git push origin -d 1.16.2-s.18
git push origin -d 1.16.2-s.19
git push origin -d 1.16.2-s.2
git push origin -d 1.16.2-s.20
git push origin -d 1.16.2-s.21
git push origin -d 1.16.2-s.22
git push origin -d 1.16.2-s.3
git push origin -d 1.16.2-s.4
git push origin -d 1.16.2-s.5
git push origin -d 1.16.2-s.6
git push origin -d 1.16.2-s.7
git push origin -d 1.16.2-s.8
git push origin -d 1.16.2-s.9
git push origin -d 1.17.0-s.0
git push origin -d 1.17.0-s.1
git push origin -d 1.17.0-s.2
git push origin -d 1.17.0-s.3
git push origin -d 1.17.0-s.4
git push origin -d 1.17.1-s.0
git push origin -d 1.17.1-s.1
git push origin -d 1.17.1-s.2
git push origin -d 1.17.1-s.3
git push origin -d 1.17.1-s.4
git push origin -d 1.17.1-s.5
git push origin -d 1.17.1-s.6
git push origin -d 1.17.1-s.7
git push origin -d 1.18.0-s.0
git push origin -d 1.18.0-s.1
git push origin -d 1.18.0-s.2
git push origin -d 1.18.1-s.0
git push origin -d 1.18.1-s.1
git push origin -d 1.18.1-s.2
git push origin -d 1.18.1-s.3
git push origin -d 1.18.1-s.4
git push origin -d 1.18.1-s.5
git push origin -d 1.18.1-s.6
git push origin -d 1.18.1-s.7
git push origin -d 1.18.2-s.0
git push origin -d 1.18.2-s.1
git push origin -d 1.18.2-s.2
git push origin -d 1.18.2-s.3
git push origin -d 1.18.2-s.4
git push origin -d 1.18.2-s.5
git push origin -d 1.18.3-s.0
git push origin -d 1.18.3-s.1
git push origin -d 1.18.3-s.2
git push origin -d 1.18.3-s.3
git push origin -d 1.18.4-s.0
git push origin -d 1.18.4-s.1
git push origin -d 1.18.4-s.2
git push origin -d 1.18.4-s.3
git push origin -d 1.18.4-s.4
git push origin -d 1.18.4-s.5
git push origin -d 1.18.4-s.6
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Метод", "editInternalResourceDialogScheme": "Метод",
"editInternalResourceDialogEnableSsl": "Активирайте TLS", "editInternalResourceDialogEnableSsl": "Активирайте TLS",
"editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.", "editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Метод", "scheme": "Метод",
"createInternalResourceDialogScheme": "Метод", "createInternalResourceDialogScheme": "Метод",
"createInternalResourceDialogEnableSsl": "Активирайте TLS", "createInternalResourceDialogEnableSsl": "Активирайте TLS",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Schéma", "editInternalResourceDialogScheme": "Schéma",
"editInternalResourceDialogEnableSsl": "Povolit SSL", "editInternalResourceDialogEnableSsl": "Povolit SSL",
"editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.", "editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Schéma", "scheme": "Schéma",
"createInternalResourceDialogScheme": "Schéma", "createInternalResourceDialogScheme": "Schéma",
"createInternalResourceDialogEnableSsl": "Povolit SSL", "createInternalResourceDialogEnableSsl": "Povolit SSL",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Schema", "editInternalResourceDialogScheme": "Schema",
"editInternalResourceDialogEnableSsl": "TLS aktivieren", "editInternalResourceDialogEnableSsl": "TLS aktivieren",
"editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.", "editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Schema", "scheme": "Schema",
"createInternalResourceDialogScheme": "Schema", "createInternalResourceDialogScheme": "Schema",
"createInternalResourceDialogEnableSsl": "TLS aktivieren", "createInternalResourceDialogEnableSsl": "TLS aktivieren",
+43 -5
View File
@@ -220,8 +220,9 @@
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.", "resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
"resourceCreate": "Create Resource", "resourceCreate": "Create Resource",
"resourceCreateDescription": "Follow the steps below to create a new resource", "resourceCreateDescription": "Follow the steps below to create a new resource",
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
"resourceSeeAll": "See All Resources", "resourceSeeAll": "See All Resources",
"resourceInfo": "Resource Information", "resourceCreateGeneral": "General",
"resourceNameDescription": "This is the display name for the resource.", "resourceNameDescription": "This is the display name for the resource.",
"siteSelect": "Select site", "siteSelect": "Select site",
"siteSearch": "Search site", "siteSearch": "Search site",
@@ -231,12 +232,15 @@
"noCountryFound": "No country found.", "noCountryFound": "No country found.",
"siteSelectionDescription": "This site will provide connectivity to the target.", "siteSelectionDescription": "This site will provide connectivity to the target.",
"resourceType": "Resource Type", "resourceType": "Resource Type",
"resourceTypeDescription": "Determine how to access the resource", "resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This cant be changed later.",
"resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
"resourceHTTPSSettings": "HTTPS Settings", "resourceHTTPSSettings": "HTTPS Settings",
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS", "resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
"resourcePortDescription": "The external port on the Pangolin instance or node where the resource will be accessible.",
"domainType": "Domain Type", "domainType": "Domain Type",
"subdomain": "Subdomain", "subdomain": "Subdomain",
"baseDomain": "Base Domain", "baseDomain": "Base Domain",
"configure": "Configure",
"subdomnainDescription": "The subdomain where the resource will be accessible.", "subdomnainDescription": "The subdomain where the resource will be accessible.",
"resourceRawSettings": "TCP/UDP Settings", "resourceRawSettings": "TCP/UDP Settings",
"resourceRawSettingsDescription": "Configure how the resource will be accessed over TCP/UDP", "resourceRawSettingsDescription": "Configure how the resource will be accessed over TCP/UDP",
@@ -1877,6 +1881,7 @@
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys", "billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
"billingCurrentKeys": "Current Keys", "billingCurrentKeys": "Current Keys",
"billingModifyCurrentPlan": "Modify Current Plan", "billingModifyCurrentPlan": "Modify Current Plan",
"billingManageLicenseSubscriptionDescription": "Manage your subscription for paid self-hosted license keys and download invoices.",
"billingConfirmUpgrade": "Confirm Upgrade", "billingConfirmUpgrade": "Confirm Upgrade",
"billingConfirmDowngrade": "Confirm Downgrade", "billingConfirmDowngrade": "Confirm Downgrade",
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.", "billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
@@ -1974,7 +1979,36 @@
"timeIsInSeconds": "Time is in seconds", "timeIsInSeconds": "Time is in seconds",
"requireDeviceApproval": "Require Device Approvals", "requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.", "requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"sshAccess": "SSH Access", "sshSettings": "SSH Settings",
"rdpSettings": "RDP Settings",
"vncSettings": "VNC Settings",
"sshServer": "SSH Server",
"rdpServer": "RDP Server",
"vncServer": "VNC Server",
"sshServerDescription": "Set up the authentication method, daemon location, and server destination",
"rdpServerDescription": "Configure the destination and port of the RDP server",
"vncServerDescription": "Configure the destination and port of the VNC server",
"sshServerMode": "Mode",
"sshServerModeStandard": "Standard SSH Server",
"sshServerModePangolin": "Pangolin SSH",
"sshServerModeStandardDescription": "Routes commands over network to an SSH server such as OpenSSH.",
"sshServerModeNative": "Native SSH Server",
"sshServerModeNativeDescription": "Executes commands directly on the host via the Site Connector. No network config required.",
"sshAuthenticationMethod": "Authentication Method",
"sshAuthMethodManual": "Manual Authentication",
"sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.",
"sshAuthMethodAutomated": "Automated Provisioning",
"sshAuthMethodAutomatedDescription": "Automatically creates users, groups, and sudo permissions on host.",
"sshAuthDaemonLocation": "Auth Daemon Location",
"sshDaemonLocationSiteDescription": "Executes locally on the machine hosting the site connector.",
"sshDaemonLocationRemote": "On Remote Host",
"sshDaemonLocationRemoteDescription": "Executes on a separate target machine on the same network.",
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
"sshDaemonPort": "Daemon Port",
"sshServerDestination": "Server Destination",
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
"destination": "Destination",
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
"roleAllowSsh": "Allow SSH", "roleAllowSsh": "Allow SSH",
"roleAllowSshAllow": "Allow", "roleAllowSshAllow": "Allow",
"roleAllowSshDisallow": "Disallow", "roleAllowSshDisallow": "Disallow",
@@ -2080,6 +2114,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Scheme", "editInternalResourceDialogScheme": "Scheme",
"editInternalResourceDialogEnableSsl": "Enable TLS", "editInternalResourceDialogEnableSsl": "Enable TLS",
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.", "editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
@@ -2129,6 +2164,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Scheme", "scheme": "Scheme",
"createInternalResourceDialogScheme": "Scheme", "createInternalResourceDialogScheme": "Scheme",
"createInternalResourceDialogEnableSsl": "Enable TLS", "createInternalResourceDialogEnableSsl": "Enable TLS",
@@ -2968,7 +3004,7 @@
"learnMore": "Learn more", "learnMore": "Learn more",
"backToHome": "Go back to home", "backToHome": "Go back to home",
"needToSignInToOrg": "Need to use your organization's identity provider?", "needToSignInToOrg": "Need to use your organization's identity provider?",
"maintenanceMode": "Maintenance Mode", "maintenanceMode": "Maintenance Page",
"maintenanceModeDescription": "Display a maintenance page to visitors", "maintenanceModeDescription": "Display a maintenance page to visitors",
"maintenanceModeType": "Maintenance Mode Type", "maintenanceModeType": "Maintenance Mode Type",
"showMaintenancePage": "Show a maintenance page to visitors", "showMaintenancePage": "Show a maintenance page to visitors",
@@ -2998,6 +3034,7 @@
"maintenanceScreenEstimatedCompletion": "Estimated Completion:", "maintenanceScreenEstimatedCompletion": "Estimated Completion:",
"createInternalResourceDialogDestinationRequired": "Destination is required", "createInternalResourceDialogDestinationRequired": "Destination is required",
"available": "Available", "available": "Available",
"disabledResourceDescription": "When disabled, the resource will be inaccessible by everyone.",
"archived": "Archived", "archived": "Archived",
"noArchivedDevices": "No archived devices found", "noArchivedDevices": "No archived devices found",
"deviceArchived": "Device archived", "deviceArchived": "Device archived",
@@ -3327,5 +3364,6 @@
"memberPortalResourceDisabled": "Resource Disabled", "memberPortalResourceDisabled": "Resource Disabled",
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"memberPortalPrevious": "Previous", "memberPortalPrevious": "Previous",
"memberPortalNext": "Next" "memberPortalNext": "Next",
"httpSettings": "HTTP Settings"
} }
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Esquema", "editInternalResourceDialogScheme": "Esquema",
"editInternalResourceDialogEnableSsl": "Activar TLS", "editInternalResourceDialogEnableSsl": "Activar TLS",
"editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.", "editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Esquema", "scheme": "Esquema",
"createInternalResourceDialogScheme": "Esquema", "createInternalResourceDialogScheme": "Esquema",
"createInternalResourceDialogEnableSsl": "Activar TLS", "createInternalResourceDialogEnableSsl": "Activar TLS",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Méthode HTTP", "editInternalResourceDialogScheme": "Méthode HTTP",
"editInternalResourceDialogEnableSsl": "Activer TLS", "editInternalResourceDialogEnableSsl": "Activer TLS",
"editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.", "editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Méthode HTTP", "scheme": "Méthode HTTP",
"createInternalResourceDialogScheme": "Méthode HTTP", "createInternalResourceDialogScheme": "Méthode HTTP",
"createInternalResourceDialogEnableSsl": "Activer TLS", "createInternalResourceDialogEnableSsl": "Activer TLS",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Metodo HTTP", "editInternalResourceDialogScheme": "Metodo HTTP",
"editInternalResourceDialogEnableSsl": "Abilitare TLS", "editInternalResourceDialogEnableSsl": "Abilitare TLS",
"editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.", "editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Metodo HTTP", "scheme": "Metodo HTTP",
"createInternalResourceDialogScheme": "Metodo HTTP", "createInternalResourceDialogScheme": "Metodo HTTP",
"createInternalResourceDialogEnableSsl": "Abilitare TLS", "createInternalResourceDialogEnableSsl": "Abilitare TLS",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "스킴", "editInternalResourceDialogScheme": "스킴",
"editInternalResourceDialogEnableSsl": "TLS 활성화", "editInternalResourceDialogEnableSsl": "TLS 활성화",
"editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.", "editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "스킴", "scheme": "스킴",
"createInternalResourceDialogScheme": "스킴", "createInternalResourceDialogScheme": "스킴",
"createInternalResourceDialogEnableSsl": "TLS 활성화", "createInternalResourceDialogEnableSsl": "TLS 활성화",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Skjema", "editInternalResourceDialogScheme": "Skjema",
"editInternalResourceDialogEnableSsl": "Aktiver TLS", "editInternalResourceDialogEnableSsl": "Aktiver TLS",
"editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.", "editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Skjema", "scheme": "Skjema",
"createInternalResourceDialogScheme": "Skjema", "createInternalResourceDialogScheme": "Skjema",
"createInternalResourceDialogEnableSsl": "Aktiver TLS", "createInternalResourceDialogEnableSsl": "Aktiver TLS",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Schema", "editInternalResourceDialogScheme": "Schema",
"editInternalResourceDialogEnableSsl": "TLS inschakelen", "editInternalResourceDialogEnableSsl": "TLS inschakelen",
"editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.", "editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Schema", "scheme": "Schema",
"createInternalResourceDialogScheme": "Schema", "createInternalResourceDialogScheme": "Schema",
"createInternalResourceDialogEnableSsl": "TLS inschakelen", "createInternalResourceDialogEnableSsl": "TLS inschakelen",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Schemat", "editInternalResourceDialogScheme": "Schemat",
"editInternalResourceDialogEnableSsl": "Włącz TLS", "editInternalResourceDialogEnableSsl": "Włącz TLS",
"editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.", "editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Schemat", "scheme": "Schemat",
"createInternalResourceDialogScheme": "Schemat", "createInternalResourceDialogScheme": "Schemat",
"createInternalResourceDialogEnableSsl": "Włącz TLS", "createInternalResourceDialogEnableSsl": "Włącz TLS",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Esquema", "editInternalResourceDialogScheme": "Esquema",
"editInternalResourceDialogEnableSsl": "Ativar TLS", "editInternalResourceDialogEnableSsl": "Ativar TLS",
"editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.", "editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Esquema", "scheme": "Esquema",
"createInternalResourceDialogScheme": "Esquema", "createInternalResourceDialogScheme": "Esquema",
"createInternalResourceDialogEnableSsl": "Ativar TLS", "createInternalResourceDialogEnableSsl": "Ativar TLS",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "СИДР", "editInternalResourceDialogModeCidr": "СИДР",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Схема", "editInternalResourceDialogScheme": "Схема",
"editInternalResourceDialogEnableSsl": "Включить TLS", "editInternalResourceDialogEnableSsl": "Включить TLS",
"editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.", "editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "СИДР", "createInternalResourceDialogModeCidr": "СИДР",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Схема", "scheme": "Схема",
"createInternalResourceDialogScheme": "Схема", "createInternalResourceDialogScheme": "Схема",
"createInternalResourceDialogEnableSsl": "Включить TLS", "createInternalResourceDialogEnableSsl": "Включить TLS",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Şema", "editInternalResourceDialogScheme": "Şema",
"editInternalResourceDialogEnableSsl": "TLS Etkinleştir", "editInternalResourceDialogEnableSsl": "TLS Etkinleştir",
"editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.", "editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Şema", "scheme": "Şema",
"createInternalResourceDialogScheme": "Şema", "createInternalResourceDialogScheme": "Şema",
"createInternalResourceDialogEnableSsl": "TLS'yi Etkinleştir", "createInternalResourceDialogEnableSsl": "TLS'yi Etkinleştir",
+2
View File
@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "方案", "editInternalResourceDialogScheme": "方案",
"editInternalResourceDialogEnableSsl": "启用 TLS", "editInternalResourceDialogEnableSsl": "启用 TLS",
"editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。", "editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "方案", "scheme": "方案",
"createInternalResourceDialogScheme": "方案", "createInternalResourceDialogScheme": "方案",
"createInternalResourceDialogEnableSsl": "启用 TLS", "createInternalResourceDialogEnableSsl": "启用 TLS",
+1
View File
@@ -6,6 +6,7 @@ const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: false, reactStrictMode: false,
reactCompiler: true, reactCompiler: true,
transpilePackages: ["@novnc/novnc"],
output: "standalone" output: "standalone"
}; };
+46 -17
View File
@@ -11,11 +11,13 @@
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0", "@asteasolutions/zod-to-openapi": "8.5.0",
"@aws-sdk/client-s3": "3.1047.0", "@aws-sdk/client-s3": "3.1047.0",
"@faker-js/faker": "10.4.0", "@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"@headlessui/react": "2.2.10", "@headlessui/react": "2.2.10",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "4.7.0", "@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2", "@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1", "@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0", "@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-avatar": "1.1.11",
@@ -45,6 +47,9 @@
"@tailwindcss/forms": "0.5.11", "@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.100.14", "@tanstack/react-query": "5.100.14",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0", "arctic": "3.7.0",
"axios": "1.16.1", "axios": "1.16.1",
"better-sqlite3": "11.9.1", "better-sqlite3": "11.9.1",
@@ -1258,6 +1263,16 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@devolutions/iron-remote-desktop": {
"version": "0.0.0",
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA=="
},
"node_modules/@devolutions/iron-remote-desktop-rdp": {
"version": "0.0.0",
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"integrity": "sha512-O0YVpOJDwUzekH3N2QKj+48WP+56wI0sj4VmaJkGoW5XgyAj2ONn2k3i+vk17Eavx+Vg6vAg3lwYRAOK4kKIDQ=="
},
"node_modules/@dotenvx/dotenvx": { "node_modules/@dotenvx/dotenvx": {
"version": "1.69.1", "version": "1.69.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.69.1.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.69.1.tgz",
@@ -1926,22 +1941,6 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@faker-js/faker": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.4.0.tgz",
"integrity": "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
"npm": ">=10"
}
},
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.7.5", "version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@@ -3554,6 +3553,12 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@oslojs/asn1": { "node_modules/@oslojs/asn1": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
@@ -8769,6 +8774,27 @@
"win32" "win32"
] ]
}, },
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -10723,6 +10749,9 @@
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"peer": true, "peer": true,
"engines": {
"node": ">=20"
},
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
} }
+6 -1
View File
@@ -34,11 +34,13 @@
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0", "@asteasolutions/zod-to-openapi": "8.5.0",
"@aws-sdk/client-s3": "3.1047.0", "@aws-sdk/client-s3": "3.1047.0",
"@faker-js/faker": "10.4.0",
"@headlessui/react": "2.2.10", "@headlessui/react": "2.2.10",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "4.7.0", "@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2", "@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1", "@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0", "@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-avatar": "1.1.11",
@@ -68,6 +70,9 @@
"@tailwindcss/forms": "0.5.11", "@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.100.14", "@tanstack/react-query": "5.100.14",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0", "arctic": "3.7.0",
"axios": "1.16.1", "axios": "1.16.1",
"better-sqlite3": "11.9.1", "better-sqlite3": "11.9.1",
+6 -1
View File
@@ -158,7 +158,12 @@ export enum ActionsEnum {
createHealthCheck = "createHealthCheck", createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck", updateHealthCheck = "updateHealthCheck",
deleteHealthCheck = "deleteHealthCheck", deleteHealthCheck = "deleteHealthCheck",
listHealthChecks = "listHealthChecks" listHealthChecks = "listHealthChecks",
createBrowserGatewayTarget = "createBrowserGatewayTarget",
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
getBrowserGatewayTarget = "getBrowserGatewayTarget",
listBrowserGatewayTargets = "listBrowserGatewayTargets"
} }
export async function checkUserActionPermission( export async function checkUserActionPermission(
+21
View File
@@ -580,6 +580,24 @@ export const trialNotifications = pgTable("trialNotifications", {
sentAt: bigint("sentAt", { mode: "number" }).notNull() sentAt: bigint("sentAt", { mode: "number" }).notNull()
}); });
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
authToken: varchar("authToken").notNull(),
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
destination: varchar("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -627,3 +645,6 @@ export type AlertEmailRecipients = InferSelectModel<
>; >;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>; export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>; export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;
+15 -7
View File
@@ -129,8 +129,6 @@ export const resources = pgTable("resources", {
ssl: boolean("ssl").notNull().default(false), ssl: boolean("ssl").notNull().default(false),
blockAccess: boolean("blockAccess").notNull().default(false), blockAccess: boolean("blockAccess").notNull().default(false),
sso: boolean("sso").notNull().default(true), sso: boolean("sso").notNull().default(true),
http: boolean("http").notNull().default(true),
protocol: varchar("protocol").notNull(),
proxyPort: integer("proxyPort"), proxyPort: integer("proxyPort"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled") emailWhitelistEnabled: boolean("emailWhitelistEnabled")
.notNull() .notNull()
@@ -147,7 +145,6 @@ export const resources = pgTable("resources", {
headers: text("headers"), // comma-separated list of headers to add to the request headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: boolean("proxyProtocol").notNull().default(false), proxyProtocol: boolean("proxyProtocol").notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1), proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: boolean("maintenanceModeEnabled") maintenanceModeEnabled: boolean("maintenanceModeEnabled")
.notNull() .notNull()
.default(false), .default(false),
@@ -159,7 +156,15 @@ export const resources = pgTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false) wildcard: boolean("wildcard").notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
}); });
export const labels = pgTable("labels", { export const labels = pgTable("labels", {
@@ -339,11 +344,11 @@ export const siteResources = pgTable("siteResources", {
niceId: varchar("niceId").notNull(), niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false), ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode destination: varchar("destination"), // ip, cidr, hostname; validate against the mode
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
alias: varchar("alias"), alias: varchar("alias"),
aliasAddress: varchar("aliasAddress"), aliasAddress: varchar("aliasAddress"),
@@ -351,8 +356,11 @@ export const siteResources = pgTable("siteResources", {
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
disableIcmp: boolean("disableIcmp").notNull().default(false), disableIcmp: boolean("disableIcmp").notNull().default(false),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 }) authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
domainId: varchar("domainId").references(() => domains.domainId, { domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null" onDelete: "set null"
+23
View File
@@ -588,6 +588,26 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull() sentAt: integer("sentAt").notNull()
}); });
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
authToken: text("authToken").notNull(),
type: text("type").notNull(), // "ssh", "rdp", "vnc"
destination: text("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -627,3 +647,6 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>; export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>; export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>; export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;
+15 -7
View File
@@ -142,8 +142,6 @@ export const resources = sqliteTable("resources", {
.notNull() .notNull()
.default(false), .default(false),
sso: integer("sso", { mode: "boolean" }).notNull().default(true), sso: integer("sso", { mode: "boolean" }).notNull().default(true),
http: integer("http", { mode: "boolean" }).notNull().default(true),
protocol: text("protocol").notNull(),
proxyPort: integer("proxyPort"), proxyPort: integer("proxyPort"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull() .notNull()
@@ -166,7 +164,6 @@ export const resources = sqliteTable("resources", {
.notNull() .notNull()
.default(false), .default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1), proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: integer("maintenanceModeEnabled", { maintenanceModeEnabled: integer("maintenanceModeEnabled", {
mode: "boolean" mode: "boolean"
}) })
@@ -180,7 +177,15 @@ export const resources = sqliteTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false) wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
}); });
export const labels = sqliteTable("labels", { export const labels = sqliteTable("labels", {
@@ -372,11 +377,11 @@ export const siteResources = sqliteTable("siteResources", {
niceId: text("niceId").notNull(), niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode
destination: text("destination").notNull(), // ip, cidr, hostname destination: text("destination"), // ip, cidr, hostname
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
alias: text("alias"), alias: text("alias"),
aliasAddress: text("aliasAddress"), aliasAddress: text("aliasAddress"),
@@ -386,8 +391,11 @@ export const siteResources = sqliteTable("siteResources", {
.notNull() .notNull()
.default(false), .default(false),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode") authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
domainId: text("domainId").references(() => domains.domainId, { domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null" onDelete: "set null"
+1 -2
View File
@@ -23,7 +23,6 @@ import {
} from "./clientResources"; } from "./clientResources";
import { BlueprintSource } from "@server/routers/blueprints/types"; import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml"; import { stringify as stringifyYaml } from "yaml";
import { faker } from "@faker-js/faker";
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource"; import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations"; import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
@@ -106,7 +105,7 @@ export async function applyBlueprint({
site.newt.newtId, site.newt.newtId,
[target], [target],
matchingHealthcheck ? [matchingHealthcheck] : [], matchingHealthcheck ? [matchingHealthcheck] : [],
result.proxyResource.protocol, result.proxyResource.mode === "udp" ? "udp" : "tcp",
site.newt.version site.newt.version
); );
} }
+19 -3
View File
@@ -225,7 +225,11 @@ export async function updateClientResources(
: resourceData["udp-ports"], : resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null, fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null, subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null domainId: domainInfo ? domainInfo.domainId : null,
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
}) })
.where( .where(
eq( eq(
@@ -360,8 +364,14 @@ export async function updateClientResources(
}); });
} else { } else {
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null;
if (resourceData.mode === "host" || resourceData.mode === "http") { if (resourceData.mode === "host" || resourceData.mode === "http") {
aliasAddress = await getNextAvailableAliasAddress(orgId, trx); const { value, release } = await getNextAvailableAliasAddress(
orgId,
trx
);
aliasAddress = value;
releaseAliasLock = release;
} }
let domainInfo: let domainInfo:
@@ -415,10 +425,16 @@ export async function updateClientResources(
: resourceData["udp-ports"], : resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null, fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null, subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null domainId: domainInfo ? domainInfo.domainId : null,
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
}) })
.returning(); .returning();
await releaseAliasLock?.();
const siteResourceId = newResource.siteResourceId; const siteResourceId = newResource.siteResourceId;
for (const site of allSites) { for (const site of allSites) {
+43 -14
View File
@@ -36,6 +36,7 @@ import { isValidRegionId } from "@server/db/regions";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { http } from "winston";
export type ProxyResourcesResults = { export type ProxyResourcesResults = {
proxyResource: Resource; proxyResource: Resource;
@@ -198,9 +199,6 @@ export async function updateProxyResources(
) )
.limit(1); .limit(1);
const http = resourceData.protocol == "http";
const protocol =
resourceData.protocol == "http" ? "tcp" : resourceData.protocol;
const resourceEnabled = const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null resourceData.enabled == undefined || resourceData.enabled == null
? true ? true
@@ -216,7 +214,9 @@ export async function updateProxyResources(
if (existingResource) { if (existingResource) {
let domain; let domain;
if (http) { if (
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
) {
domain = await getDomain( domain = await getDomain(
existingResource.resourceId, existingResource.resourceId,
resourceData["full-domain"]!, resourceData["full-domain"]!,
@@ -246,10 +246,17 @@ export async function updateProxyResources(
.update(resources) .update(resources)
.set({ .set({
name: resourceData.name || "Unnamed Resource", name: resourceData.name || "Unnamed Resource",
protocol: protocol || "tcp", mode: resourceData.mode,
http: http, proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
proxyPort: http ? null : resourceData["proxy-port"], resourceData.mode || ""
fullDomain: http ? resourceData["full-domain"] : null, )
? null
: resourceData["proxy-port"],
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
? resourceData["full-domain"]
: null,
subdomain: domain ? domain.subdomain : null, subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null, domainId: domain ? domain.domainId : null,
wildcard: domain ? domain.wildcard : false, wildcard: domain ? domain.wildcard : false,
@@ -268,6 +275,12 @@ export async function updateProxyResources(
headers: headers || null, headers: headers || null,
applyRules: applyRules:
resourceData.rules && resourceData.rules.length > 0, resourceData.rules && resourceData.rules.length > 0,
pamMode:
resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort:
resourceData["auth-daemon"]?.port || 22123,
maintenanceModeEnabled: maintenanceModeEnabled:
resourceData.maintenance?.enabled, resourceData.maintenance?.enabled,
maintenanceModeType: resourceData.maintenance?.type, maintenanceModeType: resourceData.maintenance?.type,
@@ -466,7 +479,10 @@ export async function updateProxyResources(
.set({ .set({
siteId: site.siteId, siteId: site.siteId,
ip: targetData.hostname, ip: targetData.hostname,
method: http ? targetData.method : null, method:
resourceData.mode == "http" // the other types of ssh, rdp, and vnc use the browser gateway targets and not this one so this is okay
? targetData.method
: null,
port: targetData.port, port: targetData.port,
enabled: targetData.enabled, enabled: targetData.enabled,
path: targetData.path, path: targetData.path,
@@ -687,7 +703,9 @@ export async function updateProxyResources(
} else { } else {
// create a brand new resource // create a brand new resource
let domain; let domain;
if (http) { if (
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
) {
domain = await getDomain( domain = await getDomain(
undefined, undefined,
resourceData["full-domain"]!, resourceData["full-domain"]!,
@@ -711,10 +729,17 @@ export async function updateProxyResources(
orgId, orgId,
niceId: resourceNiceId, niceId: resourceNiceId,
name: resourceData.name || "Unnamed Resource", name: resourceData.name || "Unnamed Resource",
protocol: protocol || "tcp", mode: resourceData.mode,
http: http, proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
proxyPort: http ? null : resourceData["proxy-port"], resourceData.mode || ""
fullDomain: http ? resourceData["full-domain"] : null, )
? null
: resourceData["proxy-port"],
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
? resourceData["full-domain"]
: null,
subdomain: domain ? domain.subdomain : null, subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null, domainId: domain ? domain.domainId : null,
wildcard: domain ? domain.wildcard : false, wildcard: domain ? domain.wildcard : false,
@@ -727,6 +752,10 @@ export async function updateProxyResources(
headers: headers || null, headers: headers || null,
applyRules: applyRules:
resourceData.rules && resourceData.rules.length > 0, resourceData.rules && resourceData.rules.length > 0,
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
maintenanceModeEnabled: resourceData.maintenance?.enabled, maintenanceModeEnabled: resourceData.maintenance?.enabled,
maintenanceModeType: resourceData.maintenance?.type, maintenanceModeType: resourceData.maintenance?.type,
maintenanceTitle: resourceData.maintenance?.title, maintenanceTitle: resourceData.maintenance?.title,
+85 -27
View File
@@ -161,11 +161,33 @@ export const HeaderSchema = z.object({
value: z.string().min(1) value: z.string().min(1)
}); });
export const AuthDaemonSchema = z
.object({
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
mode: z.enum(["site", "remote", "native"]).optional().default("site"),
port: z.int().min(1).max(65535).optional()
})
.refine(
(data) => {
if (data.mode === "remote") {
return data.port !== undefined;
}
return true;
},
{
path: ["port"],
message: "port is required when auth-daemon mode is 'remote'"
}
);
// Schema for individual resource // Schema for individual resource
export const ResourceSchema = z export const PublicResourceSchema = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
protocol: z.enum(["http", "tcp", "udp"]).optional(), protocol: z
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(), scheme: z.enum(["http", "https"]).optional(),
"full-domain": z.string().optional(), "full-domain": z.string().optional(),
@@ -177,7 +199,8 @@ export const ResourceSchema = z
"tls-server-name": z.string().optional(), "tls-server-name": z.string().optional(),
headers: z.array(HeaderSchema).optional(), headers: z.array(HeaderSchema).optional(),
rules: z.array(RuleSchema).optional(), rules: z.array(RuleSchema).optional(),
maintenance: MaintenanceSchema.optional() maintenance: MaintenanceSchema.optional(),
"auth-daemon": AuthDaemonSchema.optional()
}) })
.refine( .refine(
(resource) => { (resource) => {
@@ -185,9 +208,10 @@ export const ResourceSchema = z
return true; return true;
} }
// Otherwise, require name and protocol for full resource definition // Otherwise, require name and protocol/mode for full resource definition
return ( return (
resource.name !== undefined && resource.protocol !== undefined resource.name !== undefined &&
(resource.mode !== undefined || resource.protocol !== undefined)
); );
}, },
{ {
@@ -201,8 +225,8 @@ export const ResourceSchema = z
return true; return true;
} }
// If protocol is http, all targets must have method field // If protocol/mode is http, all targets must have method field
if (resource.protocol === "http") { if ((resource.mode ?? resource.protocol) === "http") {
return resource.targets.every( return resource.targets.every(
(target) => target == null || target.method !== undefined (target) => target == null || target.method !== undefined
); );
@@ -220,8 +244,9 @@ export const ResourceSchema = z
return true; return true;
} }
// If protocol is tcp or udp, no target should have method field // If protocol/mode is tcp or udp, no target should have method field
if (resource.protocol === "tcp" || resource.protocol === "udp") { const effectiveProtocol1 = resource.mode ?? resource.protocol;
if (effectiveProtocol1 === "tcp" || effectiveProtocol1 === "udp") {
return resource.targets.every( return resource.targets.every(
(target) => target == null || target.method === undefined (target) => target == null || target.method === undefined
); );
@@ -239,8 +264,8 @@ export const ResourceSchema = z
return true; return true;
} }
// If protocol is http, it must have a full-domain // If protocol/mode is http, it must have a full-domain
if (resource.protocol === "http") { if ((resource.mode ?? resource.protocol) === "http") {
return ( return (
resource["full-domain"] !== undefined && resource["full-domain"] !== undefined &&
resource["full-domain"].length > 0 resource["full-domain"].length > 0
@@ -259,8 +284,9 @@ export const ResourceSchema = z
return true; return true;
} }
// If protocol is tcp or udp, it must have both proxy-port // If protocol/mode is tcp or udp, it must have both proxy-port
if (resource.protocol === "tcp" || resource.protocol === "udp") { const effectiveProtocol2 = resource.mode ?? resource.protocol;
if (effectiveProtocol2 === "tcp" || effectiveProtocol2 === "udp") {
return resource["proxy-port"] !== undefined; return resource["proxy-port"] !== undefined;
} }
return true; return true;
@@ -277,8 +303,9 @@ export const ResourceSchema = z
return true; return true;
} }
// If protocol is tcp or udp, it must not have auth // If protocol/mode is tcp or udp, it must not have auth
if (resource.protocol === "tcp" || resource.protocol === "udp") { const effectiveProtocol3 = resource.mode ?? resource.protocol;
if (effectiveProtocol3 === "tcp" || effectiveProtocol3 === "udp") {
return resource.auth === undefined; return resource.auth === undefined;
} }
return true; return true;
@@ -349,22 +376,29 @@ export const ResourceSchema = z
message: message:
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.' 'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
} }
); )
.transform((resource) => {
// Normalize: prefer mode, fall back to protocol for backwards compatibility
if (resource.mode === undefined && resource.protocol !== undefined) {
resource.mode = resource.protocol;
}
return resource;
});
export function isTargetsOnlyResource(resource: any): boolean { export function isTargetsOnlyResource(resource: any): boolean {
return Object.keys(resource).length === 1 && resource.targets; return Object.keys(resource).length === 1 && resource.targets;
} }
export const ClientResourceSchema = z export const PrivateResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]), mode: z.enum(["host", "cidr", "http", "ssh"]),
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
sites: z.array(z.string()).optional().default([]), sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(), "destination-port": z.int().positive().optional(),
destination: z.string().min(1), destination: z.string().min(1).optional(),
// enabled: z.boolean().default(true), // enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"), "tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"),
@@ -387,11 +421,31 @@ export const ClientResourceSchema = z
error: "Admin role cannot be included in roles" error: "Admin role cannot be included in roles"
}), }),
users: z.array(z.string()).optional().default([]), users: z.array(z.string()).optional().default([]),
machines: z.array(z.string()).optional().default([]) machines: z.array(z.string()).optional().default([]),
"auth-daemon": AuthDaemonSchema.optional()
}) })
.refine(
(data) => {
// destination is optional only for ssh+native; required for everything else
const isNativeSSH =
data.mode === "ssh" &&
(data["auth-daemon"] === undefined ||
data["auth-daemon"].mode === "native");
if (!isNativeSSH && !data.destination) {
return false;
}
return true;
},
{
path: ["destination"],
message:
"destination is required unless mode is 'ssh' with auth-daemon mode 'native'"
}
)
.refine( .refine(
(data) => { (data) => {
if (data.mode === "host") { if (data.mode === "host") {
if (!data.destination) return true; // caught by the destination-required refine
// Check if it's a valid IP address using zod (v4 or v6) // Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z const isValidIP = z
.union([z.ipv4(), z.ipv6()]) .union([z.ipv4(), z.ipv6()])
@@ -419,6 +473,7 @@ export const ClientResourceSchema = z
.refine( .refine(
(data) => { (data) => {
if (data.mode === "cidr") { if (data.mode === "cidr") {
if (!data.destination) return true; // caught by the destination-required refine
// Check if it's a valid CIDR (v4 or v6) // Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()]) .union([z.cidrv4(), z.cidrv6()])
@@ -436,19 +491,19 @@ export const ClientResourceSchema = z
export const ConfigSchema = z export const ConfigSchema = z
.object({ .object({
"proxy-resources": z "proxy-resources": z
.record(z.string(), ResourceSchema) .record(z.string(), PublicResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"public-resources": z "public-resources": z
.record(z.string(), ResourceSchema) .record(z.string(), PublicResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"client-resources": z "client-resources": z
.record(z.string(), ClientResourceSchema) .record(z.string(), PrivateResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"private-resources": z "private-resources": z
.record(z.string(), ClientResourceSchema) .record(z.string(), PrivateResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
sites: z.record(z.string(), SiteSchema).optional().prefault({}) sites: z.record(z.string(), SiteSchema).optional().prefault({})
@@ -473,10 +528,13 @@ export const ConfigSchema = z
} }
return data as { return data as {
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>; "proxy-resources": Record<
string,
z.infer<typeof PublicResourceSchema>
>;
"client-resources": Record< "client-resources": Record<
string, string,
z.infer<typeof ClientResourceSchema> z.infer<typeof PrivateResourceSchema>
>; >;
sites: Record<string, z.infer<typeof SiteSchema>>; sites: Record<string, z.infer<typeof SiteSchema>>;
}; };
@@ -615,5 +673,5 @@ export const ConfigSchema = z
// Type inference from the schema // Type inference from the schema
export type Site = z.infer<typeof SiteSchema>; export type Site = z.infer<typeof SiteSchema>;
export type Target = z.infer<typeof TargetSchema>; export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof ResourceSchema>; export type Resource = z.infer<typeof PublicResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;
+3 -10
View File
@@ -331,16 +331,8 @@ export async function calculateUserClientsForOrgs(
]; ];
// Get next available subnet // Get next available subnet
const newSubnet = await getNextAvailableClientSubnet( const { value: newSubnet, release: releaseSubnetLock } =
orgId, await getNextAvailableClientSubnet(orgId, transaction);
transaction
);
if (!newSubnet) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found`
);
continue;
}
const subnet = newSubnet.split("/")[0]; const subnet = newSubnet.split("/")[0];
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
@@ -370,6 +362,7 @@ export async function calculateUserClientsForOrgs(
.insert(clients) .insert(clients)
.values(newClientData) .values(newClientData)
.returning(); .returning();
await releaseSubnetLock();
existingClientCache.set( existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId), getOrgOlmKey(orgId, olm.olmId),
newClient newClient
+139 -116
View File
@@ -327,127 +327,145 @@ export function doCidrsOverlap(cidr1: string, cidr2: string): boolean {
export async function getNextAvailableClientSubnet( export async function getNextAvailableClientSubnet(
orgId: string, orgId: string,
transaction: Transaction | typeof db = db transaction: Transaction | typeof db = db
): Promise<string> { ): Promise<{ value: string; release: () => Promise<void> }> {
return await lockManager.withLock( const lockKey = `client-subnet-allocation:${orgId}`;
`client-subnet-allocation:${orgId}`, const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
async () => { if (!acquired) {
const [org] = await transaction throw new Error(`Failed to acquire lock: ${lockKey}`);
.select() }
.from(orgs) const release = () => lockManager.releaseLock(lockKey);
.where(eq(orgs.orgId, orgId));
if (!org) { try {
throw new Error(`Organization with ID ${orgId} not found`); const [org] = await transaction
} .select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org.subnet) { if (!org) {
throw new Error( throw new Error(`Organization with ID ${orgId} not found`);
`Organization with ID ${orgId} has no subnet defined`
);
}
const existingAddressesSites = await transaction
.select({
address: sites.address
})
.from(sites)
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
const existingAddressesClients = await transaction
.select({
address: clients.subnet
})
.from(clients)
.where(
and(isNotNull(clients.subnet), eq(clients.orgId, orgId))
);
const addresses = [
...existingAddressesSites.map(
(site) => `${site.address?.split("/")[0]}/32`
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map(
(client) => `${client.address.split("/")}/32`
)
].filter((address) => address !== null) as string[];
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
return subnet;
} }
);
if (!org.subnet) {
throw new Error(
`Organization with ID ${orgId} has no subnet defined`
);
}
const existingAddressesSites = await transaction
.select({
address: sites.address
})
.from(sites)
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
const existingAddressesClients = await transaction
.select({
address: clients.subnet
})
.from(clients)
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
const addresses = [
...existingAddressesSites.map(
(site) => `${site.address?.split("/")[0]}/32`
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map(
(client) => `${client.address.split("/")[0]}/32`
)
].filter((address) => address !== null) as string[];
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
} }
export async function getNextAvailableAliasAddress( export async function getNextAvailableAliasAddress(
orgId: string, orgId: string,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<string> { ): Promise<{ value: string; release: () => Promise<void> }> {
return await lockManager.withLock( const lockKey = `alias-address-allocation:${orgId}`;
`alias-address-allocation:${orgId}`, const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
async () => { if (!acquired) {
const [org] = await trx throw new Error(`Failed to acquire lock: ${lockKey}`);
.select() }
.from(orgs) const release = () => lockManager.releaseLock(lockKey);
.where(eq(orgs.orgId, orgId));
if (!org) { try {
throw new Error(`Organization with ID ${orgId} not found`); const [org] = await trx
} .select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org.subnet) { if (!org) {
throw new Error( throw new Error(`Organization with ID ${orgId} not found`);
`Organization with ID ${orgId} has no subnet defined`
);
}
if (!org.utilitySubnet) {
throw new Error(
`Organization with ID ${orgId} has no utility subnet defined`
);
}
const existingAddresses = await trx
.select({
aliasAddress: siteResources.aliasAddress
})
.from(siteResources)
.where(
and(
isNotNull(siteResources.aliasAddress),
eq(siteResources.orgId, orgId)
)
);
const addresses = [
...existingAddresses.map(
(site) => `${site.aliasAddress?.split("/")[0]}/32`
),
// reserve a /29 for the dns server and other stuff
`${org.utilitySubnet.split("/")[0]}/29`
].filter((address) => address !== null) as string[];
let subnet = findNextAvailableCidr(
addresses,
32,
org.utilitySubnet
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// remove the cidr
subnet = subnet.split("/")[0];
return subnet;
} }
);
if (!org.subnet) {
throw new Error(
`Organization with ID ${orgId} has no subnet defined`
);
}
if (!org.utilitySubnet) {
throw new Error(
`Organization with ID ${orgId} has no utility subnet defined`
);
}
const existingAddresses = await trx
.select({
aliasAddress: siteResources.aliasAddress
})
.from(siteResources)
.where(
and(
isNotNull(siteResources.aliasAddress),
eq(siteResources.orgId, orgId)
)
);
const addresses = [
...existingAddresses.map(
(site) => `${site.aliasAddress?.split("/")[0]}/32`
),
// reserve a /29 for the dns server and other stuff
`${org.utilitySubnet.split("/")[0]}/29`
].filter((address) => address !== null) as string[];
let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// remove the cidr
subnet = subnet.split("/")[0];
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
} }
export async function getNextAvailableOrgSubnet(): Promise<string> { export async function getNextAvailableOrgSubnet(): Promise<{
return await lockManager.withLock("org-subnet-allocation", async () => { value: string;
release: () => Promise<void>;
}> {
const lockKey = "org-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey);
try {
const existingAddresses = await db const existingAddresses = await db
.select({ .select({
subnet: orgs.subnet subnet: orgs.subnet
@@ -466,8 +484,11 @@ export async function getNextAvailableOrgSubnet(): Promise<string> {
throw new Error("No available subnets remaining in space"); throw new Error("No available subnets remaining in space");
} }
return subnet; return { value: subnet, release };
}); } catch (e) {
await release();
throw e;
}
} }
export function generateRemoteSubnets( export function generateRemoteSubnets(
@@ -475,6 +496,8 @@ export function generateRemoteSubnets(
): string[] { ): string[] {
const remoteSubnets = allSiteResources const remoteSubnets = allSiteResources
.filter((sr) => { .filter((sr) => {
if (!sr.destination) return false;
if (sr.mode === "cidr") { if (sr.mode === "cidr") {
// check if its a valid CIDR using zod // check if its a valid CIDR using zod
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]); const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
@@ -496,7 +519,7 @@ export function generateRemoteSubnets(
} }
return ""; // This should never be reached due to filtering, but satisfies TypeScript return ""; // This should never be reached due to filtering, but satisfies TypeScript
}) })
.filter((subnet) => subnet !== ""); // Remove empty strings just to be safe .filter((subnet): subnet is string => subnet !== "" && subnet !== null); // Remove invalid values just to be safe
// remove duplicates // remove duplicates
return Array.from(new Set(remoteSubnets)); return Array.from(new Set(remoteSubnets));
} }
@@ -581,7 +604,7 @@ export function generateSubnetProxyTargets(
targets.push({ targets.push({
sourcePrefix: clientPrefix, sourcePrefix: clientPrefix,
destPrefix: `${siteResource.aliasAddress}/32`, destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination, rewriteTo: destination!,
portRange, portRange,
disableIcmp disableIcmp
}); });
@@ -589,7 +612,7 @@ export function generateSubnetProxyTargets(
} else if (siteResource.mode == "cidr") { } else if (siteResource.mode == "cidr") {
targets.push({ targets.push({
sourcePrefix: clientPrefix, sourcePrefix: clientPrefix,
destPrefix: siteResource.destination, destPrefix: siteResource.destination!,
portRange, portRange,
disableIcmp disableIcmp
}); });
@@ -671,7 +694,7 @@ export async function generateSubnetProxyTargetV2(
targets.push({ targets.push({
sourcePrefixes: [], sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`, destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination, rewriteTo: destination!,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId resourceId: siteResource.siteResourceId
@@ -680,7 +703,7 @@ export async function generateSubnetProxyTargetV2(
} else if (siteResource.mode == "cidr") { } else if (siteResource.mode == "cidr") {
targets.push({ targets.push({
sourcePrefixes: [], sourcePrefixes: [],
destPrefix: siteResource.destination, destPrefix: siteResource.destination!,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId resourceId: siteResource.siteResourceId
@@ -738,7 +761,7 @@ export async function generateSubnetProxyTargetV2(
protocol: siteResource.ssl ? "https" : "http", protocol: siteResource.ssl ? "https" : "http",
httpTargets: [ httpTargets: [
{ {
destAddr: siteResource.destination, destAddr: siteResource.destination!,
destPort: siteResource.destinationPort, destPort: siteResource.destinationPort,
scheme: siteResource.scheme scheme: siteResource.scheme
} }
+6
View File
@@ -890,6 +890,9 @@ async function handleSubnetProxyTargetUpdates(
} }
for (const client of removedClients) { for (const client of removedClients) {
if (!siteResource.destination) {
continue;
}
// Check if this client still has access to another resource // Check if this client still has access to another resource
// on this specific site with the same destination. We scope // on this specific site with the same destination. We scope
// by siteId (via siteNetworks) rather than networkId because // by siteId (via siteNetworks) rather than networkId because
@@ -1563,6 +1566,9 @@ async function handleMessagesForClientResources(
} }
try { try {
if (!resource.destination) {
continue;
}
// Check if this client still has access to another resource // Check if this client still has access to another resource
// on this specific site with the same destination. We scope // on this specific site with the same destination. We scope
// by siteId (via siteNetworks) rather than networkId because // by siteId (via siteNetworks) rather than networkId because
+10 -4
View File
@@ -2,7 +2,14 @@ import { PostHog } from "posthog-node";
import config from "./config"; import config from "./config";
import { getHostMeta } from "./hostMeta"; import { getHostMeta } from "./hostMeta";
import logger from "@server/logger"; import logger from "@server/logger";
import { alertRules, apiKeys, blueprints, db, roles, siteResources } from "@server/db"; import {
alertRules,
apiKeys,
blueprints,
db,
roles,
siteResources
} from "@server/db";
import { sites, users, orgs, resources, clients, idp } from "@server/db"; import { sites, users, orgs, resources, clients, idp } from "@server/db";
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm"; import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
import { APP_VERSION } from "./consts"; import { APP_VERSION } from "./consts";
@@ -143,8 +150,7 @@ class TelemetryClient {
.select({ .select({
name: resources.name, name: resources.name,
sso: resources.sso, sso: resources.sso,
protocol: resources.protocol, mode: resources.mode
http: resources.http
}) })
.from(resources); .from(resources);
@@ -311,7 +317,7 @@ class TelemetryClient {
(r) => r.sso (r) => r.sso
).length, ).length,
num_resources_non_http: stats.resources.filter( num_resources_non_http: stats.resources.filter(
(r) => !r.http (r) => r.mode !== "http"
).length, ).length,
num_newt_sites: stats.sites.filter((s) => s.type === "newt") num_newt_sites: stats.sites.filter((s) => s.type === "newt")
.length, .length,
+5 -7
View File
@@ -55,9 +55,7 @@ export async function getTraefikConfig(
resourceName: resources.name, resourceName: resources.name,
fullDomain: resources.fullDomain, fullDomain: resources.fullDomain,
ssl: resources.ssl, ssl: resources.ssl,
http: resources.http,
proxyPort: resources.proxyPort, proxyPort: resources.proxyPort,
protocol: resources.protocol,
subdomain: resources.subdomain, subdomain: resources.subdomain,
domainId: resources.domainId, domainId: resources.domainId,
enabled: resources.enabled, enabled: resources.enabled,
@@ -68,6 +66,7 @@ export async function getTraefikConfig(
headers: resources.headers, headers: resources.headers,
proxyProtocol: resources.proxyProtocol, proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion, proxyProtocolVersion: resources.proxyProtocolVersion,
mode: resources.mode,
// Target fields // Target fields
targetId: targets.targetId, targetId: targets.targetId,
@@ -115,8 +114,8 @@ export async function getTraefikConfig(
), ),
inArray(sites.type, siteTypes), inArray(sites.type, siteTypes),
allowRawResources allowRawResources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true ? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
: eq(resources.http, true) : eq(resources.mode, "http")
) )
) )
.orderBy(desc(targets.priority), targets.targetId); // stable ordering .orderBy(desc(targets.priority), targets.targetId); // stable ordering
@@ -166,9 +165,8 @@ export async function getTraefikConfig(
key: key, key: key,
fullDomain: row.fullDomain, fullDomain: row.fullDomain,
ssl: row.ssl, ssl: row.ssl,
http: row.http, mode: row.mode,
proxyPort: row.proxyPort, proxyPort: row.proxyPort,
protocol: row.protocol,
subdomain: row.subdomain, subdomain: row.subdomain,
domainId: row.domainId, domainId: row.domainId,
enabled: row.enabled, enabled: row.enabled,
@@ -580,7 +578,7 @@ export async function getTraefikConfig(
continue; continue;
} }
const protocol = resource.protocol.toLowerCase(); const protocol = resource.mode === "udp" ? "udp" : "tcp"; // all of the other ones are tcp
const port = resource.proxyPort; const port = resource.proxyPort;
if (!port) { if (!port) {
+3 -3
View File
@@ -780,9 +780,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
} }
} }
logger.debug( // logger.debug(
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}` // `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
); // );
for (const domain of allDomains) { for (const domain of allDomains) {
try { try {
+40 -6
View File
@@ -36,8 +36,6 @@ export async function getValidCertificatesForDomains(
domains: Set<string>, domains: Set<string>,
useCache: boolean = true useCache: boolean = true
): Promise<Array<CertificateResult>> { ): Promise<Array<CertificateResult>> {
const finalResults: CertificateResult[] = []; const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>(); const domainsToQuery = new Set<string>();
@@ -49,7 +47,26 @@ export async function getValidCertificatesForDomains(
if (cachedCert) { if (cachedCert) {
finalResults.push(cachedCert); // Valid cache hit finalResults.push(cachedCert); // Valid cache hit
} else { } else {
domainsToQuery.add(domain); // Cache miss or expired // Also check for a wildcard cache entry covering this domain's parent
const parts = domain.split(".");
let wildcardHit = false;
if (parts.length > 1) {
const parentDomain = parts.slice(1).join(".");
const wildcardCacheKey = `cert:*.${parentDomain}`;
const cachedWildcard =
await cache.get<CertificateResult>(wildcardCacheKey);
if (cachedWildcard) {
// Re-stamp queriedDomain so callers see the originally requested domain
finalResults.push({
...cachedWildcard,
queriedDomain: domain
});
wildcardHit = true;
}
}
if (!wildcardHit) {
domainsToQuery.add(domain); // Cache miss or expired
}
} }
} }
} else { } else {
@@ -59,7 +76,10 @@ export async function getValidCertificatesForDomains(
// 2. If all domains were resolved from the cache, return early // 2. If all domains were resolved from the cache, return early
if (domainsToQuery.size === 0) { if (domainsToQuery.size === 0) {
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!); const decryptedResults = decryptFinalResults(
finalResults,
config.getRawConfig().server.secret!
);
return decryptedResults; return decryptedResults;
} }
@@ -78,7 +98,8 @@ export async function getValidCertificatesForDomains(
const parentDomainsArray = Array.from(parentDomainsToQuery); const parentDomainsArray = Array.from(parentDomainsToQuery);
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com" // Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
const wildcardPrefixedArray = build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : []; const wildcardPrefixedArray =
build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
// 4. Build and execute a single, efficient Drizzle query // 4. Build and execute a single, efficient Drizzle query
// This query fetches all potential exact and wildcard matches in one database round-trip. // This query fetches all potential exact and wildcard matches in one database round-trip.
@@ -172,11 +193,24 @@ export async function getValidCertificatesForDomains(
if (useCache) { if (useCache) {
const cacheKey = `cert:${domain}`; const cacheKey = `cert:${domain}`;
await cache.set(cacheKey, resultCert, 180); await cache.set(cacheKey, resultCert, 180);
// Also cache wildcard certs under a pattern key so other subdomains
// can find them without a DB round-trip
if (resultCert.wildcard) {
const normalizedCertDomain = normalizeWildcardDomain(
resultCert.domain
);
const wildcardCacheKey = `cert:*.${normalizedCertDomain}`;
await cache.set(wildcardCacheKey, resultCert, 180);
}
} }
} }
} }
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!); const decryptedResults = decryptFinalResults(
finalResults,
config.getRawConfig().server.secret!
);
return decryptedResults; return decryptedResults;
} }
+304 -11
View File
@@ -12,6 +12,7 @@
*/ */
import { import {
browserGatewayTarget,
certificates, certificates,
db, db,
domainNamespaces, domainNamespaces,
@@ -95,9 +96,7 @@ export async function getTraefikConfig(
resourceName: resources.name, resourceName: resources.name,
fullDomain: resources.fullDomain, fullDomain: resources.fullDomain,
ssl: resources.ssl, ssl: resources.ssl,
http: resources.http,
proxyPort: resources.proxyPort, proxyPort: resources.proxyPort,
protocol: resources.protocol,
subdomain: resources.subdomain, subdomain: resources.subdomain,
domainId: resources.domainId, domainId: resources.domainId,
enabled: resources.enabled, enabled: resources.enabled,
@@ -109,6 +108,7 @@ export async function getTraefikConfig(
proxyProtocol: resources.proxyProtocol, proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion, proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard, wildcard: resources.wildcard,
mode: resources.mode,
maintenanceModeEnabled: resources.maintenanceModeEnabled, maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType, maintenanceModeType: resources.maintenanceModeType,
@@ -171,8 +171,8 @@ export async function getTraefikConfig(
), ),
inArray(sites.type, siteTypes), inArray(sites.type, siteTypes),
allowRawResources allowRawResources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true ? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
: eq(resources.http, true) : eq(resources.mode, "http")
) )
) )
.orderBy(desc(targets.priority), targets.targetId); // stable ordering .orderBy(desc(targets.priority), targets.targetId); // stable ordering
@@ -226,9 +226,8 @@ export async function getTraefikConfig(
key: key, key: key,
fullDomain: row.fullDomain, fullDomain: row.fullDomain,
ssl: row.ssl, ssl: row.ssl,
http: row.http,
proxyPort: row.proxyPort, proxyPort: row.proxyPort,
protocol: row.protocol, mode: row.mode,
subdomain: row.subdomain, subdomain: row.subdomain,
domainId: row.domainId, domainId: row.domainId,
enabled: row.enabled, enabled: row.enabled,
@@ -277,10 +276,119 @@ export async function getTraefikConfig(
}); });
}); });
// Query browser gateway targets for this exit node
const browserGatewayRows = await db
.select({
// Resource fields
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert,
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Browser gateway target fields
browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId,
bgType: browserGatewayTarget.type,
// Site fields
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
siteExitNodeId: sites.exitNodeId
})
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.innerJoin(
resources,
eq(resources.resourceId, browserGatewayTarget.resourceId)
)
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, resources.domainId)
)
.where(
and(
eq(resources.enabled, true),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
eq(sites.type, "local"),
sql`(${build != "saas" ? 1 : 0} = 1)`
)
),
inArray(sites.type, siteTypes)
)
);
// Group browser gateway targets by resource
type BrowserGatewayResourceEntry = {
resourceId: number;
name: string;
fullDomain: string | null;
ssl: boolean | null;
subdomain: string | null;
domainId: string | null;
enabled: boolean | null;
wildcard: boolean | null;
domainCertResolver: string | null;
preferWildcardCert: boolean | null;
targets: {
browserGatewayTargetId: number;
bgType: string;
siteId: number;
siteType: string;
siteOnline: boolean | null;
subnet: string | null;
siteExitNodeId: number | null;
}[];
};
const browserGatewayResourcesMap = new Map<
number,
BrowserGatewayResourceEntry
>();
for (const row of browserGatewayRows) {
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
if (!browserGatewayResourcesMap.has(row.resourceId)) {
browserGatewayResourcesMap.set(row.resourceId, {
resourceId: row.resourceId,
name: sanitize(row.resourceName) || "",
fullDomain: row.fullDomain,
ssl: row.ssl,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
wildcard: row.wildcard,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert,
targets: []
});
}
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
browserGatewayTargetId: row.browserGatewayTargetId,
bgType: row.bgType,
siteId: row.siteId,
siteType: row.siteType,
siteOnline: row.siteOnline,
subnet: row.subnet,
siteExitNodeId: row.siteExitNodeId
});
}
let siteResourcesWithFullDomain: { let siteResourcesWithFullDomain: {
siteResourceId: number; siteResourceId: number;
fullDomain: string | null; fullDomain: string | null;
mode: "http" | "host" | "cidr"; mode: "http" | "host" | "cidr" | "ssh";
}[] = []; }[] = [];
if (build == "enterprise") { if (build == "enterprise") {
// we dont want to do this on the cloud // we dont want to do this on the cloud
@@ -324,6 +432,12 @@ export async function getTraefikConfig(
domains.add(sr.fullDomain); domains.add(sr.fullDomain);
} }
} }
// Include browser gateway resource domains
for (const bgResource of browserGatewayResourcesMap.values()) {
if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) {
domains.add(bgResource.fullDomain);
}
}
// get the valid certs for these domains // get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
@@ -589,7 +703,7 @@ export async function getTraefikConfig(
resource.ssl ? entrypointHttps : entrypointHttp resource.ssl ? entrypointHttps : entrypointHttp
], ],
service: maintenanceServiceName, service: maintenanceServiceName,
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `,
priority: 2001, priority: 2001,
...(resource.ssl ? { tls } : {}) ...(resource.ssl ? { tls } : {})
}; };
@@ -830,7 +944,7 @@ export async function getTraefikConfig(
continue; continue;
} }
const protocol = resource.protocol.toLowerCase(); const protocol = resource.mode == "udp" ? "udp" : "tcp";
const port = resource.proxyPort; const port = resource.proxyPort;
if (!port) { if (!port) {
@@ -925,6 +1039,185 @@ export async function getTraefikConfig(
} }
} }
// Generate Traefik config for browser gateway resources
const browserGatewayPort = 39999;
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
if (!bgResource.enabled) continue;
if (!bgResource.domainId) continue;
if (!bgResource.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = bgResource.fullDomain;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
const hostRule = `Host(\`${fullDomain}\`)`;
// Build TLS config
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
let wildCard: string;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!bgResource.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = bgResource.domainCertResolver
? bgResource.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
bgResource.preferWildcardCert !== undefined &&
bgResource.preferWildcardCert !== null
? bgResource.preferWildcardCert
: globalDefaultPreferWildcard;
tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
} else {
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for browser gateway domain: ${fullDomain}`
);
continue;
}
}
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
if (bgResource.ssl) {
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
config_output.http.routers![redirectRouterName] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100
};
}
// Collect online sites for this resource (for any type)
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
// Group targets by type and generate per-type websocket routers and services
const typeMap = new Map<string, typeof bgResource.targets>();
for (const t of bgResource.targets) {
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
typeMap.get(t.bgType)!.push(t);
}
for (const [bgType, typedTargets] of typeMap.entries()) {
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
const bgRouterName = `${bgKey}-router`;
const bgServiceName = `${bgKey}-service`;
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
const servers = typedTargets
.filter((t) => {
if (!t.siteOnline && anySiteOnline) return false;
if (t.siteType === "newt") return !!t.subnet;
return false; // browser gateway only supported on newt sites
})
.map((t) => ({
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
}))
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
config_output.http.routers![bgRouterName] = {
entryPoints: [
bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: bgServiceName,
rule: bgRule,
priority: 110, // highest - websocket path takes precedence
...(bgResource.ssl ? { tls } : {})
};
config_output.http.services![bgServiceName] = {
loadBalancer: {
servers
}
};
}
// UI: serve the browser gateway page from the internal pangolin instance.
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
// how the maintenance page rewrites everything to /maintenance-screen.
const primaryType = typeMap.keys().next().value as string;
const internalHost = config.getRawConfig().server.internal_hostname;
const internalPort = config.getRawConfig().server.next_port;
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
const entrypoint = bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares![uiRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: `/${primaryType}`
}
};
config_output.http.services![bgUiServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${internalHost}:${internalPort}`
}
]
}
};
// Assets router at higher priority so /_next files load without rewrite
config_output.http.routers![
`bg-r${bgResource.resourceId}-assets-router`
] = {
entryPoints: [entrypoint],
middlewares: routerMiddlewares,
service: bgUiServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
...(bgResource.ssl ? { tls } : {})
};
// Catch-all router rewrites everything on the domain to /{primaryType}
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
{
entryPoints: [entrypoint],
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100,
...(bgResource.ssl ? { tls } : {})
};
}
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no // Traefik generates TLS certificates for those domains even when no
// matching resource exists yet. // matching resource exists yet.
@@ -1040,7 +1333,7 @@ export async function getTraefikConfig(
config_output.http.routers[`${siteResourceRouterName}-assets`] = { config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [config.getRawConfig().traefik.https_entrypoint], entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName, service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101, priority: 101,
tls tls
}; };
@@ -1143,7 +1436,7 @@ export async function getTraefikConfig(
config.getRawConfig().traefik.https_entrypoint config.getRawConfig().traefik.https_entrypoint
], ],
service: "landing-service", service: "landing-service",
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`, rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 203, priority: 203,
tls: tls tls: tls
}; };
@@ -0,0 +1,187 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
newts,
resources,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
import { generateId } from "@server/auth/sessions/app";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
const bodySchema = z.strictObject({
siteId: z.number().int().positive(),
type: z.enum(["ssh", "rdp", "vnc"]),
destination: z.string().nonempty(),
destinationPort: z.number().int().min(1).max(65535)
});
export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "put",
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target",
description: "Create a browser gateway target for a resource.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, resourceId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, type, destination, destinationPort } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found in organization ${orgId}`
)
);
}
const [site] = await db
.select()
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found in organization ${orgId}`
)
);
}
const plainToken = generateId(48);
const encryptedToken = encrypt(
plainToken,
config.getRawConfig().server.secret!
);
const [record] = await db
.insert(browserGatewayTarget)
.values({
resourceId,
siteId,
type,
destination,
destinationPort,
authToken: encryptedToken
})
.returning();
if (site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (newt) {
await sendBrowserGatewayTargets(
newt.newtId,
[record],
newt.version
);
}
}
logger.info(
`Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}`
);
return response<CreateBrowserGatewayTargetResponse>(res, {
data: record,
success: true,
error: false,
message: "Browser gateway target created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create browser gateway target"
)
);
}
}
@@ -0,0 +1,130 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db, newts, sites } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
registry.registerPath({
method: "delete",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Delete a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema
},
responses: {}
});
export async function deleteBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const [existing] = await db
.select({ bgt: browserGatewayTarget, site: sites })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
await db
.delete(browserGatewayTarget)
.where(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
)
);
if (existing.site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, existing.bgt.siteId))
.limit(1);
if (newt) {
await removeBrowserGatewayTarget(
newt.newtId,
browserGatewayTargetId,
newt.version
);
}
}
logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`);
return response(res, {
data: null,
success: true,
error: false,
message: "Browser gateway target deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to delete browser gateway target"
)
);
}
}
@@ -0,0 +1,109 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "get",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Get a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema
},
responses: {}
});
export async function getBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const [result] = await db
.select({ bgt: browserGatewayTarget })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!result) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
return response<GetBrowserGatewayTargetResponse>(res, {
data: result.bgt,
success: true,
error: false,
message: "Browser gateway target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to retrieve browser gateway target"
)
);
}
}
@@ -0,0 +1,18 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./createBrowserGatewayTarget";
export * from "./updateBrowserGatewayTarget";
export * from "./deleteBrowserGatewayTarget";
export * from "./getBrowserGatewayTarget";
export * from "./listBrowserGatewayTargets";
@@ -0,0 +1,148 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
resources,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
const querySchema = z.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
export type ListBrowserGatewayTargetsResponse = {
targets: BrowserGatewayTarget[];
total: number;
limit: number;
offset: number;
};
registry.registerPath({
method: "get",
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets",
description: "List browser gateway targets for a resource.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
query: querySchema
},
responses: {}
});
export async function listBrowserGatewayTargets(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, resourceId } = parsedParams.data;
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { limit, offset } = parsedQuery.data;
const [resource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found in organization ${orgId}`
)
);
}
const targets = await db
.select()
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.resourceId, resourceId))
.limit(limit)
.offset(offset);
return response<ListBrowserGatewayTargetsResponse>(res, {
data: {
targets: targets,
total: targets.length,
limit,
offset
},
success: true,
error: false,
message: "Browser gateway targets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to list browser gateway targets"
)
);
}
}
@@ -0,0 +1,180 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
newts,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
const bodySchema = z.strictObject({
siteId: z.number().int().positive().optional(),
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
destination: z.string().nonempty().optional(),
destinationPort: z.number().int().min(1).max(65535).optional()
});
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "post",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Update a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, type, destination, destinationPort } = parsedBody.data;
const [existing] = await db
.select({ bgt: browserGatewayTarget, site: sites })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
const updateValues: Partial<BrowserGatewayTarget> = {};
if (siteId !== undefined) updateValues.siteId = siteId;
if (type !== undefined) updateValues.type = type;
if (destination !== undefined) updateValues.destination = destination;
if (destinationPort !== undefined)
updateValues.destinationPort = destinationPort;
const [updated] = await db
.update(browserGatewayTarget)
.set(updateValues)
.where(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
)
)
.returning();
const targetSiteId = siteId ?? existing.bgt.siteId;
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, targetSiteId))
.limit(1);
if (site && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, targetSiteId))
.limit(1);
if (newt) {
await sendBrowserGatewayTargets(
newt.newtId,
[updated],
newt.version
);
}
}
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
return response<UpdateBrowserGatewayTargetResponse>(res, {
data: updated,
success: true,
error: false,
message: "Browser gateway target updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to update browser gateway target"
)
);
}
}
+46
View File
@@ -31,6 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule"; import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks"; import * as healthChecks from "#private/routers/healthChecks";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import * as labels from "#private/routers/labels"; import * as labels from "#private/routers/labels";
import * as client from "@server/routers/client"; import * as client from "@server/routers/client";
@@ -842,3 +843,48 @@ authenticated.post(
verifyClientAccess, verifyClientAccess,
client.rebuildClientAssociationsCacheRoute client.rebuildClientAssociationsCacheRoute
); );
authenticated.put(
"/org/:orgId/resource/:resourceId/browser-gateway-target",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
browserGatewayTarget.createBrowserGatewayTarget
);
authenticated.get(
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
browserGatewayTarget.listBrowserGatewayTargets
);
authenticated.get(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
browserGatewayTarget.getBrowserGatewayTarget
);
authenticated.post(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
browserGatewayTarget.updateBrowserGatewayTarget
);
authenticated.delete(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
browserGatewayTarget.deleteBrowserGatewayTarget
);
+41
View File
@@ -16,6 +16,7 @@ import * as org from "#private/routers/org";
import * as logs from "#private/routers/auditLogs"; import * as logs from "#private/routers/auditLogs";
import * as alertEvents from "#private/routers/alertEvents"; import * as alertEvents from "#private/routers/alertEvents";
import * as certificates from "#private/routers/certificates"; import * as certificates from "#private/routers/certificates";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import { import {
verifyApiKeyHasAction, verifyApiKeyHasAction,
@@ -215,3 +216,43 @@ authenticated.delete(
logActionAudit(ActionsEnum.removeUserRole), logActionAudit(ActionsEnum.removeUserRole),
user.removeUserRole user.removeUserRole
); );
authenticated.put(
"/org/:orgId/resource/:resourceId/browser-gateway-target",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
browserGatewayTarget.createBrowserGatewayTarget
);
authenticated.get(
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
browserGatewayTarget.listBrowserGatewayTargets
);
authenticated.get(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
browserGatewayTarget.getBrowserGatewayTarget
);
authenticated.post(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
browserGatewayTarget.updateBrowserGatewayTarget
);
authenticated.delete(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
browserGatewayTarget.deleteBrowserGatewayTarget
);
+319 -226
View File
@@ -23,7 +23,8 @@ import {
roundTripMessageTracker, roundTripMessageTracker,
siteResources, siteResources,
siteNetworks, siteNetworks,
userOrgs userOrgs,
sites
} from "@server/db"; } from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit"; import { logAccessAudit } from "#private/lib/logAccessAudit";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
@@ -48,7 +49,8 @@ const bodySchema = z
.strictObject({ .strictObject({
publicKey: z.string().nonempty(), publicKey: z.string().nonempty(),
resourceId: z.number().int().positive().optional(), resourceId: z.number().int().positive().optional(),
resource: z.string().nonempty().optional() // this is either the nice id or the alias resource: z.string().nonempty().optional(), // this is either the nice id or the alias
username: z.string().nonempty().optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -63,19 +65,19 @@ const bodySchema = z
); );
export type SignSshKeyResponse = { export type SignSshKeyResponse = {
certificate: string; certificate?: string;
messageIds: number[]; messageIds: number[];
messageId: number; messageId?: number;
sshUsername: string; sshUsername: string;
sshHost: string; sshHost: string;
resourceId: number; resourceId: number;
siteIds: number[]; siteIds: number[];
siteId: number; siteId: number;
keyId: string; keyId?: string;
validPrincipals: string[]; validPrincipals?: string[];
validAfter: string; validAfter?: string;
validBefore: string; validBefore?: string;
expiresIn: number; expiresIn?: number;
}; };
// registry.registerPath({ // registry.registerPath({
@@ -126,7 +128,8 @@ export async function signSshKey(
const { const {
publicKey, publicKey,
resourceId, resourceId,
resource: resourceQueryString resource: resourceQueryString,
username
} = parsedBody.data; } = parsedBody.data;
const userId = req.user?.userId; const userId = req.user?.userId;
const roleIds = req.userOrgRoleIds ?? []; const roleIds = req.userOrgRoleIds ?? [];
@@ -174,101 +177,6 @@ export async function signSshKey(
); );
} }
let usernameToUse;
if (!userOrg.pamUsername) {
if (req.user?.email) {
// Extract username from email (first part before @)
usernameToUse = req.user?.email
.split("@")[0]
.replace(/[^a-zA-Z0-9_-]/g, "");
if (!usernameToUse) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Unable to extract username from email"
)
);
}
} else if (req.user?.username) {
usernameToUse = req.user.username;
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "-");
if (!usernameToUse) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Username is not valid for SSH certificate"
)
);
}
} else {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User does not have a valid email or username for SSH certificate"
)
);
}
// prefix with p-
usernameToUse = `p-${usernameToUse}`;
// check if we have a existing user in this org with the same
const [existingUserWithSameName] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.pamUsername, usernameToUse)
)
)
.limit(1);
if (existingUserWithSameName) {
let foundUniqueUsername = false;
for (let attempt = 0; attempt < 20; attempt++) {
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
const candidateUsername = `${usernameToUse}${randomNum}`;
const [existingUser] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.pamUsername, candidateUsername)
)
)
.limit(1);
if (!existingUser) {
usernameToUse = candidateUsername;
foundUniqueUsername = true;
break;
}
}
if (!foundUniqueUsername) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Unable to generate a unique username for SSH certificate"
)
);
}
}
await db
.update(userOrgs)
.set({ pamUsername: usernameToUse })
.where(
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
);
} else {
usernameToUse = userOrg.pamUsername;
}
// Get and decrypt the org's CA keys // Get and decrypt the org's CA keys
const caKeys = await getOrgCAKeys( const caKeys = await getOrgCAKeys(
orgId, orgId,
@@ -361,90 +269,303 @@ export async function signSshKey(
); );
} }
const roleRows = await db const sitesFromNetworks = await db
.select({
sshSudoCommands: roles.sshSudoCommands,
sshUnixGroups: roles.sshUnixGroups,
sshCreateHomeDir: roles.sshCreateHomeDir,
sshSudoMode: roles.sshSudoMode
})
.from(roles)
.innerJoin(
roleSiteResources,
eq(roleSiteResources.roleId, roles.roleId)
)
.where(
and(
inArray(roles.roleId, roleIds),
eq(
roleSiteResources.siteResourceId,
resource.siteResourceId
)
)
);
const parsedSudoCommands: string[] = [];
const parsedGroupsSet = new Set<string>();
let homedir: boolean | null = null;
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
let sudoMode: "none" | "commands" | "full" = "none";
for (const roleRow of roleRows) {
try {
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
} catch {
// skip
}
try {
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
if (Array.isArray(grps))
grps.forEach((g: string) => parsedGroupsSet.add(g));
} catch {
// skip
}
if (roleRow?.sshCreateHomeDir === true) homedir = true;
const m = roleRow?.sshSudoMode ?? "none";
if (
sudoModeOrder[m as keyof typeof sudoModeOrder] >
sudoModeOrder[sudoMode]
) {
sudoMode = m as "none" | "commands" | "full";
}
}
const parsedGroups = Array.from(parsedGroupsSet);
if (homedir === null && roleRows.length > 0) {
homedir = roleRows[0].sshCreateHomeDir ?? null;
}
const sites = await db
.select({ siteId: siteNetworks.siteId }) .select({ siteId: siteNetworks.siteId })
.from(siteNetworks) .from(siteNetworks)
.where(eq(siteNetworks.networkId, resource.networkId!)); .where(eq(siteNetworks.networkId, resource.networkId!));
const siteIds = sites.map((site) => site.siteId); const siteIds = sitesFromNetworks.map((site) => site.siteId);
// Sign the public key let expiresIn: number | undefined;
const now = BigInt(Math.floor(Date.now() / 1000)); let messageIds: number[] = [];
// only valid for 5 minutes let cert:
const validFor = 300n; | {
certificate: string;
keyId: string;
validPrincipals: string[];
validAfter: Date;
validBefore: Date;
}
| undefined;
// if the pam mode is push then we generate the user's pam username and use that or pull it from the userOrgs table
// if the mode is passthrough then just use what was provided because the user will log in themselves
let usernameToUse;
if (resource.pamMode === "push") {
if (!userOrg.pamUsername) {
if (req.user?.email) {
// Extract username from email (first part before @)
usernameToUse = req.user?.email
.split("@")[0]
.replace(/[^a-zA-Z0-9_-]/g, "");
if (!usernameToUse) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Unable to extract username from email"
)
);
}
} else if (req.user?.username) {
usernameToUse = req.user.username;
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
usernameToUse = usernameToUse.replace(
/[^a-zA-Z0-9_-]/g,
"-"
);
if (!usernameToUse) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Username is not valid for SSH certificate"
)
);
}
} else {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User does not have a valid email or username for SSH certificate"
)
);
}
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, { // prefix with p-
keyId: `${usernameToUse}@${resource.niceId}`, usernameToUse = `p-${usernameToUse}`;
validPrincipals: [usernameToUse, resource.niceId],
validAfter: now - 60n, // Start 1 min ago for clock skew // check if we have a existing user in this org with the same
validBefore: now + validFor const [existingUserWithSameName] = await db
}); .select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.pamUsername, usernameToUse)
)
)
.limit(1);
if (existingUserWithSameName) {
let foundUniqueUsername = false;
for (let attempt = 0; attempt < 20; attempt++) {
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
const candidateUsername = `${usernameToUse}${randomNum}`;
const [existingUser] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.pamUsername, candidateUsername)
)
)
.limit(1);
if (!existingUser) {
usernameToUse = candidateUsername;
foundUniqueUsername = true;
break;
}
}
if (!foundUniqueUsername) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Unable to generate a unique username for SSH certificate"
)
);
}
}
await db
.update(userOrgs)
.set({ pamUsername: usernameToUse })
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, userId)
)
);
} else {
usernameToUse = userOrg.pamUsername;
}
const roleRows = await db
.select({
sshSudoCommands: roles.sshSudoCommands,
sshUnixGroups: roles.sshUnixGroups,
sshCreateHomeDir: roles.sshCreateHomeDir,
sshSudoMode: roles.sshSudoMode
})
.from(roles)
.innerJoin(
roleSiteResources,
eq(roleSiteResources.roleId, roles.roleId)
)
.where(
and(
inArray(roles.roleId, roleIds),
eq(
roleSiteResources.siteResourceId,
resource.siteResourceId
)
)
);
const parsedSudoCommands: string[] = [];
const parsedGroupsSet = new Set<string>();
let homedir: boolean | null = null;
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
let sudoMode: "none" | "commands" | "full" = "none";
for (const roleRow of roleRows) {
try {
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
} catch {
// skip
}
try {
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
if (Array.isArray(grps))
grps.forEach((g: string) => parsedGroupsSet.add(g));
} catch {
// skip
}
if (roleRow?.sshCreateHomeDir === true) homedir = true;
const m = roleRow?.sshSudoMode ?? "none";
if (
sudoModeOrder[m as keyof typeof sudoModeOrder] >
sudoModeOrder[sudoMode]
) {
sudoMode = m as "none" | "commands" | "full";
}
}
const parsedGroups = Array.from(parsedGroupsSet);
if (homedir === null && roleRows.length > 0) {
homedir = roleRows[0].sshCreateHomeDir ?? null;
}
// Sign the public key
const now = BigInt(Math.floor(Date.now() / 1000));
// only valid for 5 minutes
const validFor = 300n;
expiresIn = Number(validFor); // seconds
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
keyId: `${usernameToUse}@${resource.niceId}`,
validPrincipals: [usernameToUse, resource.niceId],
validAfter: now - 60n, // Start 1 min ago for clock skew
validBefore: now + validFor
});
const messageIds: number[] = [];
for (const siteId of siteIds) {
// get the site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (!newt) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Site associated with resource not found"
)
);
}
const [message] = await db
.insert(roundTripMessageTracker)
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000)
})
.returning();
if (!message) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create message tracker entry"
)
);
}
messageIds.push(message.messageId);
await sendToClient(newt.newtId, {
type: `newt/pam/connection`,
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: resource.authDaemonPort ?? 22123,
authDaemonMode: resource.authDaemonMode, // site, remote, native where native is the pty mode
externalAuthDaemon:
resource.authDaemonMode === "remote", // keep this for backward compatibility but new newts are using the authDaemonMode field
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
}
});
}
} else if (resource.pamMode === "passthrough") {
usernameToUse = username;
if (!usernameToUse) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Username must be provided when PAM mode is passthrough"
)
);
}
} else {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Invalid PAM mode configured for resource"
)
);
}
let sshHost: string | undefined;
if (
resource.authDaemonMode === "site" ||
resource.authDaemonMode === "remote"
) {
if (resource.alias && resource.alias != "") {
sshHost = resource.alias;
} else {
sshHost = resource.destination || ""; // TODO: IF WE HAVE THE NATIVE SSH MODE WHAT SHOULD WE DO HERE?
}
} else if (resource.authDaemonMode === "native") {
if (siteIds.length > 1) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Multiple sites associated with resource, unable to determine SSH host when in native mode"
)
);
}
const messageIds: number[] = [];
for (const siteId of siteIds) {
// get the site // get the site
const [newt] = await db const [site] = await db
.select() .select()
.from(newts) .from(sites)
.where(eq(newts.siteId, siteId)) .where(eq(sites.siteId, siteIds[0]))
.limit(1); .limit(1);
if (!newt) { if (!site) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
@@ -453,54 +574,26 @@ export async function signSshKey(
); );
} }
const [message] = await db if (!site.address) {
.insert(roundTripMessageTracker)
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000)
})
.returning();
if (!message) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create message tracker entry" "Site address not configured, unable to determine SSH host when in native mode"
) )
); );
} }
messageIds.push(message.messageId); // its the address but split off the cidr if there is one
sshHost = site.address.split("/")[0];
await sendToClient(newt.newtId, {
type: `newt/pam/connection`,
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: resource.authDaemonPort ?? 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
}
});
} }
const expiresIn = Number(validFor); // seconds if (!sshHost) {
return next(
let sshHost; createHttpError(
if (resource.alias && resource.alias != "") { HttpCode.INTERNAL_SERVER_ERROR,
sshHost = resource.alias; "Unable to determine SSH host for the resource"
} else { )
sshHost = resource.destination; );
} }
await logsDb.insert(actionAuditLog).values({ await logsDb.insert(actionAuditLog).values({
@@ -527,7 +620,7 @@ export async function signSshKey(
: undefined, : undefined,
metadata: { metadata: {
resourceName: resource.name, resourceName: resource.name,
siteId: siteIds[0], siteIds: siteIds,
sshUsername: usernameToUse, sshUsername: usernameToUse,
sshHost: sshHost sshHost: sshHost
}, },
@@ -537,18 +630,18 @@ export async function signSshKey(
return response<SignSshKeyResponse>(res, { return response<SignSshKeyResponse>(res, {
data: { data: {
certificate: cert.certificate, certificate: cert?.certificate,
messageIds: messageIds, messageIds: messageIds,
messageId: messageIds[0], // just pick the first one for backward compatibility messageId: messageIds[0], // just pick the first one for backward compatibility with older olms
sshUsername: usernameToUse, sshUsername: usernameToUse,
sshHost: sshHost, sshHost: sshHost, // just pick the first one for backward compatibility with older olms
resourceId: resource.siteResourceId, resourceId: resource.siteResourceId,
siteIds: siteIds, siteIds: siteIds,
siteId: siteIds[0], // just pick the first one for backward compatibility siteId: siteIds[0], // just pick the first one for backward compatibility with older olms
keyId: cert.keyId, keyId: cert?.keyId,
validPrincipals: cert.validPrincipals, validPrincipals: cert?.validPrincipals,
validAfter: cert.validAfter.toISOString(), validAfter: cert?.validAfter.toISOString(),
validBefore: cert.validBefore.toISOString(), validBefore: cert?.validBefore.toISOString(),
expiresIn expiresIn
}, },
success: true, success: true,
+3 -1
View File
@@ -51,7 +51,9 @@ export async function pickClientDefaults(
const olmId = generateId(15); const olmId = generateId(15);
const secret = generateId(48); const secret = generateId(48);
const newSubnet = await getNextAvailableClientSubnet(orgId); const { value: newSubnet, release } =
await getNextAvailableClientSubnet(orgId);
await release(); // release immediately — this endpoint only previews the next available value
if (!newSubnet) { if (!newSubnet) {
return next( return next(
createHttpError( createHttpError(
+2
View File
@@ -42,6 +42,8 @@ internalRouter.get("/idp", idp.listIdps);
internalRouter.get("/idp/:idpId", idp.getIdp); internalRouter.get("/idp/:idpId", idp.getIdp);
internalRouter.get("/resource/browser-target", resource.getBrowserTarget);
// Gerbil routes // Gerbil routes
const gerbilRouter = Router(); const gerbilRouter = Router();
internalRouter.use("/gerbil", gerbilRouter); internalRouter.use("/gerbil", gerbilRouter);
+28 -6
View File
@@ -1,4 +1,6 @@
import { import {
browserGatewayTarget,
BrowserGatewayTarget,
clients, clients,
clientSiteResourcesAssociationsCache, clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache, clientSitesAssociationsCache,
@@ -16,6 +18,7 @@ import logger from "@server/logger";
import { initPeerAddHandshake, updatePeer } from "../olm/peers"; import { initPeerAddHandshake, updatePeer } from "../olm/peers";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import { import {
formatEndpoint, formatEndpoint,
generateSubnetProxyTargetV2, generateSubnetProxyTargetV2,
@@ -194,7 +197,7 @@ export async function buildTargetConfigurationForNewtClient(
siteId: number, siteId: number,
version?: string | null version?: string | null
) { ) {
// Get all enabled targets with their resource protocol information // Get all enabled targets with their resource mode information
const allTargets = await db const allTargets = await db
.select({ .select({
resourceId: targets.resourceId, resourceId: targets.resourceId,
@@ -204,7 +207,7 @@ export async function buildTargetConfigurationForNewtClient(
port: targets.port, port: targets.port,
internalPort: targets.internalPort, internalPort: targets.internalPort,
enabled: targets.enabled, enabled: targets.enabled,
protocol: resources.protocol mode: resources.mode
}) })
.from(targets) .from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId)) .innerJoin(resources, eq(targets.resourceId, resources.resourceId))
@@ -233,6 +236,11 @@ export async function buildTargetConfigurationForNewtClient(
.from(targetHealthCheck) .from(targetHealthCheck)
.where(eq(targetHealthCheck.siteId, siteId)); .where(eq(targetHealthCheck.siteId, siteId));
const allBrowserGatewayTargets = await db
.select()
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.siteId, siteId));
const { tcpTargets, udpTargets } = allTargets.reduce( const { tcpTargets, udpTargets } = allTargets.reduce(
(acc, target) => { (acc, target) => {
// Filter out invalid targets // Filter out invalid targets
@@ -244,10 +252,11 @@ export async function buildTargetConfigurationForNewtClient(
const formattedTarget = `${target.internalPort}:${formatEndpoint(target.ip, target.port)}`; const formattedTarget = `${target.internalPort}:${formatEndpoint(target.ip, target.port)}`;
// Add to the appropriate protocol array // Add to the appropriate protocol array
if (target.protocol === "tcp") { if (target.mode === "udp") {
acc.tcpTargets.push(formattedTarget);
} else {
acc.udpTargets.push(formattedTarget); acc.udpTargets.push(formattedTarget);
} else {
// all other modes are tcp
acc.tcpTargets.push(formattedTarget);
} }
return acc; return acc;
@@ -304,9 +313,22 @@ export async function buildTargetConfigurationForNewtClient(
(target) => target !== null (target) => target !== null
); );
const serverSecret = config.getRawConfig().server.secret!;
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
const decryptAuthToken = decrypt(t.authToken, serverSecret);
return {
id: t.browserGatewayTargetId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort,
authToken: decryptAuthToken
};
});
return { return {
validHealthCheckTargets, validHealthCheckTargets,
tcpTargets, tcpTargets,
udpTargets udpTargets,
browserGatewayTargets
}; };
} }
@@ -43,8 +43,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const siteId = newt.siteId; const siteId = newt.siteId;
const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } = const {
message.data; publicKey,
pingResults,
newtVersion,
backwardsCompatible,
chainId
} = message.data;
if (!publicKey) { if (!publicKey) {
logger.warn("Public key not provided"); logger.warn("Public key not provided");
return; return;
@@ -191,8 +196,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.where(eq(newts.newtId, newt.newtId)); .where(eq(newts.newtId, newt.newtId));
} }
const { tcpTargets, udpTargets, validHealthCheckTargets } = const {
await buildTargetConfigurationForNewtClient(siteId, newtVersion); tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
logger.debug( logger.debug(
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
@@ -212,6 +221,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
tcp: tcpTargets tcp: tcpTargets
}, },
healthCheckTargets: validHealthCheckTargets, healthCheckTargets: validHealthCheckTargets,
browserGatewayTargets: browserGatewayTargets,
chainId: chainId chainId: chainId
} }
}, },
+72 -74
View File
@@ -203,84 +203,82 @@ export async function registerNewt(
let newSiteId: number | undefined; let newSiteId: number | undefined;
await db.transaction(async (trx) => { const { value: newClientAddress, release: releaseSubnetLock } =
const newClientAddress = await getNextAvailableClientSubnet(orgId); await getNextAvailableClientSubnet(orgId);
if (!newClientAddress) { try {
return next( await db.transaction(async (trx) => {
createHttpError( let clientAddress = newClientAddress.split("/")[0];
HttpCode.INTERNAL_SERVER_ERROR, clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org
"No available subnet found"
)
);
}
let clientAddress = newClientAddress.split("/")[0]; // Create the site (type "newt", name = niceId)
clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org const [newSite] = await trx
.insert(sites)
.values({
orgId,
name: name || niceId,
niceId,
address: clientAddress,
type: "newt",
dockerSocketEnabled: true,
status: keyRecord.approveNewSites
? "approved"
: "pending"
})
.returning();
// Create the site (type "newt", name = niceId) await logsDb.insert(statusHistory).values({
const [newSite] = await trx entityType: "site",
.insert(sites) entityId: newSite.siteId,
.values({ orgId: orgId,
orgId, status: "offline",
name: name || niceId, timestamp: Math.floor(Date.now() / 1000)
niceId, });
address: clientAddress,
type: "newt",
dockerSocketEnabled: true,
status: keyRecord.approveNewSites ? "approved" : "pending"
})
.returning();
await logsDb.insert(statusHistory).values({ newSiteId = newSite.siteId;
entityType: "site",
entityId: newSite.siteId, // Grant admin role access to the new site
orgId: orgId, const [adminRole] = await trx
status: "offline", .select()
timestamp: Math.floor(Date.now() / 1000) .from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
throw new Error(`Admin role not found for org ${orgId}`);
}
await trx.insert(roleSites).values({
roleId: adminRole.roleId,
siteId: newSite.siteId
});
// Create the newt for this site
await trx.insert(newts).values({
newtId,
secretHash,
siteId: newSite.siteId,
dateCreated: moment().toISOString()
});
// Consume the provisioning key - cascade removes siteProvisioningKeyOrg
await trx
.update(siteProvisioningKeys)
.set({
lastUsed: moment().toISOString(),
numUsed: sql`${siteProvisioningKeys.numUsed} + 1`
})
.where(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
provisioningKeyId
)
);
await usageService.add(orgId, FeatureId.SITES, 1, trx);
}); });
} finally {
newSiteId = newSite.siteId; await releaseSubnetLock();
}
// Grant admin role access to the new site
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
throw new Error(`Admin role not found for org ${orgId}`);
}
await trx.insert(roleSites).values({
roleId: adminRole.roleId,
siteId: newSite.siteId
});
// Create the newt for this site
await trx.insert(newts).values({
newtId,
secretHash,
siteId: newSite.siteId,
dateCreated: moment().toISOString()
});
// Consume the provisioning key - cascade removes siteProvisioningKeyOrg
await trx
.update(siteProvisioningKeys)
.set({
lastUsed: moment().toISOString(),
numUsed: sql`${siteProvisioningKeys.numUsed} + 1`
})
.where(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
provisioningKeyId
)
);
await usageService.add(orgId, FeatureId.SITES, 1, trx);
});
logger.info( logger.info(
`Provisioned new site (ID: ${newSiteId}) and newt (ID: ${newtId}) for org ${orgId} via provisioning key ${provisioningKeyId}` `Provisioned new site (ID: ${newSiteId}) and newt (ID: ${newtId}) for org ${orgId} via provisioning key ${provisioningKeyId}`
+8 -3
View File
@@ -9,8 +9,12 @@ import {
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
export async function sendNewtSyncMessage(newt: Newt, site: Site) { export async function sendNewtSyncMessage(newt: Newt, site: Site) {
const { tcpTargets, udpTargets, validHealthCheckTargets } = const {
await buildTargetConfigurationForNewtClient(site.siteId); tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets
} = await buildTargetConfigurationForNewtClient(site.siteId);
let exitNode: ExitNode | undefined; let exitNode: ExitNode | undefined;
if (site.exitNodeId) { if (site.exitNodeId) {
@@ -36,7 +40,8 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
}, },
healthCheckTargets: validHealthCheckTargets, healthCheckTargets: validHealthCheckTargets,
peers: peers, peers: peers,
clientTargets: targets clientTargets: targets,
browserGatewayTargets: browserGatewayTargets
} }
}, },
{ {
+55 -1
View File
@@ -1,7 +1,9 @@
import { Target, TargetHealthCheck } from "@server/db"; import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger"; import logger from "@server/logger";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
export async function addTargets( export async function addTargets(
newtId: string, newtId: string,
@@ -239,3 +241,55 @@ export async function removeTargets(
{ incrementConfigVersion: true, compress: canCompress(version, "newt") } { incrementConfigVersion: true, compress: canCompress(version, "newt") }
); );
} }
export async function sendBrowserGatewayTargets(
newtId: string,
targets: BrowserGatewayTarget[],
version?: string | null
) {
if (targets.length === 0) return;
const payload = targets.map((t) => {
const decryptAuthToken = decrypt(
t.authToken,
config.getRawConfig().server.secret!
);
return {
id: t.browserGatewayTargetId,
resourceId: t.resourceId,
siteId: t.siteId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort,
authToken: decryptAuthToken
};
});
await sendToClient(
newtId,
{
type: "newt/browsergateway/add",
data: {
targets: payload
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function removeBrowserGatewayTarget(
newtId: string,
browserGatewayTargetId: number,
version?: string | null
) {
await sendToClient(
newtId,
{
type: "newt/browsergateway/remove",
data: {
ids: [browserGatewayTargetId]
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
+110 -19
View File
@@ -23,7 +23,10 @@ import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build"; import { build } from "@server/build";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { getUniqueResourceName } from "@server/db/names"; import { getUniqueResourceName } from "@server/db/names";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -32,15 +35,58 @@ const createResourceParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
function resolveModeFromLegacyFields(data: {
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
http?: boolean;
protocol?: "tcp" | "udp";
}): {
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
error?: string;
} {
if (data.mode) {
return { mode: data.mode };
}
if (typeof data.http === "boolean" && data.protocol) {
if (data.http && data.protocol === "tcp") {
return { mode: "http" };
}
if (!data.http && data.protocol === "tcp") {
return { mode: "tcp" };
}
if (!data.http && data.protocol === "udp") {
return { mode: "udp" };
}
return {
error: "Invalid deprecated http/protocol combination"
};
}
return { mode: undefined };
}
const createHttpResourceSchema = z const createHttpResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
subdomain: z.string().nullable().optional(), subdomain: z.string().nullable().optional(),
http: z.boolean(), http: z.boolean().optional().openapi({
protocol: z.enum(["tcp", "udp"]), deprecated: true,
description:
"Deprecated. Use `mode` instead. Legacy compatibility only."
}),
protocol: z.enum(["tcp", "udp"]).optional().openapi({
deprecated: true,
description:
"Deprecated. Use `mode` instead. Legacy compatibility only."
}),
domainId: z.string(), domainId: z.string(),
stickySession: z.boolean().optional(), stickySession: z.boolean().optional(),
postAuthPath: z.string().nullable().optional() postAuthPath: z.string().nullable().optional(),
mode: z.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp"]).optional(),
// SSH Settings
pamMode: z.enum(["passthrough", "push"]).optional(),
authDaemonPort: z.int().positive().optional(),
authDaemonMode: z.enum(["site", "remote", "native"]).optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -60,13 +106,27 @@ const createHttpResourceSchema = z
const createRawResourceSchema = z const createRawResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
http: z.boolean(), http: z.boolean().optional().openapi({
protocol: z.enum(["tcp", "udp"]), deprecated: true,
description:
"Deprecated. Use `mode` instead. Legacy compatibility only."
}),
protocol: z.enum(["tcp", "udp"]).optional().openapi({
deprecated: true,
description:
"Deprecated. Use `mode` instead. Legacy compatibility only."
}),
mode: z.enum(["tcp", "udp"]).optional(),
proxyPort: z.int().min(1).max(65535) proxyPort: z.int().min(1).max(65535)
// enableProxy: z.boolean().default(true) // always true now // enableProxy: z.boolean().default(true) // always true now
}) })
.refine( .refine(
(data) => { (data) => {
const resolved = resolveModeFromLegacyFields(data);
if (resolved.error || !resolved.mode) {
return false;
}
if (!config.getRawConfig().flags?.allow_raw_resources) { if (!config.getRawConfig().flags?.allow_raw_resources) {
if (data.proxyPort !== undefined) { if (data.proxyPort !== undefined) {
return false; return false;
@@ -143,17 +203,18 @@ export async function createResource(
); );
} }
if (typeof req.body.http !== "boolean") { const resolvedMode = resolveModeFromLegacyFields(req.body);
if (resolvedMode.error) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "http field is required") createHttpError(HttpCode.BAD_REQUEST, resolvedMode.error)
); );
} }
const { http } = req.body; if (resolvedMode.mode) {
req.body.mode = resolvedMode.mode;
}
if (http) { if (typeof req.body.proxyPort === "number") {
return await createHttpResource({ req, res, next }, { orgId });
} else {
if ( if (
!config.getRawConfig().flags?.allow_raw_resources && !config.getRawConfig().flags?.allow_raw_resources &&
build == "oss" build == "oss"
@@ -167,6 +228,17 @@ export async function createResource(
} }
return await createRawResource({ req, res, next }, { orgId }); return await createRawResource({ req, res, next }, { orgId });
} }
if (req.body.mode) {
return await createHttpResource({ req, res, next }, { orgId });
} else {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"mode is required when deprecated fields are not provided"
)
);
}
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return next( return next(
@@ -198,7 +270,15 @@ async function createHttpResource(
); );
} }
const { name, domainId, postAuthPath } = parsedBody.data; const {
name,
domainId,
postAuthPath,
mode,
authDaemonPort,
authDaemonMode,
pamMode
} = parsedBody.data;
const subdomain = parsedBody.data.subdomain; const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession; const stickySession = parsedBody.data.stickySession;
@@ -322,8 +402,10 @@ async function createHttpResource(
orgId, orgId,
name, name,
subdomain: finalSubdomain, subdomain: finalSubdomain,
http: true, mode: mode,
protocol: "tcp", pamMode: pamMode,
authDaemonMode: authDaemonMode,
authDaemonPort: authDaemonPort,
ssl: true, ssl: true,
stickySession: stickySession, stickySession: stickySession,
postAuthPath: postAuthPath, postAuthPath: postAuthPath,
@@ -405,7 +487,17 @@ async function createRawResource(
); );
} }
const { name, http, protocol, proxyPort } = parsedBody.data; const { name, proxyPort } = parsedBody.data;
const resolvedMode = resolveModeFromLegacyFields(parsedBody.data);
if (resolvedMode.error || !resolvedMode.mode) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
resolvedMode.error ||
"mode is required when deprecated fields are not provided"
)
);
}
let resource: Resource | undefined; let resource: Resource | undefined;
@@ -418,9 +510,8 @@ async function createRawResource(
niceId, niceId,
orgId, orgId,
name, name,
http, proxyPort,
protocol, mode: resolvedMode.mode
proxyPort
// enableProxy // enableProxy
}) })
.returning(); .returning();
@@ -94,7 +94,7 @@ export async function createResourceRule(
); );
} }
if (!resource.http) { if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
+1 -1
View File
@@ -106,7 +106,7 @@ export async function deleteResource(
// [target], // [target],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
healthChecksToBeRemoved, healthChecksToBeRemoved,
deletedResource.protocol, deletedResource.mode === "udp" ? "udp" : "tcp",
newt.version newt.version
); );
} }
+109
View File
@@ -0,0 +1,109 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db } from "@server/db";
import { resources, targets } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const getBrowserTargetSchema = z
.object({
fullDomain: z.string().min(1, "fullDomain is required")
})
.strict();
export type GetBrowserTargetResponse = {
ip: string;
port: number;
authToken: string;
orgId: string;
resourceId: number;
niceId: string;
pamMode: "passthrough" | "push" | null;
authDaemonMode: "site" | "remote" | "native" | null;
};
export async function getBrowserTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = getBrowserTargetSchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { fullDomain } = parsed.data;
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
const [browserTarget] = await db
.select({
destination: browserGatewayTarget.destination,
destinationPort: browserGatewayTarget.destinationPort,
authToken: browserGatewayTarget.authToken,
resourceId: resources.resourceId,
niceId: resources.niceId,
orgId: resources.orgId,
pamMode: resources.pamMode,
authDaemonMode: resources.authDaemonMode
})
.from(browserGatewayTarget)
.innerJoin(
resources,
eq(browserGatewayTarget.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, fullDomain))
.limit(1);
const decryptedAuthToken = decrypt(
browserTarget.authToken,
config.getRawConfig().server.secret!
);
if (!browserTarget) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No resource found for this domain"
)
);
}
return response<GetBrowserTargetResponse>(res, {
data: {
ip: browserTarget.destination,
port: browserTarget.destinationPort,
authToken: decryptedAuthToken,
pamMode: browserTarget.pamMode,
authDaemonMode: browserTarget.authDaemonMode,
orgId: browserTarget.orgId,
resourceId: browserTarget.resourceId,
niceId: browserTarget.niceId
},
success: true,
error: false,
message: "Browser target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while retrieving the browser target"
)
);
}
}
+4 -6
View File
@@ -127,7 +127,7 @@ export async function getUserResources(
ssl: boolean; ssl: boolean;
enabled: boolean; enabled: boolean;
sso: boolean; sso: boolean;
protocol: string; mode: string;
emailWhitelistEnabled: boolean; emailWhitelistEnabled: boolean;
}> = []; }> = [];
if (accessibleResourceIds.length > 0) { if (accessibleResourceIds.length > 0) {
@@ -139,7 +139,7 @@ export async function getUserResources(
ssl: resources.ssl, ssl: resources.ssl,
enabled: resources.enabled, enabled: resources.enabled,
sso: resources.sso, sso: resources.sso,
protocol: resources.protocol, mode: resources.mode,
emailWhitelistEnabled: resources.emailWhitelistEnabled emailWhitelistEnabled: resources.emailWhitelistEnabled
}) })
.from(resources) .from(resources)
@@ -323,7 +323,7 @@ export async function getUserResources(
hasPincode || hasPincode ||
hasWhitelist hasWhitelist
), ),
protocol: resource.protocol, mode: resource.mode,
sso: resource.sso, sso: resource.sso,
password: hasPassword, password: hasPassword,
pincode: hasPincode, pincode: hasPincode,
@@ -339,7 +339,6 @@ export async function getUserResources(
name: siteResource.name, name: siteResource.name,
destination: siteResource.destination, destination: siteResource.destination,
mode: siteResource.mode, mode: siteResource.mode,
protocol: siteResource.scheme,
ssl: siteResource.ssl, ssl: siteResource.ssl,
fullDomain: siteResource.fullDomain, fullDomain: siteResource.fullDomain,
enabled: siteResource.enabled, enabled: siteResource.enabled,
@@ -387,14 +386,13 @@ export type GetUserResourcesResponse = {
domain: string; domain: string;
enabled: boolean; enabled: boolean;
protected: boolean; protected: boolean;
protocol: string; mode: string;
}>; }>;
siteResources: Array<{ siteResources: Array<{
siteResourceId: number; siteResourceId: number;
name: string; name: string;
destination: string; destination: string;
mode: string; mode: string;
protocol: string | null;
tcpPortRangeString: string | null; tcpPortRangeString: string | null;
udpPortRangeString: string | null; udpPortRangeString: string | null;
disableIcmp: boolean | null; disableIcmp: boolean | null;
+1
View File
@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
export * from "./listAllResourceNames"; export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist"; export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory"; export * from "./getStatusHistory";
export * from "./getBrowserTarget";
+47 -8
View File
@@ -1,4 +1,5 @@
import { import {
browserGatewayTarget,
db, db,
labels, labels,
resourceHeaderAuth, resourceHeaderAuth,
@@ -156,8 +157,6 @@ export type ResourceWithTargets = {
sso: boolean; sso: boolean;
pincodeId: number | null; pincodeId: number | null;
whitelist: boolean; whitelist: boolean;
http: boolean;
protocol: string;
proxyPort: number | null; proxyPort: number | null;
enabled: boolean; enabled: boolean;
domainId: string | null; domainId: string | null;
@@ -165,6 +164,7 @@ export type ResourceWithTargets = {
headerAuthId: number | null; headerAuthId: number | null;
wildcard: boolean; wildcard: boolean;
health: string | null; health: string | null;
mode: string | null;
targets: Array<{ targets: Array<{
targetId: number; targetId: number;
ip: string; ip: string;
@@ -193,8 +193,6 @@ function queryResourcesBase() {
sso: resources.sso, sso: resources.sso,
pincodeId: resourcePincode.pincodeId, pincodeId: resourcePincode.pincodeId,
whitelist: resources.emailWhitelistEnabled, whitelist: resources.emailWhitelistEnabled,
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort, proxyPort: resources.proxyPort,
enabled: resources.enabled, enabled: resources.enabled,
domainId: resources.domainId, domainId: resources.domainId,
@@ -203,7 +201,8 @@ function queryResourcesBase() {
headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId: headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
health: resources.health health: resources.health,
mode: resources.mode
}) })
.from(resources) .from(resources)
.leftJoin( .leftJoin(
@@ -364,7 +363,9 @@ export async function listResources(
if (typeof authState !== "undefined") { if (typeof authState !== "undefined") {
switch (authState) { switch (authState) {
case "none": case "none":
conditions.push(eq(resources.http, false)); conditions.push(
or(eq(resources.mode, "tcp"), eq(resources.mode, "udp"))
);
break; break;
case "protected": case "protected":
conditions.push( conditions.push(
@@ -520,6 +521,30 @@ export async function listResources(
) )
.leftJoin(sites, eq(targets.siteId, sites.siteId)); .leftJoin(sites, eq(targets.siteId, sites.siteId));
const allBgTargetSites =
resourceIdList.length === 0
? []
: await db
.select({
resourceId: browserGatewayTarget.resourceId,
siteId: browserGatewayTarget.siteId,
siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online,
siteType: sites.type
})
.from(browserGatewayTarget)
.where(
inArray(
browserGatewayTarget.resourceId,
resourceIdList
)
)
.leftJoin(
sites,
eq(sites.siteId, browserGatewayTarget.siteId)
);
// avoids TS issues with reduce/never[] // avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>(); const map = new Map<number, ResourceWithTargets>();
@@ -536,10 +561,9 @@ export async function listResources(
sso: row.sso, sso: row.sso,
pincodeId: row.pincodeId, pincodeId: row.pincodeId,
whitelist: row.whitelist, whitelist: row.whitelist,
http: row.http,
protocol: row.protocol,
proxyPort: row.proxyPort, proxyPort: row.proxyPort,
wildcard: row.wildcard, wildcard: row.wildcard,
mode: row.mode,
enabled: row.enabled, enabled: row.enabled,
domainId: row.domainId, domainId: row.domainId,
headerAuthId: row.headerAuthId, headerAuthId: row.headerAuthId,
@@ -583,6 +607,21 @@ export async function listResources(
online: isLocal ? undefined : Boolean(t.siteOnline) online: isLocal ? undefined : Boolean(t.siteOnline)
}); });
} }
const bgRaw = allBgTargetSites.filter(
(t) => t.resourceId === entry.resourceId
);
for (const t of bgRaw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
const isLocal = t.siteType === "local";
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values()); entry.sites = Array.from(siteById.values());
} }
+10 -3
View File
@@ -24,7 +24,10 @@ import {
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { build } from "@server/build"; import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -68,7 +71,11 @@ const updateHttpResourceBodySchema = z
maintenanceTitle: z.string().max(255).nullable().optional(), maintenanceTitle: z.string().max(255).nullable().optional(),
maintenanceMessage: z.string().max(2000).nullable().optional(), maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional(), maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
postAuthPath: z.string().nullable().optional() postAuthPath: z.string().nullable().optional(),
// SSH settings
pamMode: z.enum(["passthrough", "push"]).optional(),
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
authDaemonPort: z.int().min(1).max(65535).nullable().optional()
}) })
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update" error: "At least one field must be provided for update"
@@ -240,7 +247,7 @@ export async function updateResource(
); );
} }
if (resource.http) { if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
// HANDLE UPDATING HTTP RESOURCES // HANDLE UPDATING HTTP RESOURCES
return await updateHttpResource( return await updateHttpResource(
{ {
@@ -26,7 +26,9 @@ const updateResourceRuleParamsSchema = z.strictObject({
const updateResourceRuleSchema = z const updateResourceRuleSchema = z
.strictObject({ .strictObject({
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]).optional(), match: z
.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"])
.optional(),
value: z.string().min(1).optional(), value: z.string().min(1).optional(),
priority: z.int(), priority: z.int(),
enabled: z.boolean().optional() enabled: z.boolean().optional()
@@ -102,7 +104,7 @@ export async function updateResourceRule(
); );
} }
if (!resource.http) { if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
+230 -226
View File
@@ -174,6 +174,7 @@ export async function createSite(
} }
let updatedAddress = null; let updatedAddress = null;
let releaseSubnetLock: (() => Promise<void>) | null = null;
if (address) { if (address) {
if (!org.subnet) { if (!org.subnet) {
return next( return next(
@@ -244,147 +245,22 @@ export async function createSite(
); );
} }
} else { } else {
const newClientAddress = await getNextAvailableClientSubnet(orgId); const { value: newClientAddress, release } =
if (!newClientAddress) { await getNextAvailableClientSubnet(orgId);
return next( releaseSubnetLock = release;
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"No available address found"
)
);
}
updatedAddress = newClientAddress.split("/")[0]; updatedAddress = newClientAddress.split("/")[0];
} }
if (subnet && exitNodeId) {
//make sure the subnet is in the range of the exit node if provided
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, exitNodeId));
if (!exitNode) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Exit node not found")
);
}
if (!exitNode.address) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Exit node has no subnet defined"
)
);
}
const subnetIp = subnet.split("/")[0];
if (!isIpInCidr(subnetIp, exitNode.address)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Subnet is not in the CIDR range of the exit node address."
)
);
}
// lets also make sure there is no overlap with other sites on the exit node
const sitesQuery = await db
.select({
subnet: sites.subnet
})
.from(sites)
.where(
and(
eq(sites.exitNodeId, exitNodeId),
eq(sites.subnet, subnet)
)
);
if (sitesQuery.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Subnet ${subnet} overlaps with an existing site on this exit node. Please restart site creation.`
)
);
}
}
let updatedNiceId = niceId;
if (!niceId) {
updatedNiceId = await getUniqueSiteName(orgId);
} else {
// make sure the niceId is unique
const existingSite = await db
.select()
.from(sites)
.where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId)))
.limit(1);
if (existingSite.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Nice ID ${niceId} already exists. Please choose a different one.`
)
);
}
}
let newSite: Site | undefined; let newSite: Site | undefined;
await db.transaction(async (trx) => { try {
if (type == "newt") { if (subnet && exitNodeId) {
[newSite] = await trx //make sure the subnet is in the range of the exit node if provided
.insert(sites) const [exitNode] = await db
.values({ .select()
// NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT .from(exitNodes)
orgId, .where(eq(exitNodes.exitNodeId, exitNodeId));
name,
niceId: updatedNiceId!,
address: updatedAddress || null,
type,
dockerSocketEnabled: true,
status: "approved"
})
.returning();
await logsDb.insert(statusHistory).values({
entityType: "site",
entityId: newSite.siteId,
orgId: orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
} else if (type == "wireguard") {
// we are creating a site with an exit node (tunneled)
if (!subnet) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Subnet is required for tunneled sites"
)
);
}
if (!exitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Exit node ID is required for tunneled sites"
)
);
}
const { exitNode, hasAccess } = await verifyExitNodeOrgAccess(
exitNodeId,
orgId
);
if (!exitNode) { if (!exitNode) {
logger.warn("Exit node not found");
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
@@ -393,118 +269,246 @@ export async function createSite(
); );
} }
if (!hasAccess) { if (!exitNode.address) {
logger.warn("Not authorized to use this exit node");
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.BAD_REQUEST,
"Not authorized to use this exit node" "Exit node has no subnet defined"
) )
); );
} }
[newSite] = await trx const subnetIp = subnet.split("/")[0];
.insert(sites)
.values({ if (!isIpInCidr(subnetIp, exitNode.address)) {
orgId, return next(
exitNodeId, createHttpError(
name, HttpCode.BAD_REQUEST,
niceId: updatedNiceId!, "Subnet is not in the CIDR range of the exit node address."
subnet, )
type, );
pubKey: pubKey || null, }
status: "approved"
// lets also make sure there is no overlap with other sites on the exit node
const sitesQuery = await db
.select({
subnet: sites.subnet
}) })
.returning(); .from(sites)
} else if (type == "local") { .where(
[newSite] = await trx and(
.insert(sites) eq(sites.exitNodeId, exitNodeId),
.values({ eq(sites.subnet, subnet)
exitNodeId: exitNodeId || null, )
orgId, );
name,
niceId: updatedNiceId!, if (sitesQuery.length > 0) {
type, return next(
dockerSocketEnabled: false, createHttpError(
online: true, HttpCode.CONFLICT,
subnet: "0.0.0.0/32", `Subnet ${subnet} overlaps with an existing site on this exit node. Please restart site creation.`
status: "approved" )
}) );
.returning(); }
}
let updatedNiceId = niceId;
if (!niceId) {
updatedNiceId = await getUniqueSiteName(orgId);
} else { } else {
return next( // make sure the niceId is unique
createHttpError( const existingSite = await db
HttpCode.BAD_REQUEST, .select()
"Site type not recognized" .from(sites)
.where(
and(eq(sites.niceId, niceId), eq(sites.orgId, orgId))
) )
); .limit(1);
if (existingSite.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Nice ID ${niceId} already exists. Please choose a different one.`
)
);
}
} }
const adminRole = await trx await db.transaction(async (trx) => {
.select() if (type == "newt") {
.from(roles) [newSite] = await trx
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .insert(sites)
.limit(1); .values({
// NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
orgId,
name,
niceId: updatedNiceId!,
address: updatedAddress || null,
type,
dockerSocketEnabled: true,
status: "approved"
})
.returning();
if (adminRole.length === 0) { await logsDb.insert(statusHistory).values({
return next( entityType: "site",
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) entityId: newSite.siteId,
); orgId: orgId,
} status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
} else if (type == "wireguard") {
// we are creating a site with an exit node (tunneled)
if (!subnet) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Subnet is required for tunneled sites"
)
);
}
await trx.insert(roleSites).values({ if (!exitNodeId) {
roleId: adminRole[0].roleId, return next(
siteId: newSite.siteId createHttpError(
}); HttpCode.BAD_REQUEST,
"Exit node ID is required for tunneled sites"
)
);
}
if ( const { exitNode, hasAccess } =
req.user && await verifyExitNodeOrgAccess(exitNodeId, orgId);
!req.userOrgRoleIds?.includes(adminRole[0].roleId)
) { if (!exitNode) {
// make sure the user can access the site logger.warn("Exit node not found");
trx.insert(userSites).values({ return next(
userId: req.user?.userId!, createHttpError(
HttpCode.NOT_FOUND,
"Exit node not found"
)
);
}
if (!hasAccess) {
logger.warn("Not authorized to use this exit node");
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Not authorized to use this exit node"
)
);
}
[newSite] = await trx
.insert(sites)
.values({
orgId,
exitNodeId,
name,
niceId: updatedNiceId!,
subnet,
type,
pubKey: pubKey || null,
status: "approved"
})
.returning();
} else if (type == "local") {
[newSite] = await trx
.insert(sites)
.values({
exitNodeId: exitNodeId || null,
orgId,
name,
niceId: updatedNiceId!,
type,
dockerSocketEnabled: false,
online: true,
subnet: "0.0.0.0/32",
status: "approved"
})
.returning();
} else {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Site type not recognized"
)
);
}
const adminRole = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (adminRole.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Admin role not found`
)
);
}
await trx.insert(roleSites).values({
roleId: adminRole[0].roleId,
siteId: newSite.siteId siteId: newSite.siteId
}); });
}
// add the peer to the exit node if (
if (type == "newt") { req.user &&
const secretHash = await hashPassword(updatedNewtSecret); !req.userOrgRoleIds?.includes(adminRole[0].roleId)
) {
await trx.insert(newts).values({ // make sure the user can access the site
newtId: updatedNewtId, trx.insert(userSites).values({
secretHash, userId: req.user?.userId!,
siteId: newSite.siteId, siteId: newSite.siteId
dateCreated: moment().toISOString() });
});
} else if (type == "wireguard") {
if (!pubKey) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Public key is required for wireguard sites"
)
);
} }
if (!exitNodeId) { // add the peer to the exit node
return next( if (type == "newt") {
createHttpError( const secretHash = await hashPassword(updatedNewtSecret);
HttpCode.BAD_REQUEST,
"Exit node ID is required for wireguard sites" await trx.insert(newts).values({
) newtId: updatedNewtId,
); secretHash,
siteId: newSite.siteId,
dateCreated: moment().toISOString()
});
} else if (type == "wireguard") {
if (!pubKey) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Public key is required for wireguard sites"
)
);
}
if (!exitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Exit node ID is required for wireguard sites"
)
);
}
await addPeer(exitNodeId, {
publicKey: pubKey,
allowedIps: []
});
} }
await addPeer(exitNodeId, { await usageService.add(orgId, FeatureId.SITES, 1, trx);
publicKey: pubKey, });
allowedIps: [] } finally {
}); await releaseSubnetLock?.();
} }
await usageService.add(orgId, FeatureId.SITES, 1, trx);
});
if (!newSite) { if (!newSite) {
return next( return next(
+3 -1
View File
@@ -119,7 +119,9 @@ export async function pickSiteDefaults(
); );
} }
const newClientAddress = await getNextAvailableClientSubnet(orgId); const { value: newClientAddress, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId);
await releaseSubnetLock(); // release immediately — this endpoint only previews the next available value
if (!newClientAddress) { if (!newClientAddress) {
return next( return next(
createHttpError( createHttpError(
+191 -129
View File
@@ -44,14 +44,14 @@ const createSiteResourceSchema = z
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
niceId: z.string().optional(), niceId: z.string().optional(),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
mode: z.enum(["host", "cidr", "http"]), mode: z.enum(["host", "cidr", "http", "ssh"]),
ssl: z.boolean().optional(), // only used for http mode ssl: z.boolean().optional(), // only used for http mode
scheme: z.enum(["http", "https"]).optional(), scheme: z.enum(["http", "https"]).optional(),
siteIds: z.array(z.int()).optional(), siteIds: z.array(z.int()).optional(),
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
destinationPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(),
destination: z.string().min(1), destination: z.string().min(1).optional(),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
alias: z alias: z
.string() .string()
@@ -67,14 +67,18 @@ const createSiteResourceSchema = z
udpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(), disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().optional(), authDaemonPort: z.int().positive().optional(),
authDaemonMode: z.enum(["site", "remote"]).optional(), authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
pamMode: z.enum(["passthrough", "push"]).optional(),
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
}) })
.strict() .strict()
.refine( .refine(
(data) => { (data) => {
if (data.mode === "host") { if (
(data.mode === "host" || data.mode === "ssh") &&
data.destination
) {
// Check if it's a valid IP address using zod (v4 or v6) // Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z const isValidIP = z
// .union([z.ipv4(), z.ipv6()]) // .union([z.ipv4(), z.ipv6()])
@@ -116,19 +120,45 @@ const createSiteResourceSchema = z
) )
.refine( .refine(
(data) => { (data) => {
if (data.mode !== "http") return true; if (data.mode === "http") {
return ( return (
data.scheme !== undefined && data.scheme !== undefined &&
data.destinationPort !== undefined && data.scheme !== null &&
data.destinationPort >= 1 && data.destinationPort !== undefined &&
data.destinationPort <= 65535 data.destinationPort !== null &&
); data.destinationPort >= 1 &&
data.destinationPort <= 65535
);
} else if (data.mode === "ssh") {
// just check the destinationPort
return (
data.destinationPort === undefined ||
(data.destinationPort !== null &&
data.destinationPort >= 1 &&
data.destinationPort <= 65535)
);
}
}, },
{ {
message: message:
"HTTP mode requires scheme (http or https) and a valid destination port" "HTTP mode requires scheme (http or https) and a valid destination port"
} }
) )
.refine(
(data) => {
// destination is only optional for ssh mode with native authDaemonMode
if (data.mode === "ssh" && data.authDaemonMode === "native") {
return true;
}
return (
data.destination !== undefined && data.destination.trim() !== ""
);
},
{
message:
"Destination is required unless mode is ssh with authDaemonMode native"
}
)
.refine( .refine(
(data) => { (data) => {
return ( return (
@@ -212,6 +242,7 @@ export async function createSiteResource(
disableIcmp, disableIcmp,
authDaemonPort, authDaemonPort,
authDaemonMode, authDaemonMode,
pamMode,
domainId, domainId,
subdomain subdomain
} = parsedBody.data; } = parsedBody.data;
@@ -276,8 +307,8 @@ export async function createSiteResource(
.safeParse(destination).success; .safeParse(destination).success;
if ( if (
isIp && isIp &&
(isIpInCidr(destination, org.subnet) || (isIpInCidr(destination!, org.subnet) ||
isIpInCidr(destination, org.utilitySubnet)) isIpInCidr(destination!, org.utilitySubnet))
) { ) {
return next( return next(
createHttpError( createHttpError(
@@ -366,132 +397,163 @@ export async function createSiteResource(
} }
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null;
if (mode === "host" || mode === "http") { if (mode === "host" || mode === "http") {
aliasAddress = await getNextAvailableAliasAddress(orgId); const { value, release } =
await getNextAvailableAliasAddress(orgId);
aliasAddress = value;
releaseAliasLock = release;
} }
let newSiteResource: SiteResource | undefined; let newSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => { try {
const [network] = await trx await db.transaction(async (trx) => {
.insert(networks) const [network] = await trx
.values({ .insert(networks)
scope: "resource", .values({
orgId: orgId scope: "resource",
}) orgId: orgId
.returning(); })
.returning();
if (!network) { if (!network) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Failed to create network`
)
);
}
// Create the site resource
const insertValues: typeof siteResources.$inferInsert = {
niceId: updatedNiceId!,
orgId,
name,
mode,
ssl,
networkId: network.networkId,
destination,
scheme,
destinationPort,
enabled,
alias: alias ? alias.trim() : null,
aliasAddress,
tcpPortRangeString:
mode == "http" ? "443,80" : tcpPortRangeString,
udpPortRangeString: mode == "http" ? "" : udpPortRangeString,
disableIcmp: disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false
domainId,
subdomain: finalSubdomain,
fullDomain
};
if (isLicensedSshPam) {
if (authDaemonPort !== undefined)
insertValues.authDaemonPort = authDaemonPort;
if (authDaemonMode !== undefined)
insertValues.authDaemonMode = authDaemonMode;
}
[newSiteResource] = await trx
.insert(siteResources)
.values(insertValues)
.returning();
const siteResourceId = newSiteResource.siteResourceId;
//////////////////// update the associations ////////////////////
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: network.networkId
});
}
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
return next(
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
);
}
await trx.insert(roleSiteResources).values({
roleId: adminRole.roleId,
siteResourceId: siteResourceId
});
if (roleIds.length > 0) {
await trx
.insert(roleSiteResources)
.values(
roleIds.map((roleId) => ({ roleId, siteResourceId }))
);
}
if (userIds.length > 0) {
await trx
.insert(userSiteResources)
.values(
userIds.map((userId) => ({ userId, siteResourceId }))
);
}
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
for (const siteToAssign of sitesToAssign) {
const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, siteToAssign.siteId))
.limit(1);
if (!newt) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.INTERNAL_SERVER_ERROR,
`Newt not found for site ${siteToAssign.siteId}` `Failed to create network`
) )
); );
} }
}
}); let tcpPortRangeStringAdjusted = tcpPortRangeString;
if (mode === "http") {
tcpPortRangeStringAdjusted = "443,80";
} else if (mode === "ssh") {
tcpPortRangeStringAdjusted = destinationPort
? destinationPort.toString()
: "22";
}
// Create the site resource
const insertValues: typeof siteResources.$inferInsert = {
niceId: updatedNiceId!,
orgId,
name,
mode,
ssl,
networkId: network.networkId,
destination: destination, // the ssh can be null
scheme,
destinationPort,
enabled,
alias: alias ? alias.trim() : null,
aliasAddress,
tcpPortRangeString: tcpPortRangeStringAdjusted,
udpPortRangeString:
mode == "http" || mode == "ssh"
? ""
: udpPortRangeString,
disableIcmp:
disableIcmp ||
(mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false
domainId,
subdomain: finalSubdomain,
fullDomain
};
if (isLicensedSshPam) {
if (authDaemonPort !== undefined)
insertValues.authDaemonPort = authDaemonPort;
if (authDaemonMode !== undefined)
insertValues.authDaemonMode = authDaemonMode;
if (pamMode !== undefined) insertValues.pamMode = pamMode;
}
[newSiteResource] = await trx
.insert(siteResources)
.values(insertValues)
.returning();
const siteResourceId = newSiteResource.siteResourceId;
//////////////////// update the associations ////////////////////
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: network.networkId
});
}
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Admin role not found`
)
);
}
await trx.insert(roleSiteResources).values({
roleId: adminRole.roleId,
siteResourceId: siteResourceId
});
if (roleIds.length > 0) {
await trx
.insert(roleSiteResources)
.values(
roleIds.map((roleId) => ({
roleId,
siteResourceId
}))
);
}
if (userIds.length > 0) {
await trx
.insert(userSiteResources)
.values(
userIds.map((userId) => ({
userId,
siteResourceId
}))
);
}
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
for (const siteToAssign of sitesToAssign) {
const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, siteToAssign.siteId))
.limit(1);
if (!newt) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Newt not found for site ${siteToAssign.siteId}`
)
);
}
}
});
} finally {
await releaseAliasLock?.();
}
if (!newSiteResource) { if (!newSiteResource) {
return next( return next(
@@ -56,7 +56,7 @@ const updateSiteResourceSchema = z
) )
.optional(), .optional(),
// mode: z.enum(["host", "cidr", "port"]).optional(), // mode: z.enum(["host", "cidr", "port"]).optional(),
mode: z.enum(["host", "cidr", "http"]).optional(), mode: z.enum(["host", "cidr", "http", "ssh"]).optional(),
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).nullish(), scheme: z.enum(["http", "https"]).nullish(),
// proxyPort: z.int().positive().nullish(), // proxyPort: z.int().positive().nullish(),
@@ -77,14 +77,18 @@ const updateSiteResourceSchema = z
udpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(), disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().nullish(), authDaemonPort: z.int().positive().nullish(),
authDaemonMode: z.enum(["site", "remote"]).optional(), authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
pamMode: z.enum(["passthrough", "push"]).optional(),
domainId: z.string().optional(), domainId: z.string().optional(),
subdomain: z.string().optional() subdomain: z.string().optional()
}) })
.strict() .strict()
.refine( .refine(
(data) => { (data) => {
if (data.mode === "host" && data.destination) { if (
(data.mode === "host" || data.mode == "ssh") &&
data.destination
) {
const isValidIP = z const isValidIP = z
// .union([z.ipv4(), z.ipv6()]) // .union([z.ipv4(), z.ipv6()])
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
@@ -125,21 +129,45 @@ const updateSiteResourceSchema = z
) )
.refine( .refine(
(data) => { (data) => {
if (data.mode !== "http") return true; if (data.mode === "http") {
return ( return (
data.scheme !== undefined && data.scheme !== undefined &&
data.scheme !== null && data.scheme !== null &&
data.destinationPort !== undefined && data.destinationPort !== undefined &&
data.destinationPort !== null && data.destinationPort !== null &&
data.destinationPort >= 1 && data.destinationPort >= 1 &&
data.destinationPort <= 65535 data.destinationPort <= 65535
); );
} else if (data.mode === "ssh") {
// just check the destinationPort
return (
data.destinationPort === undefined ||
(data.destinationPort !== null &&
data.destinationPort >= 1 &&
data.destinationPort <= 65535)
);
}
}, },
{ {
message: message:
"HTTP mode requires scheme (http or https) and a valid destination port" "HTTP mode requires scheme (http or https) and a valid destination port"
} }
) )
.refine(
(data) => {
// destination is only optional for ssh mode with native authDaemonMode
if (data.mode === "ssh" && data.authDaemonMode === "native") {
return true;
}
return (
data.destination !== undefined && data.destination.trim() !== ""
);
},
{
message:
"Destination is required unless mode is ssh with authDaemonMode native"
}
)
.refine( .refine(
(data) => { (data) => {
return ( return (
@@ -222,6 +250,7 @@ export async function updateSiteResource(
disableIcmp, disableIcmp,
authDaemonPort, authDaemonPort,
authDaemonMode, authDaemonMode,
pamMode,
domainId, domainId,
subdomain subdomain
} = parsedBody.data; } = parsedBody.data;
@@ -430,16 +459,30 @@ export async function updateSiteResource(
const sshPamSet = const sshPamSet =
isLicensedSshPam && isLicensedSshPam &&
(authDaemonPort !== undefined || (authDaemonPort !== undefined ||
authDaemonMode !== undefined) authDaemonMode !== undefined ||
pamMode !== undefined)
? { ? {
...(authDaemonPort !== undefined && { ...(authDaemonPort !== undefined && {
authDaemonPort authDaemonPort
}), }),
...(authDaemonMode !== undefined && { ...(authDaemonMode !== undefined && {
authDaemonMode authDaemonMode
}),
...(pamMode !== undefined && {
pamMode
}) })
} }
: {}; : {};
let tcpPortRangeStringAdjusted = tcpPortRangeString;
if (mode === "http") {
tcpPortRangeStringAdjusted = "443,80";
} else if (mode === "ssh") {
tcpPortRangeStringAdjusted = destinationPort
? destinationPort.toString()
: "22";
}
[updatedSiteResource] = await trx [updatedSiteResource] = await trx
.update(siteResources) .update(siteResources)
.set({ .set({
@@ -452,12 +495,14 @@ export async function updateSiteResource(
destinationPort, destinationPort,
enabled, enabled,
alias: alias ? alias.trim() : null, alias: alias ? alias.trim() : null,
tcpPortRangeString: tcpPortRangeString: tcpPortRangeStringAdjusted,
mode == "http" ? "443,80" : tcpPortRangeString,
udpPortRangeString: udpPortRangeString:
mode == "http" ? "" : udpPortRangeString, mode == "http" || mode == "ssh"
? ""
: udpPortRangeString,
disableIcmp: disableIcmp:
disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false disableIcmp ||
(mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false
domainId, domainId,
subdomain: finalSubdomain, subdomain: finalSubdomain,
fullDomain, fullDomain,
@@ -554,13 +599,17 @@ export async function updateSiteResource(
const sshPamSet = const sshPamSet =
isLicensedSshPam && isLicensedSshPam &&
(authDaemonPort !== undefined || (authDaemonPort !== undefined ||
authDaemonMode !== undefined) authDaemonMode !== undefined ||
pamMode !== undefined)
? { ? {
...(authDaemonPort !== undefined && { ...(authDaemonPort !== undefined && {
authDaemonPort authDaemonPort
}), }),
...(authDaemonMode !== undefined && { ...(authDaemonMode !== undefined && {
authDaemonMode authDaemonMode
}),
...(pamMode !== undefined && {
pamMode
}) })
} }
: {}; : {};
@@ -832,6 +881,10 @@ export async function handleMessagingForUpdatedSiteResource(
for (const client of mergedAllClients) { for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
// todo: optimize this query if needed // todo: optimize this query if needed
if (!existingSiteResource.destination) {
continue;
}
const oldDestinationStillInUseSites = await trx const oldDestinationStillInUseSites = await trx
.select() .select()
.from(siteResources) .from(siteResources)
+1 -1
View File
@@ -314,7 +314,7 @@ export async function createTarget(
newt.newtId, newt.newtId,
newTarget, newTarget,
healthCheck, healthCheck,
resource.protocol, resource.mode === "udp" ? "udp" : "tcp",
newt.version newt.version
); );
} }
+1 -1
View File
@@ -126,7 +126,7 @@ export async function deleteTarget(
// [deletedTarget], // [deletedTarget],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
[deletedHealthCheck], [deletedHealthCheck],
resource.protocol, resource.mode === "udp" ? "udp" : "tcp",
newt.version newt.version
); );
} }
+1 -1
View File
@@ -332,7 +332,7 @@ export async function updateTarget(
newt.newtId, newt.newtId,
[updatedTarget], [updatedTarget],
[updatedHc], [updatedHc],
resource.protocol, resource.mode === "udp" ? "udp" : "tcp",
newt.version newt.version
); );
} }
@@ -1352,6 +1352,12 @@ export default function BillingPage() {
{t("billingModifyCurrentPlan") || {t("billingModifyCurrentPlan") ||
"Modify Current Plan"} "Modify Current Plan"}
</Button> </Button>
<p className="text-sm text-muted-foreground mt-2">
{t(
"billingManageLicenseSubscriptionDescription"
) ||
"Manage your subscription for paid self-hosted license keys and download invoices."}
</p>
</div> </div>
</div> </div>
</SettingsSectionBody> </SettingsSectionBody>
@@ -56,7 +56,9 @@ export default async function ClientResourcesPage(
pagination = responseData.pagination; pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined); const siteIdParam = parsePositiveInt(
searchParams.get("siteId") ?? undefined
);
let initialFilterSite: { let initialFilterSite: {
siteId: number; siteId: number;
@@ -106,7 +108,10 @@ export default async function ClientResourcesPage(
siteNiceId: siteResource.siteNiceIds[idx], siteNiceId: siteResource.siteNiceIds[idx],
online: siteResource.siteOnlines[idx] online: siteResource.siteOnlines[idx]
})), })),
mode: siteResource.mode, mode:
siteResource.pamMode && siteResource.mode === "host"
? "ssh"
: siteResource.mode,
scheme: siteResource.scheme, scheme: siteResource.scheme,
ssl: siteResource.ssl, ssl: siteResource.ssl,
siteNames: siteResource.siteNames, siteNames: siteResource.siteNames,
@@ -115,7 +120,7 @@ export default async function ClientResourcesPage(
// proxyPort: siteResource.proxyPort, // proxyPort: siteResource.proxyPort,
siteIds: siteResource.siteIds, siteIds: siteResource.siteIds,
destination: siteResource.destination, destination: siteResource.destination,
httpHttpsPort: siteResource.destinationPort ?? null, destinationPort: siteResource.destinationPort ?? null,
alias: siteResource.alias || null, alias: siteResource.alias || null,
aliasAddress: siteResource.aliasAddress || null, aliasAddress: siteResource.aliasAddress || null,
siteNiceIds: siteResource.siteNiceIds, siteNiceIds: siteResource.siteNiceIds,
@@ -125,6 +130,7 @@ export default async function ClientResourcesPage(
disableIcmp: siteResource.disableIcmp || false, disableIcmp: siteResource.disableIcmp || false,
authDaemonMode: siteResource.authDaemonMode ?? null, authDaemonMode: siteResource.authDaemonMode ?? null,
authDaemonPort: siteResource.authDaemonPort ?? null, authDaemonPort: siteResource.authDaemonPort ?? null,
pamMode: siteResource.pamMode ?? null,
subdomain: siteResource.subdomain ?? null, subdomain: siteResource.subdomain ?? null,
domainId: siteResource.domainId ?? null, domainId: siteResource.domainId ?? null,
fullDomain: siteResource.fullDomain ?? null, fullDomain: siteResource.fullDomain ?? null,
@@ -3,15 +3,7 @@
import HealthCheckCredenza from "@/components/HealthCheckCredenza"; import HealthCheckCredenza from "@/components/HealthCheckCredenza";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { HeadersInput } from "@app/components/HeadersInput";
import { import {
PathMatchDisplay, PathMatchDisplay,
PathMatchModal, PathMatchModal,
@@ -20,25 +12,12 @@ import {
} from "@app/components/PathMatchRenameModal"; } from "@app/components/PathMatchRenameModal";
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item"; import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
import { import {
SettingsContainer,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { import {
Table, Table,
TableBody, TableBody,
@@ -55,17 +34,13 @@ import {
} from "@app/components/ui/tooltip"; } from "@app/components/ui/tooltip";
import type { ResourceContextType } from "@app/contexts/resourceContext"; import type { ResourceContextType } from "@app/contexts/resourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { DockerManager, DockerState } from "@app/lib/docker"; import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries, resourceQueries } from "@app/lib/queries"; import { orgQueries, resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build"; import { build } from "@server/build";
import { tlsNameSchema } from "@server/lib/schemas";
import { type GetResourceResponse } from "@server/routers/resource"; import { type GetResourceResponse } from "@server/routers/resource";
import type { ListSitesResponse } from "@server/routers/site";
import { CreateTargetResponse } from "@server/routers/target"; import { CreateTargetResponse } from "@server/routers/target";
import { ListTargetsResponse } from "@server/routers/target/listTargets"; import { ListTargetsResponse } from "@server/routers/target/listTargets";
import { ArrayElement } from "@server/types/ArrayElement"; import { ArrayElement } from "@server/types/ArrayElement";
@@ -80,33 +55,18 @@ import {
useReactTable useReactTable
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { import { ExternalLink, Info, Plus } from "lucide-react";
AlertTriangle,
CircleCheck,
CircleX,
ExternalLink,
Info,
Plus,
Settings
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
use,
useActionState, useActionState,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useState useState
} from "react"; } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
const targetsSettingsSchema = z.object({ export type LocalTarget = Omit<
stickySession: z.boolean()
});
type LocalTarget = Omit<
ArrayElement<ListTargetsResponse["targets"]> & { ArrayElement<ListTargetsResponse["targets"]> & {
new?: boolean; new?: boolean;
updated?: boolean; updated?: boolean;
@@ -115,67 +75,43 @@ type LocalTarget = Omit<
"protocol" "protocol"
>; >;
export default function ReverseProxyTargetsPage(props: { interface ProxyResourceTargetsFormProps {
params: Promise<{ resourceId: number; orgId: string }>; orgId: string;
}) { isHttp: boolean;
const params = use(props.params); initialTargets?: LocalTarget[];
const { resource, updateResource } = useResourceContext(); /** Edit mode: when provided, shows a save button and polls for health status */
resource?: GetResourceResponse;
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery( updateResource?: ResourceContextType["updateResource"];
resourceQueries.resourceTargets({ /** Create mode: called whenever the targets list changes */
resourceId: resource.resourceId onChange?: (targets: LocalTarget[]) => void;
})
);
if (isLoadingTargets) {
return null;
}
return (
<SettingsContainer>
<ProxyResourceTargetsForm
orgId={params.orgId}
initialTargets={remoteTargets}
resource={resource}
/>
{resource.http && (
<ProxyResourceHttpForm
resource={resource}
updateResource={updateResource}
/>
)}
{!resource.http && resource.protocol == "tcp" && (
<ProxyResourceProtocolForm
resource={resource}
updateResource={updateResource}
/>
)}
</SettingsContainer>
);
} }
function ProxyResourceTargetsForm({ export function ProxyResourceTargetsForm({
orgId, orgId,
initialTargets, isHttp,
resource initialTargets = [],
}: { resource,
initialTargets: LocalTarget[]; updateResource,
orgId: string; onChange
resource: GetResourceResponse; }: ProxyResourceTargetsFormProps) {
}) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets); const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]); const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
// Notify parent of changes (create mode)
useEffect(() => {
onChange?.(targets);
}, [targets]); // eslint-disable-line react-hooks/exhaustive-deps
// Poll health status only in edit mode
const { data: polledTargets } = useQuery({ const { data: polledTargets } = useQuery({
...resourceQueries.resourceTargets({ ...resourceQueries.resourceTargets({
resourceId: resource.resourceId resourceId: resource?.resourceId ?? 0
}), }),
refetchInterval: 10_000 refetchInterval: 10_000,
enabled: !!resource
}); });
useEffect(() => { useEffect(() => {
@@ -194,6 +130,7 @@ function ProxyResourceTargetsForm({
}) })
); );
}, [polledTargets]); }, [polledTargets]);
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>( const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
new Map() new Map()
); );
@@ -201,14 +138,17 @@ function ProxyResourceTargetsForm({
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null); useState<LocalTarget | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("");
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
const initializeDockerForSite = async (siteId: number) => { const initializeDockerForSite = async (siteId: number) => {
if (dockerStates.has(siteId)) { if (dockerStates.has(siteId)) {
return; // Already initialized return;
} }
const dockerManager = new DockerManager(api, siteId); const dockerManager = new DockerManager(api, siteId);
const dockerState = await dockerManager.initializeDocker(); const dockerState = await dockerManager.initializeDocker();
setDockerStates((prev) => new Map(prev.set(siteId, dockerState))); setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
}; };
@@ -216,7 +156,6 @@ function ProxyResourceTargetsForm({
async (siteId: number) => { async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId); const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers(); const containers = await dockerManager.fetchContainers();
setDockerStates((prev) => { setDockerStates((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
const existingState = newMap.get(siteId); const existingState = newMap.get(siteId);
@@ -250,8 +189,6 @@ function ProxyResourceTargetsForm({
return false; return false;
}); });
const isHttp = resource.http;
const removeTarget = useCallback((targetId: number) => { const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => { setTargets((prevTargets) => {
const targetToRemove = prevTargets.find( const targetToRemove = prevTargets.find(
@@ -270,6 +207,42 @@ function ProxyResourceTargetsForm({
}) })
); );
// Browser-gateway targets (edit mode only)
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource?.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
type: string;
destination: string;
destinationPort: number;
}>;
};
},
enabled: !!resource
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const bgt = bgTargetsResponse.targets[0];
setBgDestination(bgt.destination);
setBgDestinationPort(String(bgt.destinationPort));
setBgSiteId(bgt.siteId);
setBgTargetId(bgt.browserGatewayTargetId);
}, [bgTargetsResponse]);
useEffect(() => {
if (sites.length > 0 && bgSiteId === null) {
setBgSiteId(sites[0].siteId);
}
}, [sites, bgSiteId]);
const updateTarget = useCallback( const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => { (targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => { setTargets((prevTargets) => {
@@ -356,7 +329,7 @@ function ProxyResourceTargetsForm({
} }
}; };
return ( return (
<div className="flex items-center justify-center w-full"> <div className="flex items-center justify-center w-full">
{row.original.siteType === "newt" ? ( {row.original.siteType === "newt" ? (
<Button <Button
@@ -375,7 +348,6 @@ function ProxyResourceTargetsForm({
{getStatusText(status)} {getStatusText(status)}
</div> </div>
</Button> </Button>
) : ( ) : (
<span>-</span> <span>-</span>
)} )}
@@ -404,9 +376,15 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType pathMatchType: row.original.pathMatchType
}} }}
onChange={(config) => onChange={(config) =>
updateTarget(row.original.targetId, updateTarget(
config.path === null && config.pathMatchType === null row.original.targetId,
? { ...config, rewritePath: null, rewritePathType: null } config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
: config : config
) )
} }
@@ -432,9 +410,15 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType pathMatchType: row.original.pathMatchType
}} }}
onChange={(config) => onChange={(config) =>
updateTarget(row.original.targetId, updateTarget(
config.path === null && config.pathMatchType === null row.original.targetId,
? { ...config, rewritePath: null, rewritePathType: null } config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
: config : config
) )
} }
@@ -587,20 +571,19 @@ function ProxyResourceTargetsForm({
}; };
if (isAdvancedMode) { if (isAdvancedMode) {
const columns = [ const cols = [
addressColumn, addressColumn,
healthCheckColumn, healthCheckColumn,
enabledColumn, enabledColumn,
actionsColumn actionsColumn
]; ];
// Only include path-related columns for HTTP resources
if (isHttp) { if (isHttp) {
columns.unshift(matchPathColumn); cols.unshift(matchPathColumn);
columns.splice(3, 0, rewritePathColumn, priorityColumn); cols.splice(3, 0, rewritePathColumn, priorityColumn);
} }
return columns; return cols;
} else { } else {
return [ return [
addressColumn, addressColumn,
@@ -622,22 +605,20 @@ function ProxyResourceTargetsForm({
]); ]);
function addNewTarget() { function addNewTarget() {
const isHttp = resource.http;
const newTarget: LocalTarget = { const newTarget: LocalTarget = {
targetId: -Date.now(), // Use negative timestamp as temporary ID targetId: -Date.now(),
ip: "", ip: "",
method: isHttp ? "http" : null, method: isHttp ? "http" : null,
port: 0, port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0, siteId: sites.length > 0 ? sites[0].siteId : 0,
siteName: sites.length > 0 ? sites[0].name : "", siteName: sites.length > 0 ? sites[0].name : "",
path: isHttp ? null : null, path: null,
pathMatchType: isHttp ? null : null, pathMatchType: null,
rewritePath: isHttp ? null : null, rewritePath: null,
rewritePathType: isHttp ? null : null, rewritePathType: null,
priority: isHttp ? 100 : 100, priority: 100,
enabled: true, enabled: true,
resourceId: resource.resourceId, resourceId: resource?.resourceId ?? 0,
hcEnabled: false, hcEnabled: false,
hcPath: null, hcPath: null,
hcMethod: null, hcMethod: null,
@@ -694,7 +675,6 @@ function ProxyResourceTargetsForm({
}); });
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
@@ -704,7 +684,6 @@ function ProxyResourceTargetsForm({
} }
}, [sites]); }, [sites]);
// Save advanced mode preference to localStorage
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem( localStorage.setItem(
@@ -717,7 +696,8 @@ function ProxyResourceTargetsForm({
const [, formAction, isSubmitting] = useActionState(saveTargets, null); const [, formAction, isSubmitting] = useActionState(saveTargets, null);
async function saveTargets() { async function saveTargets() {
// Validate that no targets have blank IPs or invalid ports if (!resource) return;
const targetsWithInvalidFields = targets.filter( const targetsWithInvalidFields = targets.filter(
(target) => (target) =>
!target.ip || !target.ip ||
@@ -726,7 +706,6 @@ function ProxyResourceTargetsForm({
target.port <= 0 || target.port <= 0 ||
isNaN(target.port) isNaN(target.port)
); );
console.log(targetsWithInvalidFields);
if (targetsWithInvalidFields.length > 0) { if (targetsWithInvalidFields.length > 0) {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -743,7 +722,6 @@ function ProxyResourceTargetsForm({
) )
); );
// Save targets
for (const target of targets) { for (const target of targets) {
const data: any = { const data: any = {
ip: target.ip, ip: target.ip,
@@ -769,8 +747,7 @@ function ProxyResourceTargetsForm({
hcUnhealthyThreshold: target.hcUnhealthyThreshold || null hcUnhealthyThreshold: target.hcUnhealthyThreshold || null
}; };
// Only include path-related fields for HTTP resources if (isHttp) {
if (resource.http) {
data.path = target.path; data.path = target.path;
data.pathMatchType = target.pathMatchType; data.pathMatchType = target.pathMatchType;
data.rewritePath = target.rewritePath; data.rewritePath = target.rewritePath;
@@ -791,12 +768,14 @@ function ProxyResourceTargetsForm({
} }
toast({ toast({
title: targets.length === 0 title:
? t("targetTargetsCleared") targets.length === 0
: t("settingsUpdated"), ? t("targetTargetsCleared")
description: targets.length === 0 : t("settingsUpdated"),
? t("targetTargetsClearedDescription") description:
: t("settingsUpdatedDescription") targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
}); });
setTargetsToRemove([]); setTargetsToRemove([]);
@@ -918,9 +897,6 @@ function ProxyResourceTargetsForm({
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table> </Table>
</div> </div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -978,15 +954,18 @@ function ProxyResourceTargetsForm({
)} )}
</SettingsSectionBody> </SettingsSectionBody>
<form className="self-end mt-4" action={formAction}> {/* Save button — only shown in edit mode */}
<Button {resource && (
disabled={isSubmitting} <form className="self-end mt-4" action={formAction}>
loading={isSubmitting} <Button
type="submit" disabled={isSubmitting}
> loading={isSubmitting}
{t("saveResourceTargets")} type="submit"
</Button> >
</form> {t("saveResourceTargets")}
</Button>
</form>
)}
</SettingsSection> </SettingsSection>
{selectedTargetForHealthCheck && ( {selectedTargetForHealthCheck && (
@@ -1049,500 +1028,3 @@ function ProxyResourceTargetsForm({
</> </>
); );
} }
function ProxyResourceHttpForm({
resource,
updateResource
}: Pick<ResourceContextType, "resource" | "updateResource">) {
const t = useTranslations();
const tlsSettingsSchema = z.object({
ssl: z.boolean(),
tlsServerName: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message: t("proxyErrorTls")
}
)
});
const tlsSettingsForm = useForm({
resolver: zodResolver(tlsSettingsSchema),
defaultValues: {
ssl: resource.ssl,
tlsServerName: resource.tlsServerName || ""
}
});
const proxySettingsSchema = z.object({
setHostHeader: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message: t("proxyErrorInvalidHeader")
}
),
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable(),
proxyProtocol: z.boolean().optional(),
proxyProtocolVersion: z.int().min(1).max(2).optional()
});
const proxySettingsForm = useForm({
resolver: zodResolver(proxySettingsSchema),
defaultValues: {
setHostHeader: resource.setHostHeader || "",
headers: resource.headers,
proxyProtocol: resource.proxyProtocol || false,
proxyProtocolVersion: resource.proxyProtocolVersion || 1
}
});
const { env } = useEnvContext();
const api = createApiClient({ env });
const targetsSettingsForm = useForm({
resolver: zodResolver(targetsSettingsSchema),
defaultValues: {
stickySession: resource.stickySession
}
});
const router = useRouter();
const [, formAction, isSubmitting] = useActionState(
saveResourceHttpSettings,
null
);
async function saveResourceHttpSettings() {
const isValidTLS = await tlsSettingsForm.trigger();
const isValidProxy = await proxySettingsForm.trigger();
const targetSettingsForm = await targetsSettingsForm.trigger();
if (!isValidTLS || !isValidProxy || !targetSettingsForm) return;
try {
// Gather all settings
const stickySessionData = targetsSettingsForm.getValues();
const tlsData = tlsSettingsForm.getValues();
const proxyData = proxySettingsForm.getValues();
// Combine into one payload
const payload = {
stickySession: stickySessionData.stickySession,
ssl: tlsData.ssl,
tlsServerName: tlsData.tlsServerName || null,
setHostHeader: proxyData.setHostHeader || null,
headers: proxyData.headers || null
};
// Single API call to update all settings
await api.post(`/resource/${resource.resourceId}`, payload);
// Update local resource context
updateResource({
...resource,
stickySession: stickySessionData.stickySession,
ssl: tlsData.ssl,
tlsServerName: tlsData.tlsServerName || null,
setHostHeader: proxyData.setHostHeader || null,
headers: proxyData.headers || null
});
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("proxyAdditional")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("proxyAdditionalDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...tlsSettingsForm}>
<form
action={formAction}
className="space-y-4"
id="tls-settings-form"
>
{!env.flags.usePangolinDns && (
<FormField
control={tlsSettingsForm.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="ssl-toggle"
label={t("proxyEnableSSL")}
description={t(
"proxyEnableSSLDescription"
)}
defaultChecked={field.value}
onCheckedChange={(val) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={tlsSettingsForm.control}
name="tlsServerName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("targetTlsSni")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t("targetTlsSniDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
<SettingsSectionForm>
<Form {...targetsSettingsForm}>
<form
action={formAction}
className="space-y-4"
id="targets-settings-form"
>
<FormField
control={targetsSettingsForm.control}
name="stickySession"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="sticky-toggle"
label={t(
"targetStickySessions"
)}
description={t(
"targetStickySessionsDescription"
)}
defaultChecked={field.value}
onCheckedChange={(val) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
action={formAction}
className="space-y-4"
id="proxy-settings-form"
>
<FormField
control={proxySettingsForm.control}
name="setHostHeader"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxyCustomHeader")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t("proxyCustomHeaderDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={proxySettingsForm.control}
name="headers"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={field.value}
onChange={(value) => {
field.onChange(value);
}}
rows={4}
/>
</FormControl>
<FormDescription>
{t("customHeadersDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
<form className="flex justify-end" action={formAction}>
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveResourceHttp")}
</Button>
</form>
</SettingsSectionBody>
</SettingsSection>
);
}
function ProxyResourceProtocolForm({
resource,
updateResource
}: Pick<ResourceContextType, "resource" | "updateResource">) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const proxySettingsSchema = z.object({
setHostHeader: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message: t("proxyErrorInvalidHeader")
}
),
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable(),
proxyProtocol: z.boolean().optional(),
proxyProtocolVersion: z.int().min(1).max(2).optional()
});
const proxySettingsForm = useForm({
resolver: zodResolver(proxySettingsSchema),
defaultValues: {
setHostHeader: resource.setHostHeader || "",
headers: resource.headers,
proxyProtocol: resource.proxyProtocol || false,
proxyProtocolVersion: resource.proxyProtocolVersion || 1
}
});
const router = useRouter();
const [, formAction, isSubmitting] = useActionState(
saveProtocolSettings,
null
);
async function saveProtocolSettings() {
const isValid = proxySettingsForm.trigger();
if (!isValid) return;
try {
// For TCP/UDP resources, save proxy protocol settings
const proxyData = proxySettingsForm.getValues();
const payload = {
proxyProtocol: proxyData.proxyProtocol || false,
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
};
await api.post(`/resource/${resource.resourceId}`, payload);
updateResource({
...resource,
proxyProtocol: proxyData.proxyProtocol || false,
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
});
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("proxyProtocol")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("proxyProtocolDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
action={formAction}
className="space-y-4"
id="proxy-protocol-settings-form"
>
<FormField
control={proxySettingsForm.control}
name="proxyProtocol"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="proxy-protocol-toggle"
label={t("enableProxyProtocol")}
description={t(
"proxyProtocolInfo"
)}
defaultChecked={
field.value || false
}
onCheckedChange={(val) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
{proxySettingsForm.watch("proxyProtocol") && (
<>
<FormField
control={proxySettingsForm.control}
name="proxyProtocolVersion"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxyProtocolVersion")}
</FormLabel>
<FormControl>
<Select
value={String(
field.value || 1
)}
onValueChange={(
value
) =>
field.onChange(
parseInt(
value,
10
)
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select version" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
{t("version1")}
</SelectItem>
<SelectItem value="2">
{t("version2")}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("versionDescription")}
</FormDescription>
</FormItem>
)}
/>
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>{t("warning")}:</strong>{" "}
{t("proxyProtocolWarning")}
</AlertDescription>
</Alert>
</>
)}
</form>
</Form>
</SettingsSectionForm>
<form action={formAction} className="flex justify-end">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveProxyProtocol")}
</Button>
</form>
</SettingsSectionBody>
</SettingsSection>
);
}
@@ -146,7 +146,7 @@ function MaintenanceSectionForm({
} }
} }
if (!resource.http) { if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
return null; return null;
} }
@@ -176,7 +176,9 @@ function MaintenanceSectionForm({
render={({ field }) => { render={({ field }) => {
const isDisabled = const isDisabled =
!isPaidUser(tierMatrix.maintencePage) || !isPaidUser(tierMatrix.maintencePage) ||
resource.http === false; !["http", "ssh", "rdp", "vnc"].includes(
resource.mode
);
return ( return (
<FormItem> <FormItem>
@@ -462,14 +464,14 @@ export default function GeneralForm() {
.refine( .refine(
(data) => { (data) => {
// For non-HTTP resources, proxyPort should be defined // For non-HTTP resources, proxyPort should be defined
if (!resource.http) { if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
return data.proxyPort !== undefined; return data.proxyPort !== undefined;
} }
// For HTTP resources, proxyPort should be undefined // For HTTP resources, proxyPort should be undefined
return data.proxyPort === undefined; return data.proxyPort === undefined;
}, },
{ {
message: !resource.http message: !["http", "ssh", "rdp", "vnc"].includes(resource.mode)
? "Port number is required for non-HTTP resources" ? "Port number is required for non-HTTP resources"
: "Port number should not be set for HTTP resources", : "Port number should not be set for HTTP resources",
path: ["proxyPort"] path: ["proxyPort"]
@@ -507,7 +509,9 @@ export default function GeneralForm() {
name: data.name, name: data.name,
niceId: data.niceId, niceId: data.niceId,
subdomain: data.subdomain subdomain: data.subdomain
? toASCII(finalizeSubdomainSanitize(data.subdomain, true)) ? toASCII(
finalizeSubdomainSanitize(data.subdomain, true)
)
: undefined, : undefined,
domainId: data.domainId, domainId: data.domainId,
proxyPort: data.proxyPort proxyPort: data.proxyPort
@@ -555,13 +559,15 @@ export default function GeneralForm() {
return ( return (
<> <>
<SettingsContainer> <SettingsContainer>
{resource?.resourceId && resource?.orgId && ( {resource?.resourceId &&
<UptimeAlertSection resource?.orgId &&
orgId={resource.orgId} resource.mode == "http" && (
resourceId={resource.resourceId} <UptimeAlertSection
startingName={resource.name} orgId={resource.orgId}
/> resourceId={resource.resourceId}
)} startingName={resource.name}
/>
)}
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -580,45 +586,48 @@ export default function GeneralForm() {
className="space-y-4" className="space-y-4"
id="general-settings-form" id="general-settings-form"
> >
<FormField <div className="grid grid-cols-2 gap-4">
control={form.control} <FormField
name="name" control={form.control}
render={({ field }) => ( name="name"
<FormItem> render={({ field }) => (
<FormLabel> <FormItem>
{t("name")} <FormLabel>
</FormLabel> {t("name")}
<FormControl> </FormLabel>
<Input {...field} /> <FormControl>
</FormControl> <Input {...field} />
<FormMessage /> </FormControl>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
/>
<FormField <FormField
control={form.control} control={form.control}
name="niceId" name="niceId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("identifier")} {t("identifier")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
placeholder={t( placeholder={t(
"enterIdentifier" "enterIdentifier"
)} )}
className="flex-1" />
/> </FormControl>
</FormControl> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
)} />
/> </div>
{!resource.http && ( {!["http", "ssh", "rdp", "vnc"].includes(
resource.mode
) && (
<> <>
<FormField <FormField
control={form.control} control={form.control}
@@ -667,7 +676,9 @@ export default function GeneralForm() {
</> </>
)} )}
{resource.http && ( {["http", "ssh", "rdp", "vnc"].includes(
resource.mode
) && (
<div className="space-y-4"> <div className="space-y-4">
<div id="resource-domain-picker"> <div id="resource-domain-picker">
<DomainPicker <DomainPicker
@@ -726,28 +737,31 @@ export default function GeneralForm() {
control={form.control} control={form.control}
name="enabled" name="enabled"
render={() => ( render={() => (
<FormItem className="col-span-2"> <FormItem>
<div className="flex items-center space-x-2"> <FormControl>
<FormControl> <SwitchInput
<SwitchInput id="enable-resource"
id="enable-resource" defaultChecked={
defaultChecked={ resource.enabled
resource.enabled }
} label={t(
label={t( "resourceEnable"
"resourceEnable" )}
)} onCheckedChange={(
onCheckedChange={( val
) =>
form.setValue(
"enabled",
val val
) => )
form.setValue( }
"enabled", />
val </FormControl>
) <FormDescription>
} {t(
/> "disabledResourceDescription"
</FormControl> )}
</div> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -0,0 +1,651 @@
"use client";
import HealthCheckCredenza from "@/components/HealthCheckCredenza";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { HeadersInput } from "@app/components/HeadersInput";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import type { ResourceContextType } from "@app/contexts/resourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { tlsNameSchema } from "@server/lib/schemas";
import { type GetResourceResponse } from "@server/routers/resource";
import type { ListSitesResponse } from "@server/routers/site";
import { CreateTargetResponse } from "@server/routers/target";
import { ListTargetsResponse } from "@server/routers/target/listTargets";
import { ArrayElement } from "@server/types/ArrayElement";
import { useQuery } from "@tanstack/react-query";
import {
LocalTarget,
ProxyResourceTargetsForm
} from "@app/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
AlertTriangle,
CircleCheck,
CircleX,
ExternalLink,
Info,
Plus,
Settings
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import {
use,
useActionState,
useCallback,
useEffect,
useMemo,
useState
} from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
const targetsSettingsSchema = z.object({
stickySession: z.boolean()
});
export default function ReverseProxyTargetsPage(props: {
params: Promise<{ resourceId: number; orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
resourceQueries.resourceTargets({
resourceId: resource.resourceId
})
);
if (isLoadingTargets) {
return null;
}
return (
<SettingsContainer>
<ProxyResourceTargetsForm
orgId={params.orgId}
isHttp={["http", "ssh", "rdp", "vnc"].includes(resource.mode)}
initialTargets={remoteTargets}
resource={resource}
updateResource={updateResource}
/>
{["http", "ssh", "rdp", "vnc"].includes(resource.mode) && (
<ProxyResourceHttpForm
resource={resource}
updateResource={updateResource}
/>
)}
{resource.mode == "tcp" && (
<ProxyResourceProtocolForm
resource={resource}
updateResource={updateResource}
/>
)}
</SettingsContainer>
);
}
function ProxyResourceHttpForm({
resource,
updateResource
}: Pick<ResourceContextType, "resource" | "updateResource">) {
const t = useTranslations();
const tlsSettingsSchema = z.object({
ssl: z.boolean(),
tlsServerName: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message: t("proxyErrorTls")
}
)
});
const tlsSettingsForm = useForm({
resolver: zodResolver(tlsSettingsSchema),
defaultValues: {
ssl: resource.ssl,
tlsServerName: resource.tlsServerName || ""
}
});
const proxySettingsSchema = z.object({
setHostHeader: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message: t("proxyErrorInvalidHeader")
}
),
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable(),
proxyProtocol: z.boolean().optional(),
proxyProtocolVersion: z.int().min(1).max(2).optional()
});
const proxySettingsForm = useForm({
resolver: zodResolver(proxySettingsSchema),
defaultValues: {
setHostHeader: resource.setHostHeader || "",
headers: resource.headers,
proxyProtocol: resource.proxyProtocol || false,
proxyProtocolVersion: resource.proxyProtocolVersion || 1
}
});
const { env } = useEnvContext();
const api = createApiClient({ env });
const targetsSettingsForm = useForm({
resolver: zodResolver(targetsSettingsSchema),
defaultValues: {
stickySession: resource.stickySession
}
});
const router = useRouter();
const [, formAction, isSubmitting] = useActionState(
saveResourceHttpSettings,
null
);
async function saveResourceHttpSettings() {
const isValidTLS = await tlsSettingsForm.trigger();
const isValidProxy = await proxySettingsForm.trigger();
const targetSettingsForm = await targetsSettingsForm.trigger();
if (!isValidTLS || !isValidProxy || !targetSettingsForm) return;
try {
// Gather all settings
const stickySessionData = targetsSettingsForm.getValues();
const tlsData = tlsSettingsForm.getValues();
const proxyData = proxySettingsForm.getValues();
// Combine into one payload
const payload = {
stickySession: stickySessionData.stickySession,
ssl: tlsData.ssl,
tlsServerName: tlsData.tlsServerName || null,
setHostHeader: proxyData.setHostHeader || null,
headers: proxyData.headers || null
};
// Single API call to update all settings
await api.post(`/resource/${resource.resourceId}`, payload);
// Update local resource context
updateResource({
...resource,
stickySession: stickySessionData.stickySession,
ssl: tlsData.ssl,
tlsServerName: tlsData.tlsServerName || null,
setHostHeader: proxyData.setHostHeader || null,
headers: proxyData.headers || null
});
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("proxyAdditional")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("proxyAdditionalDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...tlsSettingsForm}>
<form
action={formAction}
className="space-y-4"
id="tls-settings-form"
>
{!env.flags.usePangolinDns && (
<FormField
control={tlsSettingsForm.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="ssl-toggle"
label={t("proxyEnableSSL")}
description={t(
"proxyEnableSSLDescription"
)}
defaultChecked={field.value}
onCheckedChange={(val) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={tlsSettingsForm.control}
name="tlsServerName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("targetTlsSni")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t("targetTlsSniDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
<SettingsSectionForm>
<Form {...targetsSettingsForm}>
<form
action={formAction}
className="space-y-4"
id="targets-settings-form"
>
<FormField
control={targetsSettingsForm.control}
name="stickySession"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="sticky-toggle"
label={t(
"targetStickySessions"
)}
description={t(
"targetStickySessionsDescription"
)}
defaultChecked={field.value}
onCheckedChange={(val) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
action={formAction}
className="space-y-4"
id="proxy-settings-form"
>
<FormField
control={proxySettingsForm.control}
name="setHostHeader"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxyCustomHeader")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t("proxyCustomHeaderDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={proxySettingsForm.control}
name="headers"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={field.value}
onChange={(value) => {
field.onChange(value);
}}
rows={4}
/>
</FormControl>
<FormDescription>
{t("customHeadersDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
<form className="flex justify-end" action={formAction}>
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveResourceHttp")}
</Button>
</form>
</SettingsSectionBody>
</SettingsSection>
);
}
function ProxyResourceProtocolForm({
resource,
updateResource
}: Pick<ResourceContextType, "resource" | "updateResource">) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const proxySettingsSchema = z.object({
setHostHeader: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message: t("proxyErrorInvalidHeader")
}
),
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable(),
proxyProtocol: z.boolean().optional(),
proxyProtocolVersion: z.int().min(1).max(2).optional()
});
const proxySettingsForm = useForm({
resolver: zodResolver(proxySettingsSchema),
defaultValues: {
setHostHeader: resource.setHostHeader || "",
headers: resource.headers,
proxyProtocol: resource.proxyProtocol || false,
proxyProtocolVersion: resource.proxyProtocolVersion || 1
}
});
const router = useRouter();
const [, formAction, isSubmitting] = useActionState(
saveProtocolSettings,
null
);
async function saveProtocolSettings() {
const isValid = proxySettingsForm.trigger();
if (!isValid) return;
try {
// For TCP/UDP resources, save proxy protocol settings
const proxyData = proxySettingsForm.getValues();
const payload = {
proxyProtocol: proxyData.proxyProtocol || false,
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
};
await api.post(`/resource/${resource.resourceId}`, payload);
updateResource({
...resource,
proxyProtocol: proxyData.proxyProtocol || false,
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
});
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("proxyProtocol")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("proxyProtocolDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
action={formAction}
className="space-y-4"
id="proxy-protocol-settings-form"
>
<FormField
control={proxySettingsForm.control}
name="proxyProtocol"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="proxy-protocol-toggle"
label={t("enableProxyProtocol")}
description={t(
"proxyProtocolInfo"
)}
defaultChecked={
field.value || false
}
onCheckedChange={(val) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
{proxySettingsForm.watch("proxyProtocol") && (
<>
<FormField
control={proxySettingsForm.control}
name="proxyProtocolVersion"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxyProtocolVersion")}
</FormLabel>
<FormControl>
<Select
value={String(
field.value || 1
)}
onValueChange={(
value
) =>
field.onChange(
parseInt(
value,
10
)
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select version" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
{t("version1")}
</SelectItem>
<SelectItem value="2">
{t("version2")}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("versionDescription")}
</FormDescription>
</FormItem>
)}
/>
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>{t("warning")}:</strong>{" "}
{t("proxyProtocolWarning")}
</AlertDescription>
</Alert>
</>
)}
</form>
</Form>
</SettingsSectionForm>
<form action={formAction} className="flex justify-end">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveProxyProtocol")}
</Button>
</form>
</SettingsSectionBody>
</SettingsSection>
);
}
@@ -86,12 +86,12 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
href: `/{orgId}/settings/resources/proxy/{niceId}/general` href: `/{orgId}/settings/resources/proxy/{niceId}/general`
}, },
{ {
title: t("proxy"), title: t(`${resource.mode}Settings`),
href: `/{orgId}/settings/resources/proxy/{niceId}/proxy` href: `/{orgId}/settings/resources/proxy/{niceId}/${resource.mode}`
} }
]; ];
if (resource.http) { if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
navItems.push({ navItems.push({
title: t("authentication"), title: t("authentication"),
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication` href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
@@ -10,6 +10,6 @@ export default async function ResourcePage(props: {
}) { }) {
const params = await props.params; const params = await props.params;
redirect( redirect(
`/${params.orgId}/settings/resources/proxy/${params.niceId}/proxy` `/${params.orgId}/settings/resources/proxy/${params.niceId}/general`
); );
} }
@@ -0,0 +1,250 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { type Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
export default function SshSettingsPage(props: {
params: Promise<{ orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
return (
<SettingsContainer>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
/>
</SettingsContainer>
);
}
function SshServerForm({
orgId,
resource,
updateResource
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}, [bgTargetsResponse]);
const [, formAction, isSubmitting] = useActionState(save, null);
async function save() {
try {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "rdp",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "rdp",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("rdpServer")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("rdpServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</SettingsSection>
);
}
@@ -6,9 +6,7 @@ import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@/components/ui/select"; } from "@/components/ui/select";
@@ -36,7 +34,6 @@ import {
import { import {
Table, Table,
TableBody, TableBody,
TableCaption,
TableCell, TableCell,
TableHead, TableHead,
TableHeader, TableHeader,
@@ -55,18 +52,11 @@ import {
SettingsSectionTitle, SettingsSectionTitle,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionFooter, SettingsSectionFooter
SettingsSectionForm
} from "@app/components/Settings"; } from "@app/components/Settings";
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { ArrowUpDown, Check, InfoIcon, X, ChevronsUpDown } from "lucide-react"; import { ArrowUpDown, Check, InfoIcon, X, ChevronsUpDown } from "lucide-react";
import {
InfoSection,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import { import {
isValidCIDR, isValidCIDR,
@@ -78,7 +68,11 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { COUNTRIES } from "@server/db/countries"; import { COUNTRIES } from "@server/db/countries";
import { MAJOR_ASNS } from "@server/db/asns"; import { MAJOR_ASNS } from "@server/db/asns";
import { REGIONS, getRegionNameById, isValidRegionId } from "@server/db/regions"; import {
REGIONS,
getRegionNameById,
isValidRegionId
} from "@server/db/regions";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
@@ -109,25 +103,23 @@ type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
export default function ResourceRules(props: { export default function ResourceRules(props: {
params: Promise<{ resourceId: number }>; params: Promise<{ resourceId: number }>;
}) { }) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [rules, setRules] = useState<LocalRule[]>([]); const [rules, setRules] = useState<LocalRule[]>([]);
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]); const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true); const [pageLoading, setPageLoading] = useState(true);
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false); const [rulesEnabled, setRulesEnabled] = useState(
resource.applyRules ?? false
);
useEffect(() => { useEffect(() => {
setRulesEnabled(resource.applyRules); setRulesEnabled(resource.applyRules);
}, [resource.applyRules]); }, [resource.applyRules]);
const [openCountrySelect, setOpenCountrySelect] = useState(false);
const [countrySelectValue, setCountrySelectValue] = useState("");
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
useState(false); useState(false);
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false);
useState(false);
const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] = const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] =
useState(false); useState(false);
const router = useRouter(); const router = useRouter();
@@ -157,7 +149,7 @@ export default function ResourceRules(props: {
resolver: zodResolver(addRuleSchema), resolver: zodResolver(addRuleSchema),
defaultValues: { defaultValues: {
action: "ACCEPT", action: "ACCEPT",
match: "PATH", match: resource.mode == "http" ? "PATH" : "IP",
value: "" value: ""
} }
}); });
@@ -270,16 +262,12 @@ export default function ResourceRules(props: {
setLoading(false); setLoading(false);
return; return;
} }
if ( if (data.match === "REGION" && !isValidRegionId(data.value)) {
data.match === "REGION" &&
!isValidRegionId(data.value)
) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("rulesErrorInvalidRegion"), title: t("rulesErrorInvalidRegion"),
description: description:
t("rulesErrorInvalidRegionDescription") || t("rulesErrorInvalidRegionDescription") || "Invalid region."
"Invalid region."
}); });
setLoading(false); setLoading(false);
return; return;
@@ -564,12 +552,24 @@ export default function ResourceRules(props: {
<Select <Select
defaultValue={row.original.match} defaultValue={row.original.match}
onValueChange={( onValueChange={(
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" value:
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "ASN"
| "REGION"
) => ) =>
updateRule(row.original.ruleId, { updateRule(row.original.ruleId, {
match: value, match: value,
value: value:
value === "COUNTRY" ? "US" : value === "ASN" ? "AS15169" : value === "REGION" ? "021" : row.original.value value === "COUNTRY"
? "US"
: value === "ASN"
? "AS15169"
: value === "REGION"
? "021"
: row.original.value
}) })
} }
> >
@@ -577,7 +577,11 @@ export default function ResourceRules(props: {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem> {resource.mode == "http" && (
<SelectItem value="PATH">
{RuleMatch.PATH}
</SelectItem>
)}
<SelectItem value="IP">{RuleMatch.IP}</SelectItem> <SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem> <SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
{isMaxmindAvailable && ( {isMaxmindAvailable && (
@@ -669,14 +673,14 @@ export default function ResourceRules(props: {
> >
{row.original.value {row.original.value
? (() => { ? (() => {
const found = MAJOR_ASNS.find( const found = MAJOR_ASNS.find(
(asn) => (asn) =>
asn.code === asn.code ===
row.original.value row.original.value
); );
return found return found
? `${found.name} (${row.original.value})` ? `${found.name} (${row.original.value})`
: `Custom (${row.original.value})`; : `Custom (${row.original.value})`;
})() })()
: "Select ASN"} : "Select ASN"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
@@ -755,7 +759,9 @@ export default function ResourceRules(props: {
className="min-w-[200px] justify-between" className="min-w-[200px] justify-between"
> >
{(() => { {(() => {
const regionName = getRegionNameById(row.original.value); const regionName = getRegionNameById(
row.original.value
);
if (!regionName) { if (!regionName) {
return t("selectRegion"); return t("selectRegion");
} }
@@ -774,7 +780,10 @@ export default function ResourceRules(props: {
{t("noRegionFound")} {t("noRegionFound")}
</CommandEmpty> </CommandEmpty>
{REGIONS.map((continent) => ( {REGIONS.map((continent) => (
<CommandGroup key={continent.id} heading={t(continent.name)}> <CommandGroup
key={continent.id}
heading={t(continent.name)}
>
<CommandItem <CommandItem
value={continent.id} value={continent.id}
keywords={[ keywords={[
@@ -790,38 +799,48 @@ export default function ResourceRules(props: {
> >
<Check <Check
className={`mr-2 h-4 w-4 ${ className={`mr-2 h-4 w-4 ${
row.original.value === continent.id row.original.value ===
continent.id
? "opacity-100" ? "opacity-100"
: "opacity-0" : "opacity-0"
}`} }`}
/> />
{t(continent.name)} ({continent.id}) {t(continent.name)} (
{continent.id})
</CommandItem> </CommandItem>
{continent.includes.map((subregion) => ( {continent.includes.map(
<CommandItem (subregion) => (
key={subregion.id} <CommandItem
value={subregion.id} key={subregion.id}
keywords={[ value={subregion.id}
t(subregion.name), keywords={[
subregion.id t(subregion.name),
]} subregion.id
onSelect={() => { ]}
updateRule( onSelect={() => {
row.original.ruleId, updateRule(
{ value: subregion.id } row.original
); .ruleId,
}} {
> value: subregion.id
<Check }
className={`mr-2 h-4 w-4 ${ );
row.original.value === subregion.id }}
? "opacity-100" >
: "opacity-0" <Check
}`} className={`mr-2 h-4 w-4 ${
/> row.original
{t(subregion.name)} ({subregion.id}) .value ===
</CommandItem> subregion.id
))} ? "opacity-100"
: "opacity-0"
}`}
/>
{t(subregion.name)} (
{subregion.id})
</CommandItem>
)
)}
</CommandGroup> </CommandGroup>
))} ))}
</CommandList> </CommandList>
@@ -1018,7 +1037,8 @@ export default function ResourceRules(props: {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{resource.http && ( {resource.mode ==
"http" && (
<SelectItem value="PATH"> <SelectItem value="PATH">
{ {
RuleMatch.PATH RuleMatch.PATH
@@ -1311,8 +1331,8 @@ export default function ResourceRules(props: {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) : addRuleForm.watch( ) : addRuleForm.watch(
"match" "match"
) === "REGION" ? ( ) === "REGION" ? (
<Popover <Popover
open={ open={
openAddRuleRegionSelect openAddRuleRegionSelect
@@ -1334,8 +1354,16 @@ export default function ResourceRules(props: {
> >
{field.value {field.value
? (() => { ? (() => {
const regionName = getRegionNameById(field.value); const regionName =
const translatedName = regionName ? t(regionName) : field.value; getRegionNameById(
field.value
);
const translatedName =
regionName
? t(
regionName
)
: field.value;
return `${translatedName} (${field.value})`; return `${translatedName} (${field.value})`;
})() })()
: t( : t(
@@ -1357,43 +1385,31 @@ export default function ResourceRules(props: {
"noRegionFound" "noRegionFound"
)} )}
</CommandEmpty> </CommandEmpty>
{REGIONS.map((continent) => ( {REGIONS.map(
<CommandGroup key={continent.id} heading={t(continent.name)}> (
<CommandItem continent
value={continent.id} ) => (
keywords={[ <CommandGroup
t(continent.name), key={
continent.id continent.id
]} }
onSelect={() => { heading={t(
field.onChange( continent.name
continent.id )}
);
setOpenAddRuleRegionSelect(
false
);
}}
> >
<Check
className={`mr-2 h-4 w-4 ${
field.value === continent.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(continent.name)} ({continent.id})
</CommandItem>
{continent.includes.map((subregion) => (
<CommandItem <CommandItem
key={subregion.id} value={
value={subregion.id} continent.id
}
keywords={[ keywords={[
t(subregion.name), t(
subregion.id continent.name
),
continent.id
]} ]}
onSelect={() => { onSelect={() => {
field.onChange( field.onChange(
subregion.id continent.id
); );
setOpenAddRuleRegionSelect( setOpenAddRuleRegionSelect(
false false
@@ -1402,16 +1418,71 @@ export default function ResourceRules(props: {
> >
<Check <Check
className={`mr-2 h-4 w-4 ${ className={`mr-2 h-4 w-4 ${
field.value === subregion.id field.value ===
continent.id
? "opacity-100" ? "opacity-100"
: "opacity-0" : "opacity-0"
}`} }`}
/> />
{t(subregion.name)} ({subregion.id}) {t(
continent.name
)}{" "}
(
{
continent.id
}
)
</CommandItem> </CommandItem>
))} {continent.includes.map(
</CommandGroup> (
))} subregion
) => (
<CommandItem
key={
subregion.id
}
value={
subregion.id
}
keywords={[
t(
subregion.name
),
subregion.id
]}
onSelect={() => {
field.onChange(
subregion.id
);
setOpenAddRuleRegionSelect(
false
);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
field.value ===
subregion.id
? "opacity-100"
: "opacity-0"
}`}
/>
{t(
subregion.name
)}{" "}
(
{
subregion.id
}
)
</CommandItem>
)
)}
</CommandGroup>
)
)}
</CommandList> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
@@ -0,0 +1,524 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import {
SitesSelector,
type Selectedsite
} from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { Badge } from "@app/components/ui/badge";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
export default function SshSettingsPage(props: {
params: Promise<{ orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
return (
<SettingsContainer>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
/>
</SettingsContainer>
);
}
function SshServerForm({
orgId,
resource,
updateResource
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const isNativeInitially = resource.authDaemonMode === "native";
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
isNativeInitially ? "native" : "standard"
);
const isNative = sshServerMode === "native";
const [pamMode, setPamMode] = useState<"passthrough" | "push">(
(resource.pamMode as "passthrough" | "push") || "passthrough"
);
const [standardDaemonLocation, setStandardDaemonLocation] = useState<
"site" | "remote"
>(
isNativeInitially
? "site"
: (resource.authDaemonMode as "site" | "remote") || "site"
);
const form = useForm({
resolver: zodResolver(sshFormSchema),
defaultValues: {
authDaemonPort: (resource as any).authDaemonPort
? String((resource as any).authDaemonPort)
: "22123"
}
});
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [selectedSite, setSelectedSite] = useState<Selectedsite | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
if (isNativeInitially) {
setSelectedNativeSite({
siteId: first.siteId,
name: first.siteName ?? String(first.siteId),
type: "newt" as const
});
setNativeExistingTarget({
browserGatewayTargetId: first.browserGatewayTargetId,
siteId: first.siteId
});
} else {
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}
}, [bgTargetsResponse]);
const [, formAction, isSubmitting] = useActionState(save, null);
async function save() {
const isValid = await form.trigger();
if (!isValid) return;
const effectiveMode = isNative ? "native" : standardDaemonLocation;
const portVal = form.getValues().authDaemonPort;
const effectivePort =
!isNative && standardDaemonLocation === "remote" && portVal
? Number(portVal)
: null;
try {
await api.post(`/resource/${resource.resourceId}`, {
pamMode,
authDaemonMode: effectiveMode,
authDaemonPort: effectivePort
});
updateResource({
...resource,
pamMode,
authDaemonMode: effectiveMode
});
if (isNative) {
if (selectedNativeSite) {
if (nativeExistingTarget) {
await api.post(
`/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`,
{
type: "ssh",
destination: "localhost",
destinationPort: 22,
siteId: selectedNativeSite.siteId
}
);
} else {
const res = await api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: selectedNativeSite.siteId,
type: "ssh",
destination: "localhost",
destinationPort: 22
}
);
setNativeExistingTarget({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: selectedNativeSite.siteId
});
}
}
} else {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const newTargets: ExistingTarget[] = created.map(
(res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
})
);
setExistingTargets([...toUpdate, ...newTargets]);
}
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
{
id: "passthrough",
title: t("sshAuthMethodManual"),
description: t("sshAuthMethodManualDescription")
},
{
id: "push",
title: t("sshAuthMethodAutomated"),
description: t("sshAuthMethodAutomatedDescription")
}
];
const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [
{
id: "site",
title: t("internalResourceAuthDaemonSite"),
description: t("sshDaemonLocationSiteDescription")
},
{
id: "remote",
title: t("sshDaemonLocationRemote"),
description: t("sshDaemonLocationRemoteDescription")
}
];
const showDaemonLocation = !isNative && pamMode === "push";
const showDaemonPort =
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("sshServer")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("sshServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshServerMode")}
</p>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</div>
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshAuthenticationMethod")}
</p>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
onChange={setPamMode}
cols={2}
/>
</div>
{showDaemonLocation && (
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshAuthDaemonLocation")}
</p>
<StrategySelect<"site" | "remote">
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={setStandardDaemonLocation}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
)}
{showDaemonPort && (
<Form {...form}>
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Form>
)}
<div className="space-y-3">
<div>
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
{t("sshServerDestination")}
</h2>
<p className="text-sm text-muted-foreground">
{t("sshServerDestinationDescription")}
</p>
</div>
{isNative ? (
<Popover
open={nativeSiteOpen}
onOpenChange={setNativeSiteOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full max-w-xs justify-between font-normal"
>
<span className="truncate">
{selectedNativeSite?.name ??
t("siteSelect")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={orgId}
selectedSite={selectedNativeSite}
onSelectSite={(site) => {
setSelectedNativeSite(site);
setNativeSiteOpen(false);
}}
/>
</PopoverContent>
</Popover>
) : standardDaemonLocation !== "site" ? (
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
) : (
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={false}
selectedSite={selectedSite}
onSiteChange={setSelectedSite}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
)}
</div>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</SettingsSection>
);
}
@@ -0,0 +1,248 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { type Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { z } from "zod";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
export default function SshSettingsPage(props: {
params: Promise<{ orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
return (
<SettingsContainer>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
/>
</SettingsContainer>
);
}
function SshServerForm({
orgId,
resource,
updateResource
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}, [bgTargetsResponse]);
const [, formAction, isSubmitting] = useActionState(save, null);
async function save() {
try {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "vnc",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "vnc",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("vncServer")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("vncServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</SettingsSection>
);
}
File diff suppressed because it is too large Load Diff
@@ -108,11 +108,11 @@ export default async function ProxyResourcesPage(
orgId: params.orgId, orgId: params.orgId,
nice: resource.niceId, nice: resource.niceId,
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`, domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
protocol: resource.protocol,
proxyPort: resource.proxyPort, proxyPort: resource.proxyPort,
http: resource.http,
labels: resource.labels, labels: resource.labels,
authState: !resource.http authState: !["http", "ssh", "rdp", "vnc"].includes(
resource.mode || ""
)
? "none" ? "none"
: resource.sso || : resource.sso ||
resource.pincodeId !== null || resource.pincodeId !== null ||
@@ -126,6 +126,7 @@ export default async function ProxyResourcesPage(
fullDomain: resource.fullDomain ?? null, fullDomain: resource.fullDomain ?? null,
ssl: resource.ssl, ssl: resource.ssl,
wildcard: resource.wildcard, wildcard: resource.wildcard,
mode: resource.mode,
targets: resource.targets?.map((target) => ({ targets: resource.targets?.map((target) => ({
targetId: target.targetId, targetId: target.targetId,
ip: target.ip, ip: target.ip,
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+522
View File
@@ -0,0 +1,522 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast";
import type {
UserInteraction,
IronError,
FileTransferProvider
} from "@devolutions/iron-remote-desktop/dist";
import type {
RdpFileTransferProvider,
FileInfo
} from "@devolutions/iron-remote-desktop-rdp/dist";
import { GetBrowserTargetResponse } from "@server/routers/resource";
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"iron-remote-desktop": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
scale?: string;
verbose?: string;
flexcenter?: string;
module?: unknown;
},
HTMLElement
>;
}
}
}
type FormState = {
username: string;
password: string;
domain: string;
kdcProxyUrl: string;
pcb: string;
enableClipboard: boolean;
};
const isIronError = (error: unknown): error is IronError => {
return (
typeof error === "object" &&
error !== null &&
typeof (error as IronError).backtrace === "function" &&
typeof (error as IronError).kind === "function"
);
};
export default function RdpClient({
target,
error
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
}) {
const STORAGE_KEY = "pangolin_rdp_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
});
const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false);
const [unicodeMode, setUnicodeMode] = useState(false);
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
const userInteractionRef = useRef<UserInteraction | null>(null);
const backendRef = useRef<unknown>(null);
// Holds the RdpFileTransferProvider constructor so we can create a fresh
// instance per session (avoids stale upload state across reconnects).
const fileTransferClassRef = useRef<typeof RdpFileTransferProvider | null>(
null
);
// Active session's provider instance; replaced on each connect.
const fileTransferRef = useRef<RdpFileTransferProvider | null>(null);
const extensionsRef = useRef<{
displayControl: (enable: boolean) => unknown;
preConnectionBlob: (pcb: string) => unknown;
kdcProxyUrl: (url: string) => unknown;
} | null>(null);
// Load the iron-remote-desktop modules client-side and register the
// `<iron-remote-desktop>` custom element.
useEffect(() => {
let cancelled = false;
(async () => {
const [coreMod, rdpMod] = await Promise.all([
import("@devolutions/iron-remote-desktop/dist"),
import("@devolutions/iron-remote-desktop-rdp/dist")
]);
if (cancelled) return;
await rdpMod.init("INFO");
backendRef.current = rdpMod.Backend;
extensionsRef.current = {
displayControl: rdpMod.displayControl,
preConnectionBlob: rdpMod.preConnectionBlob,
kdcProxyUrl: rdpMod.kdcProxyUrl
};
// Store the class; a fresh instance is created per session.
fileTransferClassRef.current =
rdpMod.RdpFileTransferProvider as unknown as typeof RdpFileTransferProvider;
// Importing the package registers the custom element as a side
// effect. Touch the default export to avoid tree-shaking.
void coreMod;
setModuleReady(true);
})().catch((err) => {
console.error("Failed to load iron-remote-desktop modules", err);
toast({
variant: "destructive",
title: "Failed to load RDP module",
description: `${err}`
});
});
return () => {
cancelled = true;
};
}, []);
// Attach the "ready" listener synchronously the moment the custom
// element mounts. The custom element dispatches `ready` from its own
// `onMount`, so a deferred useEffect can race and miss it.
const remoteElementRef = (el: HTMLElement | null) => {
if (!el) return;
const onReady = (e: Event) => {
const event = e as CustomEvent;
userInteractionRef.current = event.detail.irgUserInteraction;
};
el.addEventListener("ready", onReady);
};
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const startSession = async () => {
setConnecting(true);
const userInteraction = userInteractionRef.current;
const exts = extensionsRef.current;
if (!userInteraction || !exts) {
setConnecting(false);
toast({
variant: "destructive",
title: "Not ready",
description: "RDP module is still initializing"
});
return;
}
userInteraction.setEnableClipboard(form.enableClipboard);
// Dispose any previous session's provider and create a fresh one so
// there is no stale upload state from a prior connection.
fileTransferRef.current?.dispose();
const ProviderClass = fileTransferClassRef.current;
const fileTransfer = ProviderClass ? new ProviderClass() : null;
fileTransferRef.current = fileTransfer;
if (fileTransfer) {
// Auto-download files when the remote copies them to clipboard.
fileTransfer.on("files-available", (files: FileInfo[]) => {
const downloadable = files.filter((f) => !f.isDirectory);
if (downloadable.length === 0) return;
toast({
title: `Downloading ${downloadable.length} file(s) from remote…`
});
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.isDirectory) continue;
const { completion } = fileTransfer.downloadFile(file, i);
completion
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
a.click();
URL.revokeObjectURL(url);
})
.catch((err) => {
toast({
variant: "destructive",
title: `Download failed: ${file.name}`,
description: `${err}`
});
});
}
});
// Notify when individual uploads complete (remote pasted a file).
fileTransfer.on("upload-complete", (file: File) => {
toast({ title: `Uploaded: ${file.name}` });
});
// Register with the web component so CLIPRDR extensions are
// wired up before connect() builds the session.
userInteraction.enableFileTransfer(
fileTransfer as unknown as FileTransferProvider
);
}
if (!target) {
setConnecting(false);
toast({
variant: "destructive",
title: "No target",
description: "No connection target available"
});
return;
}
const destination = `${target.ip}:${target.port}`;
const builder = userInteraction
.configBuilder()
.withUsername(form.username)
.withPassword(form.password)
.withDestination(destination)
.withProxyAddress(
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
)
.withServerDomain(form.domain)
.withAuthToken(target.authToken)
.withDesktopSize({
width: window.innerWidth,
height: window.innerHeight
})
.withExtension(exts.displayControl(true));
if (form.pcb !== "") {
builder.withExtension(exts.preConnectionBlob(form.pcb));
}
if (form.kdcProxyUrl !== "") {
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
}
try {
const sessionInfo = await userInteraction.connect(builder.build());
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnecting(false);
setShowLogin(false);
userInteraction.setVisibility(true);
const termInfo = await sessionInfo.run();
fileTransferRef.current?.dispose();
fileTransferRef.current = null;
setShowLogin(true);
} catch (err) {
setConnecting(false);
setShowLogin(true);
if (isIronError(err)) {
toast({
variant: "destructive",
title: "Connection failed",
description: err.backtrace()
});
} else {
toast({
variant: "destructive",
title: "Connection failed",
description: `${err}`
});
}
}
};
const ui = () => userInteractionRef.current;
const toggleCursorKind = () => {
const u = ui();
if (!u) return;
if (cursorOverrideActive) {
u.setCursorStyleOverride(null);
} else {
u.setCursorStyleOverride('url("crosshair.png") 7 7, default');
}
setCursorOverrideActive((v) => !v);
};
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{showLogin && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">RDP</h1>
<div className="space-y-4">
<Field label="Domain" id="domain">
<Input
id="domain"
value={form.domain}
onChange={(e) =>
update("domain", e.target.value)
}
/>
</Field>
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
update("username", e.target.value)
}
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
{/*
<Field label="Pre Connection Blob (optional)" id="pcb">
<Input
id="pcb"
value={form.pcb}
onChange={(e) => update("pcb", e.target.value)}
/>
</Field> */}
{/* <Field
label="KDC Proxy URL (optional)"
id="kdcProxyUrl"
>
<Input
id="kdcProxyUrl"
value={form.kdcProxyUrl}
onChange={(e) =>
update("kdcProxyUrl", e.target.value)
}
/>
</Field> */}
{/* <div className="flex items-center gap-2">
<Checkbox
id="enable_clipboard"
checked={form.enableClipboard}
onCheckedChange={(checked) =>
update("enableClipboard", checked === true)
}
/>
<Label htmlFor="enable_clipboard">
Enable Clipboard
</Label>
</div> */}
<Button
onClick={startSession}
disabled={!moduleReady}
loading={connecting}
className="w-full"
>
{moduleReady ? "Connect" : "Loading module..."}
</Button>
</div>
</div>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
style={{ display: showLogin ? "none" : "flex" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
Fit
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(2)}
>
Full
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(3)}
>
Real
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.ctrlAltDel()}
>
Ctrl+Alt+Del
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.metaKey()}
>
Meta
</Button>
{/* <Button
size="sm"
variant="secondary"
onClick={toggleCursorKind}
>
Toggle cursor
</Button> */}
<Button
size="sm"
variant="secondary"
onClick={async () => {
const ft = fileTransferRef.current;
if (!ft) return;
const files = await ft.showFilePicker({
multiple: true
});
if (files.length === 0) return;
try {
ft.uploadFiles(files);
toast({
title: "Files ready to paste",
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
});
} catch (err) {
toast({
variant: "destructive",
title: "Upload failed",
description: `${err}`
});
}
}}
>
Upload files
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
ui()?.shutdown();
setShowLogin(true);
}}
>
Terminate
</Button>
<label className="ml-2 flex items-center gap-2">
<input
type="checkbox"
checked={unicodeMode}
onChange={(e) => {
setUnicodeMode(e.target.checked);
ui()?.setKeyboardUnicodeMode(e.target.checked);
}}
/>
Unicode keyboard mode
</label>
</div>
{moduleReady && (
<iron-remote-desktop
ref={remoteElementRef}
verbose="true"
scale="fit"
flexcenter="true"
module={backendRef.current}
/>
)}
</div>
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import RdpClient from "./RdpClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "RDP"
};
export default async function RdpPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
console.log("Fetched browser target:", target);
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
return <RdpClient target={target} error={error} />;
}
+453
View File
@@ -0,0 +1,453 @@
"use client";
import "@xterm/xterm/css/xterm.css";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { SignSshKeyResponse } from "@server/private/routers/ssh";
import { GetBrowserTargetResponse } from "@server/routers/resource";
type FormState = {
username: string;
password: string;
privateKey: string;
};
type ConnectCredentials = {
username: string;
password?: string;
privateKey?: string;
certificate?: string;
};
export default function SshClient({
target,
error,
signedKeyData,
privateKey: signedPrivateKey
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
signedKeyData?: SignSshKeyResponse | null;
privateKey?: string | null;
}) {
const STORAGE_KEY = "pangolin_ssh_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { username: "", password: "", privateKey: "" };
});
const fileInputRef = useRef<HTMLInputElement>(null);
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result;
if (typeof text === "string") {
setForm((prev) => ({ ...prev, privateKey: text }));
}
};
reader.readAsText(file);
// Reset input so the same file can be re-selected if needed.
e.target.value = "";
}
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
const fitAddonRef = useRef<import("@xterm/addon-fit").FitAddon | null>(
null
);
const wsRef = useRef<WebSocket | null>(null);
// Mount the terminal div once connected.
useEffect(() => {
if (!connected || !terminalRef.current) return;
let cancelled = false;
(async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] =
await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-web-links")
]);
if (cancelled || !terminalRef.current) return;
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
theme: {
background: "#0d0d0d",
foreground: "#f0f0f0"
},
scrollback: 5000
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(terminalRef.current);
fitAddon.fit();
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Send user keystrokes to the WebSocket.
terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "data", data }));
}
});
// Send resize events.
terminal.onResize(({ cols, rows }) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows })
);
}
});
// Send the initial size once the terminal is rendered.
const { cols, rows } = terminal;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows })
);
}
})().catch(console.error);
return () => {
cancelled = true;
};
}, [connected]);
// Refit terminal when the window resizes.
useEffect(() => {
const onResize = () => fitAddonRef.current?.fit();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Cleanup on unmount.
useEffect(() => {
return () => {
wsRef.current?.close();
xtermRef.current?.dispose();
};
}, []);
// Auto-connect when signed key data is provided (push PAM mode).
useEffect(() => {
if (signedKeyData && signedPrivateKey && target) {
connect({
username: signedKeyData.sshUsername,
privateKey: signedPrivateKey,
certificate: signedKeyData.certificate
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function connect(override?: ConnectCredentials) {
setConnectError(null);
setConnecting(true);
if (!target) {
setConnectError("No target specified");
setConnecting(false);
return;
}
const username = override?.username ?? form.username;
const password = override?.password ?? form.password;
const privateKey = override?.privateKey ?? form.privateKey;
const certificate = override?.certificate;
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
const url = new URL(proxyAddress);
url.searchParams.set("host", target.ip ?? "");
url.searchParams.set("port", String(target.port ?? 22));
url.searchParams.set("username", username);
url.searchParams.set("authToken", target.authToken ?? "");
const ws = new WebSocket(url.toString(), ["ssh"]);
wsRef.current = ws;
ws.onopen = () => {
// Send credentials as the first frame so the proxy can complete
// SSH authentication before piping pty data.
ws.send(
JSON.stringify({
type: "auth",
password,
privateKey,
certificate
})
);
if (!override) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
}
setConnecting(false);
setConnected(true);
};
ws.onmessage = (evt) => {
if (typeof evt.data === "string") {
try {
const msg = JSON.parse(evt.data as string) as {
type: string;
data?: string;
error?: string;
};
if (msg.type === "data" && msg.data) {
xtermRef.current?.write(msg.data);
} else if (msg.type === "error") {
xtermRef.current?.writeln(
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
);
}
} catch {
xtermRef.current?.write(evt.data);
}
} else if (evt.data instanceof Blob) {
evt.data.text().then((t) => xtermRef.current?.write(t));
}
};
ws.onerror = () => {
setConnecting(false);
setConnected(false);
setConnectError("WebSocket connection failed");
};
ws.onclose = (evt) => {
setConnecting(false);
setConnected(false);
xtermRef.current?.writeln(
`\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n`
);
};
}
function disconnect() {
wsRef.current?.close();
xtermRef.current?.dispose();
xtermRef.current = null;
setConnected(false);
}
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
// In push mode, show a connecting/connected state without the login form.
if (signedKeyData && signedPrivateKey) {
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">
{connectError
? connectError
: connecting
? "Connecting…"
: "Initializing…"}
</p>
</div>
)}
{connected && (
<div className="flex h-screen flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
<div
ref={terminalRef}
className="flex-1 overflow-hidden"
style={{ minHeight: 0 }}
/>
</div>
)}
</div>
);
}
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">SSH</h1>
<div className="space-y-4">
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
placeholder="root"
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
setForm({
...form,
password: e.target.value
})
}
placeholder={
form.privateKey
? "Optional with key auth"
: ""
}
/>
</Field>
<Field label="Private Key (optional)" id="privateKey">
<Textarea
id="privateKey"
value={form.privateKey}
onChange={(e) =>
setForm({
...form,
privateKey: e.target.value
})
}
placeholder="Paste your private key here (PEM format)…"
rows={5}
className="font-mono text-xs"
/>
<div className="mt-1.5 flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
fileInputRef.current?.click()
}
>
Upload key file
</Button>
{form.privateKey && (
<button
type="button"
className="text-xs text-muted-foreground underline"
onClick={() =>
setForm((prev) => ({
...prev,
privateKey: ""
}))
}
>
Clear
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</Field>
{connectError && (
<p className="text-destructive text-sm">
{connectError}
</p>
)}
<Button
onClick={() => connect()}
loading={connecting}
disabled={
!form.username ||
(!form.password && !form.privateKey)
}
className="w-full"
>
{connecting ? "Connecting..." : "Connect"}
</Button>
</div>
</div>
)}
{connected && (
<div className="flex h-screen flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
<div
ref={terminalRef}
className="flex-1 overflow-hidden"
style={{ minHeight: 0 }}
/>
</div>
)}
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import SshClient from "./SshClient";
import { SignSshKeyResponse } from "@server/private/routers/ssh";
import crypto from "crypto";
function generateEphemeralKeyPair(): {
privateKeyPem: string;
publicKeyOpenSSH: string;
} {
const { publicKey: pubKeyObj, privateKey: privKeyObj } =
crypto.generateKeyPairSync("ed25519");
const privateKeyPem = privKeyObj.export({
type: "pkcs8",
format: "pem"
}) as string;
// Build OpenSSH wire format: uint32-length-prefixed strings
const pubKeyDer = pubKeyObj.export({
type: "spki",
format: "der"
}) as Buffer;
const rawPubKey = pubKeyDer.subarray(pubKeyDer.length - 32); // last 32 bytes are the Ed25519 key
function encodeField(b: Buffer): Buffer {
const len = Buffer.allocUnsafe(4);
len.writeUInt32BE(b.length, 0);
return Buffer.concat([len, b]);
}
const keyBlob = Buffer.concat([
encodeField(Buffer.from("ssh-ed25519")),
encodeField(rawPubKey)
]);
const publicKeyOpenSSH = `ssh-ed25519 ${keyBlob.toString("base64")}`;
return { privateKeyPem, publicKeyOpenSSH };
}
export const dynamic = "force-dynamic";
export const metadata = {
title: "SSH"
};
export default async function SshPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let signedKeyData: SignSshKeyResponse | null = null;
let privateKey: string | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
if (target.pamMode === "push") {
const { privateKeyPem, publicKeyOpenSSH } =
generateEphemeralKeyPair();
privateKey = privateKeyPem;
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
`/org/${target.orgId}/ssh/sign-key`,
{
publicKey: publicKeyOpenSSH,
resource: target.niceId
}
);
signedKeyData = res.data.data;
console.log("Received signed SSH key:", signedKeyData);
}
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
return (
<SshClient
target={target}
error={error}
signedKeyData={signedKeyData}
privateKey={privateKey}
/>
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast";
import { GetBrowserTargetResponse } from "@server/routers/resource";
type FormState = {
password: string;
};
export default function VncClient({
target,
error
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
}) {
const STORAGE_KEY = "pangolin_vnc_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { password: "" };
});
const [connected, setConnected] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfbRef = useRef<any>(null);
const screenRef = useRef<HTMLDivElement>(null);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
// Disconnect and clean up the RFB instance.
const disconnect = () => {
if (rfbRef.current) {
rfbRef.current.disconnect();
rfbRef.current = null;
}
setConnected(false);
};
// Clean up on unmount.
useEffect(() => {
return () => disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const connect = async () => {
if (!target) {
toast({
variant: "destructive",
title: "No target",
description: "No resource target is available"
});
return;
}
if (!screenRef.current) return;
// Disconnect any existing session first.
disconnect();
// noVNC has no ESM default export — import the module dynamically to
// keep it out of the server bundle, then grab the default export.
let RFB: new (
target: HTMLElement,
url: string,
options?: Record<string, unknown>
) => unknown;
try {
// @ts-expect-error — @novnc/novnc ships plain JS with no bundled types
const mod = await import("@novnc/novnc");
RFB = mod.default ?? mod;
} catch (err) {
toast({
variant: "destructive",
title: "Failed to load noVNC",
description: `${err}`
});
return;
}
// Build the proxy WebSocket URL:
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
const base = proxyAddress.replace(/\/$/, "");
const params = new URLSearchParams({
host: target.ip,
port: String(target.port),
authToken: target.authToken
});
const wsUrl = `${base}?${params.toString()}`;
// Clear the container so noVNC gets a clean mount point.
screenRef.current.innerHTML = "";
const options: Record<string, unknown> = {};
if (form.password) {
options.credentials = { password: form.password };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfb: any = new RFB(screenRef.current, wsUrl, options);
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener("connect", () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnected(true);
});
rfb.addEventListener(
"disconnect",
(e: { detail: { clean: boolean } }) => {
rfbRef.current = null;
setConnected(false);
}
);
rfb.addEventListener(
"securityfailure",
(e: { detail: { status: number; reason?: string } }) => {
toast({
variant: "destructive",
title: "Authentication failed",
description: e.detail.reason ?? `Status ${e.detail.status}`
});
}
);
rfbRef.current = rfb;
};
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">VNC</h1>
<div className="space-y-4">
<Field label="Password (optional)" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
<Button onClick={connect} className="w-full">
Connect
</Button>
</div>
</div>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
style={{ display: connected ? "flex" : "none" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="secondary"
onClick={() => {
if (rfbRef.current) {
rfbRef.current.sendCtrlAltDel();
}
}}
>
Ctrl+Alt+Del
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
navigator.clipboard
?.readText()
.then((text) => {
rfbRef.current?.clipboardPasteFrom(text);
})
.catch(() => {});
}}
>
Paste clipboard
</Button>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
{/* noVNC mounts a <canvas> inside this div */}
<div
ref={screenRef}
className="flex-1 overflow-hidden"
style={{ background: "#000" }}
/>
</div>
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import VncClient from "./VncClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "VNC"
};
export default async function VncPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
return <VncClient target={target} error={error} />;
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "./multi-site-selector";
import { SitesSelector, type Selectedsite } from "./site-selector";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
type SingleSiteProps = {
multiSite?: false;
selectedSite: Selectedsite | null;
onSiteChange: (site: Selectedsite | null) => void;
};
type MultiSiteProps = {
multiSite: true;
selectedSites: Selectedsite[];
onSitesChange: (sites: Selectedsite[]) => void;
};
export type BrowserGatewayTargetFormProps = {
orgId: string;
destination: string;
defaultPort: number;
destinationPort: string;
onDestinationChange: (v: string) => void;
onDestinationPortChange: (v: string) => void;
learnMoreHref?: string;
} & (SingleSiteProps | MultiSiteProps);
export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
const t = useTranslations();
const [siteOpen, setSiteOpen] = useState(false);
const siteSelector =
props.multiSite === true ? (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{formatMultiSitesSelectorLabel(
props.selectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<MultiSitesSelector
orgId={props.orgId}
selectedSites={props.selectedSites}
onSelectionChange={props.onSitesChange}
/>
</PopoverContent>
</Popover>
) : (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{props.selectedSite?.name ?? t("siteSelect")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={props.orgId}
selectedSite={props.selectedSite}
onSelectSite={(site) => {
props.onSiteChange(site);
setSiteOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("sites")}
</label>
{siteSelector}
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("destination")}
</label>
<Input
placeholder="192.168.1.1"
value={props.destination}
onChange={(e) =>
props.onDestinationChange(e.target.value)
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">{t("port")}</label>
<Input
type="number"
placeholder={props.defaultPort.toString()}
value={props.destinationPort}
onChange={(e) =>
props.onDestinationPortChange(e.target.value)
}
/>
</div>
</div>
{props.multiSite === true && props.selectedSites.length > 1 && (
<p className="text-sm text-muted-foreground">
{t("bgTargetMultiSiteDisclaimer")}{" "}
<a
href={
props.learnMoreHref ??
"https://docs.pangolin.net/manage/resources/public/ssh"
}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
)}
</div>
);
}
+13 -7
View File
@@ -77,21 +77,22 @@ export type InternalResourceRow = {
siteIds: number[]; siteIds: number[];
siteNiceIds: string[]; siteNiceIds: string[];
// mode: "host" | "cidr" | "port"; // mode: "host" | "cidr" | "port";
mode: "host" | "cidr" | "http"; mode: "host" | "cidr" | "http" | "ssh";
scheme: "http" | "https" | null; scheme: "http" | "https" | null;
ssl: boolean; ssl: boolean;
// protocol: string | null; // protocol: string | null;
// proxyPort: number | null; // proxyPort: number | null;
destination: string; destination: string | null;
httpHttpsPort: number | null; destinationPort: number | null;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
niceId: string; niceId: string;
tcpPortRangeString: string | null; tcpPortRangeString: string | null;
udpPortRangeString: string | null; udpPortRangeString: string | null;
disableIcmp: boolean; disableIcmp: boolean;
authDaemonMode?: "site" | "remote" | null; authDaemonMode?: "site" | "remote" | "native" | null;
authDaemonPort?: number | null; authDaemonPort?: number | null;
pamMode?: "passthrough" | "push" | null;
subdomain?: string | null; subdomain?: string | null;
domainId?: string | null; domainId?: string | null;
fullDomain?: string | null; fullDomain?: string | null;
@@ -106,7 +107,7 @@ function formatDestinationDisplay(row: InternalResourceRow): string {
return formatSiteResourceDestinationDisplay({ return formatSiteResourceDestinationDisplay({
mode: row.mode, mode: row.mode,
destination: row.destination, destination: row.destination,
httpHttpsPort: row.httpHttpsPort, destinationPort: row.destinationPort,
scheme: row.scheme scheme: row.scheme
}); });
} }
@@ -357,6 +358,10 @@ export default function ClientResourcesTable({
{ {
value: "http", value: "http",
label: t("editInternalResourceDialogModeHttp") label: t("editInternalResourceDialogModeHttp")
},
{
value: "ssh",
label: t("editInternalResourceDialogModeSsh")
} }
]} ]}
selectedValue={searchParams.get("mode") ?? undefined} selectedValue={searchParams.get("mode") ?? undefined}
@@ -372,13 +377,14 @@ export default function ClientResourcesTable({
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
const modeLabels: Record< const modeLabels: Record<
"host" | "cidr" | "port" | "http", "host" | "cidr" | "port" | "http" | "ssh",
string string
> = { > = {
host: t("editInternalResourceDialogModeHost"), host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"), cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"), port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp") http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh")
}; };
return <span>{modeLabels[resourceRow.mode]}</span>; return <span>{modeLabels[resourceRow.mode]}</span>;
} }
@@ -47,31 +47,36 @@ export default function CreateInternalResourceDialog({
try { try {
let data = { ...values }; let data = { ...values };
if ( if (
(data.mode === "host" || data.mode === "http") && (data.mode === "host" ||
data.mode === "http" ||
data.mode === "ssh") &&
isHostname(data.destination) isHostname(data.destination)
) { ) {
const currentAlias = data.alias?.trim() || ""; const currentAlias = data.alias?.trim() || "";
if (!currentAlias) { if (!currentAlias) {
let aliasValue = data.destination; let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") { if (data.destination?.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`; aliasValue = `${cleanForFQDN(data.name)}.internal`;
} }
data = { ...data, alias: aliasValue }; data = { ...data, alias: aliasValue };
} }
} }
// "ssh" mode maps to "host" in the backend with SSH settings
const backendMode = data.mode === "ssh" ? "host" : data.mode;
await api.put< await api.put<
AxiosResponse<{ data: { siteResourceId: number } }> AxiosResponse<{ data: { siteResourceId: number } }>
>(`/org/${orgId}/site-resource`, { >(`/org/${orgId}/site-resource`, {
name: data.name, name: data.name,
siteIds: data.siteIds, siteIds: data.siteIds,
mode: data.mode, mode: backendMode,
destination: data.destination, destination: data.destination,
enabled: true, enabled: true,
...(data.mode === "http" && { ...(data.mode === "http" && {
scheme: data.scheme, scheme: data.scheme,
ssl: data.ssl ?? false, ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? undefined, destinationPort: data.destinationPort ?? undefined,
domainId: data.httpConfigDomainId domainId: data.httpConfigDomainId
? data.httpConfigDomainId ? data.httpConfigDomainId
: undefined, : undefined,
@@ -94,7 +99,25 @@ export default function CreateInternalResourceDialog({
authDaemonPort: data.authDaemonPort authDaemonPort: data.authDaemonPort
}) })
}), }),
...((data.mode === "host" || data.mode == "cidr") && { ...(data.mode === "ssh" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: undefined,
pamMode: data.pamMode ?? undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" &&
data.authDaemonPort != null && {
authDaemonPort: data.authDaemonPort
})
}),
...((data.mode === "host" ||
data.mode === "ssh" ||
data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString, tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString, udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false disableIcmp: data.disableIcmp ?? false
+27 -5
View File
@@ -51,29 +51,34 @@ export default function EditInternalResourceDialog({
try { try {
let data = { ...values }; let data = { ...values };
if ( if (
(data.mode === "host" || data.mode === "http") && (data.mode === "host" ||
data.mode === "http" ||
data.mode === "ssh") &&
isHostname(data.destination) isHostname(data.destination)
) { ) {
const currentAlias = data.alias?.trim() || ""; const currentAlias = data.alias?.trim() || "";
if (!currentAlias) { if (!currentAlias) {
let aliasValue = data.destination; let aliasValue = data.destination;
if (data.destination.toLowerCase() === "localhost") { if (data.destination?.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`; aliasValue = `${cleanForFQDN(data.name)}.internal`;
} }
data = { ...data, alias: aliasValue }; data = { ...data, alias: aliasValue };
} }
} }
// "ssh" mode maps to "host" in the backend with SSH settings
const backendMode = data.mode === "ssh" ? "host" : data.mode;
await api.post(`/site-resource/${resource.id}`, { await api.post(`/site-resource/${resource.id}`, {
name: data.name, name: data.name,
siteIds: data.siteIds, siteIds: data.siteIds,
mode: data.mode, mode: backendMode,
niceId: data.niceId, niceId: data.niceId,
destination: data.destination, destination: data.destination,
...(data.mode === "http" && { ...(data.mode === "http" && {
scheme: data.scheme, scheme: data.scheme,
ssl: data.ssl ?? false, ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? null, destinationPort: data.destinationPort ?? null,
domainId: data.httpConfigDomainId domainId: data.httpConfigDomainId
? data.httpConfigDomainId ? data.httpConfigDomainId
: undefined, : undefined,
@@ -95,7 +100,24 @@ export default function EditInternalResourceDialog({
authDaemonPort: data.authDaemonPort || null authDaemonPort: data.authDaemonPort || null
}) })
}), }),
...((data.mode === "host" || data.mode === "cidr") && { ...(data.mode === "ssh" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: null,
pamMode: data.pamMode ?? undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" && {
authDaemonPort: data.authDaemonPort || null
})
}),
...((data.mode === "host" ||
data.mode === "ssh" ||
data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString, tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString, udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false disableIcmp: data.disableIcmp ?? false
File diff suppressed because it is too large Load Diff
-6
View File
@@ -3,7 +3,6 @@
import type { SidebarNavSection } from "@app/app/navigation"; import type { SidebarNavSection } from "@app/app/navigation";
import { OrgSelector } from "@app/components/OrgSelector"; import { OrgSelector } from "@app/components/OrgSelector";
import { SidebarNav } from "@app/components/SidebarNav"; import { SidebarNav } from "@app/components/SidebarNav";
import SupporterStatus from "@app/components/SupporterStatus";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -258,11 +257,6 @@ export function LayoutSidebar({
/> />
</div> </div>
)} )}
{build === "oss" && (
<div className="px-4">
<SupporterStatus isCollapsed={isSidebarCollapsed} />
</div>
)}
{build === "saas" && ( {build === "saas" && (
<div className="px-4"> <div className="px-4">
<SidebarSupportButton <SidebarSupportButton
+3 -19
View File
@@ -49,7 +49,7 @@ type Resource = {
domain: string; domain: string;
enabled: boolean; enabled: boolean;
protected: boolean; protected: boolean;
protocol: string; mode: string; // "http", "tcp", "udp", "rdp", "vnc", "ssh"
// Auth method fields // Auth method fields
sso?: boolean; sso?: boolean;
password?: boolean; password?: boolean;
@@ -64,7 +64,6 @@ type SiteResource = {
name: string; name: string;
destination: string; destination: string;
mode: string; mode: string;
protocol: string | null;
ssl: boolean; ssl: boolean;
fullDomain: string | null; fullDomain: string | null;
enabled: boolean; enabled: boolean;
@@ -882,21 +881,6 @@ export default function MemberResourcesPortal({
} }
</span> </span>
</div> </div>
{siteResource.protocol && (
<div>
<span className="font-medium">
{t(
"protocol"
)}
:
</span>
<span className="ml-2 text-muted-foreground uppercase">
{
siteResource.protocol
}
</span>
</div>
)}
<div> <div>
<span className="font-medium"> <span className="font-medium">
{t( {t(
@@ -954,7 +938,7 @@ export default function MemberResourcesPortal({
siteResource.fullDomain ? ( siteResource.fullDomain ? (
/* HTTP mode - show as clickable link */ /* HTTP mode - show as clickable link */
<CopyToClipboard <CopyToClipboard
text={`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`} text={`${siteResource.ssl ? "https" : (siteResource.mode ?? "http")}://${siteResource.fullDomain}`}
isLink={true} isLink={true}
/> />
) : siteResource.alias ? ( ) : siteResource.alias ? (
@@ -1037,7 +1021,7 @@ export default function MemberResourcesPortal({
<Button <Button
onClick={() => onClick={() =>
window.open( window.open(
`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`, `${siteResource.ssl ? "https" : (siteResource.mode ?? "http")}://${siteResource.fullDomain}`,
"_blank" "_blank"
) )
} }
-2
View File
@@ -19,7 +19,6 @@ import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { build } from "@server/build";
import { useState } from "react"; import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm"; import Disable2FaForm from "./Disable2FaForm";
@@ -27,7 +26,6 @@ import SecurityKeyForm from "./SecurityKeyForm";
import Enable2FaDialog from "./Enable2FaDialog"; import Enable2FaDialog from "./Enable2FaDialog";
import ChangePasswordDialog from "./ChangePasswordDialog"; import ChangePasswordDialog from "./ChangePasswordDialog";
import ViewDevicesDialog from "./ViewDevicesDialog"; import ViewDevicesDialog from "./ViewDevicesDialog";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from "@app/components/LocaleSwitcher"; import LocaleSwitcher from "@app/components/LocaleSwitcher";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
+19 -7
View File
@@ -90,9 +90,8 @@ export type ResourceRow = {
name: string; name: string;
orgId: string; orgId: string;
domain: string; domain: string;
mode: string | null;
authState: string; authState: string;
http: boolean;
protocol: string;
proxyPort: number | null; proxyPort: number | null;
enabled: boolean; enabled: boolean;
domainId?: string; domainId?: string;
@@ -366,11 +365,11 @@ export default function ProxyResourcesTable({
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
<span> <span>
{resourceRow.http {resourceRow.mode == "http"
? resourceRow.ssl ? resourceRow.ssl
? "HTTPS" ? "HTTPS"
: "HTTP" : "HTTP"
: resourceRow.protocol.toUpperCase()} : resourceRow.mode?.toUpperCase()}
</span> </span>
); );
} }
@@ -413,6 +412,9 @@ export default function ProxyResourcesTable({
), ),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
if (resourceRow.mode !== "http") {
return <span>-</span>;
}
return ( return (
<TargetStatusCell <TargetStatusCell
targets={resourceRow.targets} targets={resourceRow.targets}
@@ -441,6 +443,9 @@ export default function ProxyResourcesTable({
header: () => <span className="p-3">{t("uptime30d")}</span>, header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
if (resourceRow.mode !== "http") {
return <span>-</span>;
}
return ( return (
<UptimeMiniBar resourceId={resourceRow.id} days={30} /> <UptimeMiniBar resourceId={resourceRow.id} days={30} />
); );
@@ -453,7 +458,11 @@ export default function ProxyResourcesTable({
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
if (!resourceRow.http) { if (
!["http", "ssh", "rdp", "vnc"].includes(
resourceRow.mode || ""
)
) {
return ( return (
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<CopyToClipboard <CopyToClipboard
@@ -941,7 +950,7 @@ function ResourceEnabledForm({
resource, resource,
onToggleResourceEnabled onToggleResourceEnabled
}: ResourceEnabledFormProps) { }: ResourceEnabledFormProps) {
const enabled = resource.http const enabled = ["http", "ssh", "rdp", "vnc"].includes(resource.mode || "")
? !!resource.domainId && resource.enabled ? !!resource.domainId && resource.enabled
: resource.enabled; : resource.enabled;
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled); const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
@@ -959,7 +968,10 @@ function ResourceEnabledForm({
<Switch <Switch
checked={optimisticEnabled} checked={optimisticEnabled}
disabled={ disabled={
(resource.http && !resource.domainId) || (["http", "ssh", "rdp", "vnc"].includes(
resource.mode || ""
) &&
!resource.domainId) ||
optimisticEnabled !== enabled optimisticEnabled !== enabled
} }
name="enabled" name="enabled"
+107 -89
View File
@@ -4,11 +4,9 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { import {
ShieldCheck, ShieldCheck,
ShieldOff, ShieldOff,
Eye,
EyeOff, EyeOff,
CheckCircle2, CheckCircle2,
XCircle, XCircle
Clock
} from "lucide-react"; } from "lucide-react";
import { useResourceContext } from "@app/hooks/useResourceContext"; import { useResourceContext } from "@app/hooks/useResourceContext";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
@@ -32,12 +30,43 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`; const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
const showCertificate = !!(
["http", "ssh", "rdp", "vnc"].includes(resource.mode) &&
resource.domainId &&
resource.fullDomain &&
build != "oss"
);
const showType = !!(
["http", "ssh", "rdp", "vnc"].includes(resource.mode) && resource.mode
);
const showHealth =
!["ssh", "rdp", "vnc"].includes(resource.mode || "") &&
!!resource.health &&
resource.health !== "unknown";
const showVisibility = !resource.enabled;
const numSections = [
true, // URL or Protocol
true, // Authentication or Port
showType,
showCertificate,
showHealth,
showVisibility
].filter(Boolean).length;
return ( return (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
{/* 4 cols because of the certs */} <InfoSections cols={numSections}>
<InfoSections cols={resource.http && build != "oss" ? 5 : 4}> {/* <InfoSection>
{resource.http ? ( <InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>
<span className="inline-flex items-center">
{resource.niceId}
</span>
</InfoSectionContent>
</InfoSection> */}
{["http", "ssh", "rdp", "vnc"].includes(resource.mode) ? (
<> <>
<InfoSection> <InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle> <InfoSectionTitle>URL</InfoSectionTitle>
@@ -54,6 +83,18 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
)} )}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
{showType && (
<InfoSection>
<InfoSectionTitle>
{t("type")}
</InfoSectionTitle>
<InfoSectionContent>
<span className="inline-flex items-center">
{resource.mode!.toUpperCase()}
</span>
</InfoSectionContent>
</InfoSection>
)}
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("authentication")} {t("authentication")}
@@ -76,24 +117,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
)} )}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
{/* {isEnabled && (
<InfoSection>
<InfoSectionTitle>Socket</InfoSectionTitle>
<InfoSectionContent>
{isAvailable ? (
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</span>
) : (
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>Offline</span>
</span>
)}
</InfoSectionContent>
</InfoSection>
)} */}
</> </>
) : ( ) : (
<> <>
@@ -103,7 +126,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<span className="inline-flex items-center"> <span className="inline-flex items-center">
{resource.protocol.toUpperCase()} {resource.mode?.toUpperCase()}
</span> </span>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
@@ -141,74 +164,69 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{/* </InfoSectionContent> */} {/* </InfoSectionContent> */}
{/* </InfoSection> */} {/* </InfoSection> */}
{/* Certificate Status Column */} {/* Certificate Status Column */}
{resource.http && {showCertificate && (
resource.domainId && <InfoSection>
resource.fullDomain && <InfoSectionTitle>
build != "oss" && ( {t("certificateStatus", {
<InfoSection> defaultValue: "Certificate"
<InfoSectionTitle> })}
{t("certificateStatus", { </InfoSectionTitle>
defaultValue: "Certificate" <InfoSectionContent>
})} <CertificateStatus
</InfoSectionTitle> orgId={resource.orgId}
<InfoSectionContent> domainId={resource.domainId!}
<CertificateStatus fullDomain={resource.fullDomain!}
orgId={resource.orgId} autoFetch={true}
domainId={resource.domainId} showLabel={false}
fullDomain={resource.fullDomain} polling={true}
autoFetch={true} />
showLabel={false} </InfoSectionContent>
polling={true} </InfoSection>
/> )}
</InfoSectionContent> {showHealth && (
</InfoSection> <InfoSection>
)} <InfoSectionTitle>{t("health")}</InfoSectionTitle>
<InfoSection> <InfoSectionContent>
<InfoSectionTitle>{t("health")}</InfoSectionTitle> {resource.health === "healthy" && (
<InfoSectionContent> <div className="flex items-center space-x-2">
{resource.health === "healthy" && ( <CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
<div className="flex items-center space-x-2"> <span>
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" /> {t("resourcesTableHealthy")}
<span>{t("resourcesTableHealthy")}</span> </span>
</div> </div>
)} )}
{resource.health === "degraded" && ( {resource.health === "degraded" && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" /> <CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
<span>{t("resourcesTableDegraded")}</span> <span>
</div> {t("resourcesTableDegraded")}
)} </span>
{resource.health === "unhealthy" && ( </div>
<div className="flex items-center space-x-2"> )}
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" /> {resource.health === "unhealthy" && (
<span>{t("resourcesTableUnhealthy")}</span> <div className="flex items-center space-x-2">
</div> <XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
)} <span>
{(!resource.health || {t("resourcesTableUnhealthy")}
resource.health === "unknown") && ( </span>
<div className="flex items-center space-x-2"> </div>
<Clock className="w-4 h-4 flex-shrink-0" /> )}
<span>{t("resourcesTableUnknown")}</span> </InfoSectionContent>
</div> </InfoSection>
)} )}
</InfoSectionContent> {showVisibility && (
</InfoSection> <InfoSection>
<InfoSection> <InfoSectionTitle>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle> {t("visibility")}
<InfoSectionContent> </InfoSectionTitle>
{resource.enabled ? ( <InfoSectionContent>
<div className="flex items-center space-x-2">
<Eye className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("enabled")}</span>
</div>
) : (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" /> <EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
<span>{t("disabled")}</span> <span>{t("disabled")}</span>
</div> </div>
)} </InfoSectionContent>
</InfoSectionContent> </InfoSection>
</InfoSection> )}
</InfoSections> </InfoSections>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
+13 -2
View File
@@ -22,13 +22,24 @@ export function SettingsSectionHeader({
export function SettingsSectionForm({ export function SettingsSectionForm({
children, children,
className className,
variant = "compact"
}: { }: {
children: React.ReactNode; children: React.ReactNode;
variant?: "half" | "compact";
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("max-w-xl space-y-4", className)}>{children}</div> <div
className={cn(
variant === "half"
? "max-w-3xl space-y-4"
: "max-w-xl space-y-4",
className
)}
>
{children}
</div>
); );
} }
+8 -7
View File
@@ -44,13 +44,13 @@ function isSafeUrlForLink(href: string): boolean {
const OVERVIEW_META_CLASS = "w-full min-w-0 text-muted-foreground text-sm"; const OVERVIEW_META_CLASS = "w-full min-w-0 text-muted-foreground text-sm";
function publicProtocolLabel(r: PublicResourceRow): string { function publicProtocolLabel(r: PublicResourceRow): string {
if (r.http) { if (r.mode == "http") {
return r.ssl ? "HTTPS" : "HTTP"; return r.ssl ? "HTTPS" : "HTTP";
} }
const p = (r.protocol || "").toLowerCase(); const p = (r.mode || "").toLowerCase();
if (p === "tcp") return "TCP"; if (p === "tcp") return "TCP";
if (p === "udp") return "UDP"; if (p === "udp") return "UDP";
return (r.protocol || "—").toUpperCase(); return (r.mode || "—").toUpperCase();
} }
function PublicResourceMeta({ resource: r }: { resource: PublicResourceRow }) { function PublicResourceMeta({ resource: r }: { resource: PublicResourceRow }) {
@@ -68,12 +68,13 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
const modeLabel: Record<SiteResourceRow["mode"], string> = { const modeLabel: Record<SiteResourceRow["mode"], string> = {
host: t("editInternalResourceDialogModeHost"), host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"), cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp") http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh")
}; };
const dest = formatSiteResourceDestinationDisplay({ const dest = formatSiteResourceDestinationDisplay({
mode: row.mode, mode: row.mode,
destination: row.destination, destination: row.destination,
httpHttpsPort: row.destinationPort ?? null, destinationPort: row.destinationPort ?? null,
scheme: row.scheme scheme: row.scheme
}); });
return ( return (
@@ -90,7 +91,7 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
function PublicAccessMethod({ resource: r }: { resource: PublicResourceRow }) { function PublicAccessMethod({ resource: r }: { resource: PublicResourceRow }) {
const t = useTranslations(); const t = useTranslations();
if (!r.http) { if (!["http", "ssh", "rdp", "vnc"].includes(r.mode || "")) {
return ( return (
<CopyToClipboard <CopyToClipboard
text={r.proxyPort?.toString() ?? ""} text={r.proxyPort?.toString() ?? ""}
@@ -149,7 +150,7 @@ function PrivateAccessMethod({ row }: { row: SiteResourceRow }) {
const dest = formatSiteResourceDestinationDisplay({ const dest = formatSiteResourceDestinationDisplay({
mode: row.mode, mode: row.mode,
destination: row.destination, destination: row.destination,
httpHttpsPort: row.destinationPort, destinationPort: row.destinationPort,
scheme: row.scheme scheme: row.scheme
}); });
return ( return (

Some files were not shown because too many files have changed in this diff Show More