diff --git a/cloud b/cloud new file mode 100644 index 000000000..5173bfa2a --- /dev/null +++ b/cloud @@ -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 diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 108229942..3870a811f 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Метод", "editInternalResourceDialogEnableSsl": "Активирайте TLS", "editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Метод", "createInternalResourceDialogScheme": "Метод", "createInternalResourceDialogEnableSsl": "Активирайте TLS", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 7c118ff29..4b1909063 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Schéma", "editInternalResourceDialogEnableSsl": "Povolit SSL", "editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Schéma", "createInternalResourceDialogScheme": "Schéma", "createInternalResourceDialogEnableSsl": "Povolit SSL", diff --git a/messages/de-DE.json b/messages/de-DE.json index 11d76dab5..663a0789b 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Schema", "editInternalResourceDialogEnableSsl": "TLS aktivieren", "editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Schema", "createInternalResourceDialogScheme": "Schema", "createInternalResourceDialogEnableSsl": "TLS aktivieren", diff --git a/messages/en-US.json b/messages/en-US.json index a578008bb..9c4b3c2c8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -220,8 +220,9 @@ "resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.", "resourceCreate": "Create 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", - "resourceInfo": "Resource Information", + "resourceCreateGeneral": "General", "resourceNameDescription": "This is the display name for the resource.", "siteSelect": "Select site", "siteSearch": "Search site", @@ -231,12 +232,15 @@ "noCountryFound": "No country found.", "siteSelectionDescription": "This site will provide connectivity to the target.", "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 can’t be changed later.", + "resourceDomainDescription": "The resource will be served at this fully qualified domain name.", "resourceHTTPSSettings": "HTTPS Settings", "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", "subdomain": "Subdomain", "baseDomain": "Base Domain", + "configure": "Configure", "subdomnainDescription": "The subdomain where the resource will be accessible.", "resourceRawSettings": "TCP/UDP Settings", "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", "billingCurrentKeys": "Current Keys", "billingModifyCurrentPlan": "Modify Current Plan", + "billingManageLicenseSubscriptionDescription": "Manage your subscription for paid self-hosted license keys and download invoices.", "billingConfirmUpgrade": "Confirm Upgrade", "billingConfirmDowngrade": "Confirm Downgrade", "billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.", @@ -1974,7 +1979,36 @@ "timeIsInSeconds": "Time is in seconds", "requireDeviceApproval": "Require Device Approvals", "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", "roleAllowSshAllow": "Allow", "roleAllowSshDisallow": "Disallow", @@ -2080,6 +2114,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Scheme", "editInternalResourceDialogEnableSsl": "Enable TLS", "editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.", @@ -2129,6 +2164,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Scheme", "createInternalResourceDialogScheme": "Scheme", "createInternalResourceDialogEnableSsl": "Enable TLS", @@ -2968,7 +3004,7 @@ "learnMore": "Learn more", "backToHome": "Go back to home", "needToSignInToOrg": "Need to use your organization's identity provider?", - "maintenanceMode": "Maintenance Mode", + "maintenanceMode": "Maintenance Page", "maintenanceModeDescription": "Display a maintenance page to visitors", "maintenanceModeType": "Maintenance Mode Type", "showMaintenancePage": "Show a maintenance page to visitors", @@ -2998,6 +3034,7 @@ "maintenanceScreenEstimatedCompletion": "Estimated Completion:", "createInternalResourceDialogDestinationRequired": "Destination is required", "available": "Available", + "disabledResourceDescription": "When disabled, the resource will be inaccessible by everyone.", "archived": "Archived", "noArchivedDevices": "No archived devices found", "deviceArchived": "Device archived", @@ -3327,5 +3364,6 @@ "memberPortalResourceDisabled": "Resource Disabled", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources", "memberPortalPrevious": "Previous", - "memberPortalNext": "Next" + "memberPortalNext": "Next", + "httpSettings": "HTTP Settings" } diff --git a/messages/es-ES.json b/messages/es-ES.json index 9e5b6fc82..f42d0d495 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Esquema", "editInternalResourceDialogEnableSsl": "Activar TLS", "editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Esquema", "createInternalResourceDialogScheme": "Esquema", "createInternalResourceDialogEnableSsl": "Activar TLS", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index da3350e46..02f5e0d41 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Méthode HTTP", "editInternalResourceDialogEnableSsl": "Activer TLS", "editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Méthode HTTP", "createInternalResourceDialogScheme": "Méthode HTTP", "createInternalResourceDialogEnableSsl": "Activer TLS", diff --git a/messages/it-IT.json b/messages/it-IT.json index 3ec9c0011..1892e4985 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Metodo HTTP", "editInternalResourceDialogEnableSsl": "Abilitare TLS", "editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Metodo HTTP", "createInternalResourceDialogScheme": "Metodo HTTP", "createInternalResourceDialogEnableSsl": "Abilitare TLS", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index d1bd16382..3b78b86a0 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "스킴", "editInternalResourceDialogEnableSsl": "TLS 활성화", "editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "스킴", "createInternalResourceDialogScheme": "스킴", "createInternalResourceDialogEnableSsl": "TLS 활성화", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index d76013d16..d70df295b 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Skjema", "editInternalResourceDialogEnableSsl": "Aktiver TLS", "editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Skjema", "createInternalResourceDialogScheme": "Skjema", "createInternalResourceDialogEnableSsl": "Aktiver TLS", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index f989db342..58c5587de 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Schema", "editInternalResourceDialogEnableSsl": "TLS inschakelen", "editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Schema", "createInternalResourceDialogScheme": "Schema", "createInternalResourceDialogEnableSsl": "TLS inschakelen", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 4d801023b..9675fe84a 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Schemat", "editInternalResourceDialogEnableSsl": "Włącz TLS", "editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Schemat", "createInternalResourceDialogScheme": "Schemat", "createInternalResourceDialogEnableSsl": "Włącz TLS", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 0604c1caf..075a9d8c5 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Esquema", "editInternalResourceDialogEnableSsl": "Ativar TLS", "editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Esquema", "createInternalResourceDialogScheme": "Esquema", "createInternalResourceDialogEnableSsl": "Ativar TLS", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 0f3e48962..3e10d79c9 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "СИДР", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Схема", "editInternalResourceDialogEnableSsl": "Включить TLS", "editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "СИДР", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Схема", "createInternalResourceDialogScheme": "Схема", "createInternalResourceDialogEnableSsl": "Включить TLS", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index e1d965e8e..e83139470 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "Şema", "editInternalResourceDialogEnableSsl": "TLS Etkinleştir", "editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "Şema", "createInternalResourceDialogScheme": "Şema", "createInternalResourceDialogEnableSsl": "TLS'yi Etkinleştir", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index a23647dba..4f27045ad 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -2049,6 +2049,7 @@ "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", + "editInternalResourceDialogModeSsh": "SSH", "editInternalResourceDialogScheme": "方案", "editInternalResourceDialogEnableSsl": "启用 TLS", "editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。", @@ -2098,6 +2099,7 @@ "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", + "createInternalResourceDialogModeSsh": "SSH", "scheme": "方案", "createInternalResourceDialogScheme": "方案", "createInternalResourceDialogEnableSsl": "启用 TLS", diff --git a/next.config.ts b/next.config.ts index 078a459d6..c14a2f974 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,7 @@ const withNextIntl = createNextIntlPlugin(); const nextConfig: NextConfig = { reactStrictMode: false, reactCompiler: true, + transpilePackages: ["@novnc/novnc"], output: "standalone" }; diff --git a/package-lock.json b/package-lock.json index fb0e39ff9..ec51c2a5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,13 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "8.5.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", "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "4.7.0", "@node-rs/argon2": "2.0.2", + "@novnc/novnc": "^1.7.0", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@radix-ui/react-avatar": "1.1.11", @@ -45,6 +47,9 @@ "@tailwindcss/forms": "0.5.11", "@tanstack/react-query": "5.100.14", "@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", "axios": "1.16.1", "better-sqlite3": "11.9.1", @@ -1258,6 +1263,16 @@ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "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": { "version": "1.69.1", "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_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": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -3554,6 +3553,12 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz", @@ -8769,6 +8774,27 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -10723,6 +10749,9 @@ "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", "license": "(MPL-2.0 OR Apache-2.0)", "peer": true, + "engines": { + "node": ">=20" + }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } diff --git a/package.json b/package.json index e7c99dfc1..108782a7b 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,13 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "8.5.0", "@aws-sdk/client-s3": "3.1047.0", - "@faker-js/faker": "10.4.0", "@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", "@monaco-editor/react": "4.7.0", "@node-rs/argon2": "2.0.2", + "@novnc/novnc": "^1.7.0", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@radix-ui/react-avatar": "1.1.11", @@ -68,6 +70,9 @@ "@tailwindcss/forms": "0.5.11", "@tanstack/react-query": "5.100.14", "@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", "axios": "1.16.1", "better-sqlite3": "11.9.1", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index bba2265fb..886114998 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -158,7 +158,12 @@ export enum ActionsEnum { createHealthCheck = "createHealthCheck", updateHealthCheck = "updateHealthCheck", deleteHealthCheck = "deleteHealthCheck", - listHealthChecks = "listHealthChecks" + listHealthChecks = "listHealthChecks", + createBrowserGatewayTarget = "createBrowserGatewayTarget", + updateBrowserGatewayTarget = "updateBrowserGatewayTarget", + deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget", + getBrowserGatewayTarget = "getBrowserGatewayTarget", + listBrowserGatewayTargets = "listBrowserGatewayTargets" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 229fc9ff0..5040808a9 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -580,6 +580,24 @@ export const trialNotifications = pgTable("trialNotifications", { 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; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -627,3 +645,6 @@ export type AlertEmailRecipients = InferSelectModel< >; export type AlertWebhookActions = InferSelectModel; export type TrialNotification = InferSelectModel; +export type BrowserGatewayTarget = InferSelectModel< + typeof browserGatewayTarget +>; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 58e78735c..65fac6d98 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -129,8 +129,6 @@ export const resources = pgTable("resources", { ssl: boolean("ssl").notNull().default(false), blockAccess: boolean("blockAccess").notNull().default(false), sso: boolean("sso").notNull().default(true), - http: boolean("http").notNull().default(true), - protocol: varchar("protocol").notNull(), proxyPort: integer("proxyPort"), emailWhitelistEnabled: boolean("emailWhitelistEnabled") .notNull() @@ -147,7 +145,6 @@ export const resources = pgTable("resources", { headers: text("headers"), // comma-separated list of headers to add to the request proxyProtocol: boolean("proxyProtocol").notNull().default(false), proxyProtocolVersion: integer("proxyProtocolVersion").default(1), - maintenanceModeEnabled: boolean("maintenanceModeEnabled") .notNull() .default(false), @@ -159,7 +156,15 @@ export const resources = pgTable("resources", { maintenanceEstimatedTime: text("maintenanceEstimatedTime"), postAuthPath: text("postAuthPath"), 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", { @@ -339,11 +344,11 @@ export const siteResources = pgTable("siteResources", { niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), 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 proxyPort: integer("proxyPort"), // 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), alias: varchar("alias"), aliasAddress: varchar("aliasAddress"), @@ -351,8 +356,11 @@ export const siteResources = pgTable("siteResources", { udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), disableIcmp: boolean("disableIcmp").notNull().default(false), authDaemonPort: integer("authDaemonPort").default(22123), + pamMode: varchar("pamMode", { length: 32 }) + .$type<"passthrough" | "push">() + .default("passthrough"), authDaemonMode: varchar("authDaemonMode", { length: 32 }) - .$type<"site" | "remote">() + .$type<"site" | "remote" | "native">() .default("site"), domainId: varchar("domainId").references(() => domains.domainId, { onDelete: "set null" diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index ae7360780..b235d26d5 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -588,6 +588,26 @@ export const trialNotifications = sqliteTable("trialNotifications", { 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; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -627,3 +647,6 @@ export type AlertEmailAction = InferSelectModel; export type AlertEmailRecipient = InferSelectModel; export type AlertWebhookAction = InferSelectModel; export type TrialNotification = InferSelectModel; +export type BrowserGatewayTarget = InferSelectModel< + typeof browserGatewayTarget +>; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e3e83d222..e7b9755dc 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -142,8 +142,6 @@ export const resources = sqliteTable("resources", { .notNull() .default(false), sso: integer("sso", { mode: "boolean" }).notNull().default(true), - http: integer("http", { mode: "boolean" }).notNull().default(true), - protocol: text("protocol").notNull(), proxyPort: integer("proxyPort"), emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) .notNull() @@ -166,7 +164,6 @@ export const resources = sqliteTable("resources", { .notNull() .default(false), proxyProtocolVersion: integer("proxyProtocolVersion").default(1), - maintenanceModeEnabled: integer("maintenanceModeEnabled", { mode: "boolean" }) @@ -180,7 +177,15 @@ export const resources = sqliteTable("resources", { maintenanceEstimatedTime: text("maintenanceEstimatedTime"), postAuthPath: text("postAuthPath"), 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", { @@ -372,11 +377,11 @@ export const siteResources = sqliteTable("siteResources", { niceId: text("niceId").notNull(), name: text("name").notNull(), 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 proxyPort: integer("proxyPort"), // 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), alias: text("alias"), aliasAddress: text("aliasAddress"), @@ -386,8 +391,11 @@ export const siteResources = sqliteTable("siteResources", { .notNull() .default(false), authDaemonPort: integer("authDaemonPort").default(22123), + pamMode: text("pamMode") + .$type<"passthrough" | "push">() + .default("passthrough"), authDaemonMode: text("authDaemonMode") - .$type<"site" | "remote">() + .$type<"site" | "remote" | "native">() .default("site"), domainId: text("domainId").references(() => domains.domainId, { onDelete: "set null" diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 8ca66971a..a905a2c7b 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -23,7 +23,6 @@ import { } from "./clientResources"; import { BlueprintSource } from "@server/routers/blueprints/types"; import { stringify as stringifyYaml } from "yaml"; -import { faker } from "@faker-js/faker"; import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource"; import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations"; @@ -106,7 +105,7 @@ export async function applyBlueprint({ site.newt.newtId, [target], matchingHealthcheck ? [matchingHealthcheck] : [], - result.proxyResource.protocol, + result.proxyResource.mode === "udp" ? "udp" : "tcp", site.newt.version ); } diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index 22b951870..9b46b723c 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -225,7 +225,11 @@ export async function updateClientResources( : resourceData["udp-ports"], fullDomain: resourceData["full-domain"] || 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( eq( @@ -360,8 +364,14 @@ export async function updateClientResources( }); } else { let aliasAddress: string | null = null; + let releaseAliasLock: (() => Promise) | null = null; 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: @@ -415,10 +425,16 @@ export async function updateClientResources( : resourceData["udp-ports"], fullDomain: resourceData["full-domain"] || 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(); + await releaseAliasLock?.(); + const siteResourceId = newResource.siteResourceId; for (const site of allSites) { diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 178991962..3fb8711c8 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -36,6 +36,7 @@ import { isValidRegionId } from "@server/db/regions"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; import { tierMatrix } from "../billing/tierMatrix"; +import { http } from "winston"; export type ProxyResourcesResults = { proxyResource: Resource; @@ -198,9 +199,6 @@ export async function updateProxyResources( ) .limit(1); - const http = resourceData.protocol == "http"; - const protocol = - resourceData.protocol == "http" ? "tcp" : resourceData.protocol; const resourceEnabled = resourceData.enabled == undefined || resourceData.enabled == null ? true @@ -216,7 +214,9 @@ export async function updateProxyResources( if (existingResource) { let domain; - if (http) { + if ( + ["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "") + ) { domain = await getDomain( existingResource.resourceId, resourceData["full-domain"]!, @@ -246,10 +246,17 @@ export async function updateProxyResources( .update(resources) .set({ name: resourceData.name || "Unnamed Resource", - protocol: protocol || "tcp", - http: http, - proxyPort: http ? null : resourceData["proxy-port"], - fullDomain: http ? resourceData["full-domain"] : null, + mode: resourceData.mode, + proxyPort: ["http", "ssh", "rdp", "vnc"].includes( + resourceData.mode || "" + ) + ? null + : resourceData["proxy-port"], + fullDomain: ["http", "ssh", "rdp", "vnc"].includes( + resourceData.mode || "" + ) + ? resourceData["full-domain"] + : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, wildcard: domain ? domain.wildcard : false, @@ -268,6 +275,12 @@ export async function updateProxyResources( headers: headers || null, applyRules: 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, maintenanceModeType: resourceData.maintenance?.type, @@ -466,7 +479,10 @@ export async function updateProxyResources( .set({ siteId: site.siteId, 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, enabled: targetData.enabled, path: targetData.path, @@ -687,7 +703,9 @@ export async function updateProxyResources( } else { // create a brand new resource let domain; - if (http) { + if ( + ["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "") + ) { domain = await getDomain( undefined, resourceData["full-domain"]!, @@ -711,10 +729,17 @@ export async function updateProxyResources( orgId, niceId: resourceNiceId, name: resourceData.name || "Unnamed Resource", - protocol: protocol || "tcp", - http: http, - proxyPort: http ? null : resourceData["proxy-port"], - fullDomain: http ? resourceData["full-domain"] : null, + mode: resourceData.mode, + proxyPort: ["http", "ssh", "rdp", "vnc"].includes( + resourceData.mode || "" + ) + ? null + : resourceData["proxy-port"], + fullDomain: ["http", "ssh", "rdp", "vnc"].includes( + resourceData.mode || "" + ) + ? resourceData["full-domain"] + : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, wildcard: domain ? domain.wildcard : false, @@ -727,6 +752,10 @@ export async function updateProxyResources( headers: headers || null, applyRules: 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, maintenanceModeType: resourceData.maintenance?.type, maintenanceTitle: resourceData.maintenance?.title, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 221b5e586..90f17dd9b 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -161,11 +161,33 @@ export const HeaderSchema = z.object({ 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 -export const ResourceSchema = z +export const PublicResourceSchema = z .object({ 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(), scheme: z.enum(["http", "https"]).optional(), "full-domain": z.string().optional(), @@ -177,7 +199,8 @@ export const ResourceSchema = z "tls-server-name": z.string().optional(), headers: z.array(HeaderSchema).optional(), rules: z.array(RuleSchema).optional(), - maintenance: MaintenanceSchema.optional() + maintenance: MaintenanceSchema.optional(), + "auth-daemon": AuthDaemonSchema.optional() }) .refine( (resource) => { @@ -185,9 +208,10 @@ export const ResourceSchema = z return true; } - // Otherwise, require name and protocol for full resource definition + // Otherwise, require name and protocol/mode for full resource definition 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; } - // If protocol is http, all targets must have method field - if (resource.protocol === "http") { + // If protocol/mode is http, all targets must have method field + if ((resource.mode ?? resource.protocol) === "http") { return resource.targets.every( (target) => target == null || target.method !== undefined ); @@ -220,8 +244,9 @@ export const ResourceSchema = z return true; } - // If protocol is tcp or udp, no target should have method field - if (resource.protocol === "tcp" || resource.protocol === "udp") { + // If protocol/mode is tcp or udp, no target should have method field + const effectiveProtocol1 = resource.mode ?? resource.protocol; + if (effectiveProtocol1 === "tcp" || effectiveProtocol1 === "udp") { return resource.targets.every( (target) => target == null || target.method === undefined ); @@ -239,8 +264,8 @@ export const ResourceSchema = z return true; } - // If protocol is http, it must have a full-domain - if (resource.protocol === "http") { + // If protocol/mode is http, it must have a full-domain + if ((resource.mode ?? resource.protocol) === "http") { return ( resource["full-domain"] !== undefined && resource["full-domain"].length > 0 @@ -259,8 +284,9 @@ export const ResourceSchema = z return true; } - // If protocol is tcp or udp, it must have both proxy-port - if (resource.protocol === "tcp" || resource.protocol === "udp") { + // If protocol/mode is tcp or udp, it must have both proxy-port + const effectiveProtocol2 = resource.mode ?? resource.protocol; + if (effectiveProtocol2 === "tcp" || effectiveProtocol2 === "udp") { return resource["proxy-port"] !== undefined; } return true; @@ -277,8 +303,9 @@ export const ResourceSchema = z return true; } - // If protocol is tcp or udp, it must not have auth - if (resource.protocol === "tcp" || resource.protocol === "udp") { + // If protocol/mode is tcp or udp, it must not have auth + const effectiveProtocol3 = resource.mode ?? resource.protocol; + if (effectiveProtocol3 === "tcp" || effectiveProtocol3 === "udp") { return resource.auth === undefined; } return true; @@ -349,22 +376,29 @@ export const ResourceSchema = z 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.' } - ); + ) + .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 { return Object.keys(resource).length === 1 && resource.targets; } -export const ClientResourceSchema = z +export const PrivateResourceSchema = z .object({ 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 sites: z.array(z.string()).optional().default([]), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: 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), "tcp-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" }), 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( (data) => { 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) const isValidIP = z .union([z.ipv4(), z.ipv6()]) @@ -419,6 +473,7 @@ export const ClientResourceSchema = z .refine( (data) => { 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) const isValidCIDR = z .union([z.cidrv4(), z.cidrv6()]) @@ -436,19 +491,19 @@ export const ClientResourceSchema = z export const ConfigSchema = z .object({ "proxy-resources": z - .record(z.string(), ResourceSchema) + .record(z.string(), PublicResourceSchema) .optional() .prefault({}), "public-resources": z - .record(z.string(), ResourceSchema) + .record(z.string(), PublicResourceSchema) .optional() .prefault({}), "client-resources": z - .record(z.string(), ClientResourceSchema) + .record(z.string(), PrivateResourceSchema) .optional() .prefault({}), "private-resources": z - .record(z.string(), ClientResourceSchema) + .record(z.string(), PrivateResourceSchema) .optional() .prefault({}), sites: z.record(z.string(), SiteSchema).optional().prefault({}) @@ -473,10 +528,13 @@ export const ConfigSchema = z } return data as { - "proxy-resources": Record>; + "proxy-resources": Record< + string, + z.infer + >; "client-resources": Record< string, - z.infer + z.infer >; sites: Record>; }; @@ -615,5 +673,5 @@ export const ConfigSchema = z // Type inference from the schema export type Site = z.infer; export type Target = z.infer; -export type Resource = z.infer; +export type Resource = z.infer; export type Config = z.infer; diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 6354dd81f..090bf4d8c 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -331,16 +331,8 @@ export async function calculateUserClientsForOrgs( ]; // Get next available subnet - const newSubnet = await getNextAvailableClientSubnet( - orgId, - transaction - ); - if (!newSubnet) { - logger.warn( - `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found` - ); - continue; - } + const { value: newSubnet, release: releaseSubnetLock } = + await getNextAvailableClientSubnet(orgId, transaction); const subnet = newSubnet.split("/")[0]; const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; @@ -370,6 +362,7 @@ export async function calculateUserClientsForOrgs( .insert(clients) .values(newClientData) .returning(); + await releaseSubnetLock(); existingClientCache.set( getOrgOlmKey(orgId, olm.olmId), newClient diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 9989b978f..373191947 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -327,127 +327,145 @@ export function doCidrsOverlap(cidr1: string, cidr2: string): boolean { export async function getNextAvailableClientSubnet( orgId: string, transaction: Transaction | typeof db = db -): Promise { - return await lockManager.withLock( - `client-subnet-allocation:${orgId}`, - async () => { - const [org] = await transaction - .select() - .from(orgs) - .where(eq(orgs.orgId, orgId)); +): Promise<{ value: string; release: () => Promise }> { + const lockKey = `client-subnet-allocation:${orgId}`; + const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000); + if (!acquired) { + throw new Error(`Failed to acquire lock: ${lockKey}`); + } + const release = () => lockManager.releaseLock(lockKey); - if (!org) { - throw new Error(`Organization with ID ${orgId} not found`); - } + try { + const [org] = await transaction + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)); - 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("/")}/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) { + throw new Error(`Organization with ID ${orgId} not found`); } - ); + + 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( orgId: string, trx: Transaction | typeof db = db -): Promise { - return await lockManager.withLock( - `alias-address-allocation:${orgId}`, - async () => { - const [org] = await trx - .select() - .from(orgs) - .where(eq(orgs.orgId, orgId)); +): Promise<{ value: string; release: () => Promise }> { + const lockKey = `alias-address-allocation:${orgId}`; + const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000); + if (!acquired) { + throw new Error(`Failed to acquire lock: ${lockKey}`); + } + const release = () => lockManager.releaseLock(lockKey); - if (!org) { - throw new Error(`Organization with ID ${orgId} not found`); - } + try { + const [org] = await trx + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)); - 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 subnet; + if (!org) { + throw new Error(`Organization with ID ${orgId} not found`); } - ); + + 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 { - return await lockManager.withLock("org-subnet-allocation", async () => { +export async function getNextAvailableOrgSubnet(): Promise<{ + value: string; + release: () => Promise; +}> { + 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 .select({ subnet: orgs.subnet @@ -466,8 +484,11 @@ export async function getNextAvailableOrgSubnet(): Promise { throw new Error("No available subnets remaining in space"); } - return subnet; - }); + return { value: subnet, release }; + } catch (e) { + await release(); + throw e; + } } export function generateRemoteSubnets( @@ -475,6 +496,8 @@ export function generateRemoteSubnets( ): string[] { const remoteSubnets = allSiteResources .filter((sr) => { + if (!sr.destination) return false; + if (sr.mode === "cidr") { // check if its a valid CIDR using zod 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 }) - .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 return Array.from(new Set(remoteSubnets)); } @@ -581,7 +604,7 @@ export function generateSubnetProxyTargets( targets.push({ sourcePrefix: clientPrefix, destPrefix: `${siteResource.aliasAddress}/32`, - rewriteTo: destination, + rewriteTo: destination!, portRange, disableIcmp }); @@ -589,7 +612,7 @@ export function generateSubnetProxyTargets( } else if (siteResource.mode == "cidr") { targets.push({ sourcePrefix: clientPrefix, - destPrefix: siteResource.destination, + destPrefix: siteResource.destination!, portRange, disableIcmp }); @@ -671,7 +694,7 @@ export async function generateSubnetProxyTargetV2( targets.push({ sourcePrefixes: [], destPrefix: `${siteResource.aliasAddress}/32`, - rewriteTo: destination, + rewriteTo: destination!, portRange, disableIcmp, resourceId: siteResource.siteResourceId @@ -680,7 +703,7 @@ export async function generateSubnetProxyTargetV2( } else if (siteResource.mode == "cidr") { targets.push({ sourcePrefixes: [], - destPrefix: siteResource.destination, + destPrefix: siteResource.destination!, portRange, disableIcmp, resourceId: siteResource.siteResourceId @@ -738,7 +761,7 @@ export async function generateSubnetProxyTargetV2( protocol: siteResource.ssl ? "https" : "http", httpTargets: [ { - destAddr: siteResource.destination, + destAddr: siteResource.destination!, destPort: siteResource.destinationPort, scheme: siteResource.scheme } diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index e5543d5ef..4efc72476 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -890,6 +890,9 @@ async function handleSubnetProxyTargetUpdates( } for (const client of removedClients) { + if (!siteResource.destination) { + continue; + } // Check if this client still has access to another resource // on this specific site with the same destination. We scope // by siteId (via siteNetworks) rather than networkId because @@ -1563,6 +1566,9 @@ async function handleMessagesForClientResources( } try { + if (!resource.destination) { + continue; + } // Check if this client still has access to another resource // on this specific site with the same destination. We scope // by siteId (via siteNetworks) rather than networkId because diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index 55c01c6b1..77f7740c5 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -2,7 +2,14 @@ import { PostHog } from "posthog-node"; import config from "./config"; import { getHostMeta } from "./hostMeta"; 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 { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm"; import { APP_VERSION } from "./consts"; @@ -143,8 +150,7 @@ class TelemetryClient { .select({ name: resources.name, sso: resources.sso, - protocol: resources.protocol, - http: resources.http + mode: resources.mode }) .from(resources); @@ -311,7 +317,7 @@ class TelemetryClient { (r) => r.sso ).length, num_resources_non_http: stats.resources.filter( - (r) => !r.http + (r) => r.mode !== "http" ).length, num_newt_sites: stats.sites.filter((s) => s.type === "newt") .length, diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 7379cad7f..518dd964c 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -55,9 +55,7 @@ export async function getTraefikConfig( resourceName: resources.name, fullDomain: resources.fullDomain, ssl: resources.ssl, - http: resources.http, proxyPort: resources.proxyPort, - protocol: resources.protocol, subdomain: resources.subdomain, domainId: resources.domainId, enabled: resources.enabled, @@ -68,6 +66,7 @@ export async function getTraefikConfig( headers: resources.headers, proxyProtocol: resources.proxyProtocol, proxyProtocolVersion: resources.proxyProtocolVersion, + mode: resources.mode, // Target fields targetId: targets.targetId, @@ -115,8 +114,8 @@ export async function getTraefikConfig( ), inArray(sites.type, siteTypes), allowRawResources - ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true - : eq(resources.http, true) + ? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three + : eq(resources.mode, "http") ) ) .orderBy(desc(targets.priority), targets.targetId); // stable ordering @@ -166,9 +165,8 @@ export async function getTraefikConfig( key: key, fullDomain: row.fullDomain, ssl: row.ssl, - http: row.http, + mode: row.mode, proxyPort: row.proxyPort, - protocol: row.protocol, subdomain: row.subdomain, domainId: row.domainId, enabled: row.enabled, @@ -580,7 +578,7 @@ export async function getTraefikConfig( continue; } - const protocol = resource.protocol.toLowerCase(); + const protocol = resource.mode === "udp" ? "udp" : "tcp"; // all of the other ones are tcp const port = resource.proxyPort; if (!port) { diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index b69c2ae89..6cb784736 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -780,9 +780,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { } } - logger.debug( - `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}` - ); + // logger.debug( + // `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}` + // ); for (const domain of allDomains) { try { diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index 8875addda..31e40ed55 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -36,8 +36,6 @@ export async function getValidCertificatesForDomains( domains: Set, useCache: boolean = true ): Promise> { - - const finalResults: CertificateResult[] = []; const domainsToQuery = new Set(); @@ -49,7 +47,26 @@ export async function getValidCertificatesForDomains( if (cachedCert) { finalResults.push(cachedCert); // Valid cache hit } 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(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 { @@ -59,7 +76,10 @@ export async function getValidCertificatesForDomains( // 2. If all domains were resolved from the cache, return early if (domainsToQuery.size === 0) { - const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!); + const decryptedResults = decryptFinalResults( + finalResults, + config.getRawConfig().server.secret! + ); return decryptedResults; } @@ -78,7 +98,8 @@ export async function getValidCertificatesForDomains( const parentDomainsArray = Array.from(parentDomainsToQuery); // 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 // This query fetches all potential exact and wildcard matches in one database round-trip. @@ -172,11 +193,24 @@ export async function getValidCertificatesForDomains( if (useCache) { const cacheKey = `cert:${domain}`; 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; } diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 481192fb5..f2227a7bc 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -12,6 +12,7 @@ */ import { + browserGatewayTarget, certificates, db, domainNamespaces, @@ -95,9 +96,7 @@ export async function getTraefikConfig( resourceName: resources.name, fullDomain: resources.fullDomain, ssl: resources.ssl, - http: resources.http, proxyPort: resources.proxyPort, - protocol: resources.protocol, subdomain: resources.subdomain, domainId: resources.domainId, enabled: resources.enabled, @@ -109,6 +108,7 @@ export async function getTraefikConfig( proxyProtocol: resources.proxyProtocol, proxyProtocolVersion: resources.proxyProtocolVersion, wildcard: resources.wildcard, + mode: resources.mode, maintenanceModeEnabled: resources.maintenanceModeEnabled, maintenanceModeType: resources.maintenanceModeType, @@ -171,8 +171,8 @@ export async function getTraefikConfig( ), inArray(sites.type, siteTypes), allowRawResources - ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true - : eq(resources.http, true) + ? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three + : eq(resources.mode, "http") ) ) .orderBy(desc(targets.priority), targets.targetId); // stable ordering @@ -226,9 +226,8 @@ export async function getTraefikConfig( key: key, fullDomain: row.fullDomain, ssl: row.ssl, - http: row.http, proxyPort: row.proxyPort, - protocol: row.protocol, + mode: row.mode, subdomain: row.subdomain, domainId: row.domainId, 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: { siteResourceId: number; fullDomain: string | null; - mode: "http" | "host" | "cidr"; + mode: "http" | "host" | "cidr" | "ssh"; }[] = []; if (build == "enterprise") { // we dont want to do this on the cloud @@ -324,6 +432,12 @@ export async function getTraefikConfig( 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 validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); @@ -589,7 +703,7 @@ export async function getTraefikConfig( resource.ssl ? entrypointHttps : entrypointHttp ], service: maintenanceServiceName, - rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, + rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `, priority: 2001, ...(resource.ssl ? { tls } : {}) }; @@ -830,7 +944,7 @@ export async function getTraefikConfig( continue; } - const protocol = resource.protocol.toLowerCase(); + const protocol = resource.mode == "udp" ? "udp" : "tcp"; const port = resource.proxyPort; 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(); + 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 // Traefik generates TLS certificates for those domains even when no // matching resource exists yet. @@ -1040,7 +1333,7 @@ export async function getTraefikConfig( config_output.http.routers[`${siteResourceRouterName}-assets`] = { entryPoints: [config.getRawConfig().traefik.https_entrypoint], service: siteResourceServiceName, - rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, + rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, priority: 101, tls }; @@ -1143,7 +1436,7 @@ export async function getTraefikConfig( config.getRawConfig().traefik.https_entrypoint ], 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, tls: tls }; diff --git a/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts new file mode 100644 index 000000000..b26a1a8b6 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts @@ -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 { + 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(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" + ) + ); + } +} diff --git a/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts new file mode 100644 index 000000000..850944b29 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts @@ -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 { + 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" + ) + ); + } +} diff --git a/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts new file mode 100644 index 000000000..0ac7a8ce9 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts @@ -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 { + 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(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" + ) + ); + } +} diff --git a/server/private/routers/browserGatewayTarget/index.ts b/server/private/routers/browserGatewayTarget/index.ts new file mode 100644 index 000000000..d080510f8 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/index.ts @@ -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"; diff --git a/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts b/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts new file mode 100644 index 000000000..12e4aed69 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts @@ -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 { + 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(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" + ) + ); + } +} diff --git a/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts new file mode 100644 index 000000000..825407dc3 --- /dev/null +++ b/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts @@ -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 { + 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 = {}; + 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(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" + ) + ); + } +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 4dbdc208e..1b2bed656 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,6 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; +import * as browserGatewayTarget from "#private/routers/browserGatewayTarget"; import * as labels from "#private/routers/labels"; import * as client from "@server/routers/client"; @@ -842,3 +843,48 @@ authenticated.post( verifyClientAccess, 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 +); diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 820a843f0..542c806f4 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -16,6 +16,7 @@ import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; import * as alertEvents from "#private/routers/alertEvents"; import * as certificates from "#private/routers/certificates"; +import * as browserGatewayTarget from "#private/routers/browserGatewayTarget"; import { verifyApiKeyHasAction, @@ -215,3 +216,43 @@ authenticated.delete( logActionAudit(ActionsEnum.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 +); diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 1cbb31657..8e772118e 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -23,7 +23,8 @@ import { roundTripMessageTracker, siteResources, siteNetworks, - userOrgs + userOrgs, + sites } from "@server/db"; import { logAccessAudit } from "#private/lib/logAccessAudit"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; @@ -48,7 +49,8 @@ const bodySchema = z .strictObject({ publicKey: z.string().nonempty(), 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( (data) => { @@ -63,19 +65,19 @@ const bodySchema = z ); export type SignSshKeyResponse = { - certificate: string; + certificate?: string; messageIds: number[]; - messageId: number; + messageId?: number; sshUsername: string; sshHost: string; resourceId: number; siteIds: number[]; siteId: number; - keyId: string; - validPrincipals: string[]; - validAfter: string; - validBefore: string; - expiresIn: number; + keyId?: string; + validPrincipals?: string[]; + validAfter?: string; + validBefore?: string; + expiresIn?: number; }; // registry.registerPath({ @@ -126,7 +128,8 @@ export async function signSshKey( const { publicKey, resourceId, - resource: resourceQueryString + resource: resourceQueryString, + username } = parsedBody.data; const userId = req.user?.userId; 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 const caKeys = await getOrgCAKeys( orgId, @@ -361,90 +269,303 @@ export async function signSshKey( ); } - 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(); - 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 + const sitesFromNetworks = await db .select({ siteId: siteNetworks.siteId }) .from(siteNetworks) .where(eq(siteNetworks.networkId, resource.networkId!)); - const siteIds = sites.map((site) => site.siteId); + const siteIds = sitesFromNetworks.map((site) => site.siteId); - // Sign the public key - const now = BigInt(Math.floor(Date.now() / 1000)); - // only valid for 5 minutes - const validFor = 300n; + let expiresIn: number | undefined; + let messageIds: number[] = []; + let cert: + | { + 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, { - keyId: `${usernameToUse}@${resource.niceId}`, - validPrincipals: [usernameToUse, resource.niceId], - validAfter: now - 60n, // Start 1 min ago for clock skew - validBefore: now + validFor - }); + // 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; + } + + 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(); + 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 - const [newt] = await db + const [site] = await db .select() - .from(newts) - .where(eq(newts.siteId, siteId)) + .from(sites) + .where(eq(sites.siteId, siteIds[0])) .limit(1); - if (!newt) { + if (!site) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, @@ -453,54 +574,26 @@ export async function signSshKey( ); } - const [message] = await db - .insert(roundTripMessageTracker) - .values({ - wsClientId: newt.newtId, - messageType: `newt/pam/connection`, - sentAt: Math.floor(Date.now() / 1000) - }) - .returning(); - - if (!message) { + if (!site.address) { return next( createHttpError( 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); - - 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 - } - } - }); + // its the address but split off the cidr if there is one + sshHost = site.address.split("/")[0]; } - const expiresIn = Number(validFor); // seconds - - let sshHost; - if (resource.alias && resource.alias != "") { - sshHost = resource.alias; - } else { - sshHost = resource.destination; + if (!sshHost) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Unable to determine SSH host for the resource" + ) + ); } await logsDb.insert(actionAuditLog).values({ @@ -527,7 +620,7 @@ export async function signSshKey( : undefined, metadata: { resourceName: resource.name, - siteId: siteIds[0], + siteIds: siteIds, sshUsername: usernameToUse, sshHost: sshHost }, @@ -537,18 +630,18 @@ export async function signSshKey( return response(res, { data: { - certificate: cert.certificate, + certificate: cert?.certificate, 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, - sshHost: sshHost, + sshHost: sshHost, // just pick the first one for backward compatibility with older olms resourceId: resource.siteResourceId, siteIds: siteIds, - siteId: siteIds[0], // just pick the first one for backward compatibility - keyId: cert.keyId, - validPrincipals: cert.validPrincipals, - validAfter: cert.validAfter.toISOString(), - validBefore: cert.validBefore.toISOString(), + siteId: siteIds[0], // just pick the first one for backward compatibility with older olms + keyId: cert?.keyId, + validPrincipals: cert?.validPrincipals, + validAfter: cert?.validAfter.toISOString(), + validBefore: cert?.validBefore.toISOString(), expiresIn }, success: true, diff --git a/server/routers/client/pickClientDefaults.ts b/server/routers/client/pickClientDefaults.ts index 5dffd77d7..ece774dfb 100644 --- a/server/routers/client/pickClientDefaults.ts +++ b/server/routers/client/pickClientDefaults.ts @@ -51,7 +51,9 @@ export async function pickClientDefaults( const olmId = generateId(15); 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) { return next( createHttpError( diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 2fa5239cc..3caff4864 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -42,6 +42,8 @@ internalRouter.get("/idp", idp.listIdps); internalRouter.get("/idp/:idpId", idp.getIdp); +internalRouter.get("/resource/browser-target", resource.getBrowserTarget); + // Gerbil routes const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index f87d38450..135920d6f 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -1,4 +1,6 @@ import { + browserGatewayTarget, + BrowserGatewayTarget, clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, @@ -16,6 +18,7 @@ import logger from "@server/logger"; import { initPeerAddHandshake, updatePeer } from "../olm/peers"; import { eq, and } from "drizzle-orm"; import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; import { formatEndpoint, generateSubnetProxyTargetV2, @@ -194,7 +197,7 @@ export async function buildTargetConfigurationForNewtClient( siteId: number, 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 .select({ resourceId: targets.resourceId, @@ -204,7 +207,7 @@ export async function buildTargetConfigurationForNewtClient( port: targets.port, internalPort: targets.internalPort, enabled: targets.enabled, - protocol: resources.protocol + mode: resources.mode }) .from(targets) .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) @@ -233,6 +236,11 @@ export async function buildTargetConfigurationForNewtClient( .from(targetHealthCheck) .where(eq(targetHealthCheck.siteId, siteId)); + const allBrowserGatewayTargets = await db + .select() + .from(browserGatewayTarget) + .where(eq(browserGatewayTarget.siteId, siteId)); + const { tcpTargets, udpTargets } = allTargets.reduce( (acc, target) => { // Filter out invalid targets @@ -244,10 +252,11 @@ export async function buildTargetConfigurationForNewtClient( const formattedTarget = `${target.internalPort}:${formatEndpoint(target.ip, target.port)}`; // Add to the appropriate protocol array - if (target.protocol === "tcp") { - acc.tcpTargets.push(formattedTarget); - } else { + if (target.mode === "udp") { acc.udpTargets.push(formattedTarget); + } else { + // all other modes are tcp + acc.tcpTargets.push(formattedTarget); } return acc; @@ -304,9 +313,22 @@ export async function buildTargetConfigurationForNewtClient( (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 { validHealthCheckTargets, tcpTargets, - udpTargets + udpTargets, + browserGatewayTargets }; } diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index f3902a35d..bd4aaacb3 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -43,8 +43,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { const siteId = newt.siteId; - const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } = - message.data; + const { + publicKey, + pingResults, + newtVersion, + backwardsCompatible, + chainId + } = message.data; if (!publicKey) { logger.warn("Public key not provided"); return; @@ -191,8 +196,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .where(eq(newts.newtId, newt.newtId)); } - const { tcpTargets, udpTargets, validHealthCheckTargets } = - await buildTargetConfigurationForNewtClient(siteId, newtVersion); + const { + tcpTargets, + udpTargets, + validHealthCheckTargets, + browserGatewayTargets + } = await buildTargetConfigurationForNewtClient(siteId, newtVersion); logger.debug( `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` @@ -212,6 +221,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { tcp: tcpTargets }, healthCheckTargets: validHealthCheckTargets, + browserGatewayTargets: browserGatewayTargets, chainId: chainId } }, diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index b79118b58..3adcfb467 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -203,84 +203,82 @@ export async function registerNewt( let newSiteId: number | undefined; - await db.transaction(async (trx) => { - const newClientAddress = await getNextAvailableClientSubnet(orgId); - if (!newClientAddress) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "No available subnet found" - ) - ); - } + const { value: newClientAddress, release: releaseSubnetLock } = + await getNextAvailableClientSubnet(orgId); + try { + await db.transaction(async (trx) => { + let clientAddress = newClientAddress.split("/")[0]; + clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org - let clientAddress = newClientAddress.split("/")[0]; - clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org + // Create the site (type "newt", name = niceId) + 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) - const [newSite] = await trx - .insert(sites) - .values({ - orgId, - name: name || niceId, - niceId, - address: clientAddress, - type: "newt", - dockerSocketEnabled: true, - status: keyRecord.approveNewSites ? "approved" : "pending" - }) - .returning(); + await logsDb.insert(statusHistory).values({ + entityType: "site", + entityId: newSite.siteId, + orgId: orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000) + }); - await logsDb.insert(statusHistory).values({ - entityType: "site", - entityId: newSite.siteId, - orgId: orgId, - status: "offline", - timestamp: Math.floor(Date.now() / 1000) + newSiteId = newSite.siteId; + + // 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); }); - - newSiteId = newSite.siteId; - - // 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); - }); + } finally { + await releaseSubnetLock(); + } logger.info( `Provisioned new site (ID: ${newSiteId}) and newt (ID: ${newtId}) for org ${orgId} via provisioning key ${provisioningKeyId}` diff --git a/server/routers/newt/sync.ts b/server/routers/newt/sync.ts index 6fce13ff3..b8f152bec 100644 --- a/server/routers/newt/sync.ts +++ b/server/routers/newt/sync.ts @@ -9,8 +9,12 @@ import { import { canCompress } from "@server/lib/clientVersionChecks"; export async function sendNewtSyncMessage(newt: Newt, site: Site) { - const { tcpTargets, udpTargets, validHealthCheckTargets } = - await buildTargetConfigurationForNewtClient(site.siteId); + const { + tcpTargets, + udpTargets, + validHealthCheckTargets, + browserGatewayTargets + } = await buildTargetConfigurationForNewtClient(site.siteId); let exitNode: ExitNode | undefined; if (site.exitNodeId) { @@ -36,7 +40,8 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) { }, healthCheckTargets: validHealthCheckTargets, peers: peers, - clientTargets: targets + clientTargets: targets, + browserGatewayTargets: browserGatewayTargets } }, { diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index ac25fb27d..6d8212b12 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,7 +1,9 @@ -import { Target, TargetHealthCheck } from "@server/db"; +import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { canCompress } from "@server/lib/clientVersionChecks"; +import { decrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; export async function addTargets( newtId: string, @@ -239,3 +241,55 @@ export async function removeTargets( { 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") } + ); +} diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index f8b7551e9..aeec8b1a9 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -23,7 +23,10 @@ import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; 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 { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -32,15 +35,58 @@ const createResourceParamsSchema = z.strictObject({ 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 .strictObject({ name: z.string().min(1).max(255), subdomain: z.string().nullable().optional(), - http: z.boolean(), - protocol: z.enum(["tcp", "udp"]), + http: z.boolean().optional().openapi({ + 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(), 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( (data) => { @@ -60,13 +106,27 @@ const createHttpResourceSchema = z const createRawResourceSchema = z .strictObject({ name: z.string().min(1).max(255), - http: z.boolean(), - protocol: z.enum(["tcp", "udp"]), + http: z.boolean().optional().openapi({ + 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) // enableProxy: z.boolean().default(true) // always true now }) .refine( (data) => { + const resolved = resolveModeFromLegacyFields(data); + if (resolved.error || !resolved.mode) { + return false; + } + if (!config.getRawConfig().flags?.allow_raw_resources) { if (data.proxyPort !== undefined) { 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( - 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) { - return await createHttpResource({ req, res, next }, { orgId }); - } else { + if (typeof req.body.proxyPort === "number") { if ( !config.getRawConfig().flags?.allow_raw_resources && build == "oss" @@ -167,6 +228,17 @@ export async function createResource( } 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) { logger.error(error); 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 stickySession = parsedBody.data.stickySession; @@ -322,8 +402,10 @@ async function createHttpResource( orgId, name, subdomain: finalSubdomain, - http: true, - protocol: "tcp", + mode: mode, + pamMode: pamMode, + authDaemonMode: authDaemonMode, + authDaemonPort: authDaemonPort, ssl: true, stickySession: stickySession, 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; @@ -418,9 +510,8 @@ async function createRawResource( niceId, orgId, name, - http, - protocol, - proxyPort + proxyPort, + mode: resolvedMode.mode // enableProxy }) .returning(); diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 200ee07d4..a9870c0e6 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -94,7 +94,7 @@ export async function createResourceRule( ); } - if (!resource.http) { + if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index 682fd6aa9..ca644d299 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -106,7 +106,7 @@ export async function deleteResource( // [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 healthChecksToBeRemoved, - deletedResource.protocol, + deletedResource.mode === "udp" ? "udp" : "tcp", newt.version ); } diff --git a/server/routers/resource/getBrowserTarget.ts b/server/routers/resource/getBrowserTarget.ts new file mode 100644 index 000000000..f18419a95 --- /dev/null +++ b/server/routers/resource/getBrowserTarget.ts @@ -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 { + 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(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" + ) + ); + } +} diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index c26d819c4..a54001b70 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -127,7 +127,7 @@ export async function getUserResources( ssl: boolean; enabled: boolean; sso: boolean; - protocol: string; + mode: string; emailWhitelistEnabled: boolean; }> = []; if (accessibleResourceIds.length > 0) { @@ -139,7 +139,7 @@ export async function getUserResources( ssl: resources.ssl, enabled: resources.enabled, sso: resources.sso, - protocol: resources.protocol, + mode: resources.mode, emailWhitelistEnabled: resources.emailWhitelistEnabled }) .from(resources) @@ -323,7 +323,7 @@ export async function getUserResources( hasPincode || hasWhitelist ), - protocol: resource.protocol, + mode: resource.mode, sso: resource.sso, password: hasPassword, pincode: hasPincode, @@ -339,7 +339,6 @@ export async function getUserResources( name: siteResource.name, destination: siteResource.destination, mode: siteResource.mode, - protocol: siteResource.scheme, ssl: siteResource.ssl, fullDomain: siteResource.fullDomain, enabled: siteResource.enabled, @@ -387,14 +386,13 @@ export type GetUserResourcesResponse = { domain: string; enabled: boolean; protected: boolean; - protocol: string; + mode: string; }>; siteResources: Array<{ siteResourceId: number; name: string; destination: string; mode: string; - protocol: string | null; tcpPortRangeString: string | null; udpPortRangeString: string | null; disableIcmp: boolean | null; diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 6a259d7fe..d8ff4dba9 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -33,3 +33,4 @@ export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; export * from "./getStatusHistory"; +export * from "./getBrowserTarget"; diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index d37f04e13..55241eb0d 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,4 +1,5 @@ import { + browserGatewayTarget, db, labels, resourceHeaderAuth, @@ -156,8 +157,6 @@ export type ResourceWithTargets = { sso: boolean; pincodeId: number | null; whitelist: boolean; - http: boolean; - protocol: string; proxyPort: number | null; enabled: boolean; domainId: string | null; @@ -165,6 +164,7 @@ export type ResourceWithTargets = { headerAuthId: number | null; wildcard: boolean; health: string | null; + mode: string | null; targets: Array<{ targetId: number; ip: string; @@ -193,8 +193,6 @@ function queryResourcesBase() { sso: resources.sso, pincodeId: resourcePincode.pincodeId, whitelist: resources.emailWhitelistEnabled, - http: resources.http, - protocol: resources.protocol, proxyPort: resources.proxyPort, enabled: resources.enabled, domainId: resources.domainId, @@ -203,7 +201,8 @@ function queryResourcesBase() { headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthExtendedCompatibilityId: resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, - health: resources.health + health: resources.health, + mode: resources.mode }) .from(resources) .leftJoin( @@ -364,7 +363,9 @@ export async function listResources( if (typeof authState !== "undefined") { switch (authState) { case "none": - conditions.push(eq(resources.http, false)); + conditions.push( + or(eq(resources.mode, "tcp"), eq(resources.mode, "udp")) + ); break; case "protected": conditions.push( @@ -520,6 +521,30 @@ export async function listResources( ) .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[] const map = new Map(); @@ -536,10 +561,9 @@ export async function listResources( sso: row.sso, pincodeId: row.pincodeId, whitelist: row.whitelist, - http: row.http, - protocol: row.protocol, proxyPort: row.proxyPort, wildcard: row.wildcard, + mode: row.mode, enabled: row.enabled, domainId: row.domainId, headerAuthId: row.headerAuthId, @@ -583,6 +607,21 @@ export async function listResources( 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()); } diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 0a7052dce..17984eb2b 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -24,7 +24,10 @@ import { import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; 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 { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -68,7 +71,11 @@ const updateHttpResourceBodySchema = z maintenanceTitle: z.string().max(255).nullable().optional(), maintenanceMessage: z.string().max(2000).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, { 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 return await updateHttpResource( { diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index 4074fd93a..8242f2ade 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -26,7 +26,9 @@ const updateResourceRuleParamsSchema = z.strictObject({ const updateResourceRuleSchema = z .strictObject({ 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(), priority: z.int(), 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( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 29eb4935d..f6445342f 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -174,6 +174,7 @@ export async function createSite( } let updatedAddress = null; + let releaseSubnetLock: (() => Promise) | null = null; if (address) { if (!org.subnet) { return next( @@ -244,147 +245,22 @@ export async function createSite( ); } } else { - const newClientAddress = await getNextAvailableClientSubnet(orgId); - if (!newClientAddress) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "No available address found" - ) - ); - } - + const { value: newClientAddress, release } = + await getNextAvailableClientSubnet(orgId); + releaseSubnetLock = release; 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; - await db.transaction(async (trx) => { - if (type == "newt") { - [newSite] = await trx - .insert(sites) - .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(); - - 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 - ); + try { + 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) { - logger.warn("Exit node not found"); return next( createHttpError( HttpCode.NOT_FOUND, @@ -393,118 +269,246 @@ export async function createSite( ); } - if (!hasAccess) { - logger.warn("Not authorized to use this exit node"); + if (!exitNode.address) { return next( createHttpError( - HttpCode.FORBIDDEN, - "Not authorized to use this exit node" + HttpCode.BAD_REQUEST, + "Exit node has no subnet defined" ) ); } - [newSite] = await trx - .insert(sites) - .values({ - orgId, - exitNodeId, - name, - niceId: updatedNiceId!, - subnet, - type, - pubKey: pubKey || null, - status: "approved" + 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 }) - .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(); + .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 { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Site type not recognized" + // 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.` + ) + ); + } } - const adminRole = await trx - .select() - .from(roles) - .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) - .limit(1); + await db.transaction(async (trx) => { + if (type == "newt") { + [newSite] = await trx + .insert(sites) + .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) { - return next( - createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) - ); - } + 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" + ) + ); + } - await trx.insert(roleSites).values({ - roleId: adminRole[0].roleId, - siteId: newSite.siteId - }); + if (!exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Exit node ID is required for tunneled sites" + ) + ); + } - if ( - req.user && - !req.userOrgRoleIds?.includes(adminRole[0].roleId) - ) { - // make sure the user can access the site - trx.insert(userSites).values({ - userId: req.user?.userId!, + const { exitNode, hasAccess } = + await verifyExitNodeOrgAccess(exitNodeId, orgId); + + if (!exitNode) { + logger.warn("Exit node not found"); + return next( + 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 }); - } - // add the peer to the exit node - if (type == "newt") { - const secretHash = await hashPassword(updatedNewtSecret); - - 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 ( + req.user && + !req.userOrgRoleIds?.includes(adminRole[0].roleId) + ) { + // make sure the user can access the site + trx.insert(userSites).values({ + userId: req.user?.userId!, + siteId: newSite.siteId + }); } - if (!exitNodeId) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Exit node ID is required for wireguard sites" - ) - ); + // add the peer to the exit node + if (type == "newt") { + const secretHash = await hashPassword(updatedNewtSecret); + + 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, { - publicKey: pubKey, - allowedIps: [] - }); - } - - await usageService.add(orgId, FeatureId.SITES, 1, trx); - }); + await usageService.add(orgId, FeatureId.SITES, 1, trx); + }); + } finally { + await releaseSubnetLock?.(); + } if (!newSite) { return next( diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index 4e6e3bb17..736726577 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -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) { return next( createHttpError( diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index bc80e8b41..db52864ca 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -44,14 +44,14 @@ const createSiteResourceSchema = z name: z.string().min(1).max(255), niceId: z.string().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 scheme: z.enum(["http", "https"]).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 // proxyPort: 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), alias: z .string() @@ -67,14 +67,18 @@ const createSiteResourceSchema = z udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().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 subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org }) .strict() .refine( (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) const isValidIP = z // .union([z.ipv4(), z.ipv6()]) @@ -116,19 +120,45 @@ const createSiteResourceSchema = z ) .refine( (data) => { - if (data.mode !== "http") return true; - return ( - data.scheme !== undefined && - data.destinationPort !== undefined && - data.destinationPort >= 1 && - data.destinationPort <= 65535 - ); + if (data.mode === "http") { + return ( + data.scheme !== undefined && + data.scheme !== null && + data.destinationPort !== undefined && + 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: "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( (data) => { return ( @@ -212,6 +242,7 @@ export async function createSiteResource( disableIcmp, authDaemonPort, authDaemonMode, + pamMode, domainId, subdomain } = parsedBody.data; @@ -276,8 +307,8 @@ export async function createSiteResource( .safeParse(destination).success; if ( isIp && - (isIpInCidr(destination, org.subnet) || - isIpInCidr(destination, org.utilitySubnet)) + (isIpInCidr(destination!, org.subnet) || + isIpInCidr(destination!, org.utilitySubnet)) ) { return next( createHttpError( @@ -366,132 +397,163 @@ export async function createSiteResource( } let aliasAddress: string | null = null; + let releaseAliasLock: (() => Promise) | null = null; if (mode === "host" || mode === "http") { - aliasAddress = await getNextAvailableAliasAddress(orgId); + const { value, release } = + await getNextAvailableAliasAddress(orgId); + aliasAddress = value; + releaseAliasLock = release; } let newSiteResource: SiteResource | undefined; - await db.transaction(async (trx) => { - const [network] = await trx - .insert(networks) - .values({ - scope: "resource", - orgId: orgId - }) - .returning(); + try { + await db.transaction(async (trx) => { + const [network] = await trx + .insert(networks) + .values({ + scope: "resource", + orgId: orgId + }) + .returning(); - 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) { + if (!network) { return next( createHttpError( - HttpCode.NOT_FOUND, - `Newt not found for site ${siteToAssign.siteId}` + HttpCode.INTERNAL_SERVER_ERROR, + `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) { return next( diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 7f8ef3e25..4caec7211 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -56,7 +56,7 @@ const updateSiteResourceSchema = z ) .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(), scheme: z.enum(["http", "https"]).nullish(), // proxyPort: z.int().positive().nullish(), @@ -77,14 +77,18 @@ const updateSiteResourceSchema = z udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().optional(), 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(), subdomain: z.string().optional() }) .strict() .refine( (data) => { - if (data.mode === "host" && data.destination) { + if ( + (data.mode === "host" || data.mode == "ssh") && + data.destination + ) { const isValidIP = z // .union([z.ipv4(), z.ipv6()]) .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere @@ -125,21 +129,45 @@ const updateSiteResourceSchema = z ) .refine( (data) => { - if (data.mode !== "http") return true; - return ( - data.scheme !== undefined && - data.scheme !== null && - data.destinationPort !== undefined && - data.destinationPort !== null && - data.destinationPort >= 1 && - data.destinationPort <= 65535 - ); + if (data.mode === "http") { + return ( + data.scheme !== undefined && + data.scheme !== null && + data.destinationPort !== undefined && + 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: "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( (data) => { return ( @@ -222,6 +250,7 @@ export async function updateSiteResource( disableIcmp, authDaemonPort, authDaemonMode, + pamMode, domainId, subdomain } = parsedBody.data; @@ -430,16 +459,30 @@ export async function updateSiteResource( const sshPamSet = isLicensedSshPam && (authDaemonPort !== undefined || - authDaemonMode !== undefined) + authDaemonMode !== undefined || + pamMode !== undefined) ? { ...(authDaemonPort !== undefined && { authDaemonPort }), ...(authDaemonMode !== undefined && { 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 .update(siteResources) .set({ @@ -452,12 +495,14 @@ export async function updateSiteResource( destinationPort, enabled, alias: alias ? alias.trim() : null, - tcpPortRangeString: - mode == "http" ? "443,80" : tcpPortRangeString, + tcpPortRangeString: tcpPortRangeStringAdjusted, udpPortRangeString: - mode == "http" ? "" : udpPortRangeString, + mode == "http" || mode == "ssh" + ? "" + : udpPortRangeString, 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, subdomain: finalSubdomain, fullDomain, @@ -554,13 +599,17 @@ export async function updateSiteResource( const sshPamSet = isLicensedSshPam && (authDaemonPort !== undefined || - authDaemonMode !== undefined) + authDaemonMode !== undefined || + pamMode !== undefined) ? { ...(authDaemonPort !== undefined && { authDaemonPort }), ...(authDaemonMode !== undefined && { authDaemonMode + }), + ...(pamMode !== undefined && { + pamMode }) } : {}; @@ -832,6 +881,10 @@ export async function handleMessagingForUpdatedSiteResource( 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 // todo: optimize this query if needed + if (!existingSiteResource.destination) { + continue; + } + const oldDestinationStillInUseSites = await trx .select() .from(siteResources) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index c629e378e..f6e2d72ef 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -314,7 +314,7 @@ export async function createTarget( newt.newtId, newTarget, healthCheck, - resource.protocol, + resource.mode === "udp" ? "udp" : "tcp", newt.version ); } diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 685c41e7e..5a4b028b3 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -126,7 +126,7 @@ export async function deleteTarget( // [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 [deletedHealthCheck], - resource.protocol, + resource.mode === "udp" ? "udp" : "tcp", newt.version ); } diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 4533dc2e5..f3bdb1a9d 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -332,7 +332,7 @@ export async function updateTarget( newt.newtId, [updatedTarget], [updatedHc], - resource.protocol, + resource.mode === "udp" ? "udp" : "tcp", newt.version ); } diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index f9f9bd77f..f77ae8589 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -1352,6 +1352,12 @@ export default function BillingPage() { {t("billingModifyCurrentPlan") || "Modify Current Plan"} +

+ {t( + "billingManageLicenseSubscriptionDescription" + ) || + "Manage your subscription for paid self-hosted license keys and download invoices."} +

diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index ad661f55b..0400d63a5 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -56,7 +56,9 @@ export default async function ClientResourcesPage( pagination = responseData.pagination; } catch (e) {} - const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined); + const siteIdParam = parsePositiveInt( + searchParams.get("siteId") ?? undefined + ); let initialFilterSite: { siteId: number; @@ -106,7 +108,10 @@ export default async function ClientResourcesPage( siteNiceId: siteResource.siteNiceIds[idx], online: siteResource.siteOnlines[idx] })), - mode: siteResource.mode, + mode: + siteResource.pamMode && siteResource.mode === "host" + ? "ssh" + : siteResource.mode, scheme: siteResource.scheme, ssl: siteResource.ssl, siteNames: siteResource.siteNames, @@ -115,7 +120,7 @@ export default async function ClientResourcesPage( // proxyPort: siteResource.proxyPort, siteIds: siteResource.siteIds, destination: siteResource.destination, - httpHttpsPort: siteResource.destinationPort ?? null, + destinationPort: siteResource.destinationPort ?? null, alias: siteResource.alias || null, aliasAddress: siteResource.aliasAddress || null, siteNiceIds: siteResource.siteNiceIds, @@ -125,6 +130,7 @@ export default async function ClientResourcesPage( disableIcmp: siteResource.disableIcmp || false, authDaemonMode: siteResource.authDaemonMode ?? null, authDaemonPort: siteResource.authDaemonPort ?? null, + pamMode: siteResource.pamMode ?? null, subdomain: siteResource.subdomain ?? null, domainId: siteResource.domainId ?? null, fullDomain: siteResource.fullDomain ?? null, diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm.tsx similarity index 59% rename from src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx rename to src/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm.tsx index 823c0f957..7e0e86066 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm.tsx @@ -3,15 +3,7 @@ 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, @@ -20,25 +12,12 @@ import { } 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, @@ -55,17 +34,13 @@ import { } 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"; @@ -80,33 +55,18 @@ import { useReactTable } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; -import { - AlertTriangle, - CircleCheck, - CircleX, - ExternalLink, - Info, - Plus, - Settings -} from "lucide-react"; +import { ExternalLink, Info, Plus } 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() -}); - -type LocalTarget = Omit< +export type LocalTarget = Omit< ArrayElement & { new?: boolean; updated?: boolean; @@ -115,67 +75,43 @@ type LocalTarget = Omit< "protocol" >; -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 ( - - - - {resource.http && ( - - )} - - {!resource.http && resource.protocol == "tcp" && ( - - )} - - ); +interface ProxyResourceTargetsFormProps { + orgId: string; + isHttp: boolean; + initialTargets?: LocalTarget[]; + /** Edit mode: when provided, shows a save button and polls for health status */ + resource?: GetResourceResponse; + updateResource?: ResourceContextType["updateResource"]; + /** Create mode: called whenever the targets list changes */ + onChange?: (targets: LocalTarget[]) => void; } -function ProxyResourceTargetsForm({ +export function ProxyResourceTargetsForm({ orgId, - initialTargets, - resource -}: { - initialTargets: LocalTarget[]; - orgId: string; - resource: GetResourceResponse; -}) { + isHttp, + initialTargets = [], + resource, + updateResource, + onChange +}: ProxyResourceTargetsFormProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const [targets, setTargets] = useState(initialTargets); const [targetsToRemove, setTargetsToRemove] = useState([]); + // 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({ ...resourceQueries.resourceTargets({ - resourceId: resource.resourceId + resourceId: resource?.resourceId ?? 0 }), - refetchInterval: 10_000 + refetchInterval: 10_000, + enabled: !!resource }); useEffect(() => { @@ -194,6 +130,7 @@ function ProxyResourceTargetsForm({ }) ); }, [polledTargets]); + const [dockerStates, setDockerStates] = useState>( new Map() ); @@ -201,14 +138,17 @@ function ProxyResourceTargetsForm({ const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = useState(null); + const [bgDestination, setBgDestination] = useState(""); + const [bgDestinationPort, setBgDestinationPort] = useState(""); + const [bgSiteId, setBgSiteId] = useState(null); + const [bgTargetId, setBgTargetId] = useState(null); + const initializeDockerForSite = async (siteId: number) => { if (dockerStates.has(siteId)) { - return; // Already initialized + return; } - const dockerManager = new DockerManager(api, siteId); const dockerState = await dockerManager.initializeDocker(); - setDockerStates((prev) => new Map(prev.set(siteId, dockerState))); }; @@ -216,7 +156,6 @@ function ProxyResourceTargetsForm({ async (siteId: number) => { const dockerManager = new DockerManager(api, siteId); const containers = await dockerManager.fetchContainers(); - setDockerStates((prev) => { const newMap = new Map(prev); const existingState = newMap.get(siteId); @@ -250,8 +189,6 @@ function ProxyResourceTargetsForm({ return false; }); - const isHttp = resource.http; - const removeTarget = useCallback((targetId: number) => { setTargets((prevTargets) => { 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( (targetId: number, data: Partial) => { setTargets((prevTargets) => { @@ -356,7 +329,7 @@ function ProxyResourceTargetsForm({ } }; - return ( + return (
{row.original.siteType === "newt" ? ( - ) : ( - )} @@ -404,9 +376,15 @@ function ProxyResourceTargetsForm({ pathMatchType: row.original.pathMatchType }} onChange={(config) => - updateTarget(row.original.targetId, - config.path === null && config.pathMatchType === null - ? { ...config, rewritePath: null, rewritePathType: null } + updateTarget( + row.original.targetId, + config.path === null && + config.pathMatchType === null + ? { + ...config, + rewritePath: null, + rewritePathType: null + } : config ) } @@ -432,9 +410,15 @@ function ProxyResourceTargetsForm({ pathMatchType: row.original.pathMatchType }} onChange={(config) => - updateTarget(row.original.targetId, - config.path === null && config.pathMatchType === null - ? { ...config, rewritePath: null, rewritePathType: null } + updateTarget( + row.original.targetId, + config.path === null && + config.pathMatchType === null + ? { + ...config, + rewritePath: null, + rewritePathType: null + } : config ) } @@ -587,20 +571,19 @@ function ProxyResourceTargetsForm({ }; if (isAdvancedMode) { - const columns = [ + const cols = [ addressColumn, healthCheckColumn, enabledColumn, actionsColumn ]; - // Only include path-related columns for HTTP resources if (isHttp) { - columns.unshift(matchPathColumn); - columns.splice(3, 0, rewritePathColumn, priorityColumn); + cols.unshift(matchPathColumn); + cols.splice(3, 0, rewritePathColumn, priorityColumn); } - return columns; + return cols; } else { return [ addressColumn, @@ -622,22 +605,20 @@ function ProxyResourceTargetsForm({ ]); function addNewTarget() { - const isHttp = resource.http; - const newTarget: LocalTarget = { - targetId: -Date.now(), // Use negative timestamp as temporary ID + targetId: -Date.now(), ip: "", method: isHttp ? "http" : null, port: 0, siteId: sites.length > 0 ? sites[0].siteId : 0, siteName: sites.length > 0 ? sites[0].name : "", - path: isHttp ? null : null, - pathMatchType: isHttp ? null : null, - rewritePath: isHttp ? null : null, - rewritePathType: isHttp ? null : null, - priority: isHttp ? 100 : 100, + path: null, + pathMatchType: null, + rewritePath: null, + rewritePathType: null, + priority: 100, enabled: true, - resourceId: resource.resourceId, + resourceId: resource?.resourceId ?? 0, hcEnabled: false, hcPath: null, hcMethod: null, @@ -694,7 +675,6 @@ function ProxyResourceTargetsForm({ }); const router = useRouter(); - const queryClient = useQueryClient(); useEffect(() => { @@ -704,7 +684,6 @@ function ProxyResourceTargetsForm({ } }, [sites]); - // Save advanced mode preference to localStorage useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem( @@ -717,7 +696,8 @@ function ProxyResourceTargetsForm({ const [, formAction, isSubmitting] = useActionState(saveTargets, null); async function saveTargets() { - // Validate that no targets have blank IPs or invalid ports + if (!resource) return; + const targetsWithInvalidFields = targets.filter( (target) => !target.ip || @@ -726,7 +706,6 @@ function ProxyResourceTargetsForm({ target.port <= 0 || isNaN(target.port) ); - console.log(targetsWithInvalidFields); if (targetsWithInvalidFields.length > 0) { toast({ variant: "destructive", @@ -743,7 +722,6 @@ function ProxyResourceTargetsForm({ ) ); - // Save targets for (const target of targets) { const data: any = { ip: target.ip, @@ -769,8 +747,7 @@ function ProxyResourceTargetsForm({ hcUnhealthyThreshold: target.hcUnhealthyThreshold || null }; - // Only include path-related fields for HTTP resources - if (resource.http) { + if (isHttp) { data.path = target.path; data.pathMatchType = target.pathMatchType; data.rewritePath = target.rewritePath; @@ -791,12 +768,14 @@ function ProxyResourceTargetsForm({ } toast({ - title: targets.length === 0 - ? t("targetTargetsCleared") - : t("settingsUpdated"), - description: targets.length === 0 - ? t("targetTargetsClearedDescription") - : t("settingsUpdatedDescription") + title: + targets.length === 0 + ? t("targetTargetsCleared") + : t("settingsUpdated"), + description: + targets.length === 0 + ? t("targetTargetsClearedDescription") + : t("settingsUpdatedDescription") }); setTargetsToRemove([]); @@ -918,9 +897,6 @@ function ProxyResourceTargetsForm({ )} - {/* */} - {/* {t('targetNoOneDescription')} */} - {/* */}
@@ -978,15 +954,18 @@ function ProxyResourceTargetsForm({ )} -
- -
+ {/* Save button — only shown in edit mode */} + {resource && ( +
+ +
+ )} {selectedTargetForHealthCheck && ( @@ -1049,500 +1028,3 @@ function ProxyResourceTargetsForm({ ); } - -function ProxyResourceHttpForm({ - resource, - updateResource -}: Pick) { - 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 ( - - - - {t("proxyAdditional")} - - - {t("proxyAdditionalDescription")} - - - - -
- - {!env.flags.usePangolinDns && ( - ( - - - { - field.onChange(val); - }} - /> - - - )} - /> - )} - ( - - - {t("targetTlsSni")} - - - - - - {t("targetTlsSniDescription")} - - - - )} - /> - - -
- - -
- - ( - - - { - field.onChange(val); - }} - /> - - - )} - /> - - -
- - -
- - ( - - - {t("proxyCustomHeader")} - - - - - - {t("proxyCustomHeaderDescription")} - - - - )} - /> - ( - - - {t("customHeaders")} - - - { - field.onChange(value); - }} - rows={4} - /> - - - {t("customHeadersDescription")} - - - - )} - /> - - -
-
- -
-
-
- ); -} - -function ProxyResourceProtocolForm({ - resource, - updateResource -}: Pick) { - 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 ( - - - - {t("proxyProtocol")} - - - {t("proxyProtocolDescription")} - - - - -
- - ( - - - { - field.onChange(val); - }} - /> - - - )} - /> - - {proxySettingsForm.watch("proxyProtocol") && ( - <> - ( - - - {t("proxyProtocolVersion")} - - - - - - {t("versionDescription")} - - - )} - /> - - - - - {t("warning")}:{" "} - {t("proxyProtocolWarning")} - - - - )} - - -
-
- -
-
-
- ); -} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index 62a6b9fed..848f8c29d 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -146,7 +146,7 @@ function MaintenanceSectionForm({ } } - if (!resource.http) { + if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { return null; } @@ -176,7 +176,9 @@ function MaintenanceSectionForm({ render={({ field }) => { const isDisabled = !isPaidUser(tierMatrix.maintencePage) || - resource.http === false; + !["http", "ssh", "rdp", "vnc"].includes( + resource.mode + ); return ( @@ -462,14 +464,14 @@ export default function GeneralForm() { .refine( (data) => { // For non-HTTP resources, proxyPort should be defined - if (!resource.http) { + if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { return data.proxyPort !== undefined; } // For HTTP resources, proxyPort should be 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 should not be set for HTTP resources", path: ["proxyPort"] @@ -507,7 +509,9 @@ export default function GeneralForm() { name: data.name, niceId: data.niceId, subdomain: data.subdomain - ? toASCII(finalizeSubdomainSanitize(data.subdomain, true)) + ? toASCII( + finalizeSubdomainSanitize(data.subdomain, true) + ) : undefined, domainId: data.domainId, proxyPort: data.proxyPort @@ -555,13 +559,15 @@ export default function GeneralForm() { return ( <> - {resource?.resourceId && resource?.orgId && ( - - )} + {resource?.resourceId && + resource?.orgId && + resource.mode == "http" && ( + + )} @@ -580,45 +586,48 @@ export default function GeneralForm() { className="space-y-4" id="general-settings-form" > - ( - - - {t("name")} - - - - - - - )} - /> +
+ ( + + + {t("name")} + + + + + + + )} + /> - ( - - - {t("identifier")} - - - - - - - )} - /> + ( + + + {t("identifier")} + + + + + + + )} + /> +
- {!resource.http && ( + {!["http", "ssh", "rdp", "vnc"].includes( + resource.mode + ) && ( <> )} - {resource.http && ( + {["http", "ssh", "rdp", "vnc"].includes( + resource.mode + ) && (
( - -
- - + + + form.setValue( + "enabled", val - ) => - form.setValue( - "enabled", - val - ) - } - /> - -
+ ) + } + /> + + + {t( + "disabledResourceDescription" + )} +
)} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/http/page.tsx new file mode 100644 index 000000000..21f18a217 --- /dev/null +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/http/page.tsx @@ -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 ( + + + + {["http", "ssh", "rdp", "vnc"].includes(resource.mode) && ( + + )} + + {resource.mode == "tcp" && ( + + )} + + ); +} + +function ProxyResourceHttpForm({ + resource, + updateResource +}: Pick) { + 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 ( + + + + {t("proxyAdditional")} + + + {t("proxyAdditionalDescription")} + + + + +
+ + {!env.flags.usePangolinDns && ( + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + )} + ( + + + {t("targetTlsSni")} + + + + + + {t("targetTlsSniDescription")} + + + + )} + /> + + +
+ + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + +
+ + +
+ + ( + + + {t("proxyCustomHeader")} + + + + + + {t("proxyCustomHeaderDescription")} + + + + )} + /> + ( + + + {t("customHeaders")} + + + { + field.onChange(value); + }} + rows={4} + /> + + + {t("customHeadersDescription")} + + + + )} + /> + + +
+
+ +
+
+
+ ); +} + +function ProxyResourceProtocolForm({ + resource, + updateResource +}: Pick) { + 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 ( + + + + {t("proxyProtocol")} + + + {t("proxyProtocolDescription")} + + + + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + {proxySettingsForm.watch("proxyProtocol") && ( + <> + ( + + + {t("proxyProtocolVersion")} + + + + + + {t("versionDescription")} + + + )} + /> + + + + + {t("warning")}:{" "} + {t("proxyProtocolWarning")} + + + + )} + + +
+
+ +
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx index 2f6cd1492..5008c15c3 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx @@ -86,12 +86,12 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { href: `/{orgId}/settings/resources/proxy/{niceId}/general` }, { - title: t("proxy"), - href: `/{orgId}/settings/resources/proxy/{niceId}/proxy` + title: t(`${resource.mode}Settings`), + href: `/{orgId}/settings/resources/proxy/{niceId}/${resource.mode}` } ]; - if (resource.http) { + if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { navItems.push({ title: t("authentication"), href: `/{orgId}/settings/resources/proxy/{niceId}/authentication` diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx index 06a4af045..10950bfca 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx @@ -10,6 +10,6 @@ export default async function ResourcePage(props: { }) { const params = await props.params; redirect( - `/${params.orgId}/settings/resources/proxy/${params.niceId}/proxy` + `/${params.orgId}/settings/resources/proxy/${params.niceId}/general` ); } diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/rdp/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/rdp/page.tsx new file mode 100644 index 000000000..defd4891a --- /dev/null +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/rdp/page.tsx @@ -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 ( + + + + ); +} + +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([]); + const [bgDestination, setBgDestination] = useState(""); + const [bgDestinationPort, setBgDestinationPort] = useState("22"); + const [existingTargets, setExistingTargets] = useState( + [] + ); + + // Native mode: single site + const [selectedNativeSite, setSelectedNativeSite] = + useState(null); + const [nativeExistingTarget, setNativeExistingTarget] = + useState(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 ( + + + {t("rdpServer")} + + {t("rdpServerDescription")} + + + + + + + +
+ +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx index 60a219965..d5b942dd1 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx @@ -6,9 +6,7 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, - SelectGroup, SelectItem, - SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -36,7 +34,6 @@ import { import { Table, TableBody, - TableCaption, TableCell, TableHead, TableHeader, @@ -55,18 +52,11 @@ import { SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, - SettingsSectionFooter, - SettingsSectionForm + SettingsSectionFooter } from "@app/components/Settings"; import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; 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 { - InfoSection, - InfoSections, - InfoSectionTitle -} from "@app/components/InfoSection"; import { InfoPopup } from "@app/components/ui/info-popup"; import { isValidCIDR, @@ -78,7 +68,11 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { COUNTRIES } from "@server/db/countries"; import { MAJOR_ASNS } from "@server/db/asns"; -import { REGIONS, getRegionNameById, isValidRegionId } from "@server/db/regions"; +import { + REGIONS, + getRegionNameById, + isValidRegionId +} from "@server/db/regions"; import { Command, CommandEmpty, @@ -109,25 +103,23 @@ type LocalRule = ArrayElement & { export default function ResourceRules(props: { params: Promise<{ resourceId: number }>; }) { - const params = use(props.params); const { resource, updateResource } = useResourceContext(); const api = createApiClient(useEnvContext()); const [rules, setRules] = useState([]); const [rulesToRemove, setRulesToRemove] = useState([]); const [loading, setLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); - const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false); + const [rulesEnabled, setRulesEnabled] = useState( + resource.applyRules ?? false + ); useEffect(() => { setRulesEnabled(resource.applyRules); }, [resource.applyRules]); - const [openCountrySelect, setOpenCountrySelect] = useState(false); - const [countrySelectValue, setCountrySelectValue] = useState(""); const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = useState(false); - const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = - useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] = useState(false); const router = useRouter(); @@ -157,7 +149,7 @@ export default function ResourceRules(props: { resolver: zodResolver(addRuleSchema), defaultValues: { action: "ACCEPT", - match: "PATH", + match: resource.mode == "http" ? "PATH" : "IP", value: "" } }); @@ -270,16 +262,12 @@ export default function ResourceRules(props: { setLoading(false); return; } - if ( - data.match === "REGION" && - !isValidRegionId(data.value) - ) { + if (data.match === "REGION" && !isValidRegionId(data.value)) { toast({ variant: "destructive", title: t("rulesErrorInvalidRegion"), description: - t("rulesErrorInvalidRegionDescription") || - "Invalid region." + t("rulesErrorInvalidRegionDescription") || "Invalid region." }); setLoading(false); return; @@ -564,12 +552,24 @@ export default function ResourceRules(props: { + + + + )} + /> + + )} + +
+
+

+ {t("sshServerDestination")} +

+

+ {t("sshServerDestinationDescription")} +

+
+ {isNative ? ( + + + + + + { + setSelectedNativeSite(site); + setNativeSiteOpen(false); + }} + /> + + + ) : standardDaemonLocation !== "site" ? ( + + ) : ( + + )} +
+ + +
+ +
+ + ); +} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/vnc/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/vnc/page.tsx new file mode 100644 index 000000000..93c35925e --- /dev/null +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/vnc/page.tsx @@ -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 ( + + + + ); +} + +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([]); + const [bgDestination, setBgDestination] = useState(""); + const [bgDestinationPort, setBgDestinationPort] = useState("22"); + const [existingTargets, setExistingTargets] = useState( + [] + ); + + // Native mode: single site + const [selectedNativeSite, setSelectedNativeSite] = + useState(null); + const [nativeExistingTarget, setNativeExistingTarget] = + useState(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 ( + + + {t("vncServer")} + + {t("vncServerDescription")} + + + + + + + +
+ +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index d69bbdcf0..7c74be8b7 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -19,8 +19,20 @@ import { SettingsSectionTitle } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; -import { StrategySelect } from "@app/components/StrategySelect"; +import { + OptionSelect, + type OptionSelectOption +} from "@app/components/OptionSelect"; +import { + StrategySelect, + type StrategyOption +} from "@app/components/StrategySelect"; import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item"; +import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; +import { + SitesSelector, + type Selectedsite +} from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; import { Form, @@ -32,6 +44,11 @@ import { FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; import { Select, SelectContent, @@ -69,6 +86,10 @@ import { ListTargetsResponse } from "@server/routers/target"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; 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, @@ -80,6 +101,7 @@ import { } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; import { + ChevronsUpDown, CircleCheck, CircleX, ExternalLink, @@ -95,6 +117,7 @@ import { toASCII } from "punycode"; import { useEffect, useMemo, useState, useCallback } from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; +import { cn } from "@app/lib/cn"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), @@ -109,7 +132,17 @@ const httpResourceFormSchema = z.object({ const tcpUdpResourceFormSchema = z.object({ protocol: z.string(), proxyPort: z.int().min(1).max(65535) - // enableProxy: z.boolean().default(false) +}); + +const sshDaemonPortSchema = 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" } + ) }); const addTargetSchema = z @@ -132,23 +165,18 @@ const addTargetSchema = z }) .refine( (data) => { - // If path is provided, pathMatchType must be provided if (data.path && !data.pathMatchType) { return false; } - // If pathMatchType is provided, path must be provided if (data.pathMatchType && !data.path) { return false; } - // Validate path based on pathMatchType if (data.path && data.pathMatchType) { switch (data.pathMatchType) { case "exact": case "prefix": - // Path should start with / return data.path.startsWith("/"); case "regex": - // Validate regex try { new RegExp(data.path); return true; @@ -165,14 +193,10 @@ const addTargetSchema = z ) .refine( (data) => { - // If rewritePath is provided, rewritePathType must be provided if (data.rewritePath && !data.rewritePathType) { return false; } - // If rewritePathType is provided, rewritePath must be provided - // Exception: stripPrefix can have an empty rewritePath (to just strip the prefix) if (data.rewritePathType && !data.rewritePath) { - // Allow empty rewritePath for stripPrefix type if (data.rewritePathType !== "stripPrefix") { return false; } @@ -184,23 +208,7 @@ const addTargetSchema = z } ); -type ResourceType = "http" | "raw"; - -interface ResourceTypeOption { - id: ResourceType; - title: string; - description: string; - disabled?: boolean; -} - -export type LocalTarget = Omit< - ArrayElement & { - new?: boolean; - updated?: boolean; - siteType: string | null; - }, - "protocol" ->; +type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; export default function Page() { const { env } = useEnvContext(); @@ -222,15 +230,48 @@ export default function Page() { const [showSnippets, setShowSnippets] = useState(false); const [niceId, setNiceId] = useState(""); - // Target management state - const [targets, setTargets] = useState([]); - const [dockerStates, setDockerStates] = useState>( - new Map() - ); + // Resource type state + const [resourceType, setResourceType] = useState("http"); - const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = - useState(null); - const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); + // Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit) + const [targets, setTargets] = useState([]); + + // SSH-specific state + const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( + "native" + ); + const [pamMode, setPamMode] = useState<"passthrough" | "push">( + "passthrough" + ); + const [standardDaemonLocation, setStandardDaemonLocation] = useState< + "site" | "remote" + >("site"); + const [nativeSelectedSite, setNativeSelectedSite] = + useState(null); + const [nativeSiteOpen, setNativeSiteOpen] = useState(false); + + // Browser-gateway targets state (SSH standard, RDP, VNC) + const [bgSelectedSites, setBgSelectedSites] = useState([]); + const [bgSelectedSite, setBgSelectedSite] = useState( + null + ); + const [bgDestination, setBgDestination] = useState(""); + const [bgDestinationPort, setBgDestinationPort] = useState("22"); + + // Reset BG state when resource type changes + useEffect(() => { + if (resourceType === "rdp") { + setBgDestinationPort("3389"); + } else if (resourceType === "vnc") { + setBgDestinationPort("5900"); + } else if (resourceType === "ssh") { + setBgDestinationPort("22"); + } + setBgDestination(""); + setBgSelectedSites([]); + setBgSelectedSite(null); + setNativeSelectedSite(null); + }, [resourceType]); useEffect(() => { if (build !== "saas") return; @@ -253,91 +294,29 @@ export default function Page() { fetchExitNodes(); }, [orgId]); - const [isAdvancedMode, setIsAdvancedMode] = useState(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("create-advanced-mode"); - return saved === "true"; + // Derived flags + const isHttpResource = resourceType !== "tcp" && resourceType !== "udp"; + const isNative = sshServerMode === "native"; + const showDaemonLocation = + resourceType === "ssh" && !isNative && pamMode === "push"; + const showDaemonPort = + resourceType === "ssh" && + !isNative && + pamMode === "push" && + standardDaemonLocation === "remote"; + + // Whether raw (TCP/UDP) resources are available + const rawResourcesAllowed = + env.flags.allowRawResources && + (build !== "saas" || remoteExitNodes.length > 0); + + const availableTypes = useMemo((): NewResourceType[] => { + const base: NewResourceType[] = ["http", "ssh", "rdp", "vnc"]; + if (rawResourcesAllowed) { + base.push("tcp", "udp"); } - return false; - }); - - // Save advanced mode preference to localStorage - useEffect(() => { - if (typeof window !== "undefined") { - localStorage.setItem( - "create-advanced-mode", - isAdvancedMode.toString() - ); - } - }, [isAdvancedMode]); - - function addNewTarget() { - const isHttp = baseForm.watch("http"); - - const newTarget: LocalTarget = { - targetId: -Date.now(), // Use negative timestamp as temporary ID - ip: "", - method: isHttp ? "http" : null, - port: 0, - siteId: sites.length > 0 ? sites[0].siteId : 0, - siteName: sites.length > 0 ? sites[0].name : "", - path: isHttp ? null : null, - pathMatchType: isHttp ? null : null, - rewritePath: isHttp ? null : null, - rewritePathType: isHttp ? null : null, - priority: isHttp ? 100 : 100, - enabled: true, - resourceId: 0, - hcEnabled: false, - hcPath: null, - hcMethod: null, - hcInterval: null, - hcTimeout: null, - hcHeaders: null, - hcScheme: null, - hcHostname: null, - hcPort: null, - hcFollowRedirects: null, - hcHealth: "unknown", - hcStatus: null, - hcMode: null, - hcUnhealthyInterval: null, - hcTlsServerName: null, - hcHealthyThreshold: null, - hcUnhealthyThreshold: null, - siteType: sites.length > 0 ? sites[0].type : null, - new: true, - updated: false - }; - - setTargets((prev) => [...prev, newTarget]); - } - - const resourceTypes: ReadonlyArray = [ - { - id: "http", - title: t("resourceHTTP"), - description: t("resourceHTTPDescription") - }, - ...(!env.flags.allowRawResources - ? [] - : build === "saas" && remoteExitNodes.length === 0 - ? [] - : [ - { - id: "raw" as ResourceType, - title: t("resourceRaw"), - description: - build == "saas" - ? t("resourceRawDescriptionCloud") - : t("resourceRawDescription") - } - ]) - ]; - - // In saas mode with no exit nodes, force HTTP - const showTypeSelector = - build !== "saas" || (!loadingExitNodes && remoteExitNodes.length > 0); + return base; + }, [rawResourcesAllowed]); const baseForm = useForm({ resolver: zodResolver(baseResourceFormSchema), @@ -357,31 +336,32 @@ export default function Page() { defaultValues: { protocol: "tcp", proxyPort: undefined - // enableProxy: false } }); - const addTargetForm = useForm({ - resolver: zodResolver(addTargetSchema), + const sshDaemonPortForm = useForm({ + resolver: zodResolver(sshDaemonPortSchema), defaultValues: { - ip: "", - method: baseForm.watch("http") ? "http" : null, - port: "" as any as number, - path: null, - pathMatchType: null, - rewritePath: null, - rewritePathType: null, - priority: baseForm.watch("http") ? 100 : undefined - } as z.infer + authDaemonPort: "22123" + } }); - // Helper function to check if all targets have required fields using schema validation + // Sync form http field with resourceType + useEffect(() => { + baseForm.setValue("http", isHttpResource); + if (resourceType === "tcp") { + tcpUdpForm.setValue("protocol", "tcp"); + } else if (resourceType === "udp") { + tcpUdpForm.setValue("protocol", "udp"); + } + }, [resourceType, isHttpResource]); + const areAllTargetsValid = () => { - if (targets.length === 0) return true; // No targets is valid + if (targets.length === 0) return true; return targets.every((target) => { try { - const isHttp = baseForm.watch("http"); + const isHttp = resourceType === "http"; const targetData: any = { ip: target.ip, method: target.method, @@ -393,7 +373,6 @@ export default function Page() { rewritePathType: target.rewritePathType }; - // Only include priority for HTTP resources if (isHttp) { targetData.priority = target.priority; } @@ -406,106 +385,54 @@ export default function Page() { }); }; - const initializeDockerForSite = async (siteId: number) => { - if (dockerStates.has(siteId)) { - return; // Already initialized - } - - const dockerManager = new DockerManager(api, siteId); - const dockerState = await dockerManager.initializeDocker(); - - setDockerStates((prev) => new Map(prev.set(siteId, dockerState))); - }; - - const refreshContainersForSite = useCallback( - async (siteId: number) => { - const dockerManager = new DockerManager(api, siteId); - const containers = await dockerManager.fetchContainers(); - - setDockerStates((prev) => { - const newMap = new Map(prev); - const existingState = newMap.get(siteId); - if (existingState) { - newMap.set(siteId, { ...existingState, containers }); - } - return newMap; - }); - }, - [api] - ); - - const getDockerStateForSite = useCallback( - (siteId: number): DockerState => { - return ( - dockerStates.get(siteId) || { - isEnabled: false, - isAvailable: false, - containers: [] - } - ); - }, - [dockerStates] - ); - - const removeTarget = useCallback((targetId: number) => { - setTargets((prevTargets) => { - return prevTargets.filter((target) => target.targetId !== targetId); - }); - }, []); - - const updateTarget = useCallback( - (targetId: number, data: Partial) => { - setTargets((prevTargets) => { - const site = sites.find((site) => site.siteId === data.siteId); - return prevTargets.map((target) => - target.targetId === targetId - ? { - ...target, - ...data, - updated: true, - siteType: site ? site.type : target.siteType - } - : target - ); - }); - }, - [sites] - ); - async function onSubmit() { setCreateLoading(true); const baseData = baseForm.getValues(); - const isHttp = baseData.http; try { - const payload = { + const payload: any = { name: baseData.name, - http: baseData.http + http: isHttpResource }; let sanitizedSubdomain: string | undefined; - if (isHttp) { + if (isHttpResource) { const httpData = httpForm.getValues(); sanitizedSubdomain = httpData.subdomain ? finalizeSubdomainSanitize(httpData.subdomain, true) : undefined; + const effectiveMode = isNative + ? "native" + : standardDaemonLocation; + const portVal = sshDaemonPortForm.getValues().authDaemonPort; + const effectivePort = + !isNative && + standardDaemonLocation === "remote" && + pamMode === "push" && + portVal + ? Number(portVal) + : null; + Object.assign(payload, { subdomain: sanitizedSubdomain ? toASCII(sanitizedSubdomain) : undefined, domainId: httpData.domainId, - protocol: "tcp" + protocol: "tcp", + mode: resourceType, + pamMode, + authDaemonMode: effectiveMode, + authDaemonPort: effectivePort }); } else { const tcpUdpData = tcpUdpForm.getValues(); Object.assign(payload, { protocol: tcpUdpData.protocol, proxyPort: tcpUdpData.proxyPort - // enableProxy: tcpUdpData.enableProxy }); } @@ -526,77 +453,168 @@ export default function Page() { if (res && res.status === 201) { const id = res.data.data.resourceId; - const niceId = res.data.data.niceId; - setNiceId(niceId); + const newNiceId = res.data.data.niceId; + setNiceId(newNiceId); - // Create targets if any exist - if (targets.length > 0) { - try { - for (const target of targets) { - const data: any = { - ip: target.ip, - port: target.port, - method: target.method, - enabled: target.enabled, - siteId: target.siteId, - hcEnabled: target.hcEnabled, - hcPath: target.hcPath || null, - hcMethod: target.hcMethod || null, - hcInterval: target.hcInterval || null, - hcTimeout: target.hcTimeout || null, - hcHeaders: target.hcHeaders || null, - hcScheme: target.hcScheme || null, - hcHostname: target.hcHostname || null, - hcPort: target.hcPort || null, - hcFollowRedirects: - target.hcFollowRedirects || null, - hcStatus: target.hcStatus || null, - hcUnhealthyInterval: - target.hcUnhealthyInterval || null, - hcMode: target.hcMode || null, - hcTlsServerName: target.hcTlsServerName, - hcHealthyThreshold: - target.hcHealthyThreshold || null, - hcUnhealthyThreshold: - target.hcUnhealthyThreshold || null - }; - - // Only include path-related fields for HTTP resources - if (isHttp) { - data.path = target.path; - data.pathMatchType = target.pathMatchType; - data.rewritePath = target.rewritePath; - data.rewritePathType = target.rewritePathType; - data.priority = target.priority; + if (resourceType === "http") { + if (targets.length > 0) { + try { + for (const target of targets) { + const data: any = { + ip: target.ip, + port: target.port, + method: target.method, + enabled: target.enabled, + siteId: target.siteId, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath || null, + hcMethod: target.hcMethod || null, + hcInterval: target.hcInterval || null, + hcTimeout: target.hcTimeout || null, + hcHeaders: target.hcHeaders || null, + hcScheme: target.hcScheme || null, + hcHostname: target.hcHostname || null, + hcPort: target.hcPort || null, + hcFollowRedirects: + target.hcFollowRedirects || null, + hcStatus: target.hcStatus || null, + hcUnhealthyInterval: + target.hcUnhealthyInterval || null, + hcMode: target.hcMode || null, + hcTlsServerName: target.hcTlsServerName, + hcHealthyThreshold: + target.hcHealthyThreshold || null, + hcUnhealthyThreshold: + target.hcUnhealthyThreshold || null, + path: target.path, + pathMatchType: target.pathMatchType, + rewritePath: target.rewritePath, + rewritePathType: target.rewritePathType, + priority: target.priority + }; + await api.put(`/resource/${id}/target`, data); } - - await api.put(`/resource/${id}/target`, data); + } catch (targetError) { + console.error( + "Error creating targets:", + targetError + ); + toast({ + variant: "destructive", + title: t("targetErrorCreate"), + description: formatAxiosError( + targetError, + t("targetErrorCreateDescription") + ) + }); + } + } + router.push( + `/${orgId}/settings/resources/proxy/${newNiceId}` + ); + } else if (resourceType === "ssh") { + if (isNative) { + if (nativeSelectedSite) { + await api.put( + `/org/${orgId}/resource/${id}/browser-gateway-target`, + { + siteId: nativeSelectedSite.siteId, + type: "ssh", + destination: "localhost", + destinationPort: 22 + } + ); + } + } else { + const sitesToCreate = + standardDaemonLocation !== "site" + ? bgSelectedSites + : bgSelectedSite + ? [bgSelectedSite] + : []; + for (const site of sitesToCreate) { + await api.put( + `/org/${orgId}/resource/${id}/browser-gateway-target`, + { + siteId: site.siteId, + type: "ssh", + destination: bgDestination, + destinationPort: Number(bgDestinationPort) + } + ); } - } catch (targetError) { - console.error("Error creating targets:", targetError); - toast({ - variant: "destructive", - title: t("targetErrorCreate"), - description: formatAxiosError( - targetError, - t("targetErrorCreateDescription") - ) - }); } - } - if (isHttp) { - router.push(`/${orgId}/settings/resources/proxy/${niceId}`); + router.push( + `/${orgId}/settings/resources/proxy/${newNiceId}` + ); + } else if (resourceType === "rdp" || resourceType === "vnc") { + for (const site of bgSelectedSites) { + await api.put( + `/org/${orgId}/resource/${id}/browser-gateway-target`, + { + siteId: site.siteId, + type: resourceType, + destination: bgDestination, + destinationPort: Number(bgDestinationPort) + } + ); + } + + router.push( + `/${orgId}/settings/resources/proxy/${newNiceId}` + ); } else { - const tcpUdpData = tcpUdpForm.getValues(); - // Only show config snippets if enableProxy is explicitly true - // if (tcpUdpData.enableProxy === true) { + // TCP / UDP — create targets then show snippets + if (targets.length > 0) { + try { + for (const target of targets) { + const data: any = { + ip: target.ip, + port: target.port, + method: target.method, + enabled: target.enabled, + siteId: target.siteId, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath || null, + hcMethod: target.hcMethod || null, + hcInterval: target.hcInterval || null, + hcTimeout: target.hcTimeout || null, + hcHeaders: target.hcHeaders || null, + hcScheme: target.hcScheme || null, + hcHostname: target.hcHostname || null, + hcPort: target.hcPort || null, + hcFollowRedirects: + target.hcFollowRedirects || null, + hcStatus: target.hcStatus || null, + hcUnhealthyInterval: + target.hcUnhealthyInterval || null, + hcMode: target.hcMode || null, + hcTlsServerName: target.hcTlsServerName, + hcHealthyThreshold: + target.hcHealthyThreshold || null, + hcUnhealthyThreshold: + target.hcUnhealthyThreshold || null + }; + await api.put(`/resource/${id}/target`, data); + } + } catch (targetError) { + console.error( + "Error creating targets:", + targetError + ); + toast({ + variant: "destructive", + title: t("targetErrorCreate"), + description: formatAxiosError( + targetError, + t("targetErrorCreateDescription") + ) + }); + } + } setShowSnippets(true); router.refresh(); - // } else { - // // If enableProxy is false or undefined, go directly to resource page - // router.push(`/${orgId}/settings/resources/proxy/${id}`); - // } } } } catch (e) { @@ -614,396 +632,60 @@ export default function Page() { setCreateLoading(false); } - useEffect(() => { - // Initialize Docker for newt sites - for (const site of sites) { - if (site.type === "newt") { - initializeDockerForSite(site.siteId); - } + // SSH strategy options + const sshModeOptions: StrategyOption<"standard" | "native">[] = [ + { + id: "native", + title: t("sshServerModePangolin"), + description: t("sshServerModeNativeDescription") + }, + { + id: "standard", + title: t("sshServerModeStandard"), + description: t("sshServerModeStandardDescription") } + ]; - // If there's at least one site, set it as the default in the form - if (sites.length > 0) { - addTargetForm.setValue("siteId", sites[0].siteId); + const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [ + { + id: "passthrough", + title: t("sshAuthMethodManual"), + description: t("sshAuthMethodManualDescription") + }, + { + id: "push", + title: t("sshAuthMethodAutomated"), + description: t("sshAuthMethodAutomatedDescription") } - }, [sites]); + ]; - function TargetHealthCheck(targetId: number, config: any) { - setTargets( - targets.map((target) => - target.targetId === targetId - ? { - ...target, - ...config, - updated: true - } - : target - ) - ); - } - - const openHealthCheckDialog = useCallback((target: LocalTarget) => { - console.log(target); - setSelectedTargetForHealthCheck(target); - setHealthCheckDialogOpen(true); - }, []); - - const isHttp = baseForm.watch("http"); - - const columns = useMemo((): ColumnDef[] => { - const priorityColumn: ColumnDef = { - id: "priority", - header: () => ( -
- {t("priority")} - - - - - - -

{t("priorityDescription")}

-
-
-
-
- ), - cell: ({ row }) => { - return ( -
- { - const value = parseInt(e.target.value, 10); - if (value >= 1 && value <= 1000) { - updateTarget(row.original.targetId, { - ...row.original, - priority: value - }); - } - }} - /> -
- ); - }, - size: 120, - minSize: 100, - maxSize: 150 - }; - - const healthCheckColumn: ColumnDef = { - accessorKey: "healthCheck", - header: () => {t("healthCheck")}, - cell: ({ row }) => { - const status = row.original.hcHealth || "unknown"; - - const getStatusText = (status: string) => { - switch (status) { - case "healthy": - return t("healthCheckHealthy"); - case "unhealthy": - return t("healthCheckUnhealthy"); - case "unknown": - default: - return t("healthCheckUnknown"); - } - }; - - return ( -
- {row.original.siteType === "newt" ? ( - - - ) : ( - - - )} -
- ); - }, - size: 200, - minSize: 180, - maxSize: 250 - }; - - const matchPathColumn: ColumnDef = { - accessorKey: "path", - header: () => {t("matchPath")}, - cell: ({ row }) => { - const hasPathMatch = !!( - row.original.path || row.original.pathMatchType - ); - - return ( -
- {hasPathMatch ? ( - - updateTarget( - row.original.targetId, - config.path === null && - config.pathMatchType === null - ? { - ...config, - rewritePath: null, - rewritePathType: null - } - : config - ) - } - trigger={ - - } - /> - ) : ( - - updateTarget( - row.original.targetId, - config.path === null && - config.pathMatchType === null - ? { - ...config, - rewritePath: null, - rewritePathType: null - } - : config - ) - } - trigger={ - - } - /> - )} -
- ); - }, - size: 200, - minSize: 180, - maxSize: 200 - }; - - const addressColumn: ColumnDef = { - accessorKey: "address", - header: () => {t("address")}, - cell: ({ row }) => ( - - ), - size: 400, - minSize: 350, - maxSize: 500 - }; - - const rewritePathColumn: ColumnDef = { - accessorKey: "rewritePath", - header: () => {t("rewritePath")}, - cell: ({ row }) => { - const hasRewritePath = !!( - row.original.rewritePath || row.original.rewritePathType - ); - const noPathMatch = - !row.original.path && !row.original.pathMatchType; - - return ( -
- {hasRewritePath && !noPathMatch ? ( - - updateTarget(row.original.targetId, config) - } - trigger={ - - } - /> - ) : ( - - updateTarget(row.original.targetId, config) - } - trigger={ - - } - disabled={noPathMatch} - /> - )} -
- ); - }, - size: 200, - minSize: 180, - maxSize: 200 - }; - - const enabledColumn: ColumnDef = { - accessorKey: "enabled", - header: () => {t("enabled")}, - cell: ({ row }) => ( -
- - updateTarget(row.original.targetId, { - ...row.original, - enabled: val - }) - } - /> -
- ), - size: 100, - minSize: 80, - maxSize: 120 - }; - - const actionsColumn: ColumnDef = { - id: "actions", - header: () => {t("actions")}, - cell: ({ row }) => ( -
- -
- ), - size: 100, - minSize: 80, - maxSize: 120 - }; - - if (isAdvancedMode) { - const columns = [ - addressColumn, - healthCheckColumn, - enabledColumn, - actionsColumn - ]; - - // Only include path-related columns for HTTP resources - if (isHttp) { - columns.unshift(matchPathColumn); - columns.splice(3, 0, rewritePathColumn, priorityColumn); - } - - return columns; - } else { - return [ - addressColumn, - healthCheckColumn, - enabledColumn, - actionsColumn - ]; + const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [ + { + id: "site", + title: t("internalResourceAuthDaemonSite"), + description: t("sshDaemonLocationSiteDescription") + }, + { + id: "remote", + title: t("sshDaemonLocationRemote"), + description: t("sshDaemonLocationRemoteDescription") } - }, [ - isAdvancedMode, - isHttp, - sites, - updateTarget, - getDockerStateForSite, - refreshContainersForSite, - openHealthCheckDialog, - removeTarget, - t - ]); + ]; - const table = useReactTable({ - data: targets, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getRowId: (row) => String(row.targetId), - state: { - pagination: { - pageIndex: 0, - pageSize: 1000 - } - } - }); + const typeLabels: Record = { + http: "HTTP", + ssh: "SSH", + rdp: "RDP", + vnc: "VNC", + tcp: "TCP", + udp: "UDP" + }; + + const typeOptions: OptionSelectOption[] = + availableTypes.map((type) => ({ + value: type, + label: typeLabels[type] + })); return ( <> @@ -1026,49 +708,24 @@ export default function Page() {
{!showSnippets ? ( + {/* General Section */} - {t("resourceInfo")} + {t("resourceCreateGeneral")} + + {t("resourceCreateGeneralDescription")} + - {showTypeSelector && - resourceTypes.length > 1 && ( - <> -
- - {t("type")} - -
- - { - baseForm.setValue( - "http", - value === "http" - ); - // Update method default when switching resource type - addTargetForm.setValue( - "method", - value === "http" - ? "http" - : null - ); - }} - cols={3} - /> - - )} - - + + {/* Name */}
{ if (e.key === "Enter") { - e.preventDefault(); // block default enter refresh + e.preventDefault(); } }} className="space-y-4" @@ -1098,115 +755,65 @@ export default function Page() { />
-
-
-
- {baseForm.watch("http") ? ( - - - - {t("resourceHTTPSSettings")} - - - {t( - "resourceHTTPSSettingsDescription" - )} - - - - - = 1 - } - onDomainChange={(res) => { - if (!res) return; - - httpForm.setValue( - "subdomain", - res.subdomain - ); - httpForm.setValue( - "domainId", - res.domainId - ); - console.log( - "Domain changed:", - res - ); - }} + {/* Inline Type Selector */} +
+

+ {t("type")} +

+ + options={typeOptions} + value={resourceType} + onChange={setResourceType} + cols={6} /> - - - - ) : ( - - - - {t("resourceRawSettings")} - - - {t( - "resourceRawSettingsDescription" - )} - - - - +

+ {t("resourceTypeDescription")} +

+
+ + {/* Domain/Subdomain (HTTP-based types) */} + {isHttpResource && ( +
+ = + 1 + } + onDomainChange={(res) => { + if (!res) return; + httpForm.setValue( + "subdomain", + res.subdomain + ); + httpForm.setValue( + "domainId", + res.domainId + ); + }} + /> +

+ {t( + "resourceDomainDescription" + )} +

+
+ )} + + {/* Proxy Port (TCP/UDP types) */} + {!isHttpResource && (
{ if (e.key === "Enter") { - e.preventDefault(); // block default enter refresh + e.preventDefault(); } }} - className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start" + className="space-y-4" id="tcp-udp-settings-form" > - ( - - - {t( - "protocol" - )} - - - - - )} - /> - + + {t( + "resourcePortDescription" + )} + )} /> + )} +
+
+
+ + {/* SSH Server Section */} + {resourceType === "ssh" && ( + + + + {t("sshServer")} + + + {t("sshServerDescription")} + + + + + {/* Mode */} +
+

+ {t("sshServerMode")} +

+ + value={sshServerMode} + options={sshModeOptions} + onChange={setSshServerMode} + cols={2} + /> +
+ + {/* Auth Method (standard only) */} + {!isNative && ( +
+

+ {t( + "sshAuthenticationMethod" + )} +

+ + value={pamMode} + options={ + authMethodOptions + } + onChange={setPamMode} + cols={2} + /> +
+ )} + + {/* Daemon Location (standard + push) */} + {showDaemonLocation && ( +
+

+ {t( + "sshAuthDaemonLocation" + )} +

+ + value={ + standardDaemonLocation + } + options={ + daemonLocationOptions + } + onChange={ + setStandardDaemonLocation + } + cols={2} + /> +

+ {t( + "sshDaemonDisclaimer" + )}{" "} + + {t("learnMore")} + + +

+
+ )} + + {/* Daemon Port (standard + push + remote) */} + {showDaemonPort && ( +
+ ( + + + {t( + "sshDaemonPort" + )} + + + + + + + )} + /> + + )} + + {/* Server Destination */} +
+
+

+ {t( + "sshServerDestination" + )} +

+

+ {t( + "sshServerDestinationDescription" + )} +

+
+ {isNative ? ( + + + + + + { + setNativeSelectedSite( + site + ); + setNativeSiteOpen( + false + ); + }} + /> + + + ) : standardDaemonLocation !== + "site" ? ( + + ) : ( + + )} +
)} - - - - {t("targets")} - - - {t("targetsDescription")} - - - - {targets.length > 0 ? ( - <> -
- - - {table - .getHeaderGroups() - .map( - ( - headerGroup - ) => ( - - {headerGroup.headers.map( - ( - header - ) => { - const isActionsColumn = - header - .column - .id === - "actions"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ); - } - )} - - ) - )} - - - {table.getRowModel() - .rows?.length ? ( - table - .getRowModel() - .rows.map( - (row) => ( - - {row - .getVisibleCells() - .map( - ( - cell - ) => { - const isActionsColumn = - cell - .column - .id === - "actions"; - return ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ); - } - )} - - ) - ) - ) : ( - - - {t( - "targetNoOne" - )} - - - )} - -
-
-
-
- -
- - -
-
-
- - ) : ( -
-

- {t("targetNoOne")} -

- -
- )} - {build === "saas" && - targets.length > 1 && - new Set(targets.map((t) => t.siteId)).size > - 1 && ( -

- {t("proxyMultiSiteRoundRobinNodeHelp")}{" "} - - {t("learnMore")} - - - . -

- )} -
-
+ {/* RDP Server Section */} + {resourceType === "rdp" && ( + + + + {t("rdpServer")} + + + {t("rdpServerDescription")} + + + + + + + + + )} + + {/* VNC Server Section */} + {resourceType === "vnc" && ( + + + + {t("vncServer")} + + + {t("vncServerDescription")} + + + + + + + + + )} + + {/* Targets Section (HTTP / TCP / UDP) */} + {(resourceType === "http" || + resourceType === "tcp" || + resourceType === "udp") && ( + + )}
- {selectedTargetForHealthCheck && ( - { - if (selectedTargetForHealthCheck) { - console.log(config); - TargetHealthCheck( - selectedTargetForHealthCheck.targetId, - config - ); - } - }} - /> - )}
) : ( @@ -1627,7 +1306,7 @@ export default function Page() { type="button" onClick={() => router.push( - `/${orgId}/settings/resources/proxy/${niceId}/proxy` + `/${orgId}/settings/resources/proxy/${niceId}` ) } > diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index 8d79947b7..44430e226 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -108,11 +108,11 @@ export default async function ProxyResourcesPage( orgId: params.orgId, nice: resource.niceId, domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`, - protocol: resource.protocol, proxyPort: resource.proxyPort, - http: resource.http, labels: resource.labels, - authState: !resource.http + authState: !["http", "ssh", "rdp", "vnc"].includes( + resource.mode || "" + ) ? "none" : resource.sso || resource.pincodeId !== null || @@ -126,6 +126,7 @@ export default async function ProxyResourcesPage( fullDomain: resource.fullDomain ?? null, ssl: resource.ssl, wildcard: resource.wildcard, + mode: resource.mode, targets: resource.targets?.map((target) => ({ targetId: target.targetId, ip: target.ip, diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 000000000..bcaab339d Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx new file mode 100644 index 000000000..00e8ec55a --- /dev/null +++ b/src/app/rdp/RdpClient.tsx @@ -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 & { + 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(() => { + 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(null); + const backendRef = useRef(null); + // Holds the RdpFileTransferProvider constructor so we can create a fresh + // instance per session (avoids stale upload state across reconnects). + const fileTransferClassRef = useRef( + null + ); + // Active session's provider instance; replaced on each connect. + const fileTransferRef = useRef(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 + // `` 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 = (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 ( +
+

{error}

+
+ ); + } + + return ( +
+ {showLogin && ( +
+

RDP

+ +
+ + + update("domain", e.target.value) + } + /> + + + + update("username", e.target.value) + } + /> + + + + update("password", e.target.value) + } + /> + + {/* + + update("pcb", e.target.value)} + /> + */} + + {/* + + update("kdcProxyUrl", e.target.value) + } + /> + */} + {/*
+ + update("enableClipboard", checked === true) + } + /> + +
*/} + +
+
+ )} + +
+
+ + + + + + {/* */} + + + +
+ + {moduleReady && ( + + )} +
+
+ ); +} + +function Field({ + label, + id, + children +}: { + label: string; + id: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/rdp/page.tsx b/src/app/rdp/page.tsx new file mode 100644 index 000000000..291a35a8c --- /dev/null +++ b/src/app/rdp/page.tsx @@ -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>( + `/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 ; +} diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx new file mode 100644 index 000000000..9472e9bee --- /dev/null +++ b/src/app/ssh/SshClient.tsx @@ -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(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) return JSON.parse(saved) as FormState; + } catch { + // ignore + } + return { username: "", password: "", privateKey: "" }; + }); + + const fileInputRef = useRef(null); + + function handleKeyFile(e: React.ChangeEvent) { + 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(null); + + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef( + null + ); + const wsRef = useRef(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 ( +
+

{error}

+
+ ); + } + + // In push mode, show a connecting/connected state without the login form. + if (signedKeyData && signedPrivateKey) { + return ( +
+ {!connected && ( +
+

+ {connectError + ? connectError + : connecting + ? "Connecting…" + : "Initializing…"} +

+
+ )} + {connected && ( +
+
+ +
+
+
+ )} +
+ ); + } + + return ( +
+ {!connected && ( +
+

SSH

+ +
+ + + setForm({ + ...form, + username: e.target.value + }) + } + placeholder="root" + /> + + + + setForm({ + ...form, + password: e.target.value + }) + } + placeholder={ + form.privateKey + ? "Optional with key auth" + : "" + } + /> + + + +