mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-13 17:21:48 +02:00
Merge branch 'rdp-ssh' into dev
This commit is contained in:
@@ -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
|
||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Метод",
|
"editInternalResourceDialogScheme": "Метод",
|
||||||
"editInternalResourceDialogEnableSsl": "Активирайте TLS",
|
"editInternalResourceDialogEnableSsl": "Активирайте TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
|
"editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Метод",
|
"scheme": "Метод",
|
||||||
"createInternalResourceDialogScheme": "Метод",
|
"createInternalResourceDialogScheme": "Метод",
|
||||||
"createInternalResourceDialogEnableSsl": "Активирайте TLS",
|
"createInternalResourceDialogEnableSsl": "Активирайте TLS",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Schéma",
|
"editInternalResourceDialogScheme": "Schéma",
|
||||||
"editInternalResourceDialogEnableSsl": "Povolit SSL",
|
"editInternalResourceDialogEnableSsl": "Povolit SSL",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.",
|
"editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Schéma",
|
"scheme": "Schéma",
|
||||||
"createInternalResourceDialogScheme": "Schéma",
|
"createInternalResourceDialogScheme": "Schéma",
|
||||||
"createInternalResourceDialogEnableSsl": "Povolit SSL",
|
"createInternalResourceDialogEnableSsl": "Povolit SSL",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Schema",
|
"editInternalResourceDialogScheme": "Schema",
|
||||||
"editInternalResourceDialogEnableSsl": "TLS aktivieren",
|
"editInternalResourceDialogEnableSsl": "TLS aktivieren",
|
||||||
"editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
|
"editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Schema",
|
"scheme": "Schema",
|
||||||
"createInternalResourceDialogScheme": "Schema",
|
"createInternalResourceDialogScheme": "Schema",
|
||||||
"createInternalResourceDialogEnableSsl": "TLS aktivieren",
|
"createInternalResourceDialogEnableSsl": "TLS aktivieren",
|
||||||
|
|||||||
+43
-5
@@ -220,8 +220,9 @@
|
|||||||
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Create Resource",
|
"resourceCreate": "Create Resource",
|
||||||
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
||||||
|
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
|
||||||
"resourceSeeAll": "See All Resources",
|
"resourceSeeAll": "See All Resources",
|
||||||
"resourceInfo": "Resource Information",
|
"resourceCreateGeneral": "General",
|
||||||
"resourceNameDescription": "This is the display name for the resource.",
|
"resourceNameDescription": "This is the display name for the resource.",
|
||||||
"siteSelect": "Select site",
|
"siteSelect": "Select site",
|
||||||
"siteSearch": "Search site",
|
"siteSearch": "Search site",
|
||||||
@@ -231,12 +232,15 @@
|
|||||||
"noCountryFound": "No country found.",
|
"noCountryFound": "No country found.",
|
||||||
"siteSelectionDescription": "This site will provide connectivity to the target.",
|
"siteSelectionDescription": "This site will provide connectivity to the target.",
|
||||||
"resourceType": "Resource Type",
|
"resourceType": "Resource Type",
|
||||||
"resourceTypeDescription": "Determine how to access the resource",
|
"resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This can’t be changed later.",
|
||||||
|
"resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
|
||||||
"resourceHTTPSSettings": "HTTPS Settings",
|
"resourceHTTPSSettings": "HTTPS Settings",
|
||||||
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
|
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
|
||||||
|
"resourcePortDescription": "The external port on the Pangolin instance or node where the resource will be accessible.",
|
||||||
"domainType": "Domain Type",
|
"domainType": "Domain Type",
|
||||||
"subdomain": "Subdomain",
|
"subdomain": "Subdomain",
|
||||||
"baseDomain": "Base Domain",
|
"baseDomain": "Base Domain",
|
||||||
|
"configure": "Configure",
|
||||||
"subdomnainDescription": "The subdomain where the resource will be accessible.",
|
"subdomnainDescription": "The subdomain where the resource will be accessible.",
|
||||||
"resourceRawSettings": "TCP/UDP Settings",
|
"resourceRawSettings": "TCP/UDP Settings",
|
||||||
"resourceRawSettingsDescription": "Configure how the resource will be accessed over TCP/UDP",
|
"resourceRawSettingsDescription": "Configure how the resource will be accessed over TCP/UDP",
|
||||||
@@ -1877,6 +1881,7 @@
|
|||||||
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
|
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
|
||||||
"billingCurrentKeys": "Current Keys",
|
"billingCurrentKeys": "Current Keys",
|
||||||
"billingModifyCurrentPlan": "Modify Current Plan",
|
"billingModifyCurrentPlan": "Modify Current Plan",
|
||||||
|
"billingManageLicenseSubscriptionDescription": "Manage your subscription for paid self-hosted license keys and download invoices.",
|
||||||
"billingConfirmUpgrade": "Confirm Upgrade",
|
"billingConfirmUpgrade": "Confirm Upgrade",
|
||||||
"billingConfirmDowngrade": "Confirm Downgrade",
|
"billingConfirmDowngrade": "Confirm Downgrade",
|
||||||
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
|
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
|
||||||
@@ -1974,7 +1979,36 @@
|
|||||||
"timeIsInSeconds": "Time is in seconds",
|
"timeIsInSeconds": "Time is in seconds",
|
||||||
"requireDeviceApproval": "Require Device Approvals",
|
"requireDeviceApproval": "Require Device Approvals",
|
||||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||||
"sshAccess": "SSH Access",
|
"sshSettings": "SSH Settings",
|
||||||
|
"rdpSettings": "RDP Settings",
|
||||||
|
"vncSettings": "VNC Settings",
|
||||||
|
"sshServer": "SSH Server",
|
||||||
|
"rdpServer": "RDP Server",
|
||||||
|
"vncServer": "VNC Server",
|
||||||
|
"sshServerDescription": "Set up the authentication method, daemon location, and server destination",
|
||||||
|
"rdpServerDescription": "Configure the destination and port of the RDP server",
|
||||||
|
"vncServerDescription": "Configure the destination and port of the VNC server",
|
||||||
|
"sshServerMode": "Mode",
|
||||||
|
"sshServerModeStandard": "Standard SSH Server",
|
||||||
|
"sshServerModePangolin": "Pangolin SSH",
|
||||||
|
"sshServerModeStandardDescription": "Routes commands over network to an SSH server such as OpenSSH.",
|
||||||
|
"sshServerModeNative": "Native SSH Server",
|
||||||
|
"sshServerModeNativeDescription": "Executes commands directly on the host via the Site Connector. No network config required.",
|
||||||
|
"sshAuthenticationMethod": "Authentication Method",
|
||||||
|
"sshAuthMethodManual": "Manual Authentication",
|
||||||
|
"sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.",
|
||||||
|
"sshAuthMethodAutomated": "Automated Provisioning",
|
||||||
|
"sshAuthMethodAutomatedDescription": "Automatically creates users, groups, and sudo permissions on host.",
|
||||||
|
"sshAuthDaemonLocation": "Auth Daemon Location",
|
||||||
|
"sshDaemonLocationSiteDescription": "Executes locally on the machine hosting the site connector.",
|
||||||
|
"sshDaemonLocationRemote": "On Remote Host",
|
||||||
|
"sshDaemonLocationRemoteDescription": "Executes on a separate target machine on the same network.",
|
||||||
|
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
||||||
|
"sshDaemonPort": "Daemon Port",
|
||||||
|
"sshServerDestination": "Server Destination",
|
||||||
|
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
|
||||||
|
"destination": "Destination",
|
||||||
|
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||||
"roleAllowSsh": "Allow SSH",
|
"roleAllowSsh": "Allow SSH",
|
||||||
"roleAllowSshAllow": "Allow",
|
"roleAllowSshAllow": "Allow",
|
||||||
"roleAllowSshDisallow": "Disallow",
|
"roleAllowSshDisallow": "Disallow",
|
||||||
@@ -2080,6 +2114,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Scheme",
|
"editInternalResourceDialogScheme": "Scheme",
|
||||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
|
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
|
||||||
@@ -2129,6 +2164,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Scheme",
|
"scheme": "Scheme",
|
||||||
"createInternalResourceDialogScheme": "Scheme",
|
"createInternalResourceDialogScheme": "Scheme",
|
||||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
||||||
@@ -2968,7 +3004,7 @@
|
|||||||
"learnMore": "Learn more",
|
"learnMore": "Learn more",
|
||||||
"backToHome": "Go back to home",
|
"backToHome": "Go back to home",
|
||||||
"needToSignInToOrg": "Need to use your organization's identity provider?",
|
"needToSignInToOrg": "Need to use your organization's identity provider?",
|
||||||
"maintenanceMode": "Maintenance Mode",
|
"maintenanceMode": "Maintenance Page",
|
||||||
"maintenanceModeDescription": "Display a maintenance page to visitors",
|
"maintenanceModeDescription": "Display a maintenance page to visitors",
|
||||||
"maintenanceModeType": "Maintenance Mode Type",
|
"maintenanceModeType": "Maintenance Mode Type",
|
||||||
"showMaintenancePage": "Show a maintenance page to visitors",
|
"showMaintenancePage": "Show a maintenance page to visitors",
|
||||||
@@ -2998,6 +3034,7 @@
|
|||||||
"maintenanceScreenEstimatedCompletion": "Estimated Completion:",
|
"maintenanceScreenEstimatedCompletion": "Estimated Completion:",
|
||||||
"createInternalResourceDialogDestinationRequired": "Destination is required",
|
"createInternalResourceDialogDestinationRequired": "Destination is required",
|
||||||
"available": "Available",
|
"available": "Available",
|
||||||
|
"disabledResourceDescription": "When disabled, the resource will be inaccessible by everyone.",
|
||||||
"archived": "Archived",
|
"archived": "Archived",
|
||||||
"noArchivedDevices": "No archived devices found",
|
"noArchivedDevices": "No archived devices found",
|
||||||
"deviceArchived": "Device archived",
|
"deviceArchived": "Device archived",
|
||||||
@@ -3327,5 +3364,6 @@
|
|||||||
"memberPortalResourceDisabled": "Resource Disabled",
|
"memberPortalResourceDisabled": "Resource Disabled",
|
||||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||||
"memberPortalPrevious": "Previous",
|
"memberPortalPrevious": "Previous",
|
||||||
"memberPortalNext": "Next"
|
"memberPortalNext": "Next",
|
||||||
|
"httpSettings": "HTTP Settings"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Esquema",
|
"editInternalResourceDialogScheme": "Esquema",
|
||||||
"editInternalResourceDialogEnableSsl": "Activar TLS",
|
"editInternalResourceDialogEnableSsl": "Activar TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
|
"editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Esquema",
|
"scheme": "Esquema",
|
||||||
"createInternalResourceDialogScheme": "Esquema",
|
"createInternalResourceDialogScheme": "Esquema",
|
||||||
"createInternalResourceDialogEnableSsl": "Activar TLS",
|
"createInternalResourceDialogEnableSsl": "Activar TLS",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Méthode HTTP",
|
"editInternalResourceDialogScheme": "Méthode HTTP",
|
||||||
"editInternalResourceDialogEnableSsl": "Activer TLS",
|
"editInternalResourceDialogEnableSsl": "Activer TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
|
"editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Méthode HTTP",
|
"scheme": "Méthode HTTP",
|
||||||
"createInternalResourceDialogScheme": "Méthode HTTP",
|
"createInternalResourceDialogScheme": "Méthode HTTP",
|
||||||
"createInternalResourceDialogEnableSsl": "Activer TLS",
|
"createInternalResourceDialogEnableSsl": "Activer TLS",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Metodo HTTP",
|
"editInternalResourceDialogScheme": "Metodo HTTP",
|
||||||
"editInternalResourceDialogEnableSsl": "Abilitare TLS",
|
"editInternalResourceDialogEnableSsl": "Abilitare TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
|
"editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Metodo HTTP",
|
"scheme": "Metodo HTTP",
|
||||||
"createInternalResourceDialogScheme": "Metodo HTTP",
|
"createInternalResourceDialogScheme": "Metodo HTTP",
|
||||||
"createInternalResourceDialogEnableSsl": "Abilitare TLS",
|
"createInternalResourceDialogEnableSsl": "Abilitare TLS",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "스킴",
|
"editInternalResourceDialogScheme": "스킴",
|
||||||
"editInternalResourceDialogEnableSsl": "TLS 활성화",
|
"editInternalResourceDialogEnableSsl": "TLS 활성화",
|
||||||
"editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
|
"editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "스킴",
|
"scheme": "스킴",
|
||||||
"createInternalResourceDialogScheme": "스킴",
|
"createInternalResourceDialogScheme": "스킴",
|
||||||
"createInternalResourceDialogEnableSsl": "TLS 활성화",
|
"createInternalResourceDialogEnableSsl": "TLS 활성화",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Skjema",
|
"editInternalResourceDialogScheme": "Skjema",
|
||||||
"editInternalResourceDialogEnableSsl": "Aktiver TLS",
|
"editInternalResourceDialogEnableSsl": "Aktiver TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
|
"editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Skjema",
|
"scheme": "Skjema",
|
||||||
"createInternalResourceDialogScheme": "Skjema",
|
"createInternalResourceDialogScheme": "Skjema",
|
||||||
"createInternalResourceDialogEnableSsl": "Aktiver TLS",
|
"createInternalResourceDialogEnableSsl": "Aktiver TLS",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Schema",
|
"editInternalResourceDialogScheme": "Schema",
|
||||||
"editInternalResourceDialogEnableSsl": "TLS inschakelen",
|
"editInternalResourceDialogEnableSsl": "TLS inschakelen",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
|
"editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Schema",
|
"scheme": "Schema",
|
||||||
"createInternalResourceDialogScheme": "Schema",
|
"createInternalResourceDialogScheme": "Schema",
|
||||||
"createInternalResourceDialogEnableSsl": "TLS inschakelen",
|
"createInternalResourceDialogEnableSsl": "TLS inschakelen",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Schemat",
|
"editInternalResourceDialogScheme": "Schemat",
|
||||||
"editInternalResourceDialogEnableSsl": "Włącz TLS",
|
"editInternalResourceDialogEnableSsl": "Włącz TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
|
"editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Schemat",
|
"scheme": "Schemat",
|
||||||
"createInternalResourceDialogScheme": "Schemat",
|
"createInternalResourceDialogScheme": "Schemat",
|
||||||
"createInternalResourceDialogEnableSsl": "Włącz TLS",
|
"createInternalResourceDialogEnableSsl": "Włącz TLS",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Esquema",
|
"editInternalResourceDialogScheme": "Esquema",
|
||||||
"editInternalResourceDialogEnableSsl": "Ativar TLS",
|
"editInternalResourceDialogEnableSsl": "Ativar TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
|
"editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Esquema",
|
"scheme": "Esquema",
|
||||||
"createInternalResourceDialogScheme": "Esquema",
|
"createInternalResourceDialogScheme": "Esquema",
|
||||||
"createInternalResourceDialogEnableSsl": "Ativar TLS",
|
"createInternalResourceDialogEnableSsl": "Ativar TLS",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "СИДР",
|
"editInternalResourceDialogModeCidr": "СИДР",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Схема",
|
"editInternalResourceDialogScheme": "Схема",
|
||||||
"editInternalResourceDialogEnableSsl": "Включить TLS",
|
"editInternalResourceDialogEnableSsl": "Включить TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.",
|
"editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "СИДР",
|
"createInternalResourceDialogModeCidr": "СИДР",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Схема",
|
"scheme": "Схема",
|
||||||
"createInternalResourceDialogScheme": "Схема",
|
"createInternalResourceDialogScheme": "Схема",
|
||||||
"createInternalResourceDialogEnableSsl": "Включить TLS",
|
"createInternalResourceDialogEnableSsl": "Включить TLS",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "Şema",
|
"editInternalResourceDialogScheme": "Şema",
|
||||||
"editInternalResourceDialogEnableSsl": "TLS Etkinleştir",
|
"editInternalResourceDialogEnableSsl": "TLS Etkinleştir",
|
||||||
"editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
|
"editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "Şema",
|
"scheme": "Şema",
|
||||||
"createInternalResourceDialogScheme": "Şema",
|
"createInternalResourceDialogScheme": "Şema",
|
||||||
"createInternalResourceDialogEnableSsl": "TLS'yi Etkinleştir",
|
"createInternalResourceDialogEnableSsl": "TLS'yi Etkinleştir",
|
||||||
|
|||||||
@@ -2049,6 +2049,7 @@
|
|||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
"editInternalResourceDialogModeHttp": "HTTP",
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogModeSsh": "SSH",
|
||||||
"editInternalResourceDialogScheme": "方案",
|
"editInternalResourceDialogScheme": "方案",
|
||||||
"editInternalResourceDialogEnableSsl": "启用 TLS",
|
"editInternalResourceDialogEnableSsl": "启用 TLS",
|
||||||
"editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
|
"editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
|
||||||
@@ -2098,6 +2099,7 @@
|
|||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
"createInternalResourceDialogModeHttp": "HTTP",
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"createInternalResourceDialogModeSsh": "SSH",
|
||||||
"scheme": "方案",
|
"scheme": "方案",
|
||||||
"createInternalResourceDialogScheme": "方案",
|
"createInternalResourceDialogScheme": "方案",
|
||||||
"createInternalResourceDialogEnableSsl": "启用 TLS",
|
"createInternalResourceDialogEnableSsl": "启用 TLS",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const withNextIntl = createNextIntlPlugin();
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
transpilePackages: ["@novnc/novnc"],
|
||||||
output: "standalone"
|
output: "standalone"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+46
-17
@@ -11,11 +11,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@aws-sdk/client-s3": "3.1047.0",
|
"@aws-sdk/client-s3": "3.1047.0",
|
||||||
"@faker-js/faker": "10.4.0",
|
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||||
|
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
||||||
"@headlessui/react": "2.2.10",
|
"@headlessui/react": "2.2.10",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "2.0.2",
|
||||||
|
"@novnc/novnc": "^1.7.0",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@radix-ui/react-avatar": "1.1.11",
|
"@radix-ui/react-avatar": "1.1.11",
|
||||||
@@ -45,6 +47,9 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.100.14",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.16.1",
|
"axios": "1.16.1",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
@@ -1258,6 +1263,16 @@
|
|||||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@devolutions/iron-remote-desktop": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA=="
|
||||||
|
},
|
||||||
|
"node_modules/@devolutions/iron-remote-desktop-rdp": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-O0YVpOJDwUzekH3N2QKj+48WP+56wI0sj4VmaJkGoW5XgyAj2ONn2k3i+vk17Eavx+Vg6vAg3lwYRAOK4kKIDQ=="
|
||||||
|
},
|
||||||
"node_modules/@dotenvx/dotenvx": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.69.1",
|
"version": "1.69.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.69.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.69.1.tgz",
|
||||||
@@ -1926,22 +1941,6 @@
|
|||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@faker-js/faker": {
|
|
||||||
"version": "10.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.4.0.tgz",
|
|
||||||
"integrity": "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fakerjs"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
|
|
||||||
"npm": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.5",
|
"version": "1.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||||
@@ -3554,6 +3553,12 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@novnc/novnc": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
|
||||||
|
"license": "MPL-2.0"
|
||||||
|
},
|
||||||
"node_modules/@oslojs/asn1": {
|
"node_modules/@oslojs/asn1": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
|
||||||
@@ -8769,6 +8774,27 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-web-links": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -10723,6 +10749,9 @@
|
|||||||
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
|
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -34,11 +34,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@aws-sdk/client-s3": "3.1047.0",
|
"@aws-sdk/client-s3": "3.1047.0",
|
||||||
"@faker-js/faker": "10.4.0",
|
|
||||||
"@headlessui/react": "2.2.10",
|
"@headlessui/react": "2.2.10",
|
||||||
|
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||||
|
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "2.0.2",
|
||||||
|
"@novnc/novnc": "^1.7.0",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@radix-ui/react-avatar": "1.1.11",
|
"@radix-ui/react-avatar": "1.1.11",
|
||||||
@@ -68,6 +70,9 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.100.14",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.16.1",
|
"axios": "1.16.1",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
|
|||||||
@@ -158,7 +158,12 @@ export enum ActionsEnum {
|
|||||||
createHealthCheck = "createHealthCheck",
|
createHealthCheck = "createHealthCheck",
|
||||||
updateHealthCheck = "updateHealthCheck",
|
updateHealthCheck = "updateHealthCheck",
|
||||||
deleteHealthCheck = "deleteHealthCheck",
|
deleteHealthCheck = "deleteHealthCheck",
|
||||||
listHealthChecks = "listHealthChecks"
|
listHealthChecks = "listHealthChecks",
|
||||||
|
createBrowserGatewayTarget = "createBrowserGatewayTarget",
|
||||||
|
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
|
||||||
|
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
|
||||||
|
getBrowserGatewayTarget = "getBrowserGatewayTarget",
|
||||||
|
listBrowserGatewayTargets = "listBrowserGatewayTargets"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
@@ -580,6 +580,24 @@ export const trialNotifications = pgTable("trialNotifications", {
|
|||||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
|
||||||
|
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.references(() => resources.resourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
siteId: integer("siteId")
|
||||||
|
.references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
authToken: varchar("authToken").notNull(),
|
||||||
|
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
|
||||||
|
destination: varchar("destination").notNull(),
|
||||||
|
destinationPort: integer("destinationPort").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -627,3 +645,6 @@ export type AlertEmailRecipients = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
|
export type BrowserGatewayTarget = InferSelectModel<
|
||||||
|
typeof browserGatewayTarget
|
||||||
|
>;
|
||||||
|
|||||||
@@ -129,8 +129,6 @@ export const resources = pgTable("resources", {
|
|||||||
ssl: boolean("ssl").notNull().default(false),
|
ssl: boolean("ssl").notNull().default(false),
|
||||||
blockAccess: boolean("blockAccess").notNull().default(false),
|
blockAccess: boolean("blockAccess").notNull().default(false),
|
||||||
sso: boolean("sso").notNull().default(true),
|
sso: boolean("sso").notNull().default(true),
|
||||||
http: boolean("http").notNull().default(true),
|
|
||||||
protocol: varchar("protocol").notNull(),
|
|
||||||
proxyPort: integer("proxyPort"),
|
proxyPort: integer("proxyPort"),
|
||||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -147,7 +145,6 @@ export const resources = pgTable("resources", {
|
|||||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||||
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
||||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||||
|
|
||||||
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
@@ -159,7 +156,15 @@ export const resources = pgTable("resources", {
|
|||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||||
wildcard: boolean("wildcard").notNull().default(false)
|
wildcard: boolean("wildcard").notNull().default(false),
|
||||||
|
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||||
|
pamMode: varchar("pamMode", { length: 32 })
|
||||||
|
.$type<"passthrough" | "push">()
|
||||||
|
.default("passthrough"),
|
||||||
|
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||||
|
.$type<"site" | "remote" | "native">()
|
||||||
|
.default("site"),
|
||||||
|
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const labels = pgTable("labels", {
|
export const labels = pgTable("labels", {
|
||||||
@@ -339,11 +344,11 @@ export const siteResources = pgTable("siteResources", {
|
|||||||
niceId: varchar("niceId").notNull(),
|
niceId: varchar("niceId").notNull(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
ssl: boolean("ssl").notNull().default(false),
|
ssl: boolean("ssl").notNull().default(false),
|
||||||
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
|
||||||
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
destination: varchar("destination"), // ip, cidr, hostname; validate against the mode
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
alias: varchar("alias"),
|
alias: varchar("alias"),
|
||||||
aliasAddress: varchar("aliasAddress"),
|
aliasAddress: varchar("aliasAddress"),
|
||||||
@@ -351,8 +356,11 @@ export const siteResources = pgTable("siteResources", {
|
|||||||
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
|
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
|
||||||
disableIcmp: boolean("disableIcmp").notNull().default(false),
|
disableIcmp: boolean("disableIcmp").notNull().default(false),
|
||||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||||
|
pamMode: varchar("pamMode", { length: 32 })
|
||||||
|
.$type<"passthrough" | "push">()
|
||||||
|
.default("passthrough"),
|
||||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||||
.$type<"site" | "remote">()
|
.$type<"site" | "remote" | "native">()
|
||||||
.default("site"),
|
.default("site"),
|
||||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
|
|||||||
@@ -588,6 +588,26 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
|||||||
sentAt: integer("sentAt").notNull()
|
sentAt: integer("sentAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
|
||||||
|
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.references(() => resources.resourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
siteId: integer("siteId")
|
||||||
|
.references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
authToken: text("authToken").notNull(),
|
||||||
|
type: text("type").notNull(), // "ssh", "rdp", "vnc"
|
||||||
|
destination: text("destination").notNull(),
|
||||||
|
destinationPort: integer("destinationPort").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -627,3 +647,6 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
|||||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
|
export type BrowserGatewayTarget = InferSelectModel<
|
||||||
|
typeof browserGatewayTarget
|
||||||
|
>;
|
||||||
|
|||||||
@@ -142,8 +142,6 @@ export const resources = sqliteTable("resources", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||||
http: integer("http", { mode: "boolean" }).notNull().default(true),
|
|
||||||
protocol: text("protocol").notNull(),
|
|
||||||
proxyPort: integer("proxyPort"),
|
proxyPort: integer("proxyPort"),
|
||||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -166,7 +164,6 @@ export const resources = sqliteTable("resources", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||||
|
|
||||||
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
|
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
|
||||||
mode: "boolean"
|
mode: "boolean"
|
||||||
})
|
})
|
||||||
@@ -180,7 +177,15 @@ export const resources = sqliteTable("resources", {
|
|||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||||
|
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||||
|
pamMode: text("pamMode")
|
||||||
|
.$type<"passthrough" | "push">()
|
||||||
|
.default("passthrough"),
|
||||||
|
authDaemonMode: text("authDaemonMode")
|
||||||
|
.$type<"site" | "remote" | "native">()
|
||||||
|
.default("site"),
|
||||||
|
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const labels = sqliteTable("labels", {
|
export const labels = sqliteTable("labels", {
|
||||||
@@ -372,11 +377,11 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
niceId: text("niceId").notNull(),
|
niceId: text("niceId").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||||
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
|
||||||
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
destination: text("destination").notNull(), // ip, cidr, hostname
|
destination: text("destination"), // ip, cidr, hostname
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
alias: text("alias"),
|
alias: text("alias"),
|
||||||
aliasAddress: text("aliasAddress"),
|
aliasAddress: text("aliasAddress"),
|
||||||
@@ -386,8 +391,11 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||||
|
pamMode: text("pamMode")
|
||||||
|
.$type<"passthrough" | "push">()
|
||||||
|
.default("passthrough"),
|
||||||
authDaemonMode: text("authDaemonMode")
|
authDaemonMode: text("authDaemonMode")
|
||||||
.$type<"site" | "remote">()
|
.$type<"site" | "remote" | "native">()
|
||||||
.default("site"),
|
.default("site"),
|
||||||
domainId: text("domainId").references(() => domains.domainId, {
|
domainId: text("domainId").references(() => domains.domainId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
} from "./clientResources";
|
} from "./clientResources";
|
||||||
import { BlueprintSource } from "@server/routers/blueprints/types";
|
import { BlueprintSource } from "@server/routers/blueprints/types";
|
||||||
import { stringify as stringifyYaml } from "yaml";
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
|
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
|
||||||
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
|
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
|
||||||
|
|
||||||
@@ -106,7 +105,7 @@ export async function applyBlueprint({
|
|||||||
site.newt.newtId,
|
site.newt.newtId,
|
||||||
[target],
|
[target],
|
||||||
matchingHealthcheck ? [matchingHealthcheck] : [],
|
matchingHealthcheck ? [matchingHealthcheck] : [],
|
||||||
result.proxyResource.protocol,
|
result.proxyResource.mode === "udp" ? "udp" : "tcp",
|
||||||
site.newt.version
|
site.newt.version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,11 @@ export async function updateClientResources(
|
|||||||
: resourceData["udp-ports"],
|
: resourceData["udp-ports"],
|
||||||
fullDomain: resourceData["full-domain"] || null,
|
fullDomain: resourceData["full-domain"] || null,
|
||||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||||
domainId: domainInfo ? domainInfo.domainId : null
|
domainId: domainInfo ? domainInfo.domainId : null,
|
||||||
|
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||||
|
authDaemonMode:
|
||||||
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
|
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -360,8 +364,14 @@ export async function updateClientResources(
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
let aliasAddress: string | null = null;
|
let aliasAddress: string | null = null;
|
||||||
|
let releaseAliasLock: (() => Promise<void>) | null = null;
|
||||||
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
||||||
aliasAddress = await getNextAvailableAliasAddress(orgId, trx);
|
const { value, release } = await getNextAvailableAliasAddress(
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
aliasAddress = value;
|
||||||
|
releaseAliasLock = release;
|
||||||
}
|
}
|
||||||
|
|
||||||
let domainInfo:
|
let domainInfo:
|
||||||
@@ -415,10 +425,16 @@ export async function updateClientResources(
|
|||||||
: resourceData["udp-ports"],
|
: resourceData["udp-ports"],
|
||||||
fullDomain: resourceData["full-domain"] || null,
|
fullDomain: resourceData["full-domain"] || null,
|
||||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||||
domainId: domainInfo ? domainInfo.domainId : null
|
domainId: domainInfo ? domainInfo.domainId : null,
|
||||||
|
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||||
|
authDaemonMode:
|
||||||
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
|
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
await releaseAliasLock?.();
|
||||||
|
|
||||||
const siteResourceId = newResource.siteResourceId;
|
const siteResourceId = newResource.siteResourceId;
|
||||||
|
|
||||||
for (const site of allSites) {
|
for (const site of allSites) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { isValidRegionId } from "@server/db/regions";
|
|||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
import { http } from "winston";
|
||||||
|
|
||||||
export type ProxyResourcesResults = {
|
export type ProxyResourcesResults = {
|
||||||
proxyResource: Resource;
|
proxyResource: Resource;
|
||||||
@@ -198,9 +199,6 @@ export async function updateProxyResources(
|
|||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const http = resourceData.protocol == "http";
|
|
||||||
const protocol =
|
|
||||||
resourceData.protocol == "http" ? "tcp" : resourceData.protocol;
|
|
||||||
const resourceEnabled =
|
const resourceEnabled =
|
||||||
resourceData.enabled == undefined || resourceData.enabled == null
|
resourceData.enabled == undefined || resourceData.enabled == null
|
||||||
? true
|
? true
|
||||||
@@ -216,7 +214,9 @@ export async function updateProxyResources(
|
|||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
let domain;
|
let domain;
|
||||||
if (http) {
|
if (
|
||||||
|
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||||
|
) {
|
||||||
domain = await getDomain(
|
domain = await getDomain(
|
||||||
existingResource.resourceId,
|
existingResource.resourceId,
|
||||||
resourceData["full-domain"]!,
|
resourceData["full-domain"]!,
|
||||||
@@ -246,10 +246,17 @@ export async function updateProxyResources(
|
|||||||
.update(resources)
|
.update(resources)
|
||||||
.set({
|
.set({
|
||||||
name: resourceData.name || "Unnamed Resource",
|
name: resourceData.name || "Unnamed Resource",
|
||||||
protocol: protocol || "tcp",
|
mode: resourceData.mode,
|
||||||
http: http,
|
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
||||||
proxyPort: http ? null : resourceData["proxy-port"],
|
resourceData.mode || ""
|
||||||
fullDomain: http ? resourceData["full-domain"] : null,
|
)
|
||||||
|
? null
|
||||||
|
: resourceData["proxy-port"],
|
||||||
|
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
|
||||||
|
resourceData.mode || ""
|
||||||
|
)
|
||||||
|
? resourceData["full-domain"]
|
||||||
|
: null,
|
||||||
subdomain: domain ? domain.subdomain : null,
|
subdomain: domain ? domain.subdomain : null,
|
||||||
domainId: domain ? domain.domainId : null,
|
domainId: domain ? domain.domainId : null,
|
||||||
wildcard: domain ? domain.wildcard : false,
|
wildcard: domain ? domain.wildcard : false,
|
||||||
@@ -268,6 +275,12 @@ export async function updateProxyResources(
|
|||||||
headers: headers || null,
|
headers: headers || null,
|
||||||
applyRules:
|
applyRules:
|
||||||
resourceData.rules && resourceData.rules.length > 0,
|
resourceData.rules && resourceData.rules.length > 0,
|
||||||
|
pamMode:
|
||||||
|
resourceData["auth-daemon"]?.pam || "passthrough",
|
||||||
|
authDaemonMode:
|
||||||
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
|
authDaemonPort:
|
||||||
|
resourceData["auth-daemon"]?.port || 22123,
|
||||||
maintenanceModeEnabled:
|
maintenanceModeEnabled:
|
||||||
resourceData.maintenance?.enabled,
|
resourceData.maintenance?.enabled,
|
||||||
maintenanceModeType: resourceData.maintenance?.type,
|
maintenanceModeType: resourceData.maintenance?.type,
|
||||||
@@ -466,7 +479,10 @@ export async function updateProxyResources(
|
|||||||
.set({
|
.set({
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
ip: targetData.hostname,
|
ip: targetData.hostname,
|
||||||
method: http ? targetData.method : null,
|
method:
|
||||||
|
resourceData.mode == "http" // the other types of ssh, rdp, and vnc use the browser gateway targets and not this one so this is okay
|
||||||
|
? targetData.method
|
||||||
|
: null,
|
||||||
port: targetData.port,
|
port: targetData.port,
|
||||||
enabled: targetData.enabled,
|
enabled: targetData.enabled,
|
||||||
path: targetData.path,
|
path: targetData.path,
|
||||||
@@ -687,7 +703,9 @@ export async function updateProxyResources(
|
|||||||
} else {
|
} else {
|
||||||
// create a brand new resource
|
// create a brand new resource
|
||||||
let domain;
|
let domain;
|
||||||
if (http) {
|
if (
|
||||||
|
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||||
|
) {
|
||||||
domain = await getDomain(
|
domain = await getDomain(
|
||||||
undefined,
|
undefined,
|
||||||
resourceData["full-domain"]!,
|
resourceData["full-domain"]!,
|
||||||
@@ -711,10 +729,17 @@ export async function updateProxyResources(
|
|||||||
orgId,
|
orgId,
|
||||||
niceId: resourceNiceId,
|
niceId: resourceNiceId,
|
||||||
name: resourceData.name || "Unnamed Resource",
|
name: resourceData.name || "Unnamed Resource",
|
||||||
protocol: protocol || "tcp",
|
mode: resourceData.mode,
|
||||||
http: http,
|
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
||||||
proxyPort: http ? null : resourceData["proxy-port"],
|
resourceData.mode || ""
|
||||||
fullDomain: http ? resourceData["full-domain"] : null,
|
)
|
||||||
|
? null
|
||||||
|
: resourceData["proxy-port"],
|
||||||
|
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
|
||||||
|
resourceData.mode || ""
|
||||||
|
)
|
||||||
|
? resourceData["full-domain"]
|
||||||
|
: null,
|
||||||
subdomain: domain ? domain.subdomain : null,
|
subdomain: domain ? domain.subdomain : null,
|
||||||
domainId: domain ? domain.domainId : null,
|
domainId: domain ? domain.domainId : null,
|
||||||
wildcard: domain ? domain.wildcard : false,
|
wildcard: domain ? domain.wildcard : false,
|
||||||
@@ -727,6 +752,10 @@ export async function updateProxyResources(
|
|||||||
headers: headers || null,
|
headers: headers || null,
|
||||||
applyRules:
|
applyRules:
|
||||||
resourceData.rules && resourceData.rules.length > 0,
|
resourceData.rules && resourceData.rules.length > 0,
|
||||||
|
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||||
|
authDaemonMode:
|
||||||
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
|
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||||
maintenanceModeEnabled: resourceData.maintenance?.enabled,
|
maintenanceModeEnabled: resourceData.maintenance?.enabled,
|
||||||
maintenanceModeType: resourceData.maintenance?.type,
|
maintenanceModeType: resourceData.maintenance?.type,
|
||||||
maintenanceTitle: resourceData.maintenance?.title,
|
maintenanceTitle: resourceData.maintenance?.title,
|
||||||
|
|||||||
@@ -161,11 +161,33 @@ export const HeaderSchema = z.object({
|
|||||||
value: z.string().min(1)
|
value: z.string().min(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AuthDaemonSchema = z
|
||||||
|
.object({
|
||||||
|
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
|
||||||
|
mode: z.enum(["site", "remote", "native"]).optional().default("site"),
|
||||||
|
port: z.int().min(1).max(65535).optional()
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.mode === "remote") {
|
||||||
|
return data.port !== undefined;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["port"],
|
||||||
|
message: "port is required when auth-daemon mode is 'remote'"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Schema for individual resource
|
// Schema for individual resource
|
||||||
export const ResourceSchema = z
|
export const PublicResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
protocol: z
|
||||||
|
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
||||||
|
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
|
||||||
|
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
scheme: z.enum(["http", "https"]).optional(),
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
"full-domain": z.string().optional(),
|
"full-domain": z.string().optional(),
|
||||||
@@ -177,7 +199,8 @@ export const ResourceSchema = z
|
|||||||
"tls-server-name": z.string().optional(),
|
"tls-server-name": z.string().optional(),
|
||||||
headers: z.array(HeaderSchema).optional(),
|
headers: z.array(HeaderSchema).optional(),
|
||||||
rules: z.array(RuleSchema).optional(),
|
rules: z.array(RuleSchema).optional(),
|
||||||
maintenance: MaintenanceSchema.optional()
|
maintenance: MaintenanceSchema.optional(),
|
||||||
|
"auth-daemon": AuthDaemonSchema.optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(resource) => {
|
(resource) => {
|
||||||
@@ -185,9 +208,10 @@ export const ResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, require name and protocol for full resource definition
|
// Otherwise, require name and protocol/mode for full resource definition
|
||||||
return (
|
return (
|
||||||
resource.name !== undefined && resource.protocol !== undefined
|
resource.name !== undefined &&
|
||||||
|
(resource.mode !== undefined || resource.protocol !== undefined)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -201,8 +225,8 @@ export const ResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol is http, all targets must have method field
|
// If protocol/mode is http, all targets must have method field
|
||||||
if (resource.protocol === "http") {
|
if ((resource.mode ?? resource.protocol) === "http") {
|
||||||
return resource.targets.every(
|
return resource.targets.every(
|
||||||
(target) => target == null || target.method !== undefined
|
(target) => target == null || target.method !== undefined
|
||||||
);
|
);
|
||||||
@@ -220,8 +244,9 @@ export const ResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol is tcp or udp, no target should have method field
|
// If protocol/mode is tcp or udp, no target should have method field
|
||||||
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
const effectiveProtocol1 = resource.mode ?? resource.protocol;
|
||||||
|
if (effectiveProtocol1 === "tcp" || effectiveProtocol1 === "udp") {
|
||||||
return resource.targets.every(
|
return resource.targets.every(
|
||||||
(target) => target == null || target.method === undefined
|
(target) => target == null || target.method === undefined
|
||||||
);
|
);
|
||||||
@@ -239,8 +264,8 @@ export const ResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol is http, it must have a full-domain
|
// If protocol/mode is http, it must have a full-domain
|
||||||
if (resource.protocol === "http") {
|
if ((resource.mode ?? resource.protocol) === "http") {
|
||||||
return (
|
return (
|
||||||
resource["full-domain"] !== undefined &&
|
resource["full-domain"] !== undefined &&
|
||||||
resource["full-domain"].length > 0
|
resource["full-domain"].length > 0
|
||||||
@@ -259,8 +284,9 @@ export const ResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol is tcp or udp, it must have both proxy-port
|
// If protocol/mode is tcp or udp, it must have both proxy-port
|
||||||
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
const effectiveProtocol2 = resource.mode ?? resource.protocol;
|
||||||
|
if (effectiveProtocol2 === "tcp" || effectiveProtocol2 === "udp") {
|
||||||
return resource["proxy-port"] !== undefined;
|
return resource["proxy-port"] !== undefined;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -277,8 +303,9 @@ export const ResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol is tcp or udp, it must not have auth
|
// If protocol/mode is tcp or udp, it must not have auth
|
||||||
if (resource.protocol === "tcp" || resource.protocol === "udp") {
|
const effectiveProtocol3 = resource.mode ?? resource.protocol;
|
||||||
|
if (effectiveProtocol3 === "tcp" || effectiveProtocol3 === "udp") {
|
||||||
return resource.auth === undefined;
|
return resource.auth === undefined;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -349,22 +376,29 @@ export const ResourceSchema = z
|
|||||||
message:
|
message:
|
||||||
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
|
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
.transform((resource) => {
|
||||||
|
// Normalize: prefer mode, fall back to protocol for backwards compatibility
|
||||||
|
if (resource.mode === undefined && resource.protocol !== undefined) {
|
||||||
|
resource.mode = resource.protocol;
|
||||||
|
}
|
||||||
|
return resource;
|
||||||
|
});
|
||||||
|
|
||||||
export function isTargetsOnlyResource(resource: any): boolean {
|
export function isTargetsOnlyResource(resource: any): boolean {
|
||||||
return Object.keys(resource).length === 1 && resource.targets;
|
return Object.keys(resource).length === 1 && resource.targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClientResourceSchema = z
|
export const PrivateResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
mode: z.enum(["host", "cidr", "http"]),
|
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
||||||
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
|
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
|
||||||
sites: z.array(z.string()).optional().default([]),
|
sites: z.array(z.string()).optional().default([]),
|
||||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||||
// proxyPort: z.int().positive().optional(),
|
// proxyPort: z.int().positive().optional(),
|
||||||
"destination-port": z.int().positive().optional(),
|
"destination-port": z.int().positive().optional(),
|
||||||
destination: z.string().min(1),
|
destination: z.string().min(1).optional(),
|
||||||
// enabled: z.boolean().default(true),
|
// enabled: z.boolean().default(true),
|
||||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
"udp-ports": portRangeStringSchema.optional().default("*"),
|
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
@@ -387,11 +421,31 @@ export const ClientResourceSchema = z
|
|||||||
error: "Admin role cannot be included in roles"
|
error: "Admin role cannot be included in roles"
|
||||||
}),
|
}),
|
||||||
users: z.array(z.string()).optional().default([]),
|
users: z.array(z.string()).optional().default([]),
|
||||||
machines: z.array(z.string()).optional().default([])
|
machines: z.array(z.string()).optional().default([]),
|
||||||
|
"auth-daemon": AuthDaemonSchema.optional()
|
||||||
})
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// destination is optional only for ssh+native; required for everything else
|
||||||
|
const isNativeSSH =
|
||||||
|
data.mode === "ssh" &&
|
||||||
|
(data["auth-daemon"] === undefined ||
|
||||||
|
data["auth-daemon"].mode === "native");
|
||||||
|
if (!isNativeSSH && !data.destination) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["destination"],
|
||||||
|
message:
|
||||||
|
"destination is required unless mode is 'ssh' with auth-daemon mode 'native'"
|
||||||
|
}
|
||||||
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode === "host") {
|
if (data.mode === "host") {
|
||||||
|
if (!data.destination) return true; // caught by the destination-required refine
|
||||||
// Check if it's a valid IP address using zod (v4 or v6)
|
// Check if it's a valid IP address using zod (v4 or v6)
|
||||||
const isValidIP = z
|
const isValidIP = z
|
||||||
.union([z.ipv4(), z.ipv6()])
|
.union([z.ipv4(), z.ipv6()])
|
||||||
@@ -419,6 +473,7 @@ export const ClientResourceSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode === "cidr") {
|
if (data.mode === "cidr") {
|
||||||
|
if (!data.destination) return true; // caught by the destination-required refine
|
||||||
// Check if it's a valid CIDR (v4 or v6)
|
// Check if it's a valid CIDR (v4 or v6)
|
||||||
const isValidCIDR = z
|
const isValidCIDR = z
|
||||||
.union([z.cidrv4(), z.cidrv6()])
|
.union([z.cidrv4(), z.cidrv6()])
|
||||||
@@ -436,19 +491,19 @@ export const ClientResourceSchema = z
|
|||||||
export const ConfigSchema = z
|
export const ConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
"proxy-resources": z
|
"proxy-resources": z
|
||||||
.record(z.string(), ResourceSchema)
|
.record(z.string(), PublicResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
"public-resources": z
|
"public-resources": z
|
||||||
.record(z.string(), ResourceSchema)
|
.record(z.string(), PublicResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
"client-resources": z
|
"client-resources": z
|
||||||
.record(z.string(), ClientResourceSchema)
|
.record(z.string(), PrivateResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
"private-resources": z
|
"private-resources": z
|
||||||
.record(z.string(), ClientResourceSchema)
|
.record(z.string(), PrivateResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
||||||
@@ -473,10 +528,13 @@ export const ConfigSchema = z
|
|||||||
}
|
}
|
||||||
|
|
||||||
return data as {
|
return data as {
|
||||||
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>;
|
"proxy-resources": Record<
|
||||||
|
string,
|
||||||
|
z.infer<typeof PublicResourceSchema>
|
||||||
|
>;
|
||||||
"client-resources": Record<
|
"client-resources": Record<
|
||||||
string,
|
string,
|
||||||
z.infer<typeof ClientResourceSchema>
|
z.infer<typeof PrivateResourceSchema>
|
||||||
>;
|
>;
|
||||||
sites: Record<string, z.infer<typeof SiteSchema>>;
|
sites: Record<string, z.infer<typeof SiteSchema>>;
|
||||||
};
|
};
|
||||||
@@ -615,5 +673,5 @@ export const ConfigSchema = z
|
|||||||
// Type inference from the schema
|
// Type inference from the schema
|
||||||
export type Site = z.infer<typeof SiteSchema>;
|
export type Site = z.infer<typeof SiteSchema>;
|
||||||
export type Target = z.infer<typeof TargetSchema>;
|
export type Target = z.infer<typeof TargetSchema>;
|
||||||
export type Resource = z.infer<typeof ResourceSchema>;
|
export type Resource = z.infer<typeof PublicResourceSchema>;
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@@ -331,16 +331,8 @@ export async function calculateUserClientsForOrgs(
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Get next available subnet
|
// Get next available subnet
|
||||||
const newSubnet = await getNextAvailableClientSubnet(
|
const { value: newSubnet, release: releaseSubnetLock } =
|
||||||
orgId,
|
await getNextAvailableClientSubnet(orgId, transaction);
|
||||||
transaction
|
|
||||||
);
|
|
||||||
if (!newSubnet) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subnet = newSubnet.split("/")[0];
|
const subnet = newSubnet.split("/")[0];
|
||||||
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
|
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
|
||||||
@@ -370,6 +362,7 @@ export async function calculateUserClientsForOrgs(
|
|||||||
.insert(clients)
|
.insert(clients)
|
||||||
.values(newClientData)
|
.values(newClientData)
|
||||||
.returning();
|
.returning();
|
||||||
|
await releaseSubnetLock();
|
||||||
existingClientCache.set(
|
existingClientCache.set(
|
||||||
getOrgOlmKey(orgId, olm.olmId),
|
getOrgOlmKey(orgId, olm.olmId),
|
||||||
newClient
|
newClient
|
||||||
|
|||||||
+139
-116
@@ -327,127 +327,145 @@ export function doCidrsOverlap(cidr1: string, cidr2: string): boolean {
|
|||||||
export async function getNextAvailableClientSubnet(
|
export async function getNextAvailableClientSubnet(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
transaction: Transaction | typeof db = db
|
transaction: Transaction | typeof db = db
|
||||||
): Promise<string> {
|
): Promise<{ value: string; release: () => Promise<void> }> {
|
||||||
return await lockManager.withLock(
|
const lockKey = `client-subnet-allocation:${orgId}`;
|
||||||
`client-subnet-allocation:${orgId}`,
|
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||||
async () => {
|
if (!acquired) {
|
||||||
const [org] = await transaction
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
.select()
|
}
|
||||||
.from(orgs)
|
const release = () => lockManager.releaseLock(lockKey);
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
|
|
||||||
if (!org) {
|
try {
|
||||||
throw new Error(`Organization with ID ${orgId} not found`);
|
const [org] = await transaction
|
||||||
}
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
if (!org.subnet) {
|
if (!org) {
|
||||||
throw new Error(
|
throw new Error(`Organization with ID ${orgId} not found`);
|
||||||
`Organization with ID ${orgId} has no subnet defined`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingAddressesSites = await transaction
|
|
||||||
.select({
|
|
||||||
address: sites.address
|
|
||||||
})
|
|
||||||
.from(sites)
|
|
||||||
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
|
|
||||||
|
|
||||||
const existingAddressesClients = await transaction
|
|
||||||
.select({
|
|
||||||
address: clients.subnet
|
|
||||||
})
|
|
||||||
.from(clients)
|
|
||||||
.where(
|
|
||||||
and(isNotNull(clients.subnet), eq(clients.orgId, orgId))
|
|
||||||
);
|
|
||||||
|
|
||||||
const addresses = [
|
|
||||||
...existingAddressesSites.map(
|
|
||||||
(site) => `${site.address?.split("/")[0]}/32`
|
|
||||||
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
|
|
||||||
...existingAddressesClients.map(
|
|
||||||
(client) => `${client.address.split("/")}/32`
|
|
||||||
)
|
|
||||||
].filter((address) => address !== null) as string[];
|
|
||||||
|
|
||||||
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
|
|
||||||
if (!subnet) {
|
|
||||||
throw new Error("No available subnets remaining in space");
|
|
||||||
}
|
|
||||||
|
|
||||||
return subnet;
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
if (!org.subnet) {
|
||||||
|
throw new Error(
|
||||||
|
`Organization with ID ${orgId} has no subnet defined`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAddressesSites = await transaction
|
||||||
|
.select({
|
||||||
|
address: sites.address
|
||||||
|
})
|
||||||
|
.from(sites)
|
||||||
|
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
|
||||||
|
|
||||||
|
const existingAddressesClients = await transaction
|
||||||
|
.select({
|
||||||
|
address: clients.subnet
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
|
||||||
|
|
||||||
|
const addresses = [
|
||||||
|
...existingAddressesSites.map(
|
||||||
|
(site) => `${site.address?.split("/")[0]}/32`
|
||||||
|
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
|
||||||
|
...existingAddressesClients.map(
|
||||||
|
(client) => `${client.address.split("/")[0]}/32`
|
||||||
|
)
|
||||||
|
].filter((address) => address !== null) as string[];
|
||||||
|
|
||||||
|
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
|
||||||
|
if (!subnet) {
|
||||||
|
throw new Error("No available subnets remaining in space");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: subnet, release };
|
||||||
|
} catch (e) {
|
||||||
|
await release();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextAvailableAliasAddress(
|
export async function getNextAvailableAliasAddress(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<string> {
|
): Promise<{ value: string; release: () => Promise<void> }> {
|
||||||
return await lockManager.withLock(
|
const lockKey = `alias-address-allocation:${orgId}`;
|
||||||
`alias-address-allocation:${orgId}`,
|
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||||
async () => {
|
if (!acquired) {
|
||||||
const [org] = await trx
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
.select()
|
}
|
||||||
.from(orgs)
|
const release = () => lockManager.releaseLock(lockKey);
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
|
|
||||||
if (!org) {
|
try {
|
||||||
throw new Error(`Organization with ID ${orgId} not found`);
|
const [org] = await trx
|
||||||
}
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
if (!org.subnet) {
|
if (!org) {
|
||||||
throw new Error(
|
throw new Error(`Organization with ID ${orgId} not found`);
|
||||||
`Organization with ID ${orgId} has no subnet defined`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!org.utilitySubnet) {
|
|
||||||
throw new Error(
|
|
||||||
`Organization with ID ${orgId} has no utility subnet defined`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingAddresses = await trx
|
|
||||||
.select({
|
|
||||||
aliasAddress: siteResources.aliasAddress
|
|
||||||
})
|
|
||||||
.from(siteResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
isNotNull(siteResources.aliasAddress),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const addresses = [
|
|
||||||
...existingAddresses.map(
|
|
||||||
(site) => `${site.aliasAddress?.split("/")[0]}/32`
|
|
||||||
),
|
|
||||||
// reserve a /29 for the dns server and other stuff
|
|
||||||
`${org.utilitySubnet.split("/")[0]}/29`
|
|
||||||
].filter((address) => address !== null) as string[];
|
|
||||||
|
|
||||||
let subnet = findNextAvailableCidr(
|
|
||||||
addresses,
|
|
||||||
32,
|
|
||||||
org.utilitySubnet
|
|
||||||
);
|
|
||||||
if (!subnet) {
|
|
||||||
throw new Error("No available subnets remaining in space");
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove the cidr
|
|
||||||
subnet = subnet.split("/")[0];
|
|
||||||
|
|
||||||
return subnet;
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
if (!org.subnet) {
|
||||||
|
throw new Error(
|
||||||
|
`Organization with ID ${orgId} has no subnet defined`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org.utilitySubnet) {
|
||||||
|
throw new Error(
|
||||||
|
`Organization with ID ${orgId} has no utility subnet defined`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAddresses = await trx
|
||||||
|
.select({
|
||||||
|
aliasAddress: siteResources.aliasAddress
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNotNull(siteResources.aliasAddress),
|
||||||
|
eq(siteResources.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const addresses = [
|
||||||
|
...existingAddresses.map(
|
||||||
|
(site) => `${site.aliasAddress?.split("/")[0]}/32`
|
||||||
|
),
|
||||||
|
// reserve a /29 for the dns server and other stuff
|
||||||
|
`${org.utilitySubnet.split("/")[0]}/29`
|
||||||
|
].filter((address) => address !== null) as string[];
|
||||||
|
|
||||||
|
let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet);
|
||||||
|
if (!subnet) {
|
||||||
|
throw new Error("No available subnets remaining in space");
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the cidr
|
||||||
|
subnet = subnet.split("/")[0];
|
||||||
|
|
||||||
|
return { value: subnet, release };
|
||||||
|
} catch (e) {
|
||||||
|
await release();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextAvailableOrgSubnet(): Promise<string> {
|
export async function getNextAvailableOrgSubnet(): Promise<{
|
||||||
return await lockManager.withLock("org-subnet-allocation", async () => {
|
value: string;
|
||||||
|
release: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const lockKey = "org-subnet-allocation";
|
||||||
|
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||||
|
if (!acquired) {
|
||||||
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
|
}
|
||||||
|
const release = () => lockManager.releaseLock(lockKey);
|
||||||
|
|
||||||
|
try {
|
||||||
const existingAddresses = await db
|
const existingAddresses = await db
|
||||||
.select({
|
.select({
|
||||||
subnet: orgs.subnet
|
subnet: orgs.subnet
|
||||||
@@ -466,8 +484,11 @@ export async function getNextAvailableOrgSubnet(): Promise<string> {
|
|||||||
throw new Error("No available subnets remaining in space");
|
throw new Error("No available subnets remaining in space");
|
||||||
}
|
}
|
||||||
|
|
||||||
return subnet;
|
return { value: subnet, release };
|
||||||
});
|
} catch (e) {
|
||||||
|
await release();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateRemoteSubnets(
|
export function generateRemoteSubnets(
|
||||||
@@ -475,6 +496,8 @@ export function generateRemoteSubnets(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const remoteSubnets = allSiteResources
|
const remoteSubnets = allSiteResources
|
||||||
.filter((sr) => {
|
.filter((sr) => {
|
||||||
|
if (!sr.destination) return false;
|
||||||
|
|
||||||
if (sr.mode === "cidr") {
|
if (sr.mode === "cidr") {
|
||||||
// check if its a valid CIDR using zod
|
// check if its a valid CIDR using zod
|
||||||
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
|
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
|
||||||
@@ -496,7 +519,7 @@ export function generateRemoteSubnets(
|
|||||||
}
|
}
|
||||||
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
||||||
})
|
})
|
||||||
.filter((subnet) => subnet !== ""); // Remove empty strings just to be safe
|
.filter((subnet): subnet is string => subnet !== "" && subnet !== null); // Remove invalid values just to be safe
|
||||||
// remove duplicates
|
// remove duplicates
|
||||||
return Array.from(new Set(remoteSubnets));
|
return Array.from(new Set(remoteSubnets));
|
||||||
}
|
}
|
||||||
@@ -581,7 +604,7 @@ export function generateSubnetProxyTargets(
|
|||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefix: clientPrefix,
|
sourcePrefix: clientPrefix,
|
||||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||||
rewriteTo: destination,
|
rewriteTo: destination!,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp
|
disableIcmp
|
||||||
});
|
});
|
||||||
@@ -589,7 +612,7 @@ export function generateSubnetProxyTargets(
|
|||||||
} else if (siteResource.mode == "cidr") {
|
} else if (siteResource.mode == "cidr") {
|
||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefix: clientPrefix,
|
sourcePrefix: clientPrefix,
|
||||||
destPrefix: siteResource.destination,
|
destPrefix: siteResource.destination!,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp
|
disableIcmp
|
||||||
});
|
});
|
||||||
@@ -671,7 +694,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefixes: [],
|
sourcePrefixes: [],
|
||||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||||
rewriteTo: destination,
|
rewriteTo: destination!,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
resourceId: siteResource.siteResourceId
|
resourceId: siteResource.siteResourceId
|
||||||
@@ -680,7 +703,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
} else if (siteResource.mode == "cidr") {
|
} else if (siteResource.mode == "cidr") {
|
||||||
targets.push({
|
targets.push({
|
||||||
sourcePrefixes: [],
|
sourcePrefixes: [],
|
||||||
destPrefix: siteResource.destination,
|
destPrefix: siteResource.destination!,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
resourceId: siteResource.siteResourceId
|
resourceId: siteResource.siteResourceId
|
||||||
@@ -738,7 +761,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
protocol: siteResource.ssl ? "https" : "http",
|
protocol: siteResource.ssl ? "https" : "http",
|
||||||
httpTargets: [
|
httpTargets: [
|
||||||
{
|
{
|
||||||
destAddr: siteResource.destination,
|
destAddr: siteResource.destination!,
|
||||||
destPort: siteResource.destinationPort,
|
destPort: siteResource.destinationPort,
|
||||||
scheme: siteResource.scheme
|
scheme: siteResource.scheme
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -890,6 +890,9 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const client of removedClients) {
|
for (const client of removedClients) {
|
||||||
|
if (!siteResource.destination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Check if this client still has access to another resource
|
// Check if this client still has access to another resource
|
||||||
// on this specific site with the same destination. We scope
|
// on this specific site with the same destination. We scope
|
||||||
// by siteId (via siteNetworks) rather than networkId because
|
// by siteId (via siteNetworks) rather than networkId because
|
||||||
@@ -1563,6 +1566,9 @@ async function handleMessagesForClientResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!resource.destination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Check if this client still has access to another resource
|
// Check if this client still has access to another resource
|
||||||
// on this specific site with the same destination. We scope
|
// on this specific site with the same destination. We scope
|
||||||
// by siteId (via siteNetworks) rather than networkId because
|
// by siteId (via siteNetworks) rather than networkId because
|
||||||
|
|||||||
+10
-4
@@ -2,7 +2,14 @@ import { PostHog } from "posthog-node";
|
|||||||
import config from "./config";
|
import config from "./config";
|
||||||
import { getHostMeta } from "./hostMeta";
|
import { getHostMeta } from "./hostMeta";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { alertRules, apiKeys, blueprints, db, roles, siteResources } from "@server/db";
|
import {
|
||||||
|
alertRules,
|
||||||
|
apiKeys,
|
||||||
|
blueprints,
|
||||||
|
db,
|
||||||
|
roles,
|
||||||
|
siteResources
|
||||||
|
} from "@server/db";
|
||||||
import { sites, users, orgs, resources, clients, idp } from "@server/db";
|
import { sites, users, orgs, resources, clients, idp } from "@server/db";
|
||||||
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
|
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
|
||||||
import { APP_VERSION } from "./consts";
|
import { APP_VERSION } from "./consts";
|
||||||
@@ -143,8 +150,7 @@ class TelemetryClient {
|
|||||||
.select({
|
.select({
|
||||||
name: resources.name,
|
name: resources.name,
|
||||||
sso: resources.sso,
|
sso: resources.sso,
|
||||||
protocol: resources.protocol,
|
mode: resources.mode
|
||||||
http: resources.http
|
|
||||||
})
|
})
|
||||||
.from(resources);
|
.from(resources);
|
||||||
|
|
||||||
@@ -311,7 +317,7 @@ class TelemetryClient {
|
|||||||
(r) => r.sso
|
(r) => r.sso
|
||||||
).length,
|
).length,
|
||||||
num_resources_non_http: stats.resources.filter(
|
num_resources_non_http: stats.resources.filter(
|
||||||
(r) => !r.http
|
(r) => r.mode !== "http"
|
||||||
).length,
|
).length,
|
||||||
num_newt_sites: stats.sites.filter((s) => s.type === "newt")
|
num_newt_sites: stats.sites.filter((s) => s.type === "newt")
|
||||||
.length,
|
.length,
|
||||||
|
|||||||
@@ -55,9 +55,7 @@ export async function getTraefikConfig(
|
|||||||
resourceName: resources.name,
|
resourceName: resources.name,
|
||||||
fullDomain: resources.fullDomain,
|
fullDomain: resources.fullDomain,
|
||||||
ssl: resources.ssl,
|
ssl: resources.ssl,
|
||||||
http: resources.http,
|
|
||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
protocol: resources.protocol,
|
|
||||||
subdomain: resources.subdomain,
|
subdomain: resources.subdomain,
|
||||||
domainId: resources.domainId,
|
domainId: resources.domainId,
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
@@ -68,6 +66,7 @@ export async function getTraefikConfig(
|
|||||||
headers: resources.headers,
|
headers: resources.headers,
|
||||||
proxyProtocol: resources.proxyProtocol,
|
proxyProtocol: resources.proxyProtocol,
|
||||||
proxyProtocolVersion: resources.proxyProtocolVersion,
|
proxyProtocolVersion: resources.proxyProtocolVersion,
|
||||||
|
mode: resources.mode,
|
||||||
|
|
||||||
// Target fields
|
// Target fields
|
||||||
targetId: targets.targetId,
|
targetId: targets.targetId,
|
||||||
@@ -115,8 +114,8 @@ export async function getTraefikConfig(
|
|||||||
),
|
),
|
||||||
inArray(sites.type, siteTypes),
|
inArray(sites.type, siteTypes),
|
||||||
allowRawResources
|
allowRawResources
|
||||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
|
||||||
: eq(resources.http, true)
|
: eq(resources.mode, "http")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||||
@@ -166,9 +165,8 @@ export async function getTraefikConfig(
|
|||||||
key: key,
|
key: key,
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
http: row.http,
|
mode: row.mode,
|
||||||
proxyPort: row.proxyPort,
|
proxyPort: row.proxyPort,
|
||||||
protocol: row.protocol,
|
|
||||||
subdomain: row.subdomain,
|
subdomain: row.subdomain,
|
||||||
domainId: row.domainId,
|
domainId: row.domainId,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
@@ -580,7 +578,7 @@ export async function getTraefikConfig(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocol = resource.protocol.toLowerCase();
|
const protocol = resource.mode === "udp" ? "udp" : "tcp"; // all of the other ones are tcp
|
||||||
const port = resource.proxyPort;
|
const port = resource.proxyPort;
|
||||||
|
|
||||||
if (!port) {
|
if (!port) {
|
||||||
|
|||||||
@@ -780,9 +780,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
// logger.debug(
|
||||||
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||||
);
|
// );
|
||||||
|
|
||||||
for (const domain of allDomains) {
|
for (const domain of allDomains) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ export async function getValidCertificatesForDomains(
|
|||||||
domains: Set<string>,
|
domains: Set<string>,
|
||||||
useCache: boolean = true
|
useCache: boolean = true
|
||||||
): Promise<Array<CertificateResult>> {
|
): Promise<Array<CertificateResult>> {
|
||||||
|
|
||||||
|
|
||||||
const finalResults: CertificateResult[] = [];
|
const finalResults: CertificateResult[] = [];
|
||||||
const domainsToQuery = new Set<string>();
|
const domainsToQuery = new Set<string>();
|
||||||
|
|
||||||
@@ -49,7 +47,26 @@ export async function getValidCertificatesForDomains(
|
|||||||
if (cachedCert) {
|
if (cachedCert) {
|
||||||
finalResults.push(cachedCert); // Valid cache hit
|
finalResults.push(cachedCert); // Valid cache hit
|
||||||
} else {
|
} else {
|
||||||
domainsToQuery.add(domain); // Cache miss or expired
|
// Also check for a wildcard cache entry covering this domain's parent
|
||||||
|
const parts = domain.split(".");
|
||||||
|
let wildcardHit = false;
|
||||||
|
if (parts.length > 1) {
|
||||||
|
const parentDomain = parts.slice(1).join(".");
|
||||||
|
const wildcardCacheKey = `cert:*.${parentDomain}`;
|
||||||
|
const cachedWildcard =
|
||||||
|
await cache.get<CertificateResult>(wildcardCacheKey);
|
||||||
|
if (cachedWildcard) {
|
||||||
|
// Re-stamp queriedDomain so callers see the originally requested domain
|
||||||
|
finalResults.push({
|
||||||
|
...cachedWildcard,
|
||||||
|
queriedDomain: domain
|
||||||
|
});
|
||||||
|
wildcardHit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!wildcardHit) {
|
||||||
|
domainsToQuery.add(domain); // Cache miss or expired
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -59,7 +76,10 @@ export async function getValidCertificatesForDomains(
|
|||||||
|
|
||||||
// 2. If all domains were resolved from the cache, return early
|
// 2. If all domains were resolved from the cache, return early
|
||||||
if (domainsToQuery.size === 0) {
|
if (domainsToQuery.size === 0) {
|
||||||
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
const decryptedResults = decryptFinalResults(
|
||||||
|
finalResults,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
return decryptedResults;
|
return decryptedResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +98,8 @@ export async function getValidCertificatesForDomains(
|
|||||||
const parentDomainsArray = Array.from(parentDomainsToQuery);
|
const parentDomainsArray = Array.from(parentDomainsToQuery);
|
||||||
|
|
||||||
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
|
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
|
||||||
const wildcardPrefixedArray = build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
|
const wildcardPrefixedArray =
|
||||||
|
build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
|
||||||
|
|
||||||
// 4. Build and execute a single, efficient Drizzle query
|
// 4. Build and execute a single, efficient Drizzle query
|
||||||
// This query fetches all potential exact and wildcard matches in one database round-trip.
|
// This query fetches all potential exact and wildcard matches in one database round-trip.
|
||||||
@@ -172,11 +193,24 @@ export async function getValidCertificatesForDomains(
|
|||||||
if (useCache) {
|
if (useCache) {
|
||||||
const cacheKey = `cert:${domain}`;
|
const cacheKey = `cert:${domain}`;
|
||||||
await cache.set(cacheKey, resultCert, 180);
|
await cache.set(cacheKey, resultCert, 180);
|
||||||
|
|
||||||
|
// Also cache wildcard certs under a pattern key so other subdomains
|
||||||
|
// can find them without a DB round-trip
|
||||||
|
if (resultCert.wildcard) {
|
||||||
|
const normalizedCertDomain = normalizeWildcardDomain(
|
||||||
|
resultCert.domain
|
||||||
|
);
|
||||||
|
const wildcardCacheKey = `cert:*.${normalizedCertDomain}`;
|
||||||
|
await cache.set(wildcardCacheKey, resultCert, 180);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
const decryptedResults = decryptFinalResults(
|
||||||
|
finalResults,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
return decryptedResults;
|
return decryptedResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
certificates,
|
certificates,
|
||||||
db,
|
db,
|
||||||
domainNamespaces,
|
domainNamespaces,
|
||||||
@@ -95,9 +96,7 @@ export async function getTraefikConfig(
|
|||||||
resourceName: resources.name,
|
resourceName: resources.name,
|
||||||
fullDomain: resources.fullDomain,
|
fullDomain: resources.fullDomain,
|
||||||
ssl: resources.ssl,
|
ssl: resources.ssl,
|
||||||
http: resources.http,
|
|
||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
protocol: resources.protocol,
|
|
||||||
subdomain: resources.subdomain,
|
subdomain: resources.subdomain,
|
||||||
domainId: resources.domainId,
|
domainId: resources.domainId,
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
@@ -109,6 +108,7 @@ export async function getTraefikConfig(
|
|||||||
proxyProtocol: resources.proxyProtocol,
|
proxyProtocol: resources.proxyProtocol,
|
||||||
proxyProtocolVersion: resources.proxyProtocolVersion,
|
proxyProtocolVersion: resources.proxyProtocolVersion,
|
||||||
wildcard: resources.wildcard,
|
wildcard: resources.wildcard,
|
||||||
|
mode: resources.mode,
|
||||||
|
|
||||||
maintenanceModeEnabled: resources.maintenanceModeEnabled,
|
maintenanceModeEnabled: resources.maintenanceModeEnabled,
|
||||||
maintenanceModeType: resources.maintenanceModeType,
|
maintenanceModeType: resources.maintenanceModeType,
|
||||||
@@ -171,8 +171,8 @@ export async function getTraefikConfig(
|
|||||||
),
|
),
|
||||||
inArray(sites.type, siteTypes),
|
inArray(sites.type, siteTypes),
|
||||||
allowRawResources
|
allowRawResources
|
||||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
|
||||||
: eq(resources.http, true)
|
: eq(resources.mode, "http")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||||
@@ -226,9 +226,8 @@ export async function getTraefikConfig(
|
|||||||
key: key,
|
key: key,
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
http: row.http,
|
|
||||||
proxyPort: row.proxyPort,
|
proxyPort: row.proxyPort,
|
||||||
protocol: row.protocol,
|
mode: row.mode,
|
||||||
subdomain: row.subdomain,
|
subdomain: row.subdomain,
|
||||||
domainId: row.domainId,
|
domainId: row.domainId,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
@@ -277,10 +276,119 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Query browser gateway targets for this exit node
|
||||||
|
const browserGatewayRows = await db
|
||||||
|
.select({
|
||||||
|
// Resource fields
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
resourceName: resources.name,
|
||||||
|
fullDomain: resources.fullDomain,
|
||||||
|
ssl: resources.ssl,
|
||||||
|
subdomain: resources.subdomain,
|
||||||
|
domainId: resources.domainId,
|
||||||
|
enabled: resources.enabled,
|
||||||
|
wildcard: resources.wildcard,
|
||||||
|
domainCertResolver: domains.certResolver,
|
||||||
|
preferWildcardCert: domains.preferWildcardCert,
|
||||||
|
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
||||||
|
// Browser gateway target fields
|
||||||
|
browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId,
|
||||||
|
bgType: browserGatewayTarget.type,
|
||||||
|
// Site fields
|
||||||
|
siteId: sites.siteId,
|
||||||
|
siteType: sites.type,
|
||||||
|
siteOnline: sites.online,
|
||||||
|
subnet: sites.subnet,
|
||||||
|
siteExitNodeId: sites.exitNodeId
|
||||||
|
})
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
eq(resources.resourceId, browserGatewayTarget.resourceId)
|
||||||
|
)
|
||||||
|
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
||||||
|
.leftJoin(
|
||||||
|
domainNamespaces,
|
||||||
|
eq(domainNamespaces.domainId, resources.domainId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.enabled, true),
|
||||||
|
or(
|
||||||
|
eq(sites.exitNodeId, exitNodeId),
|
||||||
|
and(
|
||||||
|
isNull(sites.exitNodeId),
|
||||||
|
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
||||||
|
eq(sites.type, "local"),
|
||||||
|
sql`(${build != "saas" ? 1 : 0} = 1)`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
inArray(sites.type, siteTypes)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group browser gateway targets by resource
|
||||||
|
type BrowserGatewayResourceEntry = {
|
||||||
|
resourceId: number;
|
||||||
|
name: string;
|
||||||
|
fullDomain: string | null;
|
||||||
|
ssl: boolean | null;
|
||||||
|
subdomain: string | null;
|
||||||
|
domainId: string | null;
|
||||||
|
enabled: boolean | null;
|
||||||
|
wildcard: boolean | null;
|
||||||
|
domainCertResolver: string | null;
|
||||||
|
preferWildcardCert: boolean | null;
|
||||||
|
targets: {
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
bgType: string;
|
||||||
|
siteId: number;
|
||||||
|
siteType: string;
|
||||||
|
siteOnline: boolean | null;
|
||||||
|
subnet: string | null;
|
||||||
|
siteExitNodeId: number | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
const browserGatewayResourcesMap = new Map<
|
||||||
|
number,
|
||||||
|
BrowserGatewayResourceEntry
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const row of browserGatewayRows) {
|
||||||
|
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!browserGatewayResourcesMap.has(row.resourceId)) {
|
||||||
|
browserGatewayResourcesMap.set(row.resourceId, {
|
||||||
|
resourceId: row.resourceId,
|
||||||
|
name: sanitize(row.resourceName) || "",
|
||||||
|
fullDomain: row.fullDomain,
|
||||||
|
ssl: row.ssl,
|
||||||
|
subdomain: row.subdomain,
|
||||||
|
domainId: row.domainId,
|
||||||
|
enabled: row.enabled,
|
||||||
|
wildcard: row.wildcard,
|
||||||
|
domainCertResolver: row.domainCertResolver,
|
||||||
|
preferWildcardCert: row.preferWildcardCert,
|
||||||
|
targets: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
|
||||||
|
browserGatewayTargetId: row.browserGatewayTargetId,
|
||||||
|
bgType: row.bgType,
|
||||||
|
siteId: row.siteId,
|
||||||
|
siteType: row.siteType,
|
||||||
|
siteOnline: row.siteOnline,
|
||||||
|
subnet: row.subnet,
|
||||||
|
siteExitNodeId: row.siteExitNodeId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let siteResourcesWithFullDomain: {
|
let siteResourcesWithFullDomain: {
|
||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
fullDomain: string | null;
|
fullDomain: string | null;
|
||||||
mode: "http" | "host" | "cidr";
|
mode: "http" | "host" | "cidr" | "ssh";
|
||||||
}[] = [];
|
}[] = [];
|
||||||
if (build == "enterprise") {
|
if (build == "enterprise") {
|
||||||
// we dont want to do this on the cloud
|
// we dont want to do this on the cloud
|
||||||
@@ -324,6 +432,12 @@ export async function getTraefikConfig(
|
|||||||
domains.add(sr.fullDomain);
|
domains.add(sr.fullDomain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Include browser gateway resource domains
|
||||||
|
for (const bgResource of browserGatewayResourcesMap.values()) {
|
||||||
|
if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) {
|
||||||
|
domains.add(bgResource.fullDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
// get the valid certs for these domains
|
// get the valid certs for these domains
|
||||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||||
@@ -589,7 +703,7 @@ export async function getTraefikConfig(
|
|||||||
resource.ssl ? entrypointHttps : entrypointHttp
|
resource.ssl ? entrypointHttps : entrypointHttp
|
||||||
],
|
],
|
||||||
service: maintenanceServiceName,
|
service: maintenanceServiceName,
|
||||||
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `,
|
||||||
priority: 2001,
|
priority: 2001,
|
||||||
...(resource.ssl ? { tls } : {})
|
...(resource.ssl ? { tls } : {})
|
||||||
};
|
};
|
||||||
@@ -830,7 +944,7 @@ export async function getTraefikConfig(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocol = resource.protocol.toLowerCase();
|
const protocol = resource.mode == "udp" ? "udp" : "tcp";
|
||||||
const port = resource.proxyPort;
|
const port = resource.proxyPort;
|
||||||
|
|
||||||
if (!port) {
|
if (!port) {
|
||||||
@@ -925,6 +1039,185 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate Traefik config for browser gateway resources
|
||||||
|
const browserGatewayPort = 39999;
|
||||||
|
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
|
||||||
|
if (!bgResource.enabled) continue;
|
||||||
|
if (!bgResource.domainId) continue;
|
||||||
|
if (!bgResource.fullDomain) continue;
|
||||||
|
|
||||||
|
if (!config_output.http.routers) config_output.http.routers = {};
|
||||||
|
if (!config_output.http.services) config_output.http.services = {};
|
||||||
|
|
||||||
|
const fullDomain = bgResource.fullDomain;
|
||||||
|
const additionalMiddlewares =
|
||||||
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
|
const routerMiddlewares = [
|
||||||
|
badgerMiddlewareName,
|
||||||
|
...additionalMiddlewares
|
||||||
|
];
|
||||||
|
|
||||||
|
const hostRule = `Host(\`${fullDomain}\`)`;
|
||||||
|
|
||||||
|
// Build TLS config
|
||||||
|
let tls = {};
|
||||||
|
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
|
const domainParts = fullDomain.split(".");
|
||||||
|
let wildCard: string;
|
||||||
|
if (domainParts.length <= 2) {
|
||||||
|
wildCard = `*.${domainParts.join(".")}`;
|
||||||
|
} else {
|
||||||
|
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||||
|
}
|
||||||
|
if (!bgResource.subdomain) {
|
||||||
|
wildCard = fullDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalDefaultResolver =
|
||||||
|
config.getRawConfig().traefik.cert_resolver;
|
||||||
|
const globalDefaultPreferWildcard =
|
||||||
|
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||||
|
const resolverName = bgResource.domainCertResolver
|
||||||
|
? bgResource.domainCertResolver.trim()
|
||||||
|
: globalDefaultResolver;
|
||||||
|
const preferWildcard =
|
||||||
|
bgResource.preferWildcardCert !== undefined &&
|
||||||
|
bgResource.preferWildcardCert !== null
|
||||||
|
? bgResource.preferWildcardCert
|
||||||
|
: globalDefaultPreferWildcard;
|
||||||
|
|
||||||
|
tls = {
|
||||||
|
certResolver: resolverName,
|
||||||
|
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const matchingCert = validCerts.find(
|
||||||
|
(cert) => cert.queriedDomain === fullDomain
|
||||||
|
);
|
||||||
|
if (!matchingCert) {
|
||||||
|
logger.debug(
|
||||||
|
`No matching certificate found for browser gateway domain: ${fullDomain}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
|
||||||
|
|
||||||
|
if (bgResource.ssl) {
|
||||||
|
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
|
||||||
|
config_output.http.routers![redirectRouterName] = {
|
||||||
|
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
|
||||||
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
|
service: bgUiServiceName,
|
||||||
|
rule: hostRule,
|
||||||
|
priority: 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect online sites for this resource (for any type)
|
||||||
|
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
|
||||||
|
|
||||||
|
// Group targets by type and generate per-type websocket routers and services
|
||||||
|
const typeMap = new Map<string, typeof bgResource.targets>();
|
||||||
|
for (const t of bgResource.targets) {
|
||||||
|
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
|
||||||
|
typeMap.get(t.bgType)!.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [bgType, typedTargets] of typeMap.entries()) {
|
||||||
|
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
|
||||||
|
const bgRouterName = `${bgKey}-router`;
|
||||||
|
const bgServiceName = `${bgKey}-service`;
|
||||||
|
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
|
||||||
|
|
||||||
|
const servers = typedTargets
|
||||||
|
.filter((t) => {
|
||||||
|
if (!t.siteOnline && anySiteOnline) return false;
|
||||||
|
if (t.siteType === "newt") return !!t.subnet;
|
||||||
|
return false; // browser gateway only supported on newt sites
|
||||||
|
})
|
||||||
|
.map((t) => ({
|
||||||
|
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
|
||||||
|
}))
|
||||||
|
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
|
||||||
|
|
||||||
|
config_output.http.routers![bgRouterName] = {
|
||||||
|
entryPoints: [
|
||||||
|
bgResource.ssl
|
||||||
|
? config.getRawConfig().traefik.https_entrypoint
|
||||||
|
: config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: routerMiddlewares,
|
||||||
|
service: bgServiceName,
|
||||||
|
rule: bgRule,
|
||||||
|
priority: 110, // highest - websocket path takes precedence
|
||||||
|
...(bgResource.ssl ? { tls } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
config_output.http.services![bgServiceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI: serve the browser gateway page from the internal pangolin instance.
|
||||||
|
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
|
||||||
|
// how the maintenance page rewrites everything to /maintenance-screen.
|
||||||
|
const primaryType = typeMap.keys().next().value as string;
|
||||||
|
const internalHost = config.getRawConfig().server.internal_hostname;
|
||||||
|
const internalPort = config.getRawConfig().server.next_port;
|
||||||
|
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
|
||||||
|
const entrypoint = bgResource.ssl
|
||||||
|
? config.getRawConfig().traefik.https_entrypoint
|
||||||
|
: config.getRawConfig().traefik.http_entrypoint;
|
||||||
|
|
||||||
|
if (!config_output.http.middlewares) {
|
||||||
|
config_output.http.middlewares = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
config_output.http.middlewares![uiRewriteMiddlewareName] = {
|
||||||
|
replacePathRegex: {
|
||||||
|
regex: "^/(.*)",
|
||||||
|
replacement: `/${primaryType}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
config_output.http.services![bgUiServiceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: `http://${internalHost}:${internalPort}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assets router at higher priority so /_next files load without rewrite
|
||||||
|
config_output.http.routers![
|
||||||
|
`bg-r${bgResource.resourceId}-assets-router`
|
||||||
|
] = {
|
||||||
|
entryPoints: [entrypoint],
|
||||||
|
middlewares: routerMiddlewares,
|
||||||
|
service: bgUiServiceName,
|
||||||
|
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||||
|
priority: 101,
|
||||||
|
...(bgResource.ssl ? { tls } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Catch-all router rewrites everything on the domain to /{primaryType}
|
||||||
|
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
|
||||||
|
{
|
||||||
|
entryPoints: [entrypoint],
|
||||||
|
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
|
||||||
|
service: bgUiServiceName,
|
||||||
|
rule: hostRule,
|
||||||
|
priority: 100,
|
||||||
|
...(bgResource.ssl ? { tls } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||||
// Traefik generates TLS certificates for those domains even when no
|
// Traefik generates TLS certificates for those domains even when no
|
||||||
// matching resource exists yet.
|
// matching resource exists yet.
|
||||||
@@ -1040,7 +1333,7 @@ export async function getTraefikConfig(
|
|||||||
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
||||||
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
||||||
service: siteResourceServiceName,
|
service: siteResourceServiceName,
|
||||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||||
priority: 101,
|
priority: 101,
|
||||||
tls
|
tls
|
||||||
};
|
};
|
||||||
@@ -1143,7 +1436,7 @@ export async function getTraefikConfig(
|
|||||||
config.getRawConfig().traefik.https_entrypoint
|
config.getRawConfig().traefik.https_entrypoint
|
||||||
],
|
],
|
||||||
service: "landing-service",
|
service: "landing-service",
|
||||||
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
|
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||||
priority: 203,
|
priority: 203,
|
||||||
tls: tls
|
tls: tls
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
|
BrowserGatewayTarget,
|
||||||
|
db,
|
||||||
|
newts,
|
||||||
|
resources,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||||
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
siteId: z.number().int().positive(),
|
||||||
|
type: z.enum(["ssh", "rdp", "vnc"]),
|
||||||
|
destination: z.string().nonempty(),
|
||||||
|
destinationPort: z.number().int().min(1).max(65535)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "put",
|
||||||
|
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target",
|
||||||
|
description: "Create a browser gateway target for a resource.",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createBrowserGatewayTarget(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [site] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Site with ID ${siteId} not found in organization ${orgId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainToken = generateId(48);
|
||||||
|
const encryptedToken = encrypt(
|
||||||
|
plainToken,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
|
||||||
|
const [record] = await db
|
||||||
|
.insert(browserGatewayTarget)
|
||||||
|
.values({
|
||||||
|
resourceId,
|
||||||
|
siteId,
|
||||||
|
type,
|
||||||
|
destination,
|
||||||
|
destinationPort,
|
||||||
|
authToken: encryptedToken
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (site.type === "newt") {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (newt) {
|
||||||
|
await sendBrowserGatewayTargets(
|
||||||
|
newt.newtId,
|
||||||
|
[record],
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return response<CreateBrowserGatewayTargetResponse>(res, {
|
||||||
|
data: record,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Browser gateway target created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create browser gateway target"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { browserGatewayTarget, db, newts, sites } from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
browserGatewayTargetId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "delete",
|
||||||
|
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||||
|
description: "Delete a browser gateway target.",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteBrowserGatewayTarget(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ bgt: browserGatewayTarget, site: sites })
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
browserGatewayTarget.browserGatewayTargetId,
|
||||||
|
browserGatewayTargetId
|
||||||
|
),
|
||||||
|
eq(sites.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(browserGatewayTarget)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
browserGatewayTarget.browserGatewayTargetId,
|
||||||
|
browserGatewayTargetId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.site.type === "newt") {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, existing.bgt.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (newt) {
|
||||||
|
await removeBrowserGatewayTarget(
|
||||||
|
newt.newtId,
|
||||||
|
browserGatewayTargetId,
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Browser gateway target deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to delete browser gateway target"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
|
BrowserGatewayTarget,
|
||||||
|
db,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
browserGatewayTargetId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||||
|
description: "Get a browser gateway target.",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getBrowserGatewayTarget(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [result] = await db
|
||||||
|
.select({ bgt: browserGatewayTarget })
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
browserGatewayTarget.browserGatewayTargetId,
|
||||||
|
browserGatewayTargetId
|
||||||
|
),
|
||||||
|
eq(sites.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<GetBrowserGatewayTargetResponse>(res, {
|
||||||
|
data: result.bgt,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Browser gateway target retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to retrieve browser gateway target"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./createBrowserGatewayTarget";
|
||||||
|
export * from "./updateBrowserGatewayTarget";
|
||||||
|
export * from "./deleteBrowserGatewayTarget";
|
||||||
|
export * from "./getBrowserGatewayTarget";
|
||||||
|
export * from "./listBrowserGatewayTargets";
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
|
BrowserGatewayTarget,
|
||||||
|
db,
|
||||||
|
resources,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive()),
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative())
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListBrowserGatewayTargetsResponse = {
|
||||||
|
targets: BrowserGatewayTarget[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets",
|
||||||
|
description: "List browser gateway targets for a resource.",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
query: querySchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listBrowserGatewayTargets(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedQuery = querySchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await db
|
||||||
|
.select()
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.where(eq(browserGatewayTarget.resourceId, resourceId))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return response<ListBrowserGatewayTargetsResponse>(res, {
|
||||||
|
data: {
|
||||||
|
targets: targets,
|
||||||
|
total: targets.length,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Browser gateway targets retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to list browser gateway targets"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
|
BrowserGatewayTarget,
|
||||||
|
db,
|
||||||
|
newts,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
browserGatewayTargetId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
siteId: z.number().int().positive().optional(),
|
||||||
|
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
|
||||||
|
destination: z.string().nonempty().optional(),
|
||||||
|
destinationPort: z.number().int().min(1).max(65535).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||||
|
description: "Update a browser gateway target.",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateBrowserGatewayTarget(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ bgt: browserGatewayTarget, site: sites })
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
browserGatewayTarget.browserGatewayTargetId,
|
||||||
|
browserGatewayTargetId
|
||||||
|
),
|
||||||
|
eq(sites.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateValues: Partial<BrowserGatewayTarget> = {};
|
||||||
|
if (siteId !== undefined) updateValues.siteId = siteId;
|
||||||
|
if (type !== undefined) updateValues.type = type;
|
||||||
|
if (destination !== undefined) updateValues.destination = destination;
|
||||||
|
if (destinationPort !== undefined)
|
||||||
|
updateValues.destinationPort = destinationPort;
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(browserGatewayTarget)
|
||||||
|
.set(updateValues)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
browserGatewayTarget.browserGatewayTargetId,
|
||||||
|
browserGatewayTargetId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const targetSiteId = siteId ?? existing.bgt.siteId;
|
||||||
|
const [site] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, targetSiteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (site && site.type === "newt") {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, targetSiteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (newt) {
|
||||||
|
await sendBrowserGatewayTargets(
|
||||||
|
newt.newtId,
|
||||||
|
[updated],
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
|
||||||
|
|
||||||
|
return response<UpdateBrowserGatewayTargetResponse>(res, {
|
||||||
|
data: updated,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Browser gateway target updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to update browser gateway target"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
|||||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
|
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
||||||
import * as labels from "#private/routers/labels";
|
import * as labels from "#private/routers/labels";
|
||||||
import * as client from "@server/routers/client";
|
import * as client from "@server/routers/client";
|
||||||
|
|
||||||
@@ -842,3 +843,48 @@ authenticated.post(
|
|||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
client.rebuildClientAssociationsCacheRoute
|
client.rebuildClientAssociationsCacheRoute
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.createBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
|
||||||
|
browserGatewayTarget.listBrowserGatewayTargets
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.getBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.updateBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.deleteBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import * as org from "#private/routers/org";
|
|||||||
import * as logs from "#private/routers/auditLogs";
|
import * as logs from "#private/routers/auditLogs";
|
||||||
import * as alertEvents from "#private/routers/alertEvents";
|
import * as alertEvents from "#private/routers/alertEvents";
|
||||||
import * as certificates from "#private/routers/certificates";
|
import * as certificates from "#private/routers/certificates";
|
||||||
|
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyApiKeyHasAction,
|
verifyApiKeyHasAction,
|
||||||
@@ -215,3 +216,43 @@ authenticated.delete(
|
|||||||
logActionAudit(ActionsEnum.removeUserRole),
|
logActionAudit(ActionsEnum.removeUserRole),
|
||||||
user.removeUserRole
|
user.removeUserRole
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.createBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
|
||||||
|
browserGatewayTarget.listBrowserGatewayTargets
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.getBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.updateBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.deleteBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
roundTripMessageTracker,
|
roundTripMessageTracker,
|
||||||
siteResources,
|
siteResources,
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
userOrgs
|
userOrgs,
|
||||||
|
sites
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||||
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
||||||
@@ -48,7 +49,8 @@ const bodySchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
publicKey: z.string().nonempty(),
|
publicKey: z.string().nonempty(),
|
||||||
resourceId: z.number().int().positive().optional(),
|
resourceId: z.number().int().positive().optional(),
|
||||||
resource: z.string().nonempty().optional() // this is either the nice id or the alias
|
resource: z.string().nonempty().optional(), // this is either the nice id or the alias
|
||||||
|
username: z.string().nonempty().optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -63,19 +65,19 @@ const bodySchema = z
|
|||||||
);
|
);
|
||||||
|
|
||||||
export type SignSshKeyResponse = {
|
export type SignSshKeyResponse = {
|
||||||
certificate: string;
|
certificate?: string;
|
||||||
messageIds: number[];
|
messageIds: number[];
|
||||||
messageId: number;
|
messageId?: number;
|
||||||
sshUsername: string;
|
sshUsername: string;
|
||||||
sshHost: string;
|
sshHost: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
siteIds: number[];
|
siteIds: number[];
|
||||||
siteId: number;
|
siteId: number;
|
||||||
keyId: string;
|
keyId?: string;
|
||||||
validPrincipals: string[];
|
validPrincipals?: string[];
|
||||||
validAfter: string;
|
validAfter?: string;
|
||||||
validBefore: string;
|
validBefore?: string;
|
||||||
expiresIn: number;
|
expiresIn?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// registry.registerPath({
|
// registry.registerPath({
|
||||||
@@ -126,7 +128,8 @@ export async function signSshKey(
|
|||||||
const {
|
const {
|
||||||
publicKey,
|
publicKey,
|
||||||
resourceId,
|
resourceId,
|
||||||
resource: resourceQueryString
|
resource: resourceQueryString,
|
||||||
|
username
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
const roleIds = req.userOrgRoleIds ?? [];
|
const roleIds = req.userOrgRoleIds ?? [];
|
||||||
@@ -174,101 +177,6 @@ export async function signSshKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let usernameToUse;
|
|
||||||
if (!userOrg.pamUsername) {
|
|
||||||
if (req.user?.email) {
|
|
||||||
// Extract username from email (first part before @)
|
|
||||||
usernameToUse = req.user?.email
|
|
||||||
.split("@")[0]
|
|
||||||
.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
||||||
if (!usernameToUse) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Unable to extract username from email"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (req.user?.username) {
|
|
||||||
usernameToUse = req.user.username;
|
|
||||||
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
|
||||||
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
||||||
if (!usernameToUse) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Username is not valid for SSH certificate"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"User does not have a valid email or username for SSH certificate"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// prefix with p-
|
|
||||||
usernameToUse = `p-${usernameToUse}`;
|
|
||||||
|
|
||||||
// check if we have a existing user in this org with the same
|
|
||||||
const [existingUserWithSameName] = await db
|
|
||||||
.select()
|
|
||||||
.from(userOrgs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userOrgs.orgId, orgId),
|
|
||||||
eq(userOrgs.pamUsername, usernameToUse)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingUserWithSameName) {
|
|
||||||
let foundUniqueUsername = false;
|
|
||||||
for (let attempt = 0; attempt < 20; attempt++) {
|
|
||||||
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
|
||||||
const candidateUsername = `${usernameToUse}${randomNum}`;
|
|
||||||
|
|
||||||
const [existingUser] = await db
|
|
||||||
.select()
|
|
||||||
.from(userOrgs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userOrgs.orgId, orgId),
|
|
||||||
eq(userOrgs.pamUsername, candidateUsername)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!existingUser) {
|
|
||||||
usernameToUse = candidateUsername;
|
|
||||||
foundUniqueUsername = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundUniqueUsername) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
"Unable to generate a unique username for SSH certificate"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(userOrgs)
|
|
||||||
.set({ pamUsername: usernameToUse })
|
|
||||||
.where(
|
|
||||||
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
usernameToUse = userOrg.pamUsername;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get and decrypt the org's CA keys
|
// Get and decrypt the org's CA keys
|
||||||
const caKeys = await getOrgCAKeys(
|
const caKeys = await getOrgCAKeys(
|
||||||
orgId,
|
orgId,
|
||||||
@@ -361,90 +269,303 @@ export async function signSshKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleRows = await db
|
const sitesFromNetworks = await db
|
||||||
.select({
|
|
||||||
sshSudoCommands: roles.sshSudoCommands,
|
|
||||||
sshUnixGroups: roles.sshUnixGroups,
|
|
||||||
sshCreateHomeDir: roles.sshCreateHomeDir,
|
|
||||||
sshSudoMode: roles.sshSudoMode
|
|
||||||
})
|
|
||||||
.from(roles)
|
|
||||||
.innerJoin(
|
|
||||||
roleSiteResources,
|
|
||||||
eq(roleSiteResources.roleId, roles.roleId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(roles.roleId, roleIds),
|
|
||||||
eq(
|
|
||||||
roleSiteResources.siteResourceId,
|
|
||||||
resource.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsedSudoCommands: string[] = [];
|
|
||||||
const parsedGroupsSet = new Set<string>();
|
|
||||||
let homedir: boolean | null = null;
|
|
||||||
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
|
|
||||||
let sudoMode: "none" | "commands" | "full" = "none";
|
|
||||||
for (const roleRow of roleRows) {
|
|
||||||
try {
|
|
||||||
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
|
|
||||||
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
|
|
||||||
} catch {
|
|
||||||
// skip
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
|
||||||
if (Array.isArray(grps))
|
|
||||||
grps.forEach((g: string) => parsedGroupsSet.add(g));
|
|
||||||
} catch {
|
|
||||||
// skip
|
|
||||||
}
|
|
||||||
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
|
||||||
const m = roleRow?.sshSudoMode ?? "none";
|
|
||||||
if (
|
|
||||||
sudoModeOrder[m as keyof typeof sudoModeOrder] >
|
|
||||||
sudoModeOrder[sudoMode]
|
|
||||||
) {
|
|
||||||
sudoMode = m as "none" | "commands" | "full";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const parsedGroups = Array.from(parsedGroupsSet);
|
|
||||||
if (homedir === null && roleRows.length > 0) {
|
|
||||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sites = await db
|
|
||||||
.select({ siteId: siteNetworks.siteId })
|
.select({ siteId: siteNetworks.siteId })
|
||||||
.from(siteNetworks)
|
.from(siteNetworks)
|
||||||
.where(eq(siteNetworks.networkId, resource.networkId!));
|
.where(eq(siteNetworks.networkId, resource.networkId!));
|
||||||
|
|
||||||
const siteIds = sites.map((site) => site.siteId);
|
const siteIds = sitesFromNetworks.map((site) => site.siteId);
|
||||||
|
|
||||||
// Sign the public key
|
let expiresIn: number | undefined;
|
||||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
let messageIds: number[] = [];
|
||||||
// only valid for 5 minutes
|
let cert:
|
||||||
const validFor = 300n;
|
| {
|
||||||
|
certificate: string;
|
||||||
|
keyId: string;
|
||||||
|
validPrincipals: string[];
|
||||||
|
validAfter: Date;
|
||||||
|
validBefore: Date;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
// if the pam mode is push then we generate the user's pam username and use that or pull it from the userOrgs table
|
||||||
|
// if the mode is passthrough then just use what was provided because the user will log in themselves
|
||||||
|
let usernameToUse;
|
||||||
|
if (resource.pamMode === "push") {
|
||||||
|
if (!userOrg.pamUsername) {
|
||||||
|
if (req.user?.email) {
|
||||||
|
// Extract username from email (first part before @)
|
||||||
|
usernameToUse = req.user?.email
|
||||||
|
.split("@")[0]
|
||||||
|
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||||
|
if (!usernameToUse) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Unable to extract username from email"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (req.user?.username) {
|
||||||
|
usernameToUse = req.user.username;
|
||||||
|
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||||
|
usernameToUse = usernameToUse.replace(
|
||||||
|
/[^a-zA-Z0-9_-]/g,
|
||||||
|
"-"
|
||||||
|
);
|
||||||
|
if (!usernameToUse) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Username is not valid for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"User does not have a valid email or username for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
// prefix with p-
|
||||||
keyId: `${usernameToUse}@${resource.niceId}`,
|
usernameToUse = `p-${usernameToUse}`;
|
||||||
validPrincipals: [usernameToUse, resource.niceId],
|
|
||||||
validAfter: now - 60n, // Start 1 min ago for clock skew
|
// check if we have a existing user in this org with the same
|
||||||
validBefore: now + validFor
|
const [existingUserWithSameName] = await db
|
||||||
});
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
eq(userOrgs.pamUsername, usernameToUse)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUserWithSameName) {
|
||||||
|
let foundUniqueUsername = false;
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
||||||
|
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||||
|
|
||||||
|
const [existingUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
eq(userOrgs.pamUsername, candidateUsername)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
usernameToUse = candidateUsername;
|
||||||
|
foundUniqueUsername = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundUniqueUsername) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Unable to generate a unique username for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(userOrgs)
|
||||||
|
.set({ pamUsername: usernameToUse })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
eq(userOrgs.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
usernameToUse = userOrg.pamUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleRows = await db
|
||||||
|
.select({
|
||||||
|
sshSudoCommands: roles.sshSudoCommands,
|
||||||
|
sshUnixGroups: roles.sshUnixGroups,
|
||||||
|
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||||
|
sshSudoMode: roles.sshSudoMode
|
||||||
|
})
|
||||||
|
.from(roles)
|
||||||
|
.innerJoin(
|
||||||
|
roleSiteResources,
|
||||||
|
eq(roleSiteResources.roleId, roles.roleId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(roles.roleId, roleIds),
|
||||||
|
eq(
|
||||||
|
roleSiteResources.siteResourceId,
|
||||||
|
resource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedSudoCommands: string[] = [];
|
||||||
|
const parsedGroupsSet = new Set<string>();
|
||||||
|
let homedir: boolean | null = null;
|
||||||
|
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
|
||||||
|
let sudoMode: "none" | "commands" | "full" = "none";
|
||||||
|
for (const roleRow of roleRows) {
|
||||||
|
try {
|
||||||
|
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
|
||||||
|
if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
|
||||||
|
} catch {
|
||||||
|
// skip
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
||||||
|
if (Array.isArray(grps))
|
||||||
|
grps.forEach((g: string) => parsedGroupsSet.add(g));
|
||||||
|
} catch {
|
||||||
|
// skip
|
||||||
|
}
|
||||||
|
if (roleRow?.sshCreateHomeDir === true) homedir = true;
|
||||||
|
const m = roleRow?.sshSudoMode ?? "none";
|
||||||
|
if (
|
||||||
|
sudoModeOrder[m as keyof typeof sudoModeOrder] >
|
||||||
|
sudoModeOrder[sudoMode]
|
||||||
|
) {
|
||||||
|
sudoMode = m as "none" | "commands" | "full";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parsedGroups = Array.from(parsedGroupsSet);
|
||||||
|
if (homedir === null && roleRows.length > 0) {
|
||||||
|
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the public key
|
||||||
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||||
|
// only valid for 5 minutes
|
||||||
|
const validFor = 300n;
|
||||||
|
expiresIn = Number(validFor); // seconds
|
||||||
|
|
||||||
|
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||||
|
keyId: `${usernameToUse}@${resource.niceId}`,
|
||||||
|
validPrincipals: [usernameToUse, resource.niceId],
|
||||||
|
validAfter: now - 60n, // Start 1 min ago for clock skew
|
||||||
|
validBefore: now + validFor
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageIds: number[] = [];
|
||||||
|
for (const siteId of siteIds) {
|
||||||
|
// get the site
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Site associated with resource not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [message] = await db
|
||||||
|
.insert(roundTripMessageTracker)
|
||||||
|
.values({
|
||||||
|
wsClientId: newt.newtId,
|
||||||
|
messageType: `newt/pam/connection`,
|
||||||
|
sentAt: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create message tracker entry"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
messageIds.push(message.messageId);
|
||||||
|
|
||||||
|
await sendToClient(newt.newtId, {
|
||||||
|
type: `newt/pam/connection`,
|
||||||
|
data: {
|
||||||
|
messageId: message.messageId,
|
||||||
|
orgId: orgId,
|
||||||
|
agentPort: resource.authDaemonPort ?? 22123,
|
||||||
|
authDaemonMode: resource.authDaemonMode, // site, remote, native where native is the pty mode
|
||||||
|
externalAuthDaemon:
|
||||||
|
resource.authDaemonMode === "remote", // keep this for backward compatibility but new newts are using the authDaemonMode field
|
||||||
|
agentHost: resource.destination,
|
||||||
|
caCert: caKeys.publicKeyOpenSSH,
|
||||||
|
username: usernameToUse,
|
||||||
|
niceId: resource.niceId,
|
||||||
|
metadata: {
|
||||||
|
sudoMode: sudoMode,
|
||||||
|
sudoCommands: parsedSudoCommands,
|
||||||
|
homedir: homedir,
|
||||||
|
groups: parsedGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (resource.pamMode === "passthrough") {
|
||||||
|
usernameToUse = username;
|
||||||
|
if (!usernameToUse) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Username must be provided when PAM mode is passthrough"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Invalid PAM mode configured for resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sshHost: string | undefined;
|
||||||
|
if (
|
||||||
|
resource.authDaemonMode === "site" ||
|
||||||
|
resource.authDaemonMode === "remote"
|
||||||
|
) {
|
||||||
|
if (resource.alias && resource.alias != "") {
|
||||||
|
sshHost = resource.alias;
|
||||||
|
} else {
|
||||||
|
sshHost = resource.destination || ""; // TODO: IF WE HAVE THE NATIVE SSH MODE WHAT SHOULD WE DO HERE?
|
||||||
|
}
|
||||||
|
} else if (resource.authDaemonMode === "native") {
|
||||||
|
if (siteIds.length > 1) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Multiple sites associated with resource, unable to determine SSH host when in native mode"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const messageIds: number[] = [];
|
|
||||||
for (const siteId of siteIds) {
|
|
||||||
// get the site
|
// get the site
|
||||||
const [newt] = await db
|
const [site] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(newts)
|
.from(sites)
|
||||||
.where(eq(newts.siteId, siteId))
|
.where(eq(sites.siteId, siteIds[0]))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!newt) {
|
if (!site) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
@@ -453,54 +574,26 @@ export async function signSshKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [message] = await db
|
if (!site.address) {
|
||||||
.insert(roundTripMessageTracker)
|
|
||||||
.values({
|
|
||||||
wsClientId: newt.newtId,
|
|
||||||
messageType: `newt/pam/connection`,
|
|
||||||
sentAt: Math.floor(Date.now() / 1000)
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!message) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"Failed to create message tracker entry"
|
"Site address not configured, unable to determine SSH host when in native mode"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
messageIds.push(message.messageId);
|
// its the address but split off the cidr if there is one
|
||||||
|
sshHost = site.address.split("/")[0];
|
||||||
await sendToClient(newt.newtId, {
|
|
||||||
type: `newt/pam/connection`,
|
|
||||||
data: {
|
|
||||||
messageId: message.messageId,
|
|
||||||
orgId: orgId,
|
|
||||||
agentPort: resource.authDaemonPort ?? 22123,
|
|
||||||
externalAuthDaemon: resource.authDaemonMode === "remote",
|
|
||||||
agentHost: resource.destination,
|
|
||||||
caCert: caKeys.publicKeyOpenSSH,
|
|
||||||
username: usernameToUse,
|
|
||||||
niceId: resource.niceId,
|
|
||||||
metadata: {
|
|
||||||
sudoMode: sudoMode,
|
|
||||||
sudoCommands: parsedSudoCommands,
|
|
||||||
homedir: homedir,
|
|
||||||
groups: parsedGroups
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresIn = Number(validFor); // seconds
|
if (!sshHost) {
|
||||||
|
return next(
|
||||||
let sshHost;
|
createHttpError(
|
||||||
if (resource.alias && resource.alias != "") {
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
sshHost = resource.alias;
|
"Unable to determine SSH host for the resource"
|
||||||
} else {
|
)
|
||||||
sshHost = resource.destination;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await logsDb.insert(actionAuditLog).values({
|
await logsDb.insert(actionAuditLog).values({
|
||||||
@@ -527,7 +620,7 @@ export async function signSshKey(
|
|||||||
: undefined,
|
: undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
resourceName: resource.name,
|
resourceName: resource.name,
|
||||||
siteId: siteIds[0],
|
siteIds: siteIds,
|
||||||
sshUsername: usernameToUse,
|
sshUsername: usernameToUse,
|
||||||
sshHost: sshHost
|
sshHost: sshHost
|
||||||
},
|
},
|
||||||
@@ -537,18 +630,18 @@ export async function signSshKey(
|
|||||||
|
|
||||||
return response<SignSshKeyResponse>(res, {
|
return response<SignSshKeyResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
certificate: cert.certificate,
|
certificate: cert?.certificate,
|
||||||
messageIds: messageIds,
|
messageIds: messageIds,
|
||||||
messageId: messageIds[0], // just pick the first one for backward compatibility
|
messageId: messageIds[0], // just pick the first one for backward compatibility with older olms
|
||||||
sshUsername: usernameToUse,
|
sshUsername: usernameToUse,
|
||||||
sshHost: sshHost,
|
sshHost: sshHost, // just pick the first one for backward compatibility with older olms
|
||||||
resourceId: resource.siteResourceId,
|
resourceId: resource.siteResourceId,
|
||||||
siteIds: siteIds,
|
siteIds: siteIds,
|
||||||
siteId: siteIds[0], // just pick the first one for backward compatibility
|
siteId: siteIds[0], // just pick the first one for backward compatibility with older olms
|
||||||
keyId: cert.keyId,
|
keyId: cert?.keyId,
|
||||||
validPrincipals: cert.validPrincipals,
|
validPrincipals: cert?.validPrincipals,
|
||||||
validAfter: cert.validAfter.toISOString(),
|
validAfter: cert?.validAfter.toISOString(),
|
||||||
validBefore: cert.validBefore.toISOString(),
|
validBefore: cert?.validBefore.toISOString(),
|
||||||
expiresIn
|
expiresIn
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ export async function pickClientDefaults(
|
|||||||
const olmId = generateId(15);
|
const olmId = generateId(15);
|
||||||
const secret = generateId(48);
|
const secret = generateId(48);
|
||||||
|
|
||||||
const newSubnet = await getNextAvailableClientSubnet(orgId);
|
const { value: newSubnet, release } =
|
||||||
|
await getNextAvailableClientSubnet(orgId);
|
||||||
|
await release(); // release immediately — this endpoint only previews the next available value
|
||||||
if (!newSubnet) {
|
if (!newSubnet) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ internalRouter.get("/idp", idp.listIdps);
|
|||||||
|
|
||||||
internalRouter.get("/idp/:idpId", idp.getIdp);
|
internalRouter.get("/idp/:idpId", idp.getIdp);
|
||||||
|
|
||||||
|
internalRouter.get("/resource/browser-target", resource.getBrowserTarget);
|
||||||
|
|
||||||
// Gerbil routes
|
// Gerbil routes
|
||||||
const gerbilRouter = Router();
|
const gerbilRouter = Router();
|
||||||
internalRouter.use("/gerbil", gerbilRouter);
|
internalRouter.use("/gerbil", gerbilRouter);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
|
BrowserGatewayTarget,
|
||||||
clients,
|
clients,
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache,
|
||||||
@@ -16,6 +18,7 @@ import logger from "@server/logger";
|
|||||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import {
|
import {
|
||||||
formatEndpoint,
|
formatEndpoint,
|
||||||
generateSubnetProxyTargetV2,
|
generateSubnetProxyTargetV2,
|
||||||
@@ -194,7 +197,7 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
siteId: number,
|
siteId: number,
|
||||||
version?: string | null
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
// Get all enabled targets with their resource protocol information
|
// Get all enabled targets with their resource mode information
|
||||||
const allTargets = await db
|
const allTargets = await db
|
||||||
.select({
|
.select({
|
||||||
resourceId: targets.resourceId,
|
resourceId: targets.resourceId,
|
||||||
@@ -204,7 +207,7 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
port: targets.port,
|
port: targets.port,
|
||||||
internalPort: targets.internalPort,
|
internalPort: targets.internalPort,
|
||||||
enabled: targets.enabled,
|
enabled: targets.enabled,
|
||||||
protocol: resources.protocol
|
mode: resources.mode
|
||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
@@ -233,6 +236,11 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
.from(targetHealthCheck)
|
.from(targetHealthCheck)
|
||||||
.where(eq(targetHealthCheck.siteId, siteId));
|
.where(eq(targetHealthCheck.siteId, siteId));
|
||||||
|
|
||||||
|
const allBrowserGatewayTargets = await db
|
||||||
|
.select()
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.where(eq(browserGatewayTarget.siteId, siteId));
|
||||||
|
|
||||||
const { tcpTargets, udpTargets } = allTargets.reduce(
|
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||||
(acc, target) => {
|
(acc, target) => {
|
||||||
// Filter out invalid targets
|
// Filter out invalid targets
|
||||||
@@ -244,10 +252,11 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
const formattedTarget = `${target.internalPort}:${formatEndpoint(target.ip, target.port)}`;
|
const formattedTarget = `${target.internalPort}:${formatEndpoint(target.ip, target.port)}`;
|
||||||
|
|
||||||
// Add to the appropriate protocol array
|
// Add to the appropriate protocol array
|
||||||
if (target.protocol === "tcp") {
|
if (target.mode === "udp") {
|
||||||
acc.tcpTargets.push(formattedTarget);
|
|
||||||
} else {
|
|
||||||
acc.udpTargets.push(formattedTarget);
|
acc.udpTargets.push(formattedTarget);
|
||||||
|
} else {
|
||||||
|
// all other modes are tcp
|
||||||
|
acc.tcpTargets.push(formattedTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
@@ -304,9 +313,22 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
(target) => target !== null
|
(target) => target !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const serverSecret = config.getRawConfig().server.secret!;
|
||||||
|
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
|
||||||
|
const decryptAuthToken = decrypt(t.authToken, serverSecret);
|
||||||
|
return {
|
||||||
|
id: t.browserGatewayTargetId,
|
||||||
|
type: t.type,
|
||||||
|
destination: t.destination,
|
||||||
|
destinationPort: t.destinationPort,
|
||||||
|
authToken: decryptAuthToken
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validHealthCheckTargets,
|
validHealthCheckTargets,
|
||||||
tcpTargets,
|
tcpTargets,
|
||||||
udpTargets
|
udpTargets,
|
||||||
|
browserGatewayTargets
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
const siteId = newt.siteId;
|
const siteId = newt.siteId;
|
||||||
|
|
||||||
const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } =
|
const {
|
||||||
message.data;
|
publicKey,
|
||||||
|
pingResults,
|
||||||
|
newtVersion,
|
||||||
|
backwardsCompatible,
|
||||||
|
chainId
|
||||||
|
} = message.data;
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
logger.warn("Public key not provided");
|
logger.warn("Public key not provided");
|
||||||
return;
|
return;
|
||||||
@@ -191,8 +196,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.where(eq(newts.newtId, newt.newtId));
|
.where(eq(newts.newtId, newt.newtId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
const {
|
||||||
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
tcpTargets,
|
||||||
|
udpTargets,
|
||||||
|
validHealthCheckTargets,
|
||||||
|
browserGatewayTargets
|
||||||
|
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||||
@@ -212,6 +221,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
tcp: tcpTargets
|
tcp: tcpTargets
|
||||||
},
|
},
|
||||||
healthCheckTargets: validHealthCheckTargets,
|
healthCheckTargets: validHealthCheckTargets,
|
||||||
|
browserGatewayTargets: browserGatewayTargets,
|
||||||
chainId: chainId
|
chainId: chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -203,84 +203,82 @@ export async function registerNewt(
|
|||||||
|
|
||||||
let newSiteId: number | undefined;
|
let newSiteId: number | undefined;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
const { value: newClientAddress, release: releaseSubnetLock } =
|
||||||
const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
await getNextAvailableClientSubnet(orgId);
|
||||||
if (!newClientAddress) {
|
try {
|
||||||
return next(
|
await db.transaction(async (trx) => {
|
||||||
createHttpError(
|
let clientAddress = newClientAddress.split("/")[0];
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org
|
||||||
"No available subnet found"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let clientAddress = newClientAddress.split("/")[0];
|
// Create the site (type "newt", name = niceId)
|
||||||
clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org
|
const [newSite] = await trx
|
||||||
|
.insert(sites)
|
||||||
|
.values({
|
||||||
|
orgId,
|
||||||
|
name: name || niceId,
|
||||||
|
niceId,
|
||||||
|
address: clientAddress,
|
||||||
|
type: "newt",
|
||||||
|
dockerSocketEnabled: true,
|
||||||
|
status: keyRecord.approveNewSites
|
||||||
|
? "approved"
|
||||||
|
: "pending"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// Create the site (type "newt", name = niceId)
|
await logsDb.insert(statusHistory).values({
|
||||||
const [newSite] = await trx
|
entityType: "site",
|
||||||
.insert(sites)
|
entityId: newSite.siteId,
|
||||||
.values({
|
orgId: orgId,
|
||||||
orgId,
|
status: "offline",
|
||||||
name: name || niceId,
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
niceId,
|
});
|
||||||
address: clientAddress,
|
|
||||||
type: "newt",
|
|
||||||
dockerSocketEnabled: true,
|
|
||||||
status: keyRecord.approveNewSites ? "approved" : "pending"
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await logsDb.insert(statusHistory).values({
|
newSiteId = newSite.siteId;
|
||||||
entityType: "site",
|
|
||||||
entityId: newSite.siteId,
|
// Grant admin role access to the new site
|
||||||
orgId: orgId,
|
const [adminRole] = await trx
|
||||||
status: "offline",
|
.select()
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
throw new Error(`Admin role not found for org ${orgId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(roleSites).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
siteId: newSite.siteId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the newt for this site
|
||||||
|
await trx.insert(newts).values({
|
||||||
|
newtId,
|
||||||
|
secretHash,
|
||||||
|
siteId: newSite.siteId,
|
||||||
|
dateCreated: moment().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consume the provisioning key - cascade removes siteProvisioningKeyOrg
|
||||||
|
await trx
|
||||||
|
.update(siteProvisioningKeys)
|
||||||
|
.set({
|
||||||
|
lastUsed: moment().toISOString(),
|
||||||
|
numUsed: sql`${siteProvisioningKeys.numUsed} + 1`
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
siteProvisioningKeys.siteProvisioningKeyId,
|
||||||
|
provisioningKeyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await usageService.add(orgId, FeatureId.SITES, 1, trx);
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
newSiteId = newSite.siteId;
|
await releaseSubnetLock();
|
||||||
|
}
|
||||||
// Grant admin role access to the new site
|
|
||||||
const [adminRole] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!adminRole) {
|
|
||||||
throw new Error(`Admin role not found for org ${orgId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx.insert(roleSites).values({
|
|
||||||
roleId: adminRole.roleId,
|
|
||||||
siteId: newSite.siteId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the newt for this site
|
|
||||||
await trx.insert(newts).values({
|
|
||||||
newtId,
|
|
||||||
secretHash,
|
|
||||||
siteId: newSite.siteId,
|
|
||||||
dateCreated: moment().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Consume the provisioning key - cascade removes siteProvisioningKeyOrg
|
|
||||||
await trx
|
|
||||||
.update(siteProvisioningKeys)
|
|
||||||
.set({
|
|
||||||
lastUsed: moment().toISOString(),
|
|
||||||
numUsed: sql`${siteProvisioningKeys.numUsed} + 1`
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
siteProvisioningKeys.siteProvisioningKeyId,
|
|
||||||
provisioningKeyId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await usageService.add(orgId, FeatureId.SITES, 1, trx);
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Provisioned new site (ID: ${newSiteId}) and newt (ID: ${newtId}) for org ${orgId} via provisioning key ${provisioningKeyId}`
|
`Provisioned new site (ID: ${newSiteId}) and newt (ID: ${newtId}) for org ${orgId} via provisioning key ${provisioningKeyId}`
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ import {
|
|||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
||||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
const {
|
||||||
await buildTargetConfigurationForNewtClient(site.siteId);
|
tcpTargets,
|
||||||
|
udpTargets,
|
||||||
|
validHealthCheckTargets,
|
||||||
|
browserGatewayTargets
|
||||||
|
} = await buildTargetConfigurationForNewtClient(site.siteId);
|
||||||
|
|
||||||
let exitNode: ExitNode | undefined;
|
let exitNode: ExitNode | undefined;
|
||||||
if (site.exitNodeId) {
|
if (site.exitNodeId) {
|
||||||
@@ -36,7 +40,8 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
|||||||
},
|
},
|
||||||
healthCheckTargets: validHealthCheckTargets,
|
healthCheckTargets: validHealthCheckTargets,
|
||||||
peers: peers,
|
peers: peers,
|
||||||
clientTargets: targets
|
clientTargets: targets,
|
||||||
|
browserGatewayTargets: browserGatewayTargets
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Target, TargetHealthCheck } from "@server/db";
|
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export async function addTargets(
|
export async function addTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
@@ -239,3 +241,55 @@ export async function removeTargets(
|
|||||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendBrowserGatewayTargets(
|
||||||
|
newtId: string,
|
||||||
|
targets: BrowserGatewayTarget[],
|
||||||
|
version?: string | null
|
||||||
|
) {
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
|
const payload = targets.map((t) => {
|
||||||
|
const decryptAuthToken = decrypt(
|
||||||
|
t.authToken,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: t.browserGatewayTargetId,
|
||||||
|
resourceId: t.resourceId,
|
||||||
|
siteId: t.siteId,
|
||||||
|
type: t.type,
|
||||||
|
destination: t.destination,
|
||||||
|
destinationPort: t.destinationPort,
|
||||||
|
authToken: decryptAuthToken
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendToClient(
|
||||||
|
newtId,
|
||||||
|
{
|
||||||
|
type: "newt/browsergateway/add",
|
||||||
|
data: {
|
||||||
|
targets: payload
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeBrowserGatewayTarget(
|
||||||
|
newtId: string,
|
||||||
|
browserGatewayTargetId: number,
|
||||||
|
version?: string | null
|
||||||
|
) {
|
||||||
|
await sendToClient(
|
||||||
|
newtId,
|
||||||
|
{
|
||||||
|
type: "newt/browsergateway/remove",
|
||||||
|
data: {
|
||||||
|
ids: [browserGatewayTargetId]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import { getUniqueResourceName } from "@server/db/names";
|
import { getUniqueResourceName } from "@server/db/names";
|
||||||
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
import {
|
||||||
|
validateAndConstructDomain,
|
||||||
|
checkWildcardDomainConflict
|
||||||
|
} from "@server/lib/domainUtils";
|
||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
@@ -32,15 +35,58 @@ const createResourceParamsSchema = z.strictObject({
|
|||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function resolveModeFromLegacyFields(data: {
|
||||||
|
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
||||||
|
http?: boolean;
|
||||||
|
protocol?: "tcp" | "udp";
|
||||||
|
}): {
|
||||||
|
mode?: "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
if (data.mode) {
|
||||||
|
return { mode: data.mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.http === "boolean" && data.protocol) {
|
||||||
|
if (data.http && data.protocol === "tcp") {
|
||||||
|
return { mode: "http" };
|
||||||
|
}
|
||||||
|
if (!data.http && data.protocol === "tcp") {
|
||||||
|
return { mode: "tcp" };
|
||||||
|
}
|
||||||
|
if (!data.http && data.protocol === "udp") {
|
||||||
|
return { mode: "udp" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
error: "Invalid deprecated http/protocol combination"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
const createHttpResourceSchema = z
|
const createHttpResourceSchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
subdomain: z.string().nullable().optional(),
|
subdomain: z.string().nullable().optional(),
|
||||||
http: z.boolean(),
|
http: z.boolean().optional().openapi({
|
||||||
protocol: z.enum(["tcp", "udp"]),
|
deprecated: true,
|
||||||
|
description:
|
||||||
|
"Deprecated. Use `mode` instead. Legacy compatibility only."
|
||||||
|
}),
|
||||||
|
protocol: z.enum(["tcp", "udp"]).optional().openapi({
|
||||||
|
deprecated: true,
|
||||||
|
description:
|
||||||
|
"Deprecated. Use `mode` instead. Legacy compatibility only."
|
||||||
|
}),
|
||||||
domainId: z.string(),
|
domainId: z.string(),
|
||||||
stickySession: z.boolean().optional(),
|
stickySession: z.boolean().optional(),
|
||||||
postAuthPath: z.string().nullable().optional()
|
postAuthPath: z.string().nullable().optional(),
|
||||||
|
mode: z.enum(["http", "ssh", "rdp", "vnc", "tcp", "udp"]).optional(),
|
||||||
|
// SSH Settings
|
||||||
|
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||||
|
authDaemonPort: z.int().positive().optional(),
|
||||||
|
authDaemonMode: z.enum(["site", "remote", "native"]).optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -60,13 +106,27 @@ const createHttpResourceSchema = z
|
|||||||
const createRawResourceSchema = z
|
const createRawResourceSchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
http: z.boolean(),
|
http: z.boolean().optional().openapi({
|
||||||
protocol: z.enum(["tcp", "udp"]),
|
deprecated: true,
|
||||||
|
description:
|
||||||
|
"Deprecated. Use `mode` instead. Legacy compatibility only."
|
||||||
|
}),
|
||||||
|
protocol: z.enum(["tcp", "udp"]).optional().openapi({
|
||||||
|
deprecated: true,
|
||||||
|
description:
|
||||||
|
"Deprecated. Use `mode` instead. Legacy compatibility only."
|
||||||
|
}),
|
||||||
|
mode: z.enum(["tcp", "udp"]).optional(),
|
||||||
proxyPort: z.int().min(1).max(65535)
|
proxyPort: z.int().min(1).max(65535)
|
||||||
// enableProxy: z.boolean().default(true) // always true now
|
// enableProxy: z.boolean().default(true) // always true now
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
|
const resolved = resolveModeFromLegacyFields(data);
|
||||||
|
if (resolved.error || !resolved.mode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!config.getRawConfig().flags?.allow_raw_resources) {
|
if (!config.getRawConfig().flags?.allow_raw_resources) {
|
||||||
if (data.proxyPort !== undefined) {
|
if (data.proxyPort !== undefined) {
|
||||||
return false;
|
return false;
|
||||||
@@ -143,17 +203,18 @@ export async function createResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof req.body.http !== "boolean") {
|
const resolvedMode = resolveModeFromLegacyFields(req.body);
|
||||||
|
if (resolvedMode.error) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "http field is required")
|
createHttpError(HttpCode.BAD_REQUEST, resolvedMode.error)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { http } = req.body;
|
if (resolvedMode.mode) {
|
||||||
|
req.body.mode = resolvedMode.mode;
|
||||||
|
}
|
||||||
|
|
||||||
if (http) {
|
if (typeof req.body.proxyPort === "number") {
|
||||||
return await createHttpResource({ req, res, next }, { orgId });
|
|
||||||
} else {
|
|
||||||
if (
|
if (
|
||||||
!config.getRawConfig().flags?.allow_raw_resources &&
|
!config.getRawConfig().flags?.allow_raw_resources &&
|
||||||
build == "oss"
|
build == "oss"
|
||||||
@@ -167,6 +228,17 @@ export async function createResource(
|
|||||||
}
|
}
|
||||||
return await createRawResource({ req, res, next }, { orgId });
|
return await createRawResource({ req, res, next }, { orgId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.body.mode) {
|
||||||
|
return await createHttpResource({ req, res, next }, { orgId });
|
||||||
|
} else {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"mode is required when deprecated fields are not provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
@@ -198,7 +270,15 @@ async function createHttpResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, domainId, postAuthPath } = parsedBody.data;
|
const {
|
||||||
|
name,
|
||||||
|
domainId,
|
||||||
|
postAuthPath,
|
||||||
|
mode,
|
||||||
|
authDaemonPort,
|
||||||
|
authDaemonMode,
|
||||||
|
pamMode
|
||||||
|
} = parsedBody.data;
|
||||||
const subdomain = parsedBody.data.subdomain;
|
const subdomain = parsedBody.data.subdomain;
|
||||||
const stickySession = parsedBody.data.stickySession;
|
const stickySession = parsedBody.data.stickySession;
|
||||||
|
|
||||||
@@ -322,8 +402,10 @@ async function createHttpResource(
|
|||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
subdomain: finalSubdomain,
|
subdomain: finalSubdomain,
|
||||||
http: true,
|
mode: mode,
|
||||||
protocol: "tcp",
|
pamMode: pamMode,
|
||||||
|
authDaemonMode: authDaemonMode,
|
||||||
|
authDaemonPort: authDaemonPort,
|
||||||
ssl: true,
|
ssl: true,
|
||||||
stickySession: stickySession,
|
stickySession: stickySession,
|
||||||
postAuthPath: postAuthPath,
|
postAuthPath: postAuthPath,
|
||||||
@@ -405,7 +487,17 @@ async function createRawResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, http, protocol, proxyPort } = parsedBody.data;
|
const { name, proxyPort } = parsedBody.data;
|
||||||
|
const resolvedMode = resolveModeFromLegacyFields(parsedBody.data);
|
||||||
|
if (resolvedMode.error || !resolvedMode.mode) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
resolvedMode.error ||
|
||||||
|
"mode is required when deprecated fields are not provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let resource: Resource | undefined;
|
let resource: Resource | undefined;
|
||||||
|
|
||||||
@@ -418,9 +510,8 @@ async function createRawResource(
|
|||||||
niceId,
|
niceId,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
http,
|
proxyPort,
|
||||||
protocol,
|
mode: resolvedMode.mode
|
||||||
proxyPort
|
|
||||||
// enableProxy
|
// enableProxy
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export async function createResourceRule(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.http) {
|
if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export async function deleteResource(
|
|||||||
// [target],
|
// [target],
|
||||||
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
|
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
|
||||||
healthChecksToBeRemoved,
|
healthChecksToBeRemoved,
|
||||||
deletedResource.protocol,
|
deletedResource.mode === "udp" ? "udp" : "tcp",
|
||||||
newt.version
|
newt.version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { browserGatewayTarget, db } from "@server/db";
|
||||||
|
import { resources, targets } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
|
const getBrowserTargetSchema = z
|
||||||
|
.object({
|
||||||
|
fullDomain: z.string().min(1, "fullDomain is required")
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type GetBrowserTargetResponse = {
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
authToken: string;
|
||||||
|
orgId: string;
|
||||||
|
resourceId: number;
|
||||||
|
niceId: string;
|
||||||
|
pamMode: "passthrough" | "push" | null;
|
||||||
|
authDaemonMode: "site" | "remote" | "native" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getBrowserTarget(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsed = getBrowserTargetSchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsed.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullDomain } = parsed.data;
|
||||||
|
|
||||||
|
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
|
||||||
|
|
||||||
|
const [browserTarget] = await db
|
||||||
|
.select({
|
||||||
|
destination: browserGatewayTarget.destination,
|
||||||
|
destinationPort: browserGatewayTarget.destinationPort,
|
||||||
|
authToken: browserGatewayTarget.authToken,
|
||||||
|
resourceId: resources.resourceId,
|
||||||
|
niceId: resources.niceId,
|
||||||
|
orgId: resources.orgId,
|
||||||
|
pamMode: resources.pamMode,
|
||||||
|
authDaemonMode: resources.authDaemonMode
|
||||||
|
})
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
eq(browserGatewayTarget.resourceId, resources.resourceId)
|
||||||
|
)
|
||||||
|
.where(eq(resources.fullDomain, fullDomain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const decryptedAuthToken = decrypt(
|
||||||
|
browserTarget.authToken,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!browserTarget) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"No resource found for this domain"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<GetBrowserTargetResponse>(res, {
|
||||||
|
data: {
|
||||||
|
ip: browserTarget.destination,
|
||||||
|
port: browserTarget.destinationPort,
|
||||||
|
authToken: decryptedAuthToken,
|
||||||
|
pamMode: browserTarget.pamMode,
|
||||||
|
authDaemonMode: browserTarget.authDaemonMode,
|
||||||
|
orgId: browserTarget.orgId,
|
||||||
|
resourceId: browserTarget.resourceId,
|
||||||
|
niceId: browserTarget.niceId
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Browser target retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"An error occurred while retrieving the browser target"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,7 +127,7 @@ export async function getUserResources(
|
|||||||
ssl: boolean;
|
ssl: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sso: boolean;
|
sso: boolean;
|
||||||
protocol: string;
|
mode: string;
|
||||||
emailWhitelistEnabled: boolean;
|
emailWhitelistEnabled: boolean;
|
||||||
}> = [];
|
}> = [];
|
||||||
if (accessibleResourceIds.length > 0) {
|
if (accessibleResourceIds.length > 0) {
|
||||||
@@ -139,7 +139,7 @@ export async function getUserResources(
|
|||||||
ssl: resources.ssl,
|
ssl: resources.ssl,
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
sso: resources.sso,
|
sso: resources.sso,
|
||||||
protocol: resources.protocol,
|
mode: resources.mode,
|
||||||
emailWhitelistEnabled: resources.emailWhitelistEnabled
|
emailWhitelistEnabled: resources.emailWhitelistEnabled
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
@@ -323,7 +323,7 @@ export async function getUserResources(
|
|||||||
hasPincode ||
|
hasPincode ||
|
||||||
hasWhitelist
|
hasWhitelist
|
||||||
),
|
),
|
||||||
protocol: resource.protocol,
|
mode: resource.mode,
|
||||||
sso: resource.sso,
|
sso: resource.sso,
|
||||||
password: hasPassword,
|
password: hasPassword,
|
||||||
pincode: hasPincode,
|
pincode: hasPincode,
|
||||||
@@ -339,7 +339,6 @@ export async function getUserResources(
|
|||||||
name: siteResource.name,
|
name: siteResource.name,
|
||||||
destination: siteResource.destination,
|
destination: siteResource.destination,
|
||||||
mode: siteResource.mode,
|
mode: siteResource.mode,
|
||||||
protocol: siteResource.scheme,
|
|
||||||
ssl: siteResource.ssl,
|
ssl: siteResource.ssl,
|
||||||
fullDomain: siteResource.fullDomain,
|
fullDomain: siteResource.fullDomain,
|
||||||
enabled: siteResource.enabled,
|
enabled: siteResource.enabled,
|
||||||
@@ -387,14 +386,13 @@ export type GetUserResourcesResponse = {
|
|||||||
domain: string;
|
domain: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
protected: boolean;
|
protected: boolean;
|
||||||
protocol: string;
|
mode: string;
|
||||||
}>;
|
}>;
|
||||||
siteResources: Array<{
|
siteResources: Array<{
|
||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
name: string;
|
name: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
protocol: string | null;
|
|
||||||
tcpPortRangeString: string | null;
|
tcpPortRangeString: string | null;
|
||||||
udpPortRangeString: string | null;
|
udpPortRangeString: string | null;
|
||||||
disableIcmp: boolean | null;
|
disableIcmp: boolean | null;
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
|
|||||||
export * from "./listAllResourceNames";
|
export * from "./listAllResourceNames";
|
||||||
export * from "./removeEmailFromResourceWhitelist";
|
export * from "./removeEmailFromResourceWhitelist";
|
||||||
export * from "./getStatusHistory";
|
export * from "./getStatusHistory";
|
||||||
|
export * from "./getBrowserTarget";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
db,
|
db,
|
||||||
labels,
|
labels,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
@@ -156,8 +157,6 @@ export type ResourceWithTargets = {
|
|||||||
sso: boolean;
|
sso: boolean;
|
||||||
pincodeId: number | null;
|
pincodeId: number | null;
|
||||||
whitelist: boolean;
|
whitelist: boolean;
|
||||||
http: boolean;
|
|
||||||
protocol: string;
|
|
||||||
proxyPort: number | null;
|
proxyPort: number | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
domainId: string | null;
|
domainId: string | null;
|
||||||
@@ -165,6 +164,7 @@ export type ResourceWithTargets = {
|
|||||||
headerAuthId: number | null;
|
headerAuthId: number | null;
|
||||||
wildcard: boolean;
|
wildcard: boolean;
|
||||||
health: string | null;
|
health: string | null;
|
||||||
|
mode: string | null;
|
||||||
targets: Array<{
|
targets: Array<{
|
||||||
targetId: number;
|
targetId: number;
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -193,8 +193,6 @@ function queryResourcesBase() {
|
|||||||
sso: resources.sso,
|
sso: resources.sso,
|
||||||
pincodeId: resourcePincode.pincodeId,
|
pincodeId: resourcePincode.pincodeId,
|
||||||
whitelist: resources.emailWhitelistEnabled,
|
whitelist: resources.emailWhitelistEnabled,
|
||||||
http: resources.http,
|
|
||||||
protocol: resources.protocol,
|
|
||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
domainId: resources.domainId,
|
domainId: resources.domainId,
|
||||||
@@ -203,7 +201,8 @@ function queryResourcesBase() {
|
|||||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||||
headerAuthExtendedCompatibilityId:
|
headerAuthExtendedCompatibilityId:
|
||||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
||||||
health: resources.health
|
health: resources.health,
|
||||||
|
mode: resources.mode
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
@@ -364,7 +363,9 @@ export async function listResources(
|
|||||||
if (typeof authState !== "undefined") {
|
if (typeof authState !== "undefined") {
|
||||||
switch (authState) {
|
switch (authState) {
|
||||||
case "none":
|
case "none":
|
||||||
conditions.push(eq(resources.http, false));
|
conditions.push(
|
||||||
|
or(eq(resources.mode, "tcp"), eq(resources.mode, "udp"))
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "protected":
|
case "protected":
|
||||||
conditions.push(
|
conditions.push(
|
||||||
@@ -520,6 +521,30 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
||||||
|
|
||||||
|
const allBgTargetSites =
|
||||||
|
resourceIdList.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select({
|
||||||
|
resourceId: browserGatewayTarget.resourceId,
|
||||||
|
siteId: browserGatewayTarget.siteId,
|
||||||
|
siteName: sites.name,
|
||||||
|
siteNiceId: sites.niceId,
|
||||||
|
siteOnline: sites.online,
|
||||||
|
siteType: sites.type
|
||||||
|
})
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
browserGatewayTarget.resourceId,
|
||||||
|
resourceIdList
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sites,
|
||||||
|
eq(sites.siteId, browserGatewayTarget.siteId)
|
||||||
|
);
|
||||||
|
|
||||||
// avoids TS issues with reduce/never[]
|
// avoids TS issues with reduce/never[]
|
||||||
const map = new Map<number, ResourceWithTargets>();
|
const map = new Map<number, ResourceWithTargets>();
|
||||||
|
|
||||||
@@ -536,10 +561,9 @@ export async function listResources(
|
|||||||
sso: row.sso,
|
sso: row.sso,
|
||||||
pincodeId: row.pincodeId,
|
pincodeId: row.pincodeId,
|
||||||
whitelist: row.whitelist,
|
whitelist: row.whitelist,
|
||||||
http: row.http,
|
|
||||||
protocol: row.protocol,
|
|
||||||
proxyPort: row.proxyPort,
|
proxyPort: row.proxyPort,
|
||||||
wildcard: row.wildcard,
|
wildcard: row.wildcard,
|
||||||
|
mode: row.mode,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
domainId: row.domainId,
|
domainId: row.domainId,
|
||||||
headerAuthId: row.headerAuthId,
|
headerAuthId: row.headerAuthId,
|
||||||
@@ -583,6 +607,21 @@ export async function listResources(
|
|||||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const bgRaw = allBgTargetSites.filter(
|
||||||
|
(t) => t.resourceId === entry.resourceId
|
||||||
|
);
|
||||||
|
for (const t of bgRaw) {
|
||||||
|
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isLocal = t.siteType === "local";
|
||||||
|
siteById.set(t.siteId, {
|
||||||
|
siteId: t.siteId,
|
||||||
|
siteName: t.siteName ?? "",
|
||||||
|
siteNiceId: t.siteNiceId ?? "",
|
||||||
|
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||||
|
});
|
||||||
|
}
|
||||||
entry.sites = Array.from(siteById.values());
|
entry.sites = Array.from(siteById.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import {
|
|||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { OpenAPITags } from "@server/openApi";
|
import { OpenAPITags } from "@server/openApi";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
import {
|
||||||
|
validateAndConstructDomain,
|
||||||
|
checkWildcardDomainConflict
|
||||||
|
} from "@server/lib/domainUtils";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
@@ -68,7 +71,11 @@ const updateHttpResourceBodySchema = z
|
|||||||
maintenanceTitle: z.string().max(255).nullable().optional(),
|
maintenanceTitle: z.string().max(255).nullable().optional(),
|
||||||
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
||||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
||||||
postAuthPath: z.string().nullable().optional()
|
postAuthPath: z.string().nullable().optional(),
|
||||||
|
// SSH settings
|
||||||
|
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||||
|
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||||
|
authDaemonPort: z.int().min(1).max(65535).nullable().optional()
|
||||||
})
|
})
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
error: "At least one field must be provided for update"
|
error: "At least one field must be provided for update"
|
||||||
@@ -240,7 +247,7 @@ export async function updateResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resource.http) {
|
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
|
||||||
// HANDLE UPDATING HTTP RESOURCES
|
// HANDLE UPDATING HTTP RESOURCES
|
||||||
return await updateHttpResource(
|
return await updateHttpResource(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ const updateResourceRuleParamsSchema = z.strictObject({
|
|||||||
const updateResourceRuleSchema = z
|
const updateResourceRuleSchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
|
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
|
||||||
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]).optional(),
|
match: z
|
||||||
|
.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"])
|
||||||
|
.optional(),
|
||||||
value: z.string().min(1).optional(),
|
value: z.string().min(1).optional(),
|
||||||
priority: z.int(),
|
priority: z.int(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
@@ -102,7 +104,7 @@ export async function updateResourceRule(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.http) {
|
if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
+230
-226
@@ -174,6 +174,7 @@ export async function createSite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let updatedAddress = null;
|
let updatedAddress = null;
|
||||||
|
let releaseSubnetLock: (() => Promise<void>) | null = null;
|
||||||
if (address) {
|
if (address) {
|
||||||
if (!org.subnet) {
|
if (!org.subnet) {
|
||||||
return next(
|
return next(
|
||||||
@@ -244,147 +245,22 @@ export async function createSite(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
const { value: newClientAddress, release } =
|
||||||
if (!newClientAddress) {
|
await getNextAvailableClientSubnet(orgId);
|
||||||
return next(
|
releaseSubnetLock = release;
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"No available address found"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedAddress = newClientAddress.split("/")[0];
|
updatedAddress = newClientAddress.split("/")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subnet && exitNodeId) {
|
|
||||||
//make sure the subnet is in the range of the exit node if provided
|
|
||||||
const [exitNode] = await db
|
|
||||||
.select()
|
|
||||||
.from(exitNodes)
|
|
||||||
.where(eq(exitNodes.exitNodeId, exitNodeId));
|
|
||||||
|
|
||||||
if (!exitNode) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.NOT_FOUND, "Exit node not found")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!exitNode.address) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Exit node has no subnet defined"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const subnetIp = subnet.split("/")[0];
|
|
||||||
|
|
||||||
if (!isIpInCidr(subnetIp, exitNode.address)) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Subnet is not in the CIDR range of the exit node address."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// lets also make sure there is no overlap with other sites on the exit node
|
|
||||||
const sitesQuery = await db
|
|
||||||
.select({
|
|
||||||
subnet: sites.subnet
|
|
||||||
})
|
|
||||||
.from(sites)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sites.exitNodeId, exitNodeId),
|
|
||||||
eq(sites.subnet, subnet)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sitesQuery.length > 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
`Subnet ${subnet} overlaps with an existing site on this exit node. Please restart site creation.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let updatedNiceId = niceId;
|
|
||||||
if (!niceId) {
|
|
||||||
updatedNiceId = await getUniqueSiteName(orgId);
|
|
||||||
} else {
|
|
||||||
// make sure the niceId is unique
|
|
||||||
const existingSite = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingSite.length > 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
`Nice ID ${niceId} already exists. Please choose a different one.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let newSite: Site | undefined;
|
let newSite: Site | undefined;
|
||||||
await db.transaction(async (trx) => {
|
try {
|
||||||
if (type == "newt") {
|
if (subnet && exitNodeId) {
|
||||||
[newSite] = await trx
|
//make sure the subnet is in the range of the exit node if provided
|
||||||
.insert(sites)
|
const [exitNode] = await db
|
||||||
.values({
|
.select()
|
||||||
// NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
|
.from(exitNodes)
|
||||||
orgId,
|
.where(eq(exitNodes.exitNodeId, exitNodeId));
|
||||||
name,
|
|
||||||
niceId: updatedNiceId!,
|
|
||||||
address: updatedAddress || null,
|
|
||||||
type,
|
|
||||||
dockerSocketEnabled: true,
|
|
||||||
status: "approved"
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await logsDb.insert(statusHistory).values({
|
|
||||||
entityType: "site",
|
|
||||||
entityId: newSite.siteId,
|
|
||||||
orgId: orgId,
|
|
||||||
status: "offline",
|
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
|
||||||
});
|
|
||||||
} else if (type == "wireguard") {
|
|
||||||
// we are creating a site with an exit node (tunneled)
|
|
||||||
if (!subnet) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Subnet is required for tunneled sites"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!exitNodeId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Exit node ID is required for tunneled sites"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { exitNode, hasAccess } = await verifyExitNodeOrgAccess(
|
|
||||||
exitNodeId,
|
|
||||||
orgId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!exitNode) {
|
if (!exitNode) {
|
||||||
logger.warn("Exit node not found");
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.NOT_FOUND,
|
||||||
@@ -393,118 +269,246 @@ export async function createSite(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!exitNode.address) {
|
||||||
logger.warn("Not authorized to use this exit node");
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.BAD_REQUEST,
|
||||||
"Not authorized to use this exit node"
|
"Exit node has no subnet defined"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[newSite] = await trx
|
const subnetIp = subnet.split("/")[0];
|
||||||
.insert(sites)
|
|
||||||
.values({
|
if (!isIpInCidr(subnetIp, exitNode.address)) {
|
||||||
orgId,
|
return next(
|
||||||
exitNodeId,
|
createHttpError(
|
||||||
name,
|
HttpCode.BAD_REQUEST,
|
||||||
niceId: updatedNiceId!,
|
"Subnet is not in the CIDR range of the exit node address."
|
||||||
subnet,
|
)
|
||||||
type,
|
);
|
||||||
pubKey: pubKey || null,
|
}
|
||||||
status: "approved"
|
|
||||||
|
// lets also make sure there is no overlap with other sites on the exit node
|
||||||
|
const sitesQuery = await db
|
||||||
|
.select({
|
||||||
|
subnet: sites.subnet
|
||||||
})
|
})
|
||||||
.returning();
|
.from(sites)
|
||||||
} else if (type == "local") {
|
.where(
|
||||||
[newSite] = await trx
|
and(
|
||||||
.insert(sites)
|
eq(sites.exitNodeId, exitNodeId),
|
||||||
.values({
|
eq(sites.subnet, subnet)
|
||||||
exitNodeId: exitNodeId || null,
|
)
|
||||||
orgId,
|
);
|
||||||
name,
|
|
||||||
niceId: updatedNiceId!,
|
if (sitesQuery.length > 0) {
|
||||||
type,
|
return next(
|
||||||
dockerSocketEnabled: false,
|
createHttpError(
|
||||||
online: true,
|
HttpCode.CONFLICT,
|
||||||
subnet: "0.0.0.0/32",
|
`Subnet ${subnet} overlaps with an existing site on this exit node. Please restart site creation.`
|
||||||
status: "approved"
|
)
|
||||||
})
|
);
|
||||||
.returning();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedNiceId = niceId;
|
||||||
|
if (!niceId) {
|
||||||
|
updatedNiceId = await getUniqueSiteName(orgId);
|
||||||
} else {
|
} else {
|
||||||
return next(
|
// make sure the niceId is unique
|
||||||
createHttpError(
|
const existingSite = await db
|
||||||
HttpCode.BAD_REQUEST,
|
.select()
|
||||||
"Site type not recognized"
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(eq(sites.niceId, niceId), eq(sites.orgId, orgId))
|
||||||
)
|
)
|
||||||
);
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingSite.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
`Nice ID ${niceId} already exists. Please choose a different one.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminRole = await trx
|
await db.transaction(async (trx) => {
|
||||||
.select()
|
if (type == "newt") {
|
||||||
.from(roles)
|
[newSite] = await trx
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
.insert(sites)
|
||||||
.limit(1);
|
.values({
|
||||||
|
// NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
niceId: updatedNiceId!,
|
||||||
|
address: updatedAddress || null,
|
||||||
|
type,
|
||||||
|
dockerSocketEnabled: true,
|
||||||
|
status: "approved"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (adminRole.length === 0) {
|
await logsDb.insert(statusHistory).values({
|
||||||
return next(
|
entityType: "site",
|
||||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
entityId: newSite.siteId,
|
||||||
);
|
orgId: orgId,
|
||||||
}
|
status: "offline",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
} else if (type == "wireguard") {
|
||||||
|
// we are creating a site with an exit node (tunneled)
|
||||||
|
if (!subnet) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Subnet is required for tunneled sites"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await trx.insert(roleSites).values({
|
if (!exitNodeId) {
|
||||||
roleId: adminRole[0].roleId,
|
return next(
|
||||||
siteId: newSite.siteId
|
createHttpError(
|
||||||
});
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Exit node ID is required for tunneled sites"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
const { exitNode, hasAccess } =
|
||||||
req.user &&
|
await verifyExitNodeOrgAccess(exitNodeId, orgId);
|
||||||
!req.userOrgRoleIds?.includes(adminRole[0].roleId)
|
|
||||||
) {
|
if (!exitNode) {
|
||||||
// make sure the user can access the site
|
logger.warn("Exit node not found");
|
||||||
trx.insert(userSites).values({
|
return next(
|
||||||
userId: req.user?.userId!,
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Exit node not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
logger.warn("Not authorized to use this exit node");
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Not authorized to use this exit node"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[newSite] = await trx
|
||||||
|
.insert(sites)
|
||||||
|
.values({
|
||||||
|
orgId,
|
||||||
|
exitNodeId,
|
||||||
|
name,
|
||||||
|
niceId: updatedNiceId!,
|
||||||
|
subnet,
|
||||||
|
type,
|
||||||
|
pubKey: pubKey || null,
|
||||||
|
status: "approved"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
} else if (type == "local") {
|
||||||
|
[newSite] = await trx
|
||||||
|
.insert(sites)
|
||||||
|
.values({
|
||||||
|
exitNodeId: exitNodeId || null,
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
niceId: updatedNiceId!,
|
||||||
|
type,
|
||||||
|
dockerSocketEnabled: false,
|
||||||
|
online: true,
|
||||||
|
subnet: "0.0.0.0/32",
|
||||||
|
status: "approved"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
} else {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Site type not recognized"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminRole = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (adminRole.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Admin role not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(roleSites).values({
|
||||||
|
roleId: adminRole[0].roleId,
|
||||||
siteId: newSite.siteId
|
siteId: newSite.siteId
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// add the peer to the exit node
|
if (
|
||||||
if (type == "newt") {
|
req.user &&
|
||||||
const secretHash = await hashPassword(updatedNewtSecret);
|
!req.userOrgRoleIds?.includes(adminRole[0].roleId)
|
||||||
|
) {
|
||||||
await trx.insert(newts).values({
|
// make sure the user can access the site
|
||||||
newtId: updatedNewtId,
|
trx.insert(userSites).values({
|
||||||
secretHash,
|
userId: req.user?.userId!,
|
||||||
siteId: newSite.siteId,
|
siteId: newSite.siteId
|
||||||
dateCreated: moment().toISOString()
|
});
|
||||||
});
|
|
||||||
} else if (type == "wireguard") {
|
|
||||||
if (!pubKey) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Public key is required for wireguard sites"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!exitNodeId) {
|
// add the peer to the exit node
|
||||||
return next(
|
if (type == "newt") {
|
||||||
createHttpError(
|
const secretHash = await hashPassword(updatedNewtSecret);
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Exit node ID is required for wireguard sites"
|
await trx.insert(newts).values({
|
||||||
)
|
newtId: updatedNewtId,
|
||||||
);
|
secretHash,
|
||||||
|
siteId: newSite.siteId,
|
||||||
|
dateCreated: moment().toISOString()
|
||||||
|
});
|
||||||
|
} else if (type == "wireguard") {
|
||||||
|
if (!pubKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Public key is required for wireguard sites"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exitNodeId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Exit node ID is required for wireguard sites"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await addPeer(exitNodeId, {
|
||||||
|
publicKey: pubKey,
|
||||||
|
allowedIps: []
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await addPeer(exitNodeId, {
|
await usageService.add(orgId, FeatureId.SITES, 1, trx);
|
||||||
publicKey: pubKey,
|
});
|
||||||
allowedIps: []
|
} finally {
|
||||||
});
|
await releaseSubnetLock?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
await usageService.add(orgId, FeatureId.SITES, 1, trx);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!newSite) {
|
if (!newSite) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ export async function pickSiteDefaults(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
const { value: newClientAddress, release: releaseSubnetLock } =
|
||||||
|
await getNextAvailableClientSubnet(orgId);
|
||||||
|
await releaseSubnetLock(); // release immediately — this endpoint only previews the next available value
|
||||||
if (!newClientAddress) {
|
if (!newClientAddress) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -44,14 +44,14 @@ const createSiteResourceSchema = z
|
|||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
niceId: z.string().optional(),
|
niceId: z.string().optional(),
|
||||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||||
mode: z.enum(["host", "cidr", "http"]),
|
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
||||||
ssl: z.boolean().optional(), // only used for http mode
|
ssl: z.boolean().optional(), // only used for http mode
|
||||||
scheme: z.enum(["http", "https"]).optional(),
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
siteIds: z.array(z.int()).optional(),
|
siteIds: z.array(z.int()).optional(),
|
||||||
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
||||||
// proxyPort: z.int().positive().optional(),
|
// proxyPort: z.int().positive().optional(),
|
||||||
destinationPort: z.int().positive().optional(),
|
destinationPort: z.int().positive().optional(),
|
||||||
destination: z.string().min(1),
|
destination: z.string().min(1).optional(),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
@@ -67,14 +67,18 @@ const createSiteResourceSchema = z
|
|||||||
udpPortRangeString: portRangeStringSchema,
|
udpPortRangeString: portRangeStringSchema,
|
||||||
disableIcmp: z.boolean().optional(),
|
disableIcmp: z.boolean().optional(),
|
||||||
authDaemonPort: z.int().positive().optional(),
|
authDaemonPort: z.int().positive().optional(),
|
||||||
authDaemonMode: z.enum(["site", "remote"]).optional(),
|
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||||
|
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||||
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
|
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
|
||||||
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
|
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode === "host") {
|
if (
|
||||||
|
(data.mode === "host" || data.mode === "ssh") &&
|
||||||
|
data.destination
|
||||||
|
) {
|
||||||
// Check if it's a valid IP address using zod (v4 or v6)
|
// Check if it's a valid IP address using zod (v4 or v6)
|
||||||
const isValidIP = z
|
const isValidIP = z
|
||||||
// .union([z.ipv4(), z.ipv6()])
|
// .union([z.ipv4(), z.ipv6()])
|
||||||
@@ -116,19 +120,45 @@ const createSiteResourceSchema = z
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode !== "http") return true;
|
if (data.mode === "http") {
|
||||||
return (
|
return (
|
||||||
data.scheme !== undefined &&
|
data.scheme !== undefined &&
|
||||||
data.destinationPort !== undefined &&
|
data.scheme !== null &&
|
||||||
data.destinationPort >= 1 &&
|
data.destinationPort !== undefined &&
|
||||||
data.destinationPort <= 65535
|
data.destinationPort !== null &&
|
||||||
);
|
data.destinationPort >= 1 &&
|
||||||
|
data.destinationPort <= 65535
|
||||||
|
);
|
||||||
|
} else if (data.mode === "ssh") {
|
||||||
|
// just check the destinationPort
|
||||||
|
return (
|
||||||
|
data.destinationPort === undefined ||
|
||||||
|
(data.destinationPort !== null &&
|
||||||
|
data.destinationPort >= 1 &&
|
||||||
|
data.destinationPort <= 65535)
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
"HTTP mode requires scheme (http or https) and a valid destination port"
|
"HTTP mode requires scheme (http or https) and a valid destination port"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// destination is only optional for ssh mode with native authDaemonMode
|
||||||
|
if (data.mode === "ssh" && data.authDaemonMode === "native") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
data.destination !== undefined && data.destination.trim() !== ""
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Destination is required unless mode is ssh with authDaemonMode native"
|
||||||
|
}
|
||||||
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
return (
|
return (
|
||||||
@@ -212,6 +242,7 @@ export async function createSiteResource(
|
|||||||
disableIcmp,
|
disableIcmp,
|
||||||
authDaemonPort,
|
authDaemonPort,
|
||||||
authDaemonMode,
|
authDaemonMode,
|
||||||
|
pamMode,
|
||||||
domainId,
|
domainId,
|
||||||
subdomain
|
subdomain
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
@@ -276,8 +307,8 @@ export async function createSiteResource(
|
|||||||
.safeParse(destination).success;
|
.safeParse(destination).success;
|
||||||
if (
|
if (
|
||||||
isIp &&
|
isIp &&
|
||||||
(isIpInCidr(destination, org.subnet) ||
|
(isIpInCidr(destination!, org.subnet) ||
|
||||||
isIpInCidr(destination, org.utilitySubnet))
|
isIpInCidr(destination!, org.utilitySubnet))
|
||||||
) {
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -366,132 +397,163 @@ export async function createSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let aliasAddress: string | null = null;
|
let aliasAddress: string | null = null;
|
||||||
|
let releaseAliasLock: (() => Promise<void>) | null = null;
|
||||||
if (mode === "host" || mode === "http") {
|
if (mode === "host" || mode === "http") {
|
||||||
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
const { value, release } =
|
||||||
|
await getNextAvailableAliasAddress(orgId);
|
||||||
|
aliasAddress = value;
|
||||||
|
releaseAliasLock = release;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newSiteResource: SiteResource | undefined;
|
let newSiteResource: SiteResource | undefined;
|
||||||
await db.transaction(async (trx) => {
|
try {
|
||||||
const [network] = await trx
|
await db.transaction(async (trx) => {
|
||||||
.insert(networks)
|
const [network] = await trx
|
||||||
.values({
|
.insert(networks)
|
||||||
scope: "resource",
|
.values({
|
||||||
orgId: orgId
|
scope: "resource",
|
||||||
})
|
orgId: orgId
|
||||||
.returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (!network) {
|
if (!network) {
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
`Failed to create network`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the site resource
|
|
||||||
const insertValues: typeof siteResources.$inferInsert = {
|
|
||||||
niceId: updatedNiceId!,
|
|
||||||
orgId,
|
|
||||||
name,
|
|
||||||
mode,
|
|
||||||
ssl,
|
|
||||||
networkId: network.networkId,
|
|
||||||
destination,
|
|
||||||
scheme,
|
|
||||||
destinationPort,
|
|
||||||
enabled,
|
|
||||||
alias: alias ? alias.trim() : null,
|
|
||||||
aliasAddress,
|
|
||||||
tcpPortRangeString:
|
|
||||||
mode == "http" ? "443,80" : tcpPortRangeString,
|
|
||||||
udpPortRangeString: mode == "http" ? "" : udpPortRangeString,
|
|
||||||
disableIcmp: disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false
|
|
||||||
domainId,
|
|
||||||
subdomain: finalSubdomain,
|
|
||||||
fullDomain
|
|
||||||
};
|
|
||||||
if (isLicensedSshPam) {
|
|
||||||
if (authDaemonPort !== undefined)
|
|
||||||
insertValues.authDaemonPort = authDaemonPort;
|
|
||||||
if (authDaemonMode !== undefined)
|
|
||||||
insertValues.authDaemonMode = authDaemonMode;
|
|
||||||
}
|
|
||||||
[newSiteResource] = await trx
|
|
||||||
.insert(siteResources)
|
|
||||||
.values(insertValues)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const siteResourceId = newSiteResource.siteResourceId;
|
|
||||||
|
|
||||||
//////////////////// update the associations ////////////////////
|
|
||||||
|
|
||||||
for (const siteId of siteIds) {
|
|
||||||
await trx.insert(siteNetworks).values({
|
|
||||||
siteId: siteId,
|
|
||||||
networkId: network.networkId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const [adminRole] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!adminRole) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx.insert(roleSiteResources).values({
|
|
||||||
roleId: adminRole.roleId,
|
|
||||||
siteResourceId: siteResourceId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (roleIds.length > 0) {
|
|
||||||
await trx
|
|
||||||
.insert(roleSiteResources)
|
|
||||||
.values(
|
|
||||||
roleIds.map((roleId) => ({ roleId, siteResourceId }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userIds.length > 0) {
|
|
||||||
await trx
|
|
||||||
.insert(userSiteResources)
|
|
||||||
.values(
|
|
||||||
userIds.map((userId) => ({ userId, siteResourceId }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientIds.length > 0) {
|
|
||||||
await trx.insert(clientSiteResources).values(
|
|
||||||
clientIds.map((clientId) => ({
|
|
||||||
clientId,
|
|
||||||
siteResourceId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const siteToAssign of sitesToAssign) {
|
|
||||||
const [newt] = await trx
|
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, siteToAssign.siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!newt) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
`Newt not found for site ${siteToAssign.siteId}`
|
`Failed to create network`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
let tcpPortRangeStringAdjusted = tcpPortRangeString;
|
||||||
|
if (mode === "http") {
|
||||||
|
tcpPortRangeStringAdjusted = "443,80";
|
||||||
|
} else if (mode === "ssh") {
|
||||||
|
tcpPortRangeStringAdjusted = destinationPort
|
||||||
|
? destinationPort.toString()
|
||||||
|
: "22";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the site resource
|
||||||
|
const insertValues: typeof siteResources.$inferInsert = {
|
||||||
|
niceId: updatedNiceId!,
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
mode,
|
||||||
|
ssl,
|
||||||
|
networkId: network.networkId,
|
||||||
|
destination: destination, // the ssh can be null
|
||||||
|
scheme,
|
||||||
|
destinationPort,
|
||||||
|
enabled,
|
||||||
|
alias: alias ? alias.trim() : null,
|
||||||
|
aliasAddress,
|
||||||
|
tcpPortRangeString: tcpPortRangeStringAdjusted,
|
||||||
|
udpPortRangeString:
|
||||||
|
mode == "http" || mode == "ssh"
|
||||||
|
? ""
|
||||||
|
: udpPortRangeString,
|
||||||
|
disableIcmp:
|
||||||
|
disableIcmp ||
|
||||||
|
(mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false
|
||||||
|
domainId,
|
||||||
|
subdomain: finalSubdomain,
|
||||||
|
fullDomain
|
||||||
|
};
|
||||||
|
if (isLicensedSshPam) {
|
||||||
|
if (authDaemonPort !== undefined)
|
||||||
|
insertValues.authDaemonPort = authDaemonPort;
|
||||||
|
if (authDaemonMode !== undefined)
|
||||||
|
insertValues.authDaemonMode = authDaemonMode;
|
||||||
|
if (pamMode !== undefined) insertValues.pamMode = pamMode;
|
||||||
|
}
|
||||||
|
[newSiteResource] = await trx
|
||||||
|
.insert(siteResources)
|
||||||
|
.values(insertValues)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const siteResourceId = newSiteResource.siteResourceId;
|
||||||
|
|
||||||
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
|
for (const siteId of siteIds) {
|
||||||
|
await trx.insert(siteNetworks).values({
|
||||||
|
siteId: siteId,
|
||||||
|
networkId: network.networkId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [adminRole] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Admin role not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(roleSiteResources).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
siteResourceId: siteResourceId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (roleIds.length > 0) {
|
||||||
|
await trx
|
||||||
|
.insert(roleSiteResources)
|
||||||
|
.values(
|
||||||
|
roleIds.map((roleId) => ({
|
||||||
|
roleId,
|
||||||
|
siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
await trx
|
||||||
|
.insert(userSiteResources)
|
||||||
|
.values(
|
||||||
|
userIds.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientIds.length > 0) {
|
||||||
|
await trx.insert(clientSiteResources).values(
|
||||||
|
clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const siteToAssign of sitesToAssign) {
|
||||||
|
const [newt] = await trx
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, siteToAssign.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Newt not found for site ${siteToAssign.siteId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await releaseAliasLock?.();
|
||||||
|
}
|
||||||
|
|
||||||
if (!newSiteResource) {
|
if (!newSiteResource) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const updateSiteResourceSchema = z
|
|||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
||||||
mode: z.enum(["host", "cidr", "http"]).optional(),
|
mode: z.enum(["host", "cidr", "http", "ssh"]).optional(),
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
scheme: z.enum(["http", "https"]).nullish(),
|
scheme: z.enum(["http", "https"]).nullish(),
|
||||||
// proxyPort: z.int().positive().nullish(),
|
// proxyPort: z.int().positive().nullish(),
|
||||||
@@ -77,14 +77,18 @@ const updateSiteResourceSchema = z
|
|||||||
udpPortRangeString: portRangeStringSchema,
|
udpPortRangeString: portRangeStringSchema,
|
||||||
disableIcmp: z.boolean().optional(),
|
disableIcmp: z.boolean().optional(),
|
||||||
authDaemonPort: z.int().positive().nullish(),
|
authDaemonPort: z.int().positive().nullish(),
|
||||||
authDaemonMode: z.enum(["site", "remote"]).optional(),
|
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||||
|
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||||
domainId: z.string().optional(),
|
domainId: z.string().optional(),
|
||||||
subdomain: z.string().optional()
|
subdomain: z.string().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode === "host" && data.destination) {
|
if (
|
||||||
|
(data.mode === "host" || data.mode == "ssh") &&
|
||||||
|
data.destination
|
||||||
|
) {
|
||||||
const isValidIP = z
|
const isValidIP = z
|
||||||
// .union([z.ipv4(), z.ipv6()])
|
// .union([z.ipv4(), z.ipv6()])
|
||||||
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
||||||
@@ -125,21 +129,45 @@ const updateSiteResourceSchema = z
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode !== "http") return true;
|
if (data.mode === "http") {
|
||||||
return (
|
return (
|
||||||
data.scheme !== undefined &&
|
data.scheme !== undefined &&
|
||||||
data.scheme !== null &&
|
data.scheme !== null &&
|
||||||
data.destinationPort !== undefined &&
|
data.destinationPort !== undefined &&
|
||||||
data.destinationPort !== null &&
|
data.destinationPort !== null &&
|
||||||
data.destinationPort >= 1 &&
|
data.destinationPort >= 1 &&
|
||||||
data.destinationPort <= 65535
|
data.destinationPort <= 65535
|
||||||
);
|
);
|
||||||
|
} else if (data.mode === "ssh") {
|
||||||
|
// just check the destinationPort
|
||||||
|
return (
|
||||||
|
data.destinationPort === undefined ||
|
||||||
|
(data.destinationPort !== null &&
|
||||||
|
data.destinationPort >= 1 &&
|
||||||
|
data.destinationPort <= 65535)
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
"HTTP mode requires scheme (http or https) and a valid destination port"
|
"HTTP mode requires scheme (http or https) and a valid destination port"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// destination is only optional for ssh mode with native authDaemonMode
|
||||||
|
if (data.mode === "ssh" && data.authDaemonMode === "native") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
data.destination !== undefined && data.destination.trim() !== ""
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Destination is required unless mode is ssh with authDaemonMode native"
|
||||||
|
}
|
||||||
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
return (
|
return (
|
||||||
@@ -222,6 +250,7 @@ export async function updateSiteResource(
|
|||||||
disableIcmp,
|
disableIcmp,
|
||||||
authDaemonPort,
|
authDaemonPort,
|
||||||
authDaemonMode,
|
authDaemonMode,
|
||||||
|
pamMode,
|
||||||
domainId,
|
domainId,
|
||||||
subdomain
|
subdomain
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
@@ -430,16 +459,30 @@ export async function updateSiteResource(
|
|||||||
const sshPamSet =
|
const sshPamSet =
|
||||||
isLicensedSshPam &&
|
isLicensedSshPam &&
|
||||||
(authDaemonPort !== undefined ||
|
(authDaemonPort !== undefined ||
|
||||||
authDaemonMode !== undefined)
|
authDaemonMode !== undefined ||
|
||||||
|
pamMode !== undefined)
|
||||||
? {
|
? {
|
||||||
...(authDaemonPort !== undefined && {
|
...(authDaemonPort !== undefined && {
|
||||||
authDaemonPort
|
authDaemonPort
|
||||||
}),
|
}),
|
||||||
...(authDaemonMode !== undefined && {
|
...(authDaemonMode !== undefined && {
|
||||||
authDaemonMode
|
authDaemonMode
|
||||||
|
}),
|
||||||
|
...(pamMode !== undefined && {
|
||||||
|
pamMode
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
let tcpPortRangeStringAdjusted = tcpPortRangeString;
|
||||||
|
if (mode === "http") {
|
||||||
|
tcpPortRangeStringAdjusted = "443,80";
|
||||||
|
} else if (mode === "ssh") {
|
||||||
|
tcpPortRangeStringAdjusted = destinationPort
|
||||||
|
? destinationPort.toString()
|
||||||
|
: "22";
|
||||||
|
}
|
||||||
|
|
||||||
[updatedSiteResource] = await trx
|
[updatedSiteResource] = await trx
|
||||||
.update(siteResources)
|
.update(siteResources)
|
||||||
.set({
|
.set({
|
||||||
@@ -452,12 +495,14 @@ export async function updateSiteResource(
|
|||||||
destinationPort,
|
destinationPort,
|
||||||
enabled,
|
enabled,
|
||||||
alias: alias ? alias.trim() : null,
|
alias: alias ? alias.trim() : null,
|
||||||
tcpPortRangeString:
|
tcpPortRangeString: tcpPortRangeStringAdjusted,
|
||||||
mode == "http" ? "443,80" : tcpPortRangeString,
|
|
||||||
udpPortRangeString:
|
udpPortRangeString:
|
||||||
mode == "http" ? "" : udpPortRangeString,
|
mode == "http" || mode == "ssh"
|
||||||
|
? ""
|
||||||
|
: udpPortRangeString,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false
|
disableIcmp ||
|
||||||
|
(mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false
|
||||||
domainId,
|
domainId,
|
||||||
subdomain: finalSubdomain,
|
subdomain: finalSubdomain,
|
||||||
fullDomain,
|
fullDomain,
|
||||||
@@ -554,13 +599,17 @@ export async function updateSiteResource(
|
|||||||
const sshPamSet =
|
const sshPamSet =
|
||||||
isLicensedSshPam &&
|
isLicensedSshPam &&
|
||||||
(authDaemonPort !== undefined ||
|
(authDaemonPort !== undefined ||
|
||||||
authDaemonMode !== undefined)
|
authDaemonMode !== undefined ||
|
||||||
|
pamMode !== undefined)
|
||||||
? {
|
? {
|
||||||
...(authDaemonPort !== undefined && {
|
...(authDaemonPort !== undefined && {
|
||||||
authDaemonPort
|
authDaemonPort
|
||||||
}),
|
}),
|
||||||
...(authDaemonMode !== undefined && {
|
...(authDaemonMode !== undefined && {
|
||||||
authDaemonMode
|
authDaemonMode
|
||||||
|
}),
|
||||||
|
...(pamMode !== undefined && {
|
||||||
|
pamMode
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
@@ -832,6 +881,10 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
for (const client of mergedAllClients) {
|
for (const client of mergedAllClients) {
|
||||||
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
|
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
|
||||||
// todo: optimize this query if needed
|
// todo: optimize this query if needed
|
||||||
|
if (!existingSiteResource.destination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const oldDestinationStillInUseSites = await trx
|
const oldDestinationStillInUseSites = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ export async function createTarget(
|
|||||||
newt.newtId,
|
newt.newtId,
|
||||||
newTarget,
|
newTarget,
|
||||||
healthCheck,
|
healthCheck,
|
||||||
resource.protocol,
|
resource.mode === "udp" ? "udp" : "tcp",
|
||||||
newt.version
|
newt.version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export async function deleteTarget(
|
|||||||
// [deletedTarget],
|
// [deletedTarget],
|
||||||
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
|
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
|
||||||
[deletedHealthCheck],
|
[deletedHealthCheck],
|
||||||
resource.protocol,
|
resource.mode === "udp" ? "udp" : "tcp",
|
||||||
newt.version
|
newt.version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export async function updateTarget(
|
|||||||
newt.newtId,
|
newt.newtId,
|
||||||
[updatedTarget],
|
[updatedTarget],
|
||||||
[updatedHc],
|
[updatedHc],
|
||||||
resource.protocol,
|
resource.mode === "udp" ? "udp" : "tcp",
|
||||||
newt.version
|
newt.version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1352,6 +1352,12 @@ export default function BillingPage() {
|
|||||||
{t("billingModifyCurrentPlan") ||
|
{t("billingModifyCurrentPlan") ||
|
||||||
"Modify Current Plan"}
|
"Modify Current Plan"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{t(
|
||||||
|
"billingManageLicenseSubscriptionDescription"
|
||||||
|
) ||
|
||||||
|
"Manage your subscription for paid self-hosted license keys and download invoices."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ export default async function ClientResourcesPage(
|
|||||||
pagination = responseData.pagination;
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
|
const siteIdParam = parsePositiveInt(
|
||||||
|
searchParams.get("siteId") ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
let initialFilterSite: {
|
let initialFilterSite: {
|
||||||
siteId: number;
|
siteId: number;
|
||||||
@@ -106,7 +108,10 @@ export default async function ClientResourcesPage(
|
|||||||
siteNiceId: siteResource.siteNiceIds[idx],
|
siteNiceId: siteResource.siteNiceIds[idx],
|
||||||
online: siteResource.siteOnlines[idx]
|
online: siteResource.siteOnlines[idx]
|
||||||
})),
|
})),
|
||||||
mode: siteResource.mode,
|
mode:
|
||||||
|
siteResource.pamMode && siteResource.mode === "host"
|
||||||
|
? "ssh"
|
||||||
|
: siteResource.mode,
|
||||||
scheme: siteResource.scheme,
|
scheme: siteResource.scheme,
|
||||||
ssl: siteResource.ssl,
|
ssl: siteResource.ssl,
|
||||||
siteNames: siteResource.siteNames,
|
siteNames: siteResource.siteNames,
|
||||||
@@ -115,7 +120,7 @@ export default async function ClientResourcesPage(
|
|||||||
// proxyPort: siteResource.proxyPort,
|
// proxyPort: siteResource.proxyPort,
|
||||||
siteIds: siteResource.siteIds,
|
siteIds: siteResource.siteIds,
|
||||||
destination: siteResource.destination,
|
destination: siteResource.destination,
|
||||||
httpHttpsPort: siteResource.destinationPort ?? null,
|
destinationPort: siteResource.destinationPort ?? null,
|
||||||
alias: siteResource.alias || null,
|
alias: siteResource.alias || null,
|
||||||
aliasAddress: siteResource.aliasAddress || null,
|
aliasAddress: siteResource.aliasAddress || null,
|
||||||
siteNiceIds: siteResource.siteNiceIds,
|
siteNiceIds: siteResource.siteNiceIds,
|
||||||
@@ -125,6 +130,7 @@ export default async function ClientResourcesPage(
|
|||||||
disableIcmp: siteResource.disableIcmp || false,
|
disableIcmp: siteResource.disableIcmp || false,
|
||||||
authDaemonMode: siteResource.authDaemonMode ?? null,
|
authDaemonMode: siteResource.authDaemonMode ?? null,
|
||||||
authDaemonPort: siteResource.authDaemonPort ?? null,
|
authDaemonPort: siteResource.authDaemonPort ?? null,
|
||||||
|
pamMode: siteResource.pamMode ?? null,
|
||||||
subdomain: siteResource.subdomain ?? null,
|
subdomain: siteResource.subdomain ?? null,
|
||||||
domainId: siteResource.domainId ?? null,
|
domainId: siteResource.domainId ?? null,
|
||||||
fullDomain: siteResource.fullDomain ?? null,
|
fullDomain: siteResource.fullDomain ?? null,
|
||||||
|
|||||||
+123
-641
@@ -3,15 +3,7 @@
|
|||||||
import HealthCheckCredenza from "@/components/HealthCheckCredenza";
|
import HealthCheckCredenza from "@/components/HealthCheckCredenza";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { HeadersInput } from "@app/components/HeadersInput";
|
|
||||||
import {
|
import {
|
||||||
PathMatchDisplay,
|
PathMatchDisplay,
|
||||||
PathMatchModal,
|
PathMatchModal,
|
||||||
@@ -20,25 +12,12 @@ import {
|
|||||||
} from "@app/components/PathMatchRenameModal";
|
} from "@app/components/PathMatchRenameModal";
|
||||||
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
|
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "@app/components/ui/form";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -55,17 +34,13 @@ import {
|
|||||||
} from "@app/components/ui/tooltip";
|
} from "@app/components/ui/tooltip";
|
||||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tlsNameSchema } from "@server/lib/schemas";
|
|
||||||
import { type GetResourceResponse } from "@server/routers/resource";
|
import { type GetResourceResponse } from "@server/routers/resource";
|
||||||
import type { ListSitesResponse } from "@server/routers/site";
|
|
||||||
import { CreateTargetResponse } from "@server/routers/target";
|
import { CreateTargetResponse } from "@server/routers/target";
|
||||||
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
||||||
import { ArrayElement } from "@server/types/ArrayElement";
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
@@ -80,33 +55,18 @@ import {
|
|||||||
useReactTable
|
useReactTable
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import {
|
import { ExternalLink, Info, Plus } from "lucide-react";
|
||||||
AlertTriangle,
|
|
||||||
CircleCheck,
|
|
||||||
CircleX,
|
|
||||||
ExternalLink,
|
|
||||||
Info,
|
|
||||||
Plus,
|
|
||||||
Settings
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
use,
|
|
||||||
useActionState,
|
useActionState,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState
|
useState
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const targetsSettingsSchema = z.object({
|
export type LocalTarget = Omit<
|
||||||
stickySession: z.boolean()
|
|
||||||
});
|
|
||||||
|
|
||||||
type LocalTarget = Omit<
|
|
||||||
ArrayElement<ListTargetsResponse["targets"]> & {
|
ArrayElement<ListTargetsResponse["targets"]> & {
|
||||||
new?: boolean;
|
new?: boolean;
|
||||||
updated?: boolean;
|
updated?: boolean;
|
||||||
@@ -115,67 +75,43 @@ type LocalTarget = Omit<
|
|||||||
"protocol"
|
"protocol"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export default function ReverseProxyTargetsPage(props: {
|
interface ProxyResourceTargetsFormProps {
|
||||||
params: Promise<{ resourceId: number; orgId: string }>;
|
orgId: string;
|
||||||
}) {
|
isHttp: boolean;
|
||||||
const params = use(props.params);
|
initialTargets?: LocalTarget[];
|
||||||
const { resource, updateResource } = useResourceContext();
|
/** Edit mode: when provided, shows a save button and polls for health status */
|
||||||
|
resource?: GetResourceResponse;
|
||||||
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
|
updateResource?: ResourceContextType["updateResource"];
|
||||||
resourceQueries.resourceTargets({
|
/** Create mode: called whenever the targets list changes */
|
||||||
resourceId: resource.resourceId
|
onChange?: (targets: LocalTarget[]) => void;
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoadingTargets) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsContainer>
|
|
||||||
<ProxyResourceTargetsForm
|
|
||||||
orgId={params.orgId}
|
|
||||||
initialTargets={remoteTargets}
|
|
||||||
resource={resource}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{resource.http && (
|
|
||||||
<ProxyResourceHttpForm
|
|
||||||
resource={resource}
|
|
||||||
updateResource={updateResource}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!resource.http && resource.protocol == "tcp" && (
|
|
||||||
<ProxyResourceProtocolForm
|
|
||||||
resource={resource}
|
|
||||||
updateResource={updateResource}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProxyResourceTargetsForm({
|
export function ProxyResourceTargetsForm({
|
||||||
orgId,
|
orgId,
|
||||||
initialTargets,
|
isHttp,
|
||||||
resource
|
initialTargets = [],
|
||||||
}: {
|
resource,
|
||||||
initialTargets: LocalTarget[];
|
updateResource,
|
||||||
orgId: string;
|
onChange
|
||||||
resource: GetResourceResponse;
|
}: ProxyResourceTargetsFormProps) {
|
||||||
}) {
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
|
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
|
||||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// Notify parent of changes (create mode)
|
||||||
|
useEffect(() => {
|
||||||
|
onChange?.(targets);
|
||||||
|
}, [targets]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Poll health status only in edit mode
|
||||||
const { data: polledTargets } = useQuery({
|
const { data: polledTargets } = useQuery({
|
||||||
...resourceQueries.resourceTargets({
|
...resourceQueries.resourceTargets({
|
||||||
resourceId: resource.resourceId
|
resourceId: resource?.resourceId ?? 0
|
||||||
}),
|
}),
|
||||||
refetchInterval: 10_000
|
refetchInterval: 10_000,
|
||||||
|
enabled: !!resource
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -194,6 +130,7 @@ function ProxyResourceTargetsForm({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}, [polledTargets]);
|
}, [polledTargets]);
|
||||||
|
|
||||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
||||||
new Map()
|
new Map()
|
||||||
);
|
);
|
||||||
@@ -201,14 +138,17 @@ function ProxyResourceTargetsForm({
|
|||||||
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
||||||
useState<LocalTarget | null>(null);
|
useState<LocalTarget | null>(null);
|
||||||
|
|
||||||
|
const [bgDestination, setBgDestination] = useState("");
|
||||||
|
const [bgDestinationPort, setBgDestinationPort] = useState("");
|
||||||
|
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
|
||||||
|
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
|
||||||
|
|
||||||
const initializeDockerForSite = async (siteId: number) => {
|
const initializeDockerForSite = async (siteId: number) => {
|
||||||
if (dockerStates.has(siteId)) {
|
if (dockerStates.has(siteId)) {
|
||||||
return; // Already initialized
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dockerManager = new DockerManager(api, siteId);
|
const dockerManager = new DockerManager(api, siteId);
|
||||||
const dockerState = await dockerManager.initializeDocker();
|
const dockerState = await dockerManager.initializeDocker();
|
||||||
|
|
||||||
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,7 +156,6 @@ function ProxyResourceTargetsForm({
|
|||||||
async (siteId: number) => {
|
async (siteId: number) => {
|
||||||
const dockerManager = new DockerManager(api, siteId);
|
const dockerManager = new DockerManager(api, siteId);
|
||||||
const containers = await dockerManager.fetchContainers();
|
const containers = await dockerManager.fetchContainers();
|
||||||
|
|
||||||
setDockerStates((prev) => {
|
setDockerStates((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
const existingState = newMap.get(siteId);
|
const existingState = newMap.get(siteId);
|
||||||
@@ -250,8 +189,6 @@ function ProxyResourceTargetsForm({
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isHttp = resource.http;
|
|
||||||
|
|
||||||
const removeTarget = useCallback((targetId: number) => {
|
const removeTarget = useCallback((targetId: number) => {
|
||||||
setTargets((prevTargets) => {
|
setTargets((prevTargets) => {
|
||||||
const targetToRemove = prevTargets.find(
|
const targetToRemove = prevTargets.find(
|
||||||
@@ -270,6 +207,42 @@ function ProxyResourceTargetsForm({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Browser-gateway targets (edit mode only)
|
||||||
|
const { data: bgTargetsResponse } = useQuery({
|
||||||
|
queryKey: ["browserGatewayTargets", resource?.resourceId, orgId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get(
|
||||||
|
`/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets`
|
||||||
|
);
|
||||||
|
return res.data.data as {
|
||||||
|
targets: Array<{
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
resourceId: number;
|
||||||
|
siteId: number;
|
||||||
|
type: string;
|
||||||
|
destination: string;
|
||||||
|
destinationPort: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!resource
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bgTargetsResponse?.targets?.length) return;
|
||||||
|
const bgt = bgTargetsResponse.targets[0];
|
||||||
|
setBgDestination(bgt.destination);
|
||||||
|
setBgDestinationPort(String(bgt.destinationPort));
|
||||||
|
setBgSiteId(bgt.siteId);
|
||||||
|
setBgTargetId(bgt.browserGatewayTargetId);
|
||||||
|
}, [bgTargetsResponse]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sites.length > 0 && bgSiteId === null) {
|
||||||
|
setBgSiteId(sites[0].siteId);
|
||||||
|
}
|
||||||
|
}, [sites, bgSiteId]);
|
||||||
|
|
||||||
const updateTarget = useCallback(
|
const updateTarget = useCallback(
|
||||||
(targetId: number, data: Partial<LocalTarget>) => {
|
(targetId: number, data: Partial<LocalTarget>) => {
|
||||||
setTargets((prevTargets) => {
|
setTargets((prevTargets) => {
|
||||||
@@ -356,7 +329,7 @@ function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center w-full">
|
<div className="flex items-center justify-center w-full">
|
||||||
{row.original.siteType === "newt" ? (
|
{row.original.siteType === "newt" ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -375,7 +348,6 @@ function ProxyResourceTargetsForm({
|
|||||||
{getStatusText(status)}
|
{getStatusText(status)}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
) : (
|
) : (
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
)}
|
)}
|
||||||
@@ -404,9 +376,15 @@ function ProxyResourceTargetsForm({
|
|||||||
pathMatchType: row.original.pathMatchType
|
pathMatchType: row.original.pathMatchType
|
||||||
}}
|
}}
|
||||||
onChange={(config) =>
|
onChange={(config) =>
|
||||||
updateTarget(row.original.targetId,
|
updateTarget(
|
||||||
config.path === null && config.pathMatchType === null
|
row.original.targetId,
|
||||||
? { ...config, rewritePath: null, rewritePathType: null }
|
config.path === null &&
|
||||||
|
config.pathMatchType === null
|
||||||
|
? {
|
||||||
|
...config,
|
||||||
|
rewritePath: null,
|
||||||
|
rewritePathType: null
|
||||||
|
}
|
||||||
: config
|
: config
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -432,9 +410,15 @@ function ProxyResourceTargetsForm({
|
|||||||
pathMatchType: row.original.pathMatchType
|
pathMatchType: row.original.pathMatchType
|
||||||
}}
|
}}
|
||||||
onChange={(config) =>
|
onChange={(config) =>
|
||||||
updateTarget(row.original.targetId,
|
updateTarget(
|
||||||
config.path === null && config.pathMatchType === null
|
row.original.targetId,
|
||||||
? { ...config, rewritePath: null, rewritePathType: null }
|
config.path === null &&
|
||||||
|
config.pathMatchType === null
|
||||||
|
? {
|
||||||
|
...config,
|
||||||
|
rewritePath: null,
|
||||||
|
rewritePathType: null
|
||||||
|
}
|
||||||
: config
|
: config
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -587,20 +571,19 @@ function ProxyResourceTargetsForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isAdvancedMode) {
|
if (isAdvancedMode) {
|
||||||
const columns = [
|
const cols = [
|
||||||
addressColumn,
|
addressColumn,
|
||||||
healthCheckColumn,
|
healthCheckColumn,
|
||||||
enabledColumn,
|
enabledColumn,
|
||||||
actionsColumn
|
actionsColumn
|
||||||
];
|
];
|
||||||
|
|
||||||
// Only include path-related columns for HTTP resources
|
|
||||||
if (isHttp) {
|
if (isHttp) {
|
||||||
columns.unshift(matchPathColumn);
|
cols.unshift(matchPathColumn);
|
||||||
columns.splice(3, 0, rewritePathColumn, priorityColumn);
|
cols.splice(3, 0, rewritePathColumn, priorityColumn);
|
||||||
}
|
}
|
||||||
|
|
||||||
return columns;
|
return cols;
|
||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
addressColumn,
|
addressColumn,
|
||||||
@@ -622,22 +605,20 @@ function ProxyResourceTargetsForm({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function addNewTarget() {
|
function addNewTarget() {
|
||||||
const isHttp = resource.http;
|
|
||||||
|
|
||||||
const newTarget: LocalTarget = {
|
const newTarget: LocalTarget = {
|
||||||
targetId: -Date.now(), // Use negative timestamp as temporary ID
|
targetId: -Date.now(),
|
||||||
ip: "",
|
ip: "",
|
||||||
method: isHttp ? "http" : null,
|
method: isHttp ? "http" : null,
|
||||||
port: 0,
|
port: 0,
|
||||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||||
siteName: sites.length > 0 ? sites[0].name : "",
|
siteName: sites.length > 0 ? sites[0].name : "",
|
||||||
path: isHttp ? null : null,
|
path: null,
|
||||||
pathMatchType: isHttp ? null : null,
|
pathMatchType: null,
|
||||||
rewritePath: isHttp ? null : null,
|
rewritePath: null,
|
||||||
rewritePathType: isHttp ? null : null,
|
rewritePathType: null,
|
||||||
priority: isHttp ? 100 : 100,
|
priority: 100,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
resourceId: resource.resourceId,
|
resourceId: resource?.resourceId ?? 0,
|
||||||
hcEnabled: false,
|
hcEnabled: false,
|
||||||
hcPath: null,
|
hcPath: null,
|
||||||
hcMethod: null,
|
hcMethod: null,
|
||||||
@@ -694,7 +675,6 @@ function ProxyResourceTargetsForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -704,7 +684,6 @@ function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
}, [sites]);
|
}, [sites]);
|
||||||
|
|
||||||
// Save advanced mode preference to localStorage
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@@ -717,7 +696,8 @@ function ProxyResourceTargetsForm({
|
|||||||
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
||||||
|
|
||||||
async function saveTargets() {
|
async function saveTargets() {
|
||||||
// Validate that no targets have blank IPs or invalid ports
|
if (!resource) return;
|
||||||
|
|
||||||
const targetsWithInvalidFields = targets.filter(
|
const targetsWithInvalidFields = targets.filter(
|
||||||
(target) =>
|
(target) =>
|
||||||
!target.ip ||
|
!target.ip ||
|
||||||
@@ -726,7 +706,6 @@ function ProxyResourceTargetsForm({
|
|||||||
target.port <= 0 ||
|
target.port <= 0 ||
|
||||||
isNaN(target.port)
|
isNaN(target.port)
|
||||||
);
|
);
|
||||||
console.log(targetsWithInvalidFields);
|
|
||||||
if (targetsWithInvalidFields.length > 0) {
|
if (targetsWithInvalidFields.length > 0) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -743,7 +722,6 @@ function ProxyResourceTargetsForm({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save targets
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const data: any = {
|
const data: any = {
|
||||||
ip: target.ip,
|
ip: target.ip,
|
||||||
@@ -769,8 +747,7 @@ function ProxyResourceTargetsForm({
|
|||||||
hcUnhealthyThreshold: target.hcUnhealthyThreshold || null
|
hcUnhealthyThreshold: target.hcUnhealthyThreshold || null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only include path-related fields for HTTP resources
|
if (isHttp) {
|
||||||
if (resource.http) {
|
|
||||||
data.path = target.path;
|
data.path = target.path;
|
||||||
data.pathMatchType = target.pathMatchType;
|
data.pathMatchType = target.pathMatchType;
|
||||||
data.rewritePath = target.rewritePath;
|
data.rewritePath = target.rewritePath;
|
||||||
@@ -791,12 +768,14 @@ function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: targets.length === 0
|
title:
|
||||||
? t("targetTargetsCleared")
|
targets.length === 0
|
||||||
: t("settingsUpdated"),
|
? t("targetTargetsCleared")
|
||||||
description: targets.length === 0
|
: t("settingsUpdated"),
|
||||||
? t("targetTargetsClearedDescription")
|
description:
|
||||||
: t("settingsUpdatedDescription")
|
targets.length === 0
|
||||||
|
? t("targetTargetsClearedDescription")
|
||||||
|
: t("settingsUpdatedDescription")
|
||||||
});
|
});
|
||||||
|
|
||||||
setTargetsToRemove([]);
|
setTargetsToRemove([]);
|
||||||
@@ -918,9 +897,6 @@ function ProxyResourceTargetsForm({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
{/* <TableCaption> */}
|
|
||||||
{/* {t('targetNoOneDescription')} */}
|
|
||||||
{/* </TableCaption> */}
|
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -978,15 +954,18 @@ function ProxyResourceTargetsForm({
|
|||||||
)}
|
)}
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
|
|
||||||
<form className="self-end mt-4" action={formAction}>
|
{/* Save button — only shown in edit mode */}
|
||||||
<Button
|
{resource && (
|
||||||
disabled={isSubmitting}
|
<form className="self-end mt-4" action={formAction}>
|
||||||
loading={isSubmitting}
|
<Button
|
||||||
type="submit"
|
disabled={isSubmitting}
|
||||||
>
|
loading={isSubmitting}
|
||||||
{t("saveResourceTargets")}
|
type="submit"
|
||||||
</Button>
|
>
|
||||||
</form>
|
{t("saveResourceTargets")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{selectedTargetForHealthCheck && (
|
{selectedTargetForHealthCheck && (
|
||||||
@@ -1049,500 +1028,3 @@ function ProxyResourceTargetsForm({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProxyResourceHttpForm({
|
|
||||||
resource,
|
|
||||||
updateResource
|
|
||||||
}: Pick<ResourceContextType, "resource" | "updateResource">) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const tlsSettingsSchema = z.object({
|
|
||||||
ssl: z.boolean(),
|
|
||||||
tlsServerName: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data) {
|
|
||||||
return tlsNameSchema.safeParse(data).success;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: t("proxyErrorTls")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
const tlsSettingsForm = useForm({
|
|
||||||
resolver: zodResolver(tlsSettingsSchema),
|
|
||||||
defaultValues: {
|
|
||||||
ssl: resource.ssl,
|
|
||||||
tlsServerName: resource.tlsServerName || ""
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const proxySettingsSchema = z.object({
|
|
||||||
setHostHeader: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data) {
|
|
||||||
return tlsNameSchema.safeParse(data).success;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: t("proxyErrorInvalidHeader")
|
|
||||||
}
|
|
||||||
),
|
|
||||||
headers: z
|
|
||||||
.array(z.object({ name: z.string(), value: z.string() }))
|
|
||||||
.nullable(),
|
|
||||||
proxyProtocol: z.boolean().optional(),
|
|
||||||
proxyProtocolVersion: z.int().min(1).max(2).optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
const proxySettingsForm = useForm({
|
|
||||||
resolver: zodResolver(proxySettingsSchema),
|
|
||||||
defaultValues: {
|
|
||||||
setHostHeader: resource.setHostHeader || "",
|
|
||||||
headers: resource.headers,
|
|
||||||
proxyProtocol: resource.proxyProtocol || false,
|
|
||||||
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
|
||||||
const api = createApiClient({ env });
|
|
||||||
|
|
||||||
const targetsSettingsForm = useForm({
|
|
||||||
resolver: zodResolver(targetsSettingsSchema),
|
|
||||||
defaultValues: {
|
|
||||||
stickySession: resource.stickySession
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const [, formAction, isSubmitting] = useActionState(
|
|
||||||
saveResourceHttpSettings,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
async function saveResourceHttpSettings() {
|
|
||||||
const isValidTLS = await tlsSettingsForm.trigger();
|
|
||||||
const isValidProxy = await proxySettingsForm.trigger();
|
|
||||||
const targetSettingsForm = await targetsSettingsForm.trigger();
|
|
||||||
if (!isValidTLS || !isValidProxy || !targetSettingsForm) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Gather all settings
|
|
||||||
const stickySessionData = targetsSettingsForm.getValues();
|
|
||||||
const tlsData = tlsSettingsForm.getValues();
|
|
||||||
const proxyData = proxySettingsForm.getValues();
|
|
||||||
|
|
||||||
// Combine into one payload
|
|
||||||
const payload = {
|
|
||||||
stickySession: stickySessionData.stickySession,
|
|
||||||
ssl: tlsData.ssl,
|
|
||||||
tlsServerName: tlsData.tlsServerName || null,
|
|
||||||
setHostHeader: proxyData.setHostHeader || null,
|
|
||||||
headers: proxyData.headers || null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Single API call to update all settings
|
|
||||||
await api.post(`/resource/${resource.resourceId}`, payload);
|
|
||||||
|
|
||||||
// Update local resource context
|
|
||||||
updateResource({
|
|
||||||
...resource,
|
|
||||||
stickySession: stickySessionData.stickySession,
|
|
||||||
ssl: tlsData.ssl,
|
|
||||||
tlsServerName: tlsData.tlsServerName || null,
|
|
||||||
setHostHeader: proxyData.setHostHeader || null,
|
|
||||||
headers: proxyData.headers || null
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("settingsUpdated"),
|
|
||||||
description: t("settingsUpdatedDescription")
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("settingsErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
err,
|
|
||||||
t("settingsErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("proxyAdditional")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("proxyAdditionalDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm>
|
|
||||||
<Form {...tlsSettingsForm}>
|
|
||||||
<form
|
|
||||||
action={formAction}
|
|
||||||
className="space-y-4"
|
|
||||||
id="tls-settings-form"
|
|
||||||
>
|
|
||||||
{!env.flags.usePangolinDns && (
|
|
||||||
<FormField
|
|
||||||
control={tlsSettingsForm.control}
|
|
||||||
name="ssl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<SwitchInput
|
|
||||||
id="ssl-toggle"
|
|
||||||
label={t("proxyEnableSSL")}
|
|
||||||
description={t(
|
|
||||||
"proxyEnableSSLDescription"
|
|
||||||
)}
|
|
||||||
defaultChecked={field.value}
|
|
||||||
onCheckedChange={(val) => {
|
|
||||||
field.onChange(val);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<FormField
|
|
||||||
control={tlsSettingsForm.control}
|
|
||||||
name="tlsServerName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("targetTlsSni")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("targetTlsSniDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
|
|
||||||
<SettingsSectionForm>
|
|
||||||
<Form {...targetsSettingsForm}>
|
|
||||||
<form
|
|
||||||
action={formAction}
|
|
||||||
className="space-y-4"
|
|
||||||
id="targets-settings-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={targetsSettingsForm.control}
|
|
||||||
name="stickySession"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<SwitchInput
|
|
||||||
id="sticky-toggle"
|
|
||||||
label={t(
|
|
||||||
"targetStickySessions"
|
|
||||||
)}
|
|
||||||
description={t(
|
|
||||||
"targetStickySessionsDescription"
|
|
||||||
)}
|
|
||||||
defaultChecked={field.value}
|
|
||||||
onCheckedChange={(val) => {
|
|
||||||
field.onChange(val);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
|
|
||||||
<SettingsSectionForm>
|
|
||||||
<Form {...proxySettingsForm}>
|
|
||||||
<form
|
|
||||||
action={formAction}
|
|
||||||
className="space-y-4"
|
|
||||||
id="proxy-settings-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={proxySettingsForm.control}
|
|
||||||
name="setHostHeader"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("proxyCustomHeader")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("proxyCustomHeaderDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={proxySettingsForm.control}
|
|
||||||
name="headers"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("customHeaders")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<HeadersInput
|
|
||||||
value={field.value}
|
|
||||||
onChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
}}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("customHeadersDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
<form className="flex justify-end" action={formAction}>
|
|
||||||
<Button
|
|
||||||
disabled={isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{t("saveResourceHttp")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProxyResourceProtocolForm({
|
|
||||||
resource,
|
|
||||||
updateResource
|
|
||||||
}: Pick<ResourceContextType, "resource" | "updateResource">) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
const proxySettingsSchema = z.object({
|
|
||||||
setHostHeader: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data) {
|
|
||||||
return tlsNameSchema.safeParse(data).success;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: t("proxyErrorInvalidHeader")
|
|
||||||
}
|
|
||||||
),
|
|
||||||
headers: z
|
|
||||||
.array(z.object({ name: z.string(), value: z.string() }))
|
|
||||||
.nullable(),
|
|
||||||
proxyProtocol: z.boolean().optional(),
|
|
||||||
proxyProtocolVersion: z.int().min(1).max(2).optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
const proxySettingsForm = useForm({
|
|
||||||
resolver: zodResolver(proxySettingsSchema),
|
|
||||||
defaultValues: {
|
|
||||||
setHostHeader: resource.setHostHeader || "",
|
|
||||||
headers: resource.headers,
|
|
||||||
proxyProtocol: resource.proxyProtocol || false,
|
|
||||||
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [, formAction, isSubmitting] = useActionState(
|
|
||||||
saveProtocolSettings,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
async function saveProtocolSettings() {
|
|
||||||
const isValid = proxySettingsForm.trigger();
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// For TCP/UDP resources, save proxy protocol settings
|
|
||||||
const proxyData = proxySettingsForm.getValues();
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
proxyProtocol: proxyData.proxyProtocol || false,
|
|
||||||
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
|
||||||
};
|
|
||||||
|
|
||||||
await api.post(`/resource/${resource.resourceId}`, payload);
|
|
||||||
|
|
||||||
updateResource({
|
|
||||||
...resource,
|
|
||||||
proxyProtocol: proxyData.proxyProtocol || false,
|
|
||||||
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("settingsUpdated"),
|
|
||||||
description: t("settingsUpdatedDescription")
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("settingsErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
err,
|
|
||||||
t("settingsErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("proxyProtocol")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("proxyProtocolDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm>
|
|
||||||
<Form {...proxySettingsForm}>
|
|
||||||
<form
|
|
||||||
action={formAction}
|
|
||||||
className="space-y-4"
|
|
||||||
id="proxy-protocol-settings-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={proxySettingsForm.control}
|
|
||||||
name="proxyProtocol"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<SwitchInput
|
|
||||||
id="proxy-protocol-toggle"
|
|
||||||
label={t("enableProxyProtocol")}
|
|
||||||
description={t(
|
|
||||||
"proxyProtocolInfo"
|
|
||||||
)}
|
|
||||||
defaultChecked={
|
|
||||||
field.value || false
|
|
||||||
}
|
|
||||||
onCheckedChange={(val) => {
|
|
||||||
field.onChange(val);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{proxySettingsForm.watch("proxyProtocol") && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={proxySettingsForm.control}
|
|
||||||
name="proxyProtocolVersion"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("proxyProtocolVersion")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
value={String(
|
|
||||||
field.value || 1
|
|
||||||
)}
|
|
||||||
onValueChange={(
|
|
||||||
value
|
|
||||||
) =>
|
|
||||||
field.onChange(
|
|
||||||
parseInt(
|
|
||||||
value,
|
|
||||||
10
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select version" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">
|
|
||||||
{t("version1")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="2">
|
|
||||||
{t("version2")}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("versionDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Alert>
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
<strong>{t("warning")}:</strong>{" "}
|
|
||||||
{t("proxyProtocolWarning")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
<form action={formAction} className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
disabled={isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{t("saveProxyProtocol")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -146,7 +146,7 @@ function MaintenanceSectionForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.http) {
|
if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +176,9 @@ function MaintenanceSectionForm({
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
!isPaidUser(tierMatrix.maintencePage) ||
|
!isPaidUser(tierMatrix.maintencePage) ||
|
||||||
resource.http === false;
|
!["http", "ssh", "rdp", "vnc"].includes(
|
||||||
|
resource.mode
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
@@ -462,14 +464,14 @@ export default function GeneralForm() {
|
|||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// For non-HTTP resources, proxyPort should be defined
|
// For non-HTTP resources, proxyPort should be defined
|
||||||
if (!resource.http) {
|
if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
|
||||||
return data.proxyPort !== undefined;
|
return data.proxyPort !== undefined;
|
||||||
}
|
}
|
||||||
// For HTTP resources, proxyPort should be undefined
|
// For HTTP resources, proxyPort should be undefined
|
||||||
return data.proxyPort === undefined;
|
return data.proxyPort === undefined;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: !resource.http
|
message: !["http", "ssh", "rdp", "vnc"].includes(resource.mode)
|
||||||
? "Port number is required for non-HTTP resources"
|
? "Port number is required for non-HTTP resources"
|
||||||
: "Port number should not be set for HTTP resources",
|
: "Port number should not be set for HTTP resources",
|
||||||
path: ["proxyPort"]
|
path: ["proxyPort"]
|
||||||
@@ -507,7 +509,9 @@ export default function GeneralForm() {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
subdomain: data.subdomain
|
subdomain: data.subdomain
|
||||||
? toASCII(finalizeSubdomainSanitize(data.subdomain, true))
|
? toASCII(
|
||||||
|
finalizeSubdomainSanitize(data.subdomain, true)
|
||||||
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
domainId: data.domainId,
|
domainId: data.domainId,
|
||||||
proxyPort: data.proxyPort
|
proxyPort: data.proxyPort
|
||||||
@@ -555,13 +559,15 @@ export default function GeneralForm() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
{resource?.resourceId && resource?.orgId && (
|
{resource?.resourceId &&
|
||||||
<UptimeAlertSection
|
resource?.orgId &&
|
||||||
orgId={resource.orgId}
|
resource.mode == "http" && (
|
||||||
resourceId={resource.resourceId}
|
<UptimeAlertSection
|
||||||
startingName={resource.name}
|
orgId={resource.orgId}
|
||||||
/>
|
resourceId={resource.resourceId}
|
||||||
)}
|
startingName={resource.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
@@ -580,45 +586,48 @@ export default function GeneralForm() {
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="general-settings-form"
|
id="general-settings-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<div className="grid grid-cols-2 gap-4">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="name"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="name"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>
|
<FormItem>
|
||||||
{t("name")}
|
<FormLabel>
|
||||||
</FormLabel>
|
{t("name")}
|
||||||
<FormControl>
|
</FormLabel>
|
||||||
<Input {...field} />
|
<FormControl>
|
||||||
</FormControl>
|
<Input {...field} />
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="niceId"
|
name="niceId"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("identifier")}
|
{t("identifier")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"enterIdentifier"
|
"enterIdentifier"
|
||||||
)}
|
)}
|
||||||
className="flex-1"
|
/>
|
||||||
/>
|
</FormControl>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
<FormMessage />
|
</FormItem>
|
||||||
</FormItem>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
{!resource.http && (
|
{!["http", "ssh", "rdp", "vnc"].includes(
|
||||||
|
resource.mode
|
||||||
|
) && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -667,7 +676,9 @@ export default function GeneralForm() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{resource.http && (
|
{["http", "ssh", "rdp", "vnc"].includes(
|
||||||
|
resource.mode
|
||||||
|
) && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div id="resource-domain-picker">
|
<div id="resource-domain-picker">
|
||||||
<DomainPicker
|
<DomainPicker
|
||||||
@@ -726,28 +737,31 @@ export default function GeneralForm() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="enabled"
|
name="enabled"
|
||||||
render={() => (
|
render={() => (
|
||||||
<FormItem className="col-span-2">
|
<FormItem>
|
||||||
<div className="flex items-center space-x-2">
|
<FormControl>
|
||||||
<FormControl>
|
<SwitchInput
|
||||||
<SwitchInput
|
id="enable-resource"
|
||||||
id="enable-resource"
|
defaultChecked={
|
||||||
defaultChecked={
|
resource.enabled
|
||||||
resource.enabled
|
}
|
||||||
}
|
label={t(
|
||||||
label={t(
|
"resourceEnable"
|
||||||
"resourceEnable"
|
)}
|
||||||
)}
|
onCheckedChange={(
|
||||||
onCheckedChange={(
|
val
|
||||||
|
) =>
|
||||||
|
form.setValue(
|
||||||
|
"enabled",
|
||||||
val
|
val
|
||||||
) =>
|
)
|
||||||
form.setValue(
|
}
|
||||||
"enabled",
|
/>
|
||||||
val
|
</FormControl>
|
||||||
)
|
<FormDescription>
|
||||||
}
|
{t(
|
||||||
/>
|
"disabledResourceDescription"
|
||||||
</FormControl>
|
)}
|
||||||
</div>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,651 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import HealthCheckCredenza from "@/components/HealthCheckCredenza";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { HeadersInput } from "@app/components/HeadersInput";
|
||||||
|
import {
|
||||||
|
PathMatchDisplay,
|
||||||
|
PathMatchModal,
|
||||||
|
PathRewriteDisplay,
|
||||||
|
PathRewriteModal
|
||||||
|
} from "@app/components/PathMatchRenameModal";
|
||||||
|
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from "@app/components/ui/table";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "@app/components/ui/tooltip";
|
||||||
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
|
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||||
|
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { tlsNameSchema } from "@server/lib/schemas";
|
||||||
|
import { type GetResourceResponse } from "@server/routers/resource";
|
||||||
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
|
import { CreateTargetResponse } from "@server/routers/target";
|
||||||
|
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
||||||
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
LocalTarget,
|
||||||
|
ProxyResourceTargetsForm
|
||||||
|
} from "@app/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm";
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CircleCheck,
|
||||||
|
CircleX,
|
||||||
|
ExternalLink,
|
||||||
|
Info,
|
||||||
|
Plus,
|
||||||
|
Settings
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
use,
|
||||||
|
useActionState,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState
|
||||||
|
} from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const targetsSettingsSchema = z.object({
|
||||||
|
stickySession: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ReverseProxyTargetsPage(props: {
|
||||||
|
params: Promise<{ resourceId: number; orgId: string }>;
|
||||||
|
}) {
|
||||||
|
const params = use(props.params);
|
||||||
|
const { resource, updateResource } = useResourceContext();
|
||||||
|
|
||||||
|
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
|
||||||
|
resourceQueries.resourceTargets({
|
||||||
|
resourceId: resource.resourceId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoadingTargets) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<ProxyResourceTargetsForm
|
||||||
|
orgId={params.orgId}
|
||||||
|
isHttp={["http", "ssh", "rdp", "vnc"].includes(resource.mode)}
|
||||||
|
initialTargets={remoteTargets}
|
||||||
|
resource={resource}
|
||||||
|
updateResource={updateResource}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{["http", "ssh", "rdp", "vnc"].includes(resource.mode) && (
|
||||||
|
<ProxyResourceHttpForm
|
||||||
|
resource={resource}
|
||||||
|
updateResource={updateResource}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resource.mode == "tcp" && (
|
||||||
|
<ProxyResourceProtocolForm
|
||||||
|
resource={resource}
|
||||||
|
updateResource={updateResource}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProxyResourceHttpForm({
|
||||||
|
resource,
|
||||||
|
updateResource
|
||||||
|
}: Pick<ResourceContextType, "resource" | "updateResource">) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const tlsSettingsSchema = z.object({
|
||||||
|
ssl: z.boolean(),
|
||||||
|
tlsServerName: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data) {
|
||||||
|
return tlsNameSchema.safeParse(data).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("proxyErrorTls")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const tlsSettingsForm = useForm({
|
||||||
|
resolver: zodResolver(tlsSettingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
ssl: resource.ssl,
|
||||||
|
tlsServerName: resource.tlsServerName || ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxySettingsSchema = z.object({
|
||||||
|
setHostHeader: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data) {
|
||||||
|
return tlsNameSchema.safeParse(data).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("proxyErrorInvalidHeader")
|
||||||
|
}
|
||||||
|
),
|
||||||
|
headers: z
|
||||||
|
.array(z.object({ name: z.string(), value: z.string() }))
|
||||||
|
.nullable(),
|
||||||
|
proxyProtocol: z.boolean().optional(),
|
||||||
|
proxyProtocolVersion: z.int().min(1).max(2).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxySettingsForm = useForm({
|
||||||
|
resolver: zodResolver(proxySettingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
setHostHeader: resource.setHostHeader || "",
|
||||||
|
headers: resource.headers,
|
||||||
|
proxyProtocol: resource.proxyProtocol || false,
|
||||||
|
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
|
const targetsSettingsForm = useForm({
|
||||||
|
resolver: zodResolver(targetsSettingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
stickySession: resource.stickySession
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const [, formAction, isSubmitting] = useActionState(
|
||||||
|
saveResourceHttpSettings,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
async function saveResourceHttpSettings() {
|
||||||
|
const isValidTLS = await tlsSettingsForm.trigger();
|
||||||
|
const isValidProxy = await proxySettingsForm.trigger();
|
||||||
|
const targetSettingsForm = await targetsSettingsForm.trigger();
|
||||||
|
if (!isValidTLS || !isValidProxy || !targetSettingsForm) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Gather all settings
|
||||||
|
const stickySessionData = targetsSettingsForm.getValues();
|
||||||
|
const tlsData = tlsSettingsForm.getValues();
|
||||||
|
const proxyData = proxySettingsForm.getValues();
|
||||||
|
|
||||||
|
// Combine into one payload
|
||||||
|
const payload = {
|
||||||
|
stickySession: stickySessionData.stickySession,
|
||||||
|
ssl: tlsData.ssl,
|
||||||
|
tlsServerName: tlsData.tlsServerName || null,
|
||||||
|
setHostHeader: proxyData.setHostHeader || null,
|
||||||
|
headers: proxyData.headers || null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single API call to update all settings
|
||||||
|
await api.post(`/resource/${resource.resourceId}`, payload);
|
||||||
|
|
||||||
|
// Update local resource context
|
||||||
|
updateResource({
|
||||||
|
...resource,
|
||||||
|
stickySession: stickySessionData.stickySession,
|
||||||
|
ssl: tlsData.ssl,
|
||||||
|
tlsServerName: tlsData.tlsServerName || null,
|
||||||
|
setHostHeader: proxyData.setHostHeader || null,
|
||||||
|
headers: proxyData.headers || null
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("settingsUpdated"),
|
||||||
|
description: t("settingsUpdatedDescription")
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settingsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("settingsErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("proxyAdditional")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("proxyAdditionalDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...tlsSettingsForm}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="space-y-4"
|
||||||
|
id="tls-settings-form"
|
||||||
|
>
|
||||||
|
{!env.flags.usePangolinDns && (
|
||||||
|
<FormField
|
||||||
|
control={tlsSettingsForm.control}
|
||||||
|
name="ssl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="ssl-toggle"
|
||||||
|
label={t("proxyEnableSSL")}
|
||||||
|
description={t(
|
||||||
|
"proxyEnableSSLDescription"
|
||||||
|
)}
|
||||||
|
defaultChecked={field.value}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
field.onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={tlsSettingsForm.control}
|
||||||
|
name="tlsServerName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("targetTlsSni")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("targetTlsSniDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...targetsSettingsForm}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="space-y-4"
|
||||||
|
id="targets-settings-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={targetsSettingsForm.control}
|
||||||
|
name="stickySession"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="sticky-toggle"
|
||||||
|
label={t(
|
||||||
|
"targetStickySessions"
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"targetStickySessionsDescription"
|
||||||
|
)}
|
||||||
|
defaultChecked={field.value}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
field.onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...proxySettingsForm}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="space-y-4"
|
||||||
|
id="proxy-settings-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={proxySettingsForm.control}
|
||||||
|
name="setHostHeader"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("proxyCustomHeader")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("proxyCustomHeaderDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={proxySettingsForm.control}
|
||||||
|
name="headers"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("customHeaders")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<HeadersInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("customHeadersDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
<form className="flex justify-end" action={formAction}>
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("saveResourceHttp")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProxyResourceProtocolForm({
|
||||||
|
resource,
|
||||||
|
updateResource
|
||||||
|
}: Pick<ResourceContextType, "resource" | "updateResource">) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const proxySettingsSchema = z.object({
|
||||||
|
setHostHeader: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data) {
|
||||||
|
return tlsNameSchema.safeParse(data).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("proxyErrorInvalidHeader")
|
||||||
|
}
|
||||||
|
),
|
||||||
|
headers: z
|
||||||
|
.array(z.object({ name: z.string(), value: z.string() }))
|
||||||
|
.nullable(),
|
||||||
|
proxyProtocol: z.boolean().optional(),
|
||||||
|
proxyProtocolVersion: z.int().min(1).max(2).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxySettingsForm = useForm({
|
||||||
|
resolver: zodResolver(proxySettingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
setHostHeader: resource.setHostHeader || "",
|
||||||
|
headers: resource.headers,
|
||||||
|
proxyProtocol: resource.proxyProtocol || false,
|
||||||
|
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [, formAction, isSubmitting] = useActionState(
|
||||||
|
saveProtocolSettings,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
async function saveProtocolSettings() {
|
||||||
|
const isValid = proxySettingsForm.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For TCP/UDP resources, save proxy protocol settings
|
||||||
|
const proxyData = proxySettingsForm.getValues();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
proxyProtocol: proxyData.proxyProtocol || false,
|
||||||
|
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.post(`/resource/${resource.resourceId}`, payload);
|
||||||
|
|
||||||
|
updateResource({
|
||||||
|
...resource,
|
||||||
|
proxyProtocol: proxyData.proxyProtocol || false,
|
||||||
|
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("settingsUpdated"),
|
||||||
|
description: t("settingsUpdatedDescription")
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settingsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("settingsErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("proxyProtocol")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("proxyProtocolDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...proxySettingsForm}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="space-y-4"
|
||||||
|
id="proxy-protocol-settings-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={proxySettingsForm.control}
|
||||||
|
name="proxyProtocol"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="proxy-protocol-toggle"
|
||||||
|
label={t("enableProxyProtocol")}
|
||||||
|
description={t(
|
||||||
|
"proxyProtocolInfo"
|
||||||
|
)}
|
||||||
|
defaultChecked={
|
||||||
|
field.value || false
|
||||||
|
}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
field.onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{proxySettingsForm.watch("proxyProtocol") && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={proxySettingsForm.control}
|
||||||
|
name="proxyProtocolVersion"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("proxyProtocolVersion")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={String(
|
||||||
|
field.value || 1
|
||||||
|
)}
|
||||||
|
onValueChange={(
|
||||||
|
value
|
||||||
|
) =>
|
||||||
|
field.onChange(
|
||||||
|
parseInt(
|
||||||
|
value,
|
||||||
|
10
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select version" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">
|
||||||
|
{t("version1")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="2">
|
||||||
|
{t("version2")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("versionDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>{t("warning")}:</strong>{" "}
|
||||||
|
{t("proxyProtocolWarning")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
<form action={formAction} className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("saveProxyProtocol")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,12 +86,12 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||||||
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
|
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("proxy"),
|
title: t(`${resource.mode}Settings`),
|
||||||
href: `/{orgId}/settings/resources/proxy/{niceId}/proxy`
|
href: `/{orgId}/settings/resources/proxy/{niceId}/${resource.mode}`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (resource.http) {
|
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
|
||||||
navItems.push({
|
navItems.push({
|
||||||
title: t("authentication"),
|
title: t("authentication"),
|
||||||
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
|
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ export default async function ResourcePage(props: {
|
|||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
redirect(
|
redirect(
|
||||||
`/${params.orgId}/settings/resources/proxy/${params.niceId}/proxy`
|
`/${params.orgId}/settings/resources/proxy/${params.niceId}/general`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||||
|
import { type Selectedsite } from "@app/components/site-selector";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { use, useActionState, useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { GetResourceResponse } from "@server/routers/resource";
|
||||||
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
|
|
||||||
|
type ExistingTarget = {
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
siteId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sshFormSchema = z.object({
|
||||||
|
authDaemonPort: z.string().refine(
|
||||||
|
(val) => {
|
||||||
|
if (!val) return true;
|
||||||
|
const n = Number(val);
|
||||||
|
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||||
|
},
|
||||||
|
{ message: "Port must be between 1 and 65535" }
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function SshSettingsPage(props: {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
}) {
|
||||||
|
const params = use(props.params);
|
||||||
|
const { resource, updateResource } = useResourceContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SshServerForm
|
||||||
|
orgId={params.orgId}
|
||||||
|
resource={resource}
|
||||||
|
updateResource={updateResource}
|
||||||
|
/>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SshServerForm({
|
||||||
|
orgId,
|
||||||
|
resource,
|
||||||
|
updateResource
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
resource: GetResourceResponse;
|
||||||
|
updateResource: ResourceContextType["updateResource"];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Standard mode: multi-site
|
||||||
|
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||||
|
const [bgDestination, setBgDestination] = useState("");
|
||||||
|
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||||
|
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Native mode: single site
|
||||||
|
const [selectedNativeSite, setSelectedNativeSite] =
|
||||||
|
useState<Selectedsite | null>(null);
|
||||||
|
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||||
|
useState<ExistingTarget | null>(null);
|
||||||
|
|
||||||
|
const { data: bgTargetsResponse } = useQuery({
|
||||||
|
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||||
|
);
|
||||||
|
return res.data.data as {
|
||||||
|
targets: Array<{
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
resourceId: number;
|
||||||
|
siteId: number;
|
||||||
|
siteName?: string;
|
||||||
|
type: string;
|
||||||
|
destination: string;
|
||||||
|
destinationPort: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bgTargetsResponse?.targets?.length) return;
|
||||||
|
const targets = bgTargetsResponse.targets;
|
||||||
|
const first = targets[0];
|
||||||
|
|
||||||
|
setBgDestination(first.destination);
|
||||||
|
setBgDestinationPort(String(first.destinationPort));
|
||||||
|
setExistingTargets(
|
||||||
|
targets.map((t) => ({
|
||||||
|
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||||
|
siteId: t.siteId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setSelectedSites(
|
||||||
|
targets.map((t) => ({
|
||||||
|
siteId: t.siteId,
|
||||||
|
name: t.siteName ?? String(t.siteId),
|
||||||
|
type: "newt" as const
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [bgTargetsResponse]);
|
||||||
|
|
||||||
|
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
if (bgDestination && bgDestinationPort) {
|
||||||
|
const selectedSiteIds = new Set(
|
||||||
|
selectedSites.map((s) => s.siteId)
|
||||||
|
);
|
||||||
|
const existingSiteIds = new Set(
|
||||||
|
existingTargets.map((t) => t.siteId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toDelete = existingTargets.filter(
|
||||||
|
(t) => !selectedSiteIds.has(t.siteId)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
toDelete.map((t) =>
|
||||||
|
api.delete(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toUpdate = existingTargets.filter((t) =>
|
||||||
|
selectedSiteIds.has(t.siteId)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
toUpdate.map((t) =>
|
||||||
|
api.post(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||||
|
{
|
||||||
|
type: "rdp",
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort),
|
||||||
|
siteId: t.siteId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toCreate = selectedSites.filter(
|
||||||
|
(s) => !existingSiteIds.has(s.siteId)
|
||||||
|
);
|
||||||
|
const created = await Promise.all(
|
||||||
|
toCreate.map((s) =>
|
||||||
|
api.put(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||||
|
{
|
||||||
|
siteId: s.siteId,
|
||||||
|
type: "rdp",
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||||
|
browserGatewayTargetId:
|
||||||
|
res.data.data.browserGatewayTargetId,
|
||||||
|
siteId: toCreate[i].siteId
|
||||||
|
}));
|
||||||
|
setExistingTargets([...toUpdate, ...newTargets]);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("settingsUpdated"),
|
||||||
|
description: t("settingsUpdatedDescription")
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settingsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("settingsErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>{t("rdpServer")}</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("rdpServerDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<BrowserGatewayTargetForm
|
||||||
|
orgId={orgId}
|
||||||
|
multiSite={true}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSitesChange={setSelectedSites}
|
||||||
|
destination={bgDestination}
|
||||||
|
destinationPort={bgDestinationPort}
|
||||||
|
onDestinationChange={setBgDestination}
|
||||||
|
onDestinationPortChange={setBgDestinationPort}
|
||||||
|
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
|
||||||
|
defaultPort={3389}
|
||||||
|
/>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
<form action={formAction} className="flex justify-end mt-4">
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
@@ -36,7 +34,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCaption,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
@@ -55,18 +52,11 @@ import {
|
|||||||
SettingsSectionTitle,
|
SettingsSectionTitle,
|
||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
SettingsSectionFooter,
|
SettingsSectionFooter
|
||||||
SettingsSectionForm
|
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|
||||||
import { ArrowUpDown, Check, InfoIcon, X, ChevronsUpDown } from "lucide-react";
|
import { ArrowUpDown, Check, InfoIcon, X, ChevronsUpDown } from "lucide-react";
|
||||||
import {
|
|
||||||
InfoSection,
|
|
||||||
InfoSections,
|
|
||||||
InfoSectionTitle
|
|
||||||
} from "@app/components/InfoSection";
|
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
import {
|
import {
|
||||||
isValidCIDR,
|
isValidCIDR,
|
||||||
@@ -78,7 +68,11 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { COUNTRIES } from "@server/db/countries";
|
import { COUNTRIES } from "@server/db/countries";
|
||||||
import { MAJOR_ASNS } from "@server/db/asns";
|
import { MAJOR_ASNS } from "@server/db/asns";
|
||||||
import { REGIONS, getRegionNameById, isValidRegionId } from "@server/db/regions";
|
import {
|
||||||
|
REGIONS,
|
||||||
|
getRegionNameById,
|
||||||
|
isValidRegionId
|
||||||
|
} from "@server/db/regions";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -109,25 +103,23 @@ type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
|||||||
export default function ResourceRules(props: {
|
export default function ResourceRules(props: {
|
||||||
params: Promise<{ resourceId: number }>;
|
params: Promise<{ resourceId: number }>;
|
||||||
}) {
|
}) {
|
||||||
const params = use(props.params);
|
|
||||||
const { resource, updateResource } = useResourceContext();
|
const { resource, updateResource } = useResourceContext();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [rules, setRules] = useState<LocalRule[]>([]);
|
const [rules, setRules] = useState<LocalRule[]>([]);
|
||||||
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false);
|
const [rulesEnabled, setRulesEnabled] = useState(
|
||||||
|
resource.applyRules ?? false
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRulesEnabled(resource.applyRules);
|
setRulesEnabled(resource.applyRules);
|
||||||
}, [resource.applyRules]);
|
}, [resource.applyRules]);
|
||||||
|
|
||||||
const [openCountrySelect, setOpenCountrySelect] = useState(false);
|
|
||||||
const [countrySelectValue, setCountrySelectValue] = useState("");
|
|
||||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] =
|
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false);
|
||||||
useState(false);
|
|
||||||
const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] =
|
const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -157,7 +149,7 @@ export default function ResourceRules(props: {
|
|||||||
resolver: zodResolver(addRuleSchema),
|
resolver: zodResolver(addRuleSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
action: "ACCEPT",
|
action: "ACCEPT",
|
||||||
match: "PATH",
|
match: resource.mode == "http" ? "PATH" : "IP",
|
||||||
value: ""
|
value: ""
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -270,16 +262,12 @@ export default function ResourceRules(props: {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (data.match === "REGION" && !isValidRegionId(data.value)) {
|
||||||
data.match === "REGION" &&
|
|
||||||
!isValidRegionId(data.value)
|
|
||||||
) {
|
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("rulesErrorInvalidRegion"),
|
title: t("rulesErrorInvalidRegion"),
|
||||||
description:
|
description:
|
||||||
t("rulesErrorInvalidRegionDescription") ||
|
t("rulesErrorInvalidRegionDescription") || "Invalid region."
|
||||||
"Invalid region."
|
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -564,12 +552,24 @@ export default function ResourceRules(props: {
|
|||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.match}
|
defaultValue={row.original.match}
|
||||||
onValueChange={(
|
onValueChange={(
|
||||||
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION"
|
value:
|
||||||
|
| "CIDR"
|
||||||
|
| "IP"
|
||||||
|
| "PATH"
|
||||||
|
| "COUNTRY"
|
||||||
|
| "ASN"
|
||||||
|
| "REGION"
|
||||||
) =>
|
) =>
|
||||||
updateRule(row.original.ruleId, {
|
updateRule(row.original.ruleId, {
|
||||||
match: value,
|
match: value,
|
||||||
value:
|
value:
|
||||||
value === "COUNTRY" ? "US" : value === "ASN" ? "AS15169" : value === "REGION" ? "021" : row.original.value
|
value === "COUNTRY"
|
||||||
|
? "US"
|
||||||
|
: value === "ASN"
|
||||||
|
? "AS15169"
|
||||||
|
: value === "REGION"
|
||||||
|
? "021"
|
||||||
|
: row.original.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -577,7 +577,11 @@ export default function ResourceRules(props: {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
{resource.mode == "http" && (
|
||||||
|
<SelectItem value="PATH">
|
||||||
|
{RuleMatch.PATH}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||||
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
||||||
{isMaxmindAvailable && (
|
{isMaxmindAvailable && (
|
||||||
@@ -669,14 +673,14 @@ export default function ResourceRules(props: {
|
|||||||
>
|
>
|
||||||
{row.original.value
|
{row.original.value
|
||||||
? (() => {
|
? (() => {
|
||||||
const found = MAJOR_ASNS.find(
|
const found = MAJOR_ASNS.find(
|
||||||
(asn) =>
|
(asn) =>
|
||||||
asn.code ===
|
asn.code ===
|
||||||
row.original.value
|
row.original.value
|
||||||
);
|
);
|
||||||
return found
|
return found
|
||||||
? `${found.name} (${row.original.value})`
|
? `${found.name} (${row.original.value})`
|
||||||
: `Custom (${row.original.value})`;
|
: `Custom (${row.original.value})`;
|
||||||
})()
|
})()
|
||||||
: "Select ASN"}
|
: "Select ASN"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
@@ -755,7 +759,9 @@ export default function ResourceRules(props: {
|
|||||||
className="min-w-[200px] justify-between"
|
className="min-w-[200px] justify-between"
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const regionName = getRegionNameById(row.original.value);
|
const regionName = getRegionNameById(
|
||||||
|
row.original.value
|
||||||
|
);
|
||||||
if (!regionName) {
|
if (!regionName) {
|
||||||
return t("selectRegion");
|
return t("selectRegion");
|
||||||
}
|
}
|
||||||
@@ -774,7 +780,10 @@ export default function ResourceRules(props: {
|
|||||||
{t("noRegionFound")}
|
{t("noRegionFound")}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
{REGIONS.map((continent) => (
|
{REGIONS.map((continent) => (
|
||||||
<CommandGroup key={continent.id} heading={t(continent.name)}>
|
<CommandGroup
|
||||||
|
key={continent.id}
|
||||||
|
heading={t(continent.name)}
|
||||||
|
>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={continent.id}
|
value={continent.id}
|
||||||
keywords={[
|
keywords={[
|
||||||
@@ -790,38 +799,48 @@ export default function ResourceRules(props: {
|
|||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={`mr-2 h-4 w-4 ${
|
className={`mr-2 h-4 w-4 ${
|
||||||
row.original.value === continent.id
|
row.original.value ===
|
||||||
|
continent.id
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0"
|
: "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{t(continent.name)} ({continent.id})
|
{t(continent.name)} (
|
||||||
|
{continent.id})
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
{continent.includes.map((subregion) => (
|
{continent.includes.map(
|
||||||
<CommandItem
|
(subregion) => (
|
||||||
key={subregion.id}
|
<CommandItem
|
||||||
value={subregion.id}
|
key={subregion.id}
|
||||||
keywords={[
|
value={subregion.id}
|
||||||
t(subregion.name),
|
keywords={[
|
||||||
subregion.id
|
t(subregion.name),
|
||||||
]}
|
subregion.id
|
||||||
onSelect={() => {
|
]}
|
||||||
updateRule(
|
onSelect={() => {
|
||||||
row.original.ruleId,
|
updateRule(
|
||||||
{ value: subregion.id }
|
row.original
|
||||||
);
|
.ruleId,
|
||||||
}}
|
{
|
||||||
>
|
value: subregion.id
|
||||||
<Check
|
}
|
||||||
className={`mr-2 h-4 w-4 ${
|
);
|
||||||
row.original.value === subregion.id
|
}}
|
||||||
? "opacity-100"
|
>
|
||||||
: "opacity-0"
|
<Check
|
||||||
}`}
|
className={`mr-2 h-4 w-4 ${
|
||||||
/>
|
row.original
|
||||||
{t(subregion.name)} ({subregion.id})
|
.value ===
|
||||||
</CommandItem>
|
subregion.id
|
||||||
))}
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{t(subregion.name)} (
|
||||||
|
{subregion.id})
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
))}
|
))}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
@@ -1018,7 +1037,8 @@ export default function ResourceRules(props: {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resource.http && (
|
{resource.mode ==
|
||||||
|
"http" && (
|
||||||
<SelectItem value="PATH">
|
<SelectItem value="PATH">
|
||||||
{
|
{
|
||||||
RuleMatch.PATH
|
RuleMatch.PATH
|
||||||
@@ -1311,8 +1331,8 @@ export default function ResourceRules(props: {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
) : addRuleForm.watch(
|
) : addRuleForm.watch(
|
||||||
"match"
|
"match"
|
||||||
) === "REGION" ? (
|
) === "REGION" ? (
|
||||||
<Popover
|
<Popover
|
||||||
open={
|
open={
|
||||||
openAddRuleRegionSelect
|
openAddRuleRegionSelect
|
||||||
@@ -1334,8 +1354,16 @@ export default function ResourceRules(props: {
|
|||||||
>
|
>
|
||||||
{field.value
|
{field.value
|
||||||
? (() => {
|
? (() => {
|
||||||
const regionName = getRegionNameById(field.value);
|
const regionName =
|
||||||
const translatedName = regionName ? t(regionName) : field.value;
|
getRegionNameById(
|
||||||
|
field.value
|
||||||
|
);
|
||||||
|
const translatedName =
|
||||||
|
regionName
|
||||||
|
? t(
|
||||||
|
regionName
|
||||||
|
)
|
||||||
|
: field.value;
|
||||||
return `${translatedName} (${field.value})`;
|
return `${translatedName} (${field.value})`;
|
||||||
})()
|
})()
|
||||||
: t(
|
: t(
|
||||||
@@ -1357,43 +1385,31 @@ export default function ResourceRules(props: {
|
|||||||
"noRegionFound"
|
"noRegionFound"
|
||||||
)}
|
)}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
{REGIONS.map((continent) => (
|
{REGIONS.map(
|
||||||
<CommandGroup key={continent.id} heading={t(continent.name)}>
|
(
|
||||||
<CommandItem
|
continent
|
||||||
value={continent.id}
|
) => (
|
||||||
keywords={[
|
<CommandGroup
|
||||||
t(continent.name),
|
key={
|
||||||
continent.id
|
continent.id
|
||||||
]}
|
}
|
||||||
onSelect={() => {
|
heading={t(
|
||||||
field.onChange(
|
continent.name
|
||||||
continent.id
|
)}
|
||||||
);
|
|
||||||
setOpenAddRuleRegionSelect(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Check
|
|
||||||
className={`mr-2 h-4 w-4 ${
|
|
||||||
field.value === continent.id
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{t(continent.name)} ({continent.id})
|
|
||||||
</CommandItem>
|
|
||||||
{continent.includes.map((subregion) => (
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={subregion.id}
|
value={
|
||||||
value={subregion.id}
|
continent.id
|
||||||
|
}
|
||||||
keywords={[
|
keywords={[
|
||||||
t(subregion.name),
|
t(
|
||||||
subregion.id
|
continent.name
|
||||||
|
),
|
||||||
|
continent.id
|
||||||
]}
|
]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
field.onChange(
|
field.onChange(
|
||||||
subregion.id
|
continent.id
|
||||||
);
|
);
|
||||||
setOpenAddRuleRegionSelect(
|
setOpenAddRuleRegionSelect(
|
||||||
false
|
false
|
||||||
@@ -1402,16 +1418,71 @@ export default function ResourceRules(props: {
|
|||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={`mr-2 h-4 w-4 ${
|
className={`mr-2 h-4 w-4 ${
|
||||||
field.value === subregion.id
|
field.value ===
|
||||||
|
continent.id
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0"
|
: "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{t(subregion.name)} ({subregion.id})
|
{t(
|
||||||
|
continent.name
|
||||||
|
)}{" "}
|
||||||
|
(
|
||||||
|
{
|
||||||
|
continent.id
|
||||||
|
}
|
||||||
|
|
||||||
|
)
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
{continent.includes.map(
|
||||||
</CommandGroup>
|
(
|
||||||
))}
|
subregion
|
||||||
|
) => (
|
||||||
|
<CommandItem
|
||||||
|
key={
|
||||||
|
subregion.id
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
subregion.id
|
||||||
|
}
|
||||||
|
keywords={[
|
||||||
|
t(
|
||||||
|
subregion.name
|
||||||
|
),
|
||||||
|
subregion.id
|
||||||
|
]}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(
|
||||||
|
subregion.id
|
||||||
|
);
|
||||||
|
setOpenAddRuleRegionSelect(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
field.value ===
|
||||||
|
subregion.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{t(
|
||||||
|
subregion.name
|
||||||
|
)}{" "}
|
||||||
|
(
|
||||||
|
{
|
||||||
|
subregion.id
|
||||||
|
}
|
||||||
|
|
||||||
|
)
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CommandGroup>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -0,0 +1,524 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||||
|
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||||
|
import {
|
||||||
|
SitesSelector,
|
||||||
|
type Selectedsite
|
||||||
|
} from "@app/components/site-selector";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { use, useActionState, useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { GetResourceResponse } from "@server/routers/resource";
|
||||||
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
|
|
||||||
|
type ExistingTarget = {
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
siteId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sshFormSchema = z.object({
|
||||||
|
authDaemonPort: z.string().refine(
|
||||||
|
(val) => {
|
||||||
|
if (!val) return true;
|
||||||
|
const n = Number(val);
|
||||||
|
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||||
|
},
|
||||||
|
{ message: "Port must be between 1 and 65535" }
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function SshSettingsPage(props: {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
}) {
|
||||||
|
const params = use(props.params);
|
||||||
|
const { resource, updateResource } = useResourceContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SshServerForm
|
||||||
|
orgId={params.orgId}
|
||||||
|
resource={resource}
|
||||||
|
updateResource={updateResource}
|
||||||
|
/>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SshServerForm({
|
||||||
|
orgId,
|
||||||
|
resource,
|
||||||
|
updateResource
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
resource: GetResourceResponse;
|
||||||
|
updateResource: ResourceContextType["updateResource"];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isNativeInitially = resource.authDaemonMode === "native";
|
||||||
|
|
||||||
|
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||||
|
isNativeInitially ? "native" : "standard"
|
||||||
|
);
|
||||||
|
const isNative = sshServerMode === "native";
|
||||||
|
|
||||||
|
const [pamMode, setPamMode] = useState<"passthrough" | "push">(
|
||||||
|
(resource.pamMode as "passthrough" | "push") || "passthrough"
|
||||||
|
);
|
||||||
|
|
||||||
|
const [standardDaemonLocation, setStandardDaemonLocation] = useState<
|
||||||
|
"site" | "remote"
|
||||||
|
>(
|
||||||
|
isNativeInitially
|
||||||
|
? "site"
|
||||||
|
: (resource.authDaemonMode as "site" | "remote") || "site"
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(sshFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
authDaemonPort: (resource as any).authDaemonPort
|
||||||
|
? String((resource as any).authDaemonPort)
|
||||||
|
: "22123"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Standard mode: multi-site
|
||||||
|
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||||
|
const [selectedSite, setSelectedSite] = useState<Selectedsite | null>(null);
|
||||||
|
const [bgDestination, setBgDestination] = useState("");
|
||||||
|
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||||
|
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Native mode: single site
|
||||||
|
const [selectedNativeSite, setSelectedNativeSite] =
|
||||||
|
useState<Selectedsite | null>(null);
|
||||||
|
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||||
|
useState<ExistingTarget | null>(null);
|
||||||
|
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: bgTargetsResponse } = useQuery({
|
||||||
|
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||||
|
);
|
||||||
|
return res.data.data as {
|
||||||
|
targets: Array<{
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
resourceId: number;
|
||||||
|
siteId: number;
|
||||||
|
siteName?: string;
|
||||||
|
type: string;
|
||||||
|
destination: string;
|
||||||
|
destinationPort: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bgTargetsResponse?.targets?.length) return;
|
||||||
|
const targets = bgTargetsResponse.targets;
|
||||||
|
const first = targets[0];
|
||||||
|
if (isNativeInitially) {
|
||||||
|
setSelectedNativeSite({
|
||||||
|
siteId: first.siteId,
|
||||||
|
name: first.siteName ?? String(first.siteId),
|
||||||
|
type: "newt" as const
|
||||||
|
});
|
||||||
|
setNativeExistingTarget({
|
||||||
|
browserGatewayTargetId: first.browserGatewayTargetId,
|
||||||
|
siteId: first.siteId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setBgDestination(first.destination);
|
||||||
|
setBgDestinationPort(String(first.destinationPort));
|
||||||
|
setExistingTargets(
|
||||||
|
targets.map((t) => ({
|
||||||
|
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||||
|
siteId: t.siteId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setSelectedSites(
|
||||||
|
targets.map((t) => ({
|
||||||
|
siteId: t.siteId,
|
||||||
|
name: t.siteName ?? String(t.siteId),
|
||||||
|
type: "newt" as const
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [bgTargetsResponse]);
|
||||||
|
|
||||||
|
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const effectiveMode = isNative ? "native" : standardDaemonLocation;
|
||||||
|
const portVal = form.getValues().authDaemonPort;
|
||||||
|
const effectivePort =
|
||||||
|
!isNative && standardDaemonLocation === "remote" && portVal
|
||||||
|
? Number(portVal)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/resource/${resource.resourceId}`, {
|
||||||
|
pamMode,
|
||||||
|
authDaemonMode: effectiveMode,
|
||||||
|
authDaemonPort: effectivePort
|
||||||
|
});
|
||||||
|
|
||||||
|
updateResource({
|
||||||
|
...resource,
|
||||||
|
pamMode,
|
||||||
|
authDaemonMode: effectiveMode
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isNative) {
|
||||||
|
if (selectedNativeSite) {
|
||||||
|
if (nativeExistingTarget) {
|
||||||
|
await api.post(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`,
|
||||||
|
{
|
||||||
|
type: "ssh",
|
||||||
|
destination: "localhost",
|
||||||
|
destinationPort: 22,
|
||||||
|
siteId: selectedNativeSite.siteId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const res = await api.put(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||||
|
{
|
||||||
|
siteId: selectedNativeSite.siteId,
|
||||||
|
type: "ssh",
|
||||||
|
destination: "localhost",
|
||||||
|
destinationPort: 22
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setNativeExistingTarget({
|
||||||
|
browserGatewayTargetId:
|
||||||
|
res.data.data.browserGatewayTargetId,
|
||||||
|
siteId: selectedNativeSite.siteId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (bgDestination && bgDestinationPort) {
|
||||||
|
const selectedSiteIds = new Set(
|
||||||
|
selectedSites.map((s) => s.siteId)
|
||||||
|
);
|
||||||
|
const existingSiteIds = new Set(
|
||||||
|
existingTargets.map((t) => t.siteId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toDelete = existingTargets.filter(
|
||||||
|
(t) => !selectedSiteIds.has(t.siteId)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
toDelete.map((t) =>
|
||||||
|
api.delete(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toUpdate = existingTargets.filter((t) =>
|
||||||
|
selectedSiteIds.has(t.siteId)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
toUpdate.map((t) =>
|
||||||
|
api.post(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||||
|
{
|
||||||
|
type: "ssh",
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort),
|
||||||
|
siteId: t.siteId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toCreate = selectedSites.filter(
|
||||||
|
(s) => !existingSiteIds.has(s.siteId)
|
||||||
|
);
|
||||||
|
const created = await Promise.all(
|
||||||
|
toCreate.map((s) =>
|
||||||
|
api.put(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||||
|
{
|
||||||
|
siteId: s.siteId,
|
||||||
|
type: "ssh",
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTargets: ExistingTarget[] = created.map(
|
||||||
|
(res, i) => ({
|
||||||
|
browserGatewayTargetId:
|
||||||
|
res.data.data.browserGatewayTargetId,
|
||||||
|
siteId: toCreate[i].siteId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setExistingTargets([...toUpdate, ...newTargets]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("settingsUpdated"),
|
||||||
|
description: t("settingsUpdatedDescription")
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settingsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("settingsErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
|
||||||
|
{
|
||||||
|
id: "passthrough",
|
||||||
|
title: t("sshAuthMethodManual"),
|
||||||
|
description: t("sshAuthMethodManualDescription")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "push",
|
||||||
|
title: t("sshAuthMethodAutomated"),
|
||||||
|
description: t("sshAuthMethodAutomatedDescription")
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [
|
||||||
|
{
|
||||||
|
id: "site",
|
||||||
|
title: t("internalResourceAuthDaemonSite"),
|
||||||
|
description: t("sshDaemonLocationSiteDescription")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "remote",
|
||||||
|
title: t("sshDaemonLocationRemote"),
|
||||||
|
description: t("sshDaemonLocationRemoteDescription")
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const showDaemonLocation = !isNative && pamMode === "push";
|
||||||
|
const showDaemonPort =
|
||||||
|
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>{t("sshServer")}</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("sshServerDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{t("sshServerMode")}
|
||||||
|
</p>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{sshServerMode == "standard"
|
||||||
|
? t("sshServerModeStandard")
|
||||||
|
: t("sshServerModePangolin")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{t("sshAuthenticationMethod")}
|
||||||
|
</p>
|
||||||
|
<StrategySelect<"passthrough" | "push">
|
||||||
|
value={pamMode}
|
||||||
|
options={authMethodOptions}
|
||||||
|
onChange={setPamMode}
|
||||||
|
cols={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDaemonLocation && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{t("sshAuthDaemonLocation")}
|
||||||
|
</p>
|
||||||
|
<StrategySelect<"site" | "remote">
|
||||||
|
value={standardDaemonLocation}
|
||||||
|
options={daemonLocationOptions}
|
||||||
|
onChange={setStandardDaemonLocation}
|
||||||
|
cols={2}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("sshDaemonDisclaimer")}{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{t("learnMore")}
|
||||||
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDaemonPort && (
|
||||||
|
<Form {...form}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="authDaemonPort"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("sshDaemonPort")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
|
||||||
|
{t("sshServerDestination")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("sshServerDestinationDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isNative ? (
|
||||||
|
<Popover
|
||||||
|
open={nativeSiteOpen}
|
||||||
|
onOpenChange={setNativeSiteOpen}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="w-full max-w-xs justify-between font-normal"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedNativeSite?.name ??
|
||||||
|
t("siteSelect")}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<SitesSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSite={selectedNativeSite}
|
||||||
|
onSelectSite={(site) => {
|
||||||
|
setSelectedNativeSite(site);
|
||||||
|
setNativeSiteOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : standardDaemonLocation !== "site" ? (
|
||||||
|
<BrowserGatewayTargetForm
|
||||||
|
orgId={orgId}
|
||||||
|
multiSite={true}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSitesChange={setSelectedSites}
|
||||||
|
destination={bgDestination}
|
||||||
|
destinationPort={bgDestinationPort}
|
||||||
|
onDestinationChange={setBgDestination}
|
||||||
|
onDestinationPortChange={setBgDestinationPort}
|
||||||
|
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||||
|
defaultPort={22}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BrowserGatewayTargetForm
|
||||||
|
orgId={orgId}
|
||||||
|
multiSite={false}
|
||||||
|
selectedSite={selectedSite}
|
||||||
|
onSiteChange={setSelectedSite}
|
||||||
|
destination={bgDestination}
|
||||||
|
destinationPort={bgDestinationPort}
|
||||||
|
onDestinationChange={setBgDestination}
|
||||||
|
onDestinationPortChange={setBgDestinationPort}
|
||||||
|
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||||
|
defaultPort={22}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
<form action={formAction} className="flex justify-end mt-4">
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||||
|
import { type Selectedsite } from "@app/components/site-selector";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { use, useActionState, useEffect, useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { GetResourceResponse } from "@server/routers/resource";
|
||||||
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
|
|
||||||
|
type ExistingTarget = {
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
siteId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sshFormSchema = z.object({
|
||||||
|
authDaemonPort: z.string().refine(
|
||||||
|
(val) => {
|
||||||
|
if (!val) return true;
|
||||||
|
const n = Number(val);
|
||||||
|
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||||
|
},
|
||||||
|
{ message: "Port must be between 1 and 65535" }
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function SshSettingsPage(props: {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
}) {
|
||||||
|
const params = use(props.params);
|
||||||
|
const { resource, updateResource } = useResourceContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SshServerForm
|
||||||
|
orgId={params.orgId}
|
||||||
|
resource={resource}
|
||||||
|
updateResource={updateResource}
|
||||||
|
/>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SshServerForm({
|
||||||
|
orgId,
|
||||||
|
resource,
|
||||||
|
updateResource
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
resource: GetResourceResponse;
|
||||||
|
updateResource: ResourceContextType["updateResource"];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Standard mode: multi-site
|
||||||
|
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||||
|
const [bgDestination, setBgDestination] = useState("");
|
||||||
|
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||||
|
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Native mode: single site
|
||||||
|
const [selectedNativeSite, setSelectedNativeSite] =
|
||||||
|
useState<Selectedsite | null>(null);
|
||||||
|
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||||
|
useState<ExistingTarget | null>(null);
|
||||||
|
|
||||||
|
const { data: bgTargetsResponse } = useQuery({
|
||||||
|
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||||
|
);
|
||||||
|
return res.data.data as {
|
||||||
|
targets: Array<{
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
resourceId: number;
|
||||||
|
siteId: number;
|
||||||
|
siteName?: string;
|
||||||
|
type: string;
|
||||||
|
destination: string;
|
||||||
|
destinationPort: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bgTargetsResponse?.targets?.length) return;
|
||||||
|
const targets = bgTargetsResponse.targets;
|
||||||
|
const first = targets[0];
|
||||||
|
|
||||||
|
setBgDestination(first.destination);
|
||||||
|
setBgDestinationPort(String(first.destinationPort));
|
||||||
|
setExistingTargets(
|
||||||
|
targets.map((t) => ({
|
||||||
|
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||||
|
siteId: t.siteId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setSelectedSites(
|
||||||
|
targets.map((t) => ({
|
||||||
|
siteId: t.siteId,
|
||||||
|
name: t.siteName ?? String(t.siteId),
|
||||||
|
type: "newt" as const
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [bgTargetsResponse]);
|
||||||
|
|
||||||
|
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
if (bgDestination && bgDestinationPort) {
|
||||||
|
const selectedSiteIds = new Set(
|
||||||
|
selectedSites.map((s) => s.siteId)
|
||||||
|
);
|
||||||
|
const existingSiteIds = new Set(
|
||||||
|
existingTargets.map((t) => t.siteId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toDelete = existingTargets.filter(
|
||||||
|
(t) => !selectedSiteIds.has(t.siteId)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
toDelete.map((t) =>
|
||||||
|
api.delete(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toUpdate = existingTargets.filter((t) =>
|
||||||
|
selectedSiteIds.has(t.siteId)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
toUpdate.map((t) =>
|
||||||
|
api.post(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||||
|
{
|
||||||
|
type: "vnc",
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort),
|
||||||
|
siteId: t.siteId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toCreate = selectedSites.filter(
|
||||||
|
(s) => !existingSiteIds.has(s.siteId)
|
||||||
|
);
|
||||||
|
const created = await Promise.all(
|
||||||
|
toCreate.map((s) =>
|
||||||
|
api.put(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||||
|
{
|
||||||
|
siteId: s.siteId,
|
||||||
|
type: "vnc",
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||||
|
browserGatewayTargetId:
|
||||||
|
res.data.data.browserGatewayTargetId,
|
||||||
|
siteId: toCreate[i].siteId
|
||||||
|
}));
|
||||||
|
setExistingTargets([...toUpdate, ...newTargets]);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("settingsUpdated"),
|
||||||
|
description: t("settingsUpdatedDescription")
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settingsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("settingsErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>{t("vncServer")}</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("vncServerDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<BrowserGatewayTargetForm
|
||||||
|
orgId={orgId}
|
||||||
|
multiSite={true}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSitesChange={setSelectedSites}
|
||||||
|
destination={bgDestination}
|
||||||
|
destinationPort={bgDestinationPort}
|
||||||
|
onDestinationChange={setBgDestination}
|
||||||
|
onDestinationPortChange={setBgDestinationPort}
|
||||||
|
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
|
||||||
|
defaultPort={5900}
|
||||||
|
/>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
<form action={formAction} className="flex justify-end mt-4">
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -108,11 +108,11 @@ export default async function ProxyResourcesPage(
|
|||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
nice: resource.niceId,
|
nice: resource.niceId,
|
||||||
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
||||||
protocol: resource.protocol,
|
|
||||||
proxyPort: resource.proxyPort,
|
proxyPort: resource.proxyPort,
|
||||||
http: resource.http,
|
|
||||||
labels: resource.labels,
|
labels: resource.labels,
|
||||||
authState: !resource.http
|
authState: !["http", "ssh", "rdp", "vnc"].includes(
|
||||||
|
resource.mode || ""
|
||||||
|
)
|
||||||
? "none"
|
? "none"
|
||||||
: resource.sso ||
|
: resource.sso ||
|
||||||
resource.pincodeId !== null ||
|
resource.pincodeId !== null ||
|
||||||
@@ -126,6 +126,7 @@ export default async function ProxyResourcesPage(
|
|||||||
fullDomain: resource.fullDomain ?? null,
|
fullDomain: resource.fullDomain ?? null,
|
||||||
ssl: resource.ssl,
|
ssl: resource.ssl,
|
||||||
wildcard: resource.wildcard,
|
wildcard: resource.wildcard,
|
||||||
|
mode: resource.mode,
|
||||||
targets: resource.targets?.map((target) => ({
|
targets: resource.targets?.map((target) => ({
|
||||||
targetId: target.targetId,
|
targetId: target.targetId,
|
||||||
ip: target.ip,
|
ip: target.ip,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,522 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import type {
|
||||||
|
UserInteraction,
|
||||||
|
IronError,
|
||||||
|
FileTransferProvider
|
||||||
|
} from "@devolutions/iron-remote-desktop/dist";
|
||||||
|
import type {
|
||||||
|
RdpFileTransferProvider,
|
||||||
|
FileInfo
|
||||||
|
} from "@devolutions/iron-remote-desktop-rdp/dist";
|
||||||
|
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||||
|
|
||||||
|
declare module "react" {
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
"iron-remote-desktop": React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLElement> & {
|
||||||
|
scale?: string;
|
||||||
|
verbose?: string;
|
||||||
|
flexcenter?: string;
|
||||||
|
module?: unknown;
|
||||||
|
},
|
||||||
|
HTMLElement
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
domain: string;
|
||||||
|
kdcProxyUrl: string;
|
||||||
|
pcb: string;
|
||||||
|
enableClipboard: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isIronError = (error: unknown): error is IronError => {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
typeof (error as IronError).backtrace === "function" &&
|
||||||
|
typeof (error as IronError).kind === "function"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RdpClient({
|
||||||
|
target,
|
||||||
|
error
|
||||||
|
}: {
|
||||||
|
target: GetBrowserTargetResponse | null;
|
||||||
|
error: string | null;
|
||||||
|
}) {
|
||||||
|
const STORAGE_KEY = "pangolin_rdp_credentials";
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) return JSON.parse(saved) as FormState;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
domain: "",
|
||||||
|
kdcProxyUrl: "",
|
||||||
|
pcb: "",
|
||||||
|
enableClipboard: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showLogin, setShowLogin] = useState(true);
|
||||||
|
const [moduleReady, setModuleReady] = useState(false);
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [unicodeMode, setUnicodeMode] = useState(false);
|
||||||
|
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
|
||||||
|
|
||||||
|
const userInteractionRef = useRef<UserInteraction | null>(null);
|
||||||
|
const backendRef = useRef<unknown>(null);
|
||||||
|
// Holds the RdpFileTransferProvider constructor so we can create a fresh
|
||||||
|
// instance per session (avoids stale upload state across reconnects).
|
||||||
|
const fileTransferClassRef = useRef<typeof RdpFileTransferProvider | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
// Active session's provider instance; replaced on each connect.
|
||||||
|
const fileTransferRef = useRef<RdpFileTransferProvider | null>(null);
|
||||||
|
const extensionsRef = useRef<{
|
||||||
|
displayControl: (enable: boolean) => unknown;
|
||||||
|
preConnectionBlob: (pcb: string) => unknown;
|
||||||
|
kdcProxyUrl: (url: string) => unknown;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Load the iron-remote-desktop modules client-side and register the
|
||||||
|
// `<iron-remote-desktop>` custom element.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [coreMod, rdpMod] = await Promise.all([
|
||||||
|
import("@devolutions/iron-remote-desktop/dist"),
|
||||||
|
import("@devolutions/iron-remote-desktop-rdp/dist")
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
await rdpMod.init("INFO");
|
||||||
|
|
||||||
|
backendRef.current = rdpMod.Backend;
|
||||||
|
extensionsRef.current = {
|
||||||
|
displayControl: rdpMod.displayControl,
|
||||||
|
preConnectionBlob: rdpMod.preConnectionBlob,
|
||||||
|
kdcProxyUrl: rdpMod.kdcProxyUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the class; a fresh instance is created per session.
|
||||||
|
fileTransferClassRef.current =
|
||||||
|
rdpMod.RdpFileTransferProvider as unknown as typeof RdpFileTransferProvider;
|
||||||
|
|
||||||
|
// Importing the package registers the custom element as a side
|
||||||
|
// effect. Touch the default export to avoid tree-shaking.
|
||||||
|
void coreMod;
|
||||||
|
|
||||||
|
setModuleReady(true);
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error("Failed to load iron-remote-desktop modules", err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to load RDP module",
|
||||||
|
description: `${err}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Attach the "ready" listener synchronously the moment the custom
|
||||||
|
// element mounts. The custom element dispatches `ready` from its own
|
||||||
|
// `onMount`, so a deferred useEffect can race and miss it.
|
||||||
|
const remoteElementRef = (el: HTMLElement | null) => {
|
||||||
|
if (!el) return;
|
||||||
|
const onReady = (e: Event) => {
|
||||||
|
const event = e as CustomEvent;
|
||||||
|
userInteractionRef.current = event.detail.irgUserInteraction;
|
||||||
|
};
|
||||||
|
el.addEventListener("ready", onReady);
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startSession = async () => {
|
||||||
|
setConnecting(true);
|
||||||
|
const userInteraction = userInteractionRef.current;
|
||||||
|
const exts = extensionsRef.current;
|
||||||
|
if (!userInteraction || !exts) {
|
||||||
|
setConnecting(false);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Not ready",
|
||||||
|
description: "RDP module is still initializing"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userInteraction.setEnableClipboard(form.enableClipboard);
|
||||||
|
|
||||||
|
// Dispose any previous session's provider and create a fresh one so
|
||||||
|
// there is no stale upload state from a prior connection.
|
||||||
|
fileTransferRef.current?.dispose();
|
||||||
|
const ProviderClass = fileTransferClassRef.current;
|
||||||
|
const fileTransfer = ProviderClass ? new ProviderClass() : null;
|
||||||
|
fileTransferRef.current = fileTransfer;
|
||||||
|
|
||||||
|
if (fileTransfer) {
|
||||||
|
// Auto-download files when the remote copies them to clipboard.
|
||||||
|
fileTransfer.on("files-available", (files: FileInfo[]) => {
|
||||||
|
const downloadable = files.filter((f) => !f.isDirectory);
|
||||||
|
if (downloadable.length === 0) return;
|
||||||
|
toast({
|
||||||
|
title: `Downloading ${downloadable.length} file(s) from remote…`
|
||||||
|
});
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
if (file.isDirectory) continue;
|
||||||
|
const { completion } = fileTransfer.downloadFile(file, i);
|
||||||
|
completion
|
||||||
|
.then((blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = file.name;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: `Download failed: ${file.name}`,
|
||||||
|
description: `${err}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify when individual uploads complete (remote pasted a file).
|
||||||
|
fileTransfer.on("upload-complete", (file: File) => {
|
||||||
|
toast({ title: `Uploaded: ${file.name}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register with the web component so CLIPRDR extensions are
|
||||||
|
// wired up before connect() builds the session.
|
||||||
|
userInteraction.enableFileTransfer(
|
||||||
|
fileTransfer as unknown as FileTransferProvider
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
setConnecting(false);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "No target",
|
||||||
|
description: "No connection target available"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = `${target.ip}:${target.port}`;
|
||||||
|
|
||||||
|
const builder = userInteraction
|
||||||
|
.configBuilder()
|
||||||
|
.withUsername(form.username)
|
||||||
|
.withPassword(form.password)
|
||||||
|
.withDestination(destination)
|
||||||
|
.withProxyAddress(
|
||||||
|
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
|
||||||
|
)
|
||||||
|
.withServerDomain(form.domain)
|
||||||
|
.withAuthToken(target.authToken)
|
||||||
|
.withDesktopSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
})
|
||||||
|
.withExtension(exts.displayControl(true));
|
||||||
|
|
||||||
|
if (form.pcb !== "") {
|
||||||
|
builder.withExtension(exts.preConnectionBlob(form.pcb));
|
||||||
|
}
|
||||||
|
if (form.kdcProxyUrl !== "") {
|
||||||
|
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionInfo = await userInteraction.connect(builder.build());
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setConnecting(false);
|
||||||
|
setShowLogin(false);
|
||||||
|
userInteraction.setVisibility(true);
|
||||||
|
|
||||||
|
const termInfo = await sessionInfo.run();
|
||||||
|
fileTransferRef.current?.dispose();
|
||||||
|
fileTransferRef.current = null;
|
||||||
|
setShowLogin(true);
|
||||||
|
} catch (err) {
|
||||||
|
setConnecting(false);
|
||||||
|
setShowLogin(true);
|
||||||
|
if (isIronError(err)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Connection failed",
|
||||||
|
description: err.backtrace()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Connection failed",
|
||||||
|
description: `${err}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ui = () => userInteractionRef.current;
|
||||||
|
|
||||||
|
const toggleCursorKind = () => {
|
||||||
|
const u = ui();
|
||||||
|
if (!u) return;
|
||||||
|
if (cursorOverrideActive) {
|
||||||
|
u.setCursorStyleOverride(null);
|
||||||
|
} else {
|
||||||
|
u.setCursorStyleOverride('url("crosshair.png") 7 7, default');
|
||||||
|
}
|
||||||
|
setCursorOverrideActive((v) => !v);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{showLogin && (
|
||||||
|
<div className="mx-auto max-w-2xl p-6">
|
||||||
|
<h1 className="mb-4 text-2xl font-semibold">RDP</h1>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label="Domain" id="domain">
|
||||||
|
<Input
|
||||||
|
id="domain"
|
||||||
|
value={form.domain}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("domain", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Username" id="username">
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("username", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Password" id="password">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("password", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{/*
|
||||||
|
<Field label="Pre Connection Blob (optional)" id="pcb">
|
||||||
|
<Input
|
||||||
|
id="pcb"
|
||||||
|
value={form.pcb}
|
||||||
|
onChange={(e) => update("pcb", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field> */}
|
||||||
|
|
||||||
|
{/* <Field
|
||||||
|
label="KDC Proxy URL (optional)"
|
||||||
|
id="kdcProxyUrl"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="kdcProxyUrl"
|
||||||
|
value={form.kdcProxyUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("kdcProxyUrl", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field> */}
|
||||||
|
{/* <div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enable_clipboard"
|
||||||
|
checked={form.enableClipboard}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
update("enableClipboard", checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="enable_clipboard">
|
||||||
|
Enable Clipboard
|
||||||
|
</Label>
|
||||||
|
</div> */}
|
||||||
|
<Button
|
||||||
|
onClick={startSession}
|
||||||
|
disabled={!moduleReady}
|
||||||
|
loading={connecting}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{moduleReady ? "Connect" : "Loading module..."}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-screen flex-col bg-neutral-900"
|
||||||
|
style={{ display: showLogin ? "none" : "flex" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => ui()?.setScale(1)}
|
||||||
|
>
|
||||||
|
Fit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => ui()?.setScale(2)}
|
||||||
|
>
|
||||||
|
Full
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => ui()?.setScale(3)}
|
||||||
|
>
|
||||||
|
Real
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => ui()?.ctrlAltDel()}
|
||||||
|
>
|
||||||
|
Ctrl+Alt+Del
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => ui()?.metaKey()}
|
||||||
|
>
|
||||||
|
Meta
|
||||||
|
</Button>
|
||||||
|
{/* <Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={toggleCursorKind}
|
||||||
|
>
|
||||||
|
Toggle cursor
|
||||||
|
</Button> */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
const ft = fileTransferRef.current;
|
||||||
|
if (!ft) return;
|
||||||
|
const files = await ft.showFilePicker({
|
||||||
|
multiple: true
|
||||||
|
});
|
||||||
|
if (files.length === 0) return;
|
||||||
|
try {
|
||||||
|
ft.uploadFiles(files);
|
||||||
|
toast({
|
||||||
|
title: "Files ready to paste",
|
||||||
|
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Upload failed",
|
||||||
|
description: `${err}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload files
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
ui()?.shutdown();
|
||||||
|
setShowLogin(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Terminate
|
||||||
|
</Button>
|
||||||
|
<label className="ml-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={unicodeMode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUnicodeMode(e.target.checked);
|
||||||
|
ui()?.setKeyboardUnicodeMode(e.target.checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Unicode keyboard mode
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{moduleReady && (
|
||||||
|
<iron-remote-desktop
|
||||||
|
ref={remoteElementRef}
|
||||||
|
verbose="true"
|
||||||
|
scale="fit"
|
||||||
|
flexcenter="true"
|
||||||
|
module={backendRef.current}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { priv } from "@app/lib/api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||||
|
import RdpClient from "./RdpClient";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "RDP"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RdpPage() {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get("host") || "";
|
||||||
|
const hostname = host.split(":")[0];
|
||||||
|
|
||||||
|
let target: GetBrowserTargetResponse | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||||
|
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||||
|
);
|
||||||
|
target = res.data.data;
|
||||||
|
console.log("Fetched browser target:", target);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching browser target:", error);
|
||||||
|
error = "No resource found for this domain";
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RdpClient target={target} error={error} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import type { SignSshKeyResponse } from "@server/private/routers/ssh";
|
||||||
|
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
privateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConnectCredentials = {
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
certificate?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SshClient({
|
||||||
|
target,
|
||||||
|
error,
|
||||||
|
signedKeyData,
|
||||||
|
privateKey: signedPrivateKey
|
||||||
|
}: {
|
||||||
|
target: GetBrowserTargetResponse | null;
|
||||||
|
error: string | null;
|
||||||
|
signedKeyData?: SignSshKeyResponse | null;
|
||||||
|
privateKey?: string | null;
|
||||||
|
}) {
|
||||||
|
const STORAGE_KEY = "pangolin_ssh_credentials";
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) return JSON.parse(saved) as FormState;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { username: "", password: "", privateKey: "" };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const text = ev.target?.result;
|
||||||
|
if (typeof text === "string") {
|
||||||
|
setForm((prev) => ({ ...prev, privateKey: text }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
// Reset input so the same file can be re-selected if needed.
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [connectError, setConnectError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
|
||||||
|
const fitAddonRef = useRef<import("@xterm/addon-fit").FitAddon | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
// Mount the terminal div once connected.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connected || !terminalRef.current) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] =
|
||||||
|
await Promise.all([
|
||||||
|
import("@xterm/xterm"),
|
||||||
|
import("@xterm/addon-fit"),
|
||||||
|
import("@xterm/addon-web-links")
|
||||||
|
]);
|
||||||
|
if (cancelled || !terminalRef.current) return;
|
||||||
|
|
||||||
|
const terminal = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
||||||
|
theme: {
|
||||||
|
background: "#0d0d0d",
|
||||||
|
foreground: "#f0f0f0"
|
||||||
|
},
|
||||||
|
scrollback: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
const webLinksAddon = new WebLinksAddon();
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
|
terminal.open(terminalRef.current);
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
xtermRef.current = terminal;
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
|
||||||
|
// Send user keystrokes to the WebSocket.
|
||||||
|
terminal.onData((data) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: "data", data }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send resize events.
|
||||||
|
terminal.onResize(({ cols, rows }) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(
|
||||||
|
JSON.stringify({ type: "resize", cols, rows })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the initial size once the terminal is rendered.
|
||||||
|
const { cols, rows } = terminal;
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(
|
||||||
|
JSON.stringify({ type: "resize", cols, rows })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})().catch(console.error);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [connected]);
|
||||||
|
|
||||||
|
// Refit terminal when the window resizes.
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => fitAddonRef.current?.fit();
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => window.removeEventListener("resize", onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
wsRef.current?.close();
|
||||||
|
xtermRef.current?.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-connect when signed key data is provided (push PAM mode).
|
||||||
|
useEffect(() => {
|
||||||
|
if (signedKeyData && signedPrivateKey && target) {
|
||||||
|
connect({
|
||||||
|
username: signedKeyData.sshUsername,
|
||||||
|
privateKey: signedPrivateKey,
|
||||||
|
certificate: signedKeyData.certificate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function connect(override?: ConnectCredentials) {
|
||||||
|
setConnectError(null);
|
||||||
|
setConnecting(true);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
setConnectError("No target specified");
|
||||||
|
setConnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = override?.username ?? form.username;
|
||||||
|
const password = override?.password ?? form.password;
|
||||||
|
const privateKey = override?.privateKey ?? form.privateKey;
|
||||||
|
const certificate = override?.certificate;
|
||||||
|
|
||||||
|
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
|
||||||
|
const url = new URL(proxyAddress);
|
||||||
|
url.searchParams.set("host", target.ip ?? "");
|
||||||
|
url.searchParams.set("port", String(target.port ?? 22));
|
||||||
|
url.searchParams.set("username", username);
|
||||||
|
url.searchParams.set("authToken", target.authToken ?? "");
|
||||||
|
|
||||||
|
const ws = new WebSocket(url.toString(), ["ssh"]);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
// Send credentials as the first frame so the proxy can complete
|
||||||
|
// SSH authentication before piping pty data.
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "auth",
|
||||||
|
password,
|
||||||
|
privateKey,
|
||||||
|
certificate
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!override) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setConnecting(false);
|
||||||
|
setConnected(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
if (typeof evt.data === "string") {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(evt.data as string) as {
|
||||||
|
type: string;
|
||||||
|
data?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (msg.type === "data" && msg.data) {
|
||||||
|
xtermRef.current?.write(msg.data);
|
||||||
|
} else if (msg.type === "error") {
|
||||||
|
xtermRef.current?.writeln(
|
||||||
|
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
xtermRef.current?.write(evt.data);
|
||||||
|
}
|
||||||
|
} else if (evt.data instanceof Blob) {
|
||||||
|
evt.data.text().then((t) => xtermRef.current?.write(t));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setConnecting(false);
|
||||||
|
setConnected(false);
|
||||||
|
setConnectError("WebSocket connection failed");
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (evt) => {
|
||||||
|
setConnecting(false);
|
||||||
|
setConnected(false);
|
||||||
|
xtermRef.current?.writeln(
|
||||||
|
`\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
wsRef.current?.close();
|
||||||
|
xtermRef.current?.dispose();
|
||||||
|
xtermRef.current = null;
|
||||||
|
setConnected(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In push mode, show a connecting/connected state without the login form.
|
||||||
|
if (signedKeyData && signedPrivateKey) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{!connected && (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{connectError
|
||||||
|
? connectError
|
||||||
|
: connecting
|
||||||
|
? "Connecting…"
|
||||||
|
: "Initializing…"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{connected && (
|
||||||
|
<div className="flex h-screen flex-col bg-neutral-900">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={disconnect}
|
||||||
|
>
|
||||||
|
Terminate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={terminalRef}
|
||||||
|
className="flex-1 overflow-hidden"
|
||||||
|
style={{ minHeight: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{!connected && (
|
||||||
|
<div className="mx-auto max-w-2xl p-6">
|
||||||
|
<h1 className="mb-4 text-2xl font-semibold">SSH</h1>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label="Username" id="username">
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
username: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="root"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Password" id="password">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
password: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
form.privateKey
|
||||||
|
? "Optional with key auth"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Private Key (optional)" id="privateKey">
|
||||||
|
<Textarea
|
||||||
|
id="privateKey"
|
||||||
|
value={form.privateKey}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
privateKey: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Paste your private key here (PEM format)…"
|
||||||
|
rows={5}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Upload key file
|
||||||
|
</Button>
|
||||||
|
{form.privateKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-muted-foreground underline"
|
||||||
|
onClick={() =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
privateKey: ""
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept=".pem,.key,.pub,*"
|
||||||
|
onChange={handleKeyFile}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{connectError && (
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{connectError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => connect()}
|
||||||
|
loading={connecting}
|
||||||
|
disabled={
|
||||||
|
!form.username ||
|
||||||
|
(!form.password && !form.privateKey)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{connecting ? "Connecting..." : "Connect"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connected && (
|
||||||
|
<div className="flex h-screen flex-col bg-neutral-900">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={disconnect}
|
||||||
|
>
|
||||||
|
Terminate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={terminalRef}
|
||||||
|
className="flex-1 overflow-hidden"
|
||||||
|
style={{ minHeight: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { priv } from "@app/lib/api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||||
|
import SshClient from "./SshClient";
|
||||||
|
import { SignSshKeyResponse } from "@server/private/routers/ssh";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
function generateEphemeralKeyPair(): {
|
||||||
|
privateKeyPem: string;
|
||||||
|
publicKeyOpenSSH: string;
|
||||||
|
} {
|
||||||
|
const { publicKey: pubKeyObj, privateKey: privKeyObj } =
|
||||||
|
crypto.generateKeyPairSync("ed25519");
|
||||||
|
|
||||||
|
const privateKeyPem = privKeyObj.export({
|
||||||
|
type: "pkcs8",
|
||||||
|
format: "pem"
|
||||||
|
}) as string;
|
||||||
|
|
||||||
|
// Build OpenSSH wire format: uint32-length-prefixed strings
|
||||||
|
const pubKeyDer = pubKeyObj.export({
|
||||||
|
type: "spki",
|
||||||
|
format: "der"
|
||||||
|
}) as Buffer;
|
||||||
|
const rawPubKey = pubKeyDer.subarray(pubKeyDer.length - 32); // last 32 bytes are the Ed25519 key
|
||||||
|
|
||||||
|
function encodeField(b: Buffer): Buffer {
|
||||||
|
const len = Buffer.allocUnsafe(4);
|
||||||
|
len.writeUInt32BE(b.length, 0);
|
||||||
|
return Buffer.concat([len, b]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyBlob = Buffer.concat([
|
||||||
|
encodeField(Buffer.from("ssh-ed25519")),
|
||||||
|
encodeField(rawPubKey)
|
||||||
|
]);
|
||||||
|
const publicKeyOpenSSH = `ssh-ed25519 ${keyBlob.toString("base64")}`;
|
||||||
|
|
||||||
|
return { privateKeyPem, publicKeyOpenSSH };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "SSH"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SshPage() {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get("host") || "";
|
||||||
|
const hostname = host.split(":")[0];
|
||||||
|
|
||||||
|
let target: GetBrowserTargetResponse | null = null;
|
||||||
|
let signedKeyData: SignSshKeyResponse | null = null;
|
||||||
|
let privateKey: string | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||||
|
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||||
|
);
|
||||||
|
target = res.data.data;
|
||||||
|
|
||||||
|
if (target.pamMode === "push") {
|
||||||
|
const { privateKeyPem, publicKeyOpenSSH } =
|
||||||
|
generateEphemeralKeyPair();
|
||||||
|
privateKey = privateKeyPem;
|
||||||
|
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
|
||||||
|
`/org/${target.orgId}/ssh/sign-key`,
|
||||||
|
{
|
||||||
|
publicKey: publicKeyOpenSSH,
|
||||||
|
resource: target.niceId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
signedKeyData = res.data.data;
|
||||||
|
console.log("Received signed SSH key:", signedKeyData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching browser target:", error);
|
||||||
|
error = "No resource found for this domain";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SshClient
|
||||||
|
target={target}
|
||||||
|
error={error}
|
||||||
|
signedKeyData={signedKeyData}
|
||||||
|
privateKey={privateKey}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VncClient({
|
||||||
|
target,
|
||||||
|
error
|
||||||
|
}: {
|
||||||
|
target: GetBrowserTargetResponse | null;
|
||||||
|
error: string | null;
|
||||||
|
}) {
|
||||||
|
const STORAGE_KEY = "pangolin_vnc_credentials";
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) return JSON.parse(saved) as FormState;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { password: "" };
|
||||||
|
});
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const rfbRef = useRef<any>(null);
|
||||||
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disconnect and clean up the RFB instance.
|
||||||
|
const disconnect = () => {
|
||||||
|
if (rfbRef.current) {
|
||||||
|
rfbRef.current.disconnect();
|
||||||
|
rfbRef.current = null;
|
||||||
|
}
|
||||||
|
setConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => disconnect();
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
if (!target) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "No target",
|
||||||
|
description: "No resource target is available"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!screenRef.current) return;
|
||||||
|
|
||||||
|
// Disconnect any existing session first.
|
||||||
|
disconnect();
|
||||||
|
|
||||||
|
// noVNC has no ESM default export — import the module dynamically to
|
||||||
|
// keep it out of the server bundle, then grab the default export.
|
||||||
|
let RFB: new (
|
||||||
|
target: HTMLElement,
|
||||||
|
url: string,
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
) => unknown;
|
||||||
|
try {
|
||||||
|
// @ts-expect-error — @novnc/novnc ships plain JS with no bundled types
|
||||||
|
const mod = await import("@novnc/novnc");
|
||||||
|
RFB = mod.default ?? mod;
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to load noVNC",
|
||||||
|
description: `${err}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the proxy WebSocket URL:
|
||||||
|
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
|
||||||
|
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
|
||||||
|
const base = proxyAddress.replace(/\/$/, "");
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
host: target.ip,
|
||||||
|
port: String(target.port),
|
||||||
|
authToken: target.authToken
|
||||||
|
});
|
||||||
|
const wsUrl = `${base}?${params.toString()}`;
|
||||||
|
|
||||||
|
// Clear the container so noVNC gets a clean mount point.
|
||||||
|
screenRef.current.innerHTML = "";
|
||||||
|
|
||||||
|
const options: Record<string, unknown> = {};
|
||||||
|
if (form.password) {
|
||||||
|
options.credentials = { password: form.password };
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const rfb: any = new RFB(screenRef.current, wsUrl, options);
|
||||||
|
|
||||||
|
rfb.scaleViewport = true;
|
||||||
|
rfb.resizeSession = true;
|
||||||
|
|
||||||
|
rfb.addEventListener("connect", () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
rfb.addEventListener(
|
||||||
|
"disconnect",
|
||||||
|
(e: { detail: { clean: boolean } }) => {
|
||||||
|
rfbRef.current = null;
|
||||||
|
setConnected(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
rfb.addEventListener(
|
||||||
|
"securityfailure",
|
||||||
|
(e: { detail: { status: number; reason?: string } }) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Authentication failed",
|
||||||
|
description: e.detail.reason ?? `Status ${e.detail.status}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
rfbRef.current = rfb;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{!connected && (
|
||||||
|
<div className="mx-auto max-w-2xl p-6">
|
||||||
|
<h1 className="mb-4 text-2xl font-semibold">VNC</h1>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label="Password (optional)" id="password">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("password", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button onClick={connect} className="w-full">
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-screen flex-col bg-neutral-900"
|
||||||
|
style={{ display: connected ? "flex" : "none" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (rfbRef.current) {
|
||||||
|
rfbRef.current.sendCtrlAltDel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ctrl+Alt+Del
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard
|
||||||
|
?.readText()
|
||||||
|
.then((text) => {
|
||||||
|
rfbRef.current?.clipboardPasteFrom(text);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Paste clipboard
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={disconnect}
|
||||||
|
>
|
||||||
|
Terminate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* noVNC mounts a <canvas> inside this div */}
|
||||||
|
<div
|
||||||
|
ref={screenRef}
|
||||||
|
className="flex-1 overflow-hidden"
|
||||||
|
style={{ background: "#000" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { priv } from "@app/lib/api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||||
|
import VncClient from "./VncClient";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "VNC"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function VncPage() {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get("host") || "";
|
||||||
|
const hostname = host.split(":")[0];
|
||||||
|
|
||||||
|
let target: GetBrowserTargetResponse | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||||
|
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||||
|
);
|
||||||
|
target = res.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching browser target:", error);
|
||||||
|
error = "No resource found for this domain";
|
||||||
|
}
|
||||||
|
|
||||||
|
return <VncClient target={target} error={error} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
MultiSitesSelector,
|
||||||
|
formatMultiSitesSelectorLabel
|
||||||
|
} from "./multi-site-selector";
|
||||||
|
import { SitesSelector, type Selectedsite } from "./site-selector";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||||
|
|
||||||
|
type SingleSiteProps = {
|
||||||
|
multiSite?: false;
|
||||||
|
selectedSite: Selectedsite | null;
|
||||||
|
onSiteChange: (site: Selectedsite | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MultiSiteProps = {
|
||||||
|
multiSite: true;
|
||||||
|
selectedSites: Selectedsite[];
|
||||||
|
onSitesChange: (sites: Selectedsite[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowserGatewayTargetFormProps = {
|
||||||
|
orgId: string;
|
||||||
|
destination: string;
|
||||||
|
defaultPort: number;
|
||||||
|
destinationPort: string;
|
||||||
|
onDestinationChange: (v: string) => void;
|
||||||
|
onDestinationPortChange: (v: string) => void;
|
||||||
|
learnMoreHref?: string;
|
||||||
|
} & (SingleSiteProps | MultiSiteProps);
|
||||||
|
|
||||||
|
export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [siteOpen, setSiteOpen] = useState(false);
|
||||||
|
|
||||||
|
const siteSelector =
|
||||||
|
props.multiSite === true ? (
|
||||||
|
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{formatMultiSitesSelectorLabel(
|
||||||
|
props.selectedSites,
|
||||||
|
t
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<MultiSitesSelector
|
||||||
|
orgId={props.orgId}
|
||||||
|
selectedSites={props.selectedSites}
|
||||||
|
onSelectionChange={props.onSitesChange}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{props.selectedSite?.name ?? t("siteSelect")}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||||
|
<SitesSelector
|
||||||
|
orgId={props.orgId}
|
||||||
|
selectedSite={props.selectedSite}
|
||||||
|
onSelectSite={(site) => {
|
||||||
|
props.onSiteChange(site);
|
||||||
|
setSiteOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">
|
||||||
|
{t("sites")}
|
||||||
|
</label>
|
||||||
|
{siteSelector}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">
|
||||||
|
{t("destination")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
value={props.destination}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.onDestinationChange(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-semibold">{t("port")}</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={props.defaultPort.toString()}
|
||||||
|
value={props.destinationPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
props.onDestinationPortChange(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{props.multiSite === true && props.selectedSites.length > 1 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("bgTargetMultiSiteDisclaimer")}{" "}
|
||||||
|
<a
|
||||||
|
href={
|
||||||
|
props.learnMoreHref ??
|
||||||
|
"https://docs.pangolin.net/manage/resources/public/ssh"
|
||||||
|
}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{t("learnMore")}
|
||||||
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,21 +77,22 @@ export type InternalResourceRow = {
|
|||||||
siteIds: number[];
|
siteIds: number[];
|
||||||
siteNiceIds: string[];
|
siteNiceIds: string[];
|
||||||
// mode: "host" | "cidr" | "port";
|
// mode: "host" | "cidr" | "port";
|
||||||
mode: "host" | "cidr" | "http";
|
mode: "host" | "cidr" | "http" | "ssh";
|
||||||
scheme: "http" | "https" | null;
|
scheme: "http" | "https" | null;
|
||||||
ssl: boolean;
|
ssl: boolean;
|
||||||
// protocol: string | null;
|
// protocol: string | null;
|
||||||
// proxyPort: number | null;
|
// proxyPort: number | null;
|
||||||
destination: string;
|
destination: string | null;
|
||||||
httpHttpsPort: number | null;
|
destinationPort: number | null;
|
||||||
alias: string | null;
|
alias: string | null;
|
||||||
aliasAddress: string | null;
|
aliasAddress: string | null;
|
||||||
niceId: string;
|
niceId: string;
|
||||||
tcpPortRangeString: string | null;
|
tcpPortRangeString: string | null;
|
||||||
udpPortRangeString: string | null;
|
udpPortRangeString: string | null;
|
||||||
disableIcmp: boolean;
|
disableIcmp: boolean;
|
||||||
authDaemonMode?: "site" | "remote" | null;
|
authDaemonMode?: "site" | "remote" | "native" | null;
|
||||||
authDaemonPort?: number | null;
|
authDaemonPort?: number | null;
|
||||||
|
pamMode?: "passthrough" | "push" | null;
|
||||||
subdomain?: string | null;
|
subdomain?: string | null;
|
||||||
domainId?: string | null;
|
domainId?: string | null;
|
||||||
fullDomain?: string | null;
|
fullDomain?: string | null;
|
||||||
@@ -106,7 +107,7 @@ function formatDestinationDisplay(row: InternalResourceRow): string {
|
|||||||
return formatSiteResourceDestinationDisplay({
|
return formatSiteResourceDestinationDisplay({
|
||||||
mode: row.mode,
|
mode: row.mode,
|
||||||
destination: row.destination,
|
destination: row.destination,
|
||||||
httpHttpsPort: row.httpHttpsPort,
|
destinationPort: row.destinationPort,
|
||||||
scheme: row.scheme
|
scheme: row.scheme
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -357,6 +358,10 @@ export default function ClientResourcesTable({
|
|||||||
{
|
{
|
||||||
value: "http",
|
value: "http",
|
||||||
label: t("editInternalResourceDialogModeHttp")
|
label: t("editInternalResourceDialogModeHttp")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ssh",
|
||||||
|
label: t("editInternalResourceDialogModeSsh")
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
selectedValue={searchParams.get("mode") ?? undefined}
|
selectedValue={searchParams.get("mode") ?? undefined}
|
||||||
@@ -372,13 +377,14 @@ export default function ClientResourcesTable({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
const modeLabels: Record<
|
const modeLabels: Record<
|
||||||
"host" | "cidr" | "port" | "http",
|
"host" | "cidr" | "port" | "http" | "ssh",
|
||||||
string
|
string
|
||||||
> = {
|
> = {
|
||||||
host: t("editInternalResourceDialogModeHost"),
|
host: t("editInternalResourceDialogModeHost"),
|
||||||
cidr: t("editInternalResourceDialogModeCidr"),
|
cidr: t("editInternalResourceDialogModeCidr"),
|
||||||
port: t("editInternalResourceDialogModePort"),
|
port: t("editInternalResourceDialogModePort"),
|
||||||
http: t("editInternalResourceDialogModeHttp")
|
http: t("editInternalResourceDialogModeHttp"),
|
||||||
|
ssh: t("editInternalResourceDialogModeSsh")
|
||||||
};
|
};
|
||||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,31 +47,36 @@ export default function CreateInternalResourceDialog({
|
|||||||
try {
|
try {
|
||||||
let data = { ...values };
|
let data = { ...values };
|
||||||
if (
|
if (
|
||||||
(data.mode === "host" || data.mode === "http") &&
|
(data.mode === "host" ||
|
||||||
|
data.mode === "http" ||
|
||||||
|
data.mode === "ssh") &&
|
||||||
isHostname(data.destination)
|
isHostname(data.destination)
|
||||||
) {
|
) {
|
||||||
const currentAlias = data.alias?.trim() || "";
|
const currentAlias = data.alias?.trim() || "";
|
||||||
if (!currentAlias) {
|
if (!currentAlias) {
|
||||||
let aliasValue = data.destination;
|
let aliasValue = data.destination;
|
||||||
if (data.destination.toLowerCase() === "localhost") {
|
if (data.destination?.toLowerCase() === "localhost") {
|
||||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||||
}
|
}
|
||||||
data = { ...data, alias: aliasValue };
|
data = { ...data, alias: aliasValue };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "ssh" mode maps to "host" in the backend with SSH settings
|
||||||
|
const backendMode = data.mode === "ssh" ? "host" : data.mode;
|
||||||
|
|
||||||
await api.put<
|
await api.put<
|
||||||
AxiosResponse<{ data: { siteResourceId: number } }>
|
AxiosResponse<{ data: { siteResourceId: number } }>
|
||||||
>(`/org/${orgId}/site-resource`, {
|
>(`/org/${orgId}/site-resource`, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
siteIds: data.siteIds,
|
siteIds: data.siteIds,
|
||||||
mode: data.mode,
|
mode: backendMode,
|
||||||
destination: data.destination,
|
destination: data.destination,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...(data.mode === "http" && {
|
...(data.mode === "http" && {
|
||||||
scheme: data.scheme,
|
scheme: data.scheme,
|
||||||
ssl: data.ssl ?? false,
|
ssl: data.ssl ?? false,
|
||||||
destinationPort: data.httpHttpsPort ?? undefined,
|
destinationPort: data.destinationPort ?? undefined,
|
||||||
domainId: data.httpConfigDomainId
|
domainId: data.httpConfigDomainId
|
||||||
? data.httpConfigDomainId
|
? data.httpConfigDomainId
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -94,7 +99,25 @@ export default function CreateInternalResourceDialog({
|
|||||||
authDaemonPort: data.authDaemonPort
|
authDaemonPort: data.authDaemonPort
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
...((data.mode === "host" || data.mode == "cidr") && {
|
...(data.mode === "ssh" && {
|
||||||
|
alias:
|
||||||
|
data.alias &&
|
||||||
|
typeof data.alias === "string" &&
|
||||||
|
data.alias.trim()
|
||||||
|
? data.alias
|
||||||
|
: undefined,
|
||||||
|
pamMode: data.pamMode ?? undefined,
|
||||||
|
...(data.authDaemonMode != null && {
|
||||||
|
authDaemonMode: data.authDaemonMode
|
||||||
|
}),
|
||||||
|
...(data.authDaemonMode === "remote" &&
|
||||||
|
data.authDaemonPort != null && {
|
||||||
|
authDaemonPort: data.authDaemonPort
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
...((data.mode === "host" ||
|
||||||
|
data.mode === "ssh" ||
|
||||||
|
data.mode === "cidr") && {
|
||||||
tcpPortRangeString: data.tcpPortRangeString,
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
udpPortRangeString: data.udpPortRangeString,
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
disableIcmp: data.disableIcmp ?? false
|
disableIcmp: data.disableIcmp ?? false
|
||||||
|
|||||||
@@ -51,29 +51,34 @@ export default function EditInternalResourceDialog({
|
|||||||
try {
|
try {
|
||||||
let data = { ...values };
|
let data = { ...values };
|
||||||
if (
|
if (
|
||||||
(data.mode === "host" || data.mode === "http") &&
|
(data.mode === "host" ||
|
||||||
|
data.mode === "http" ||
|
||||||
|
data.mode === "ssh") &&
|
||||||
isHostname(data.destination)
|
isHostname(data.destination)
|
||||||
) {
|
) {
|
||||||
const currentAlias = data.alias?.trim() || "";
|
const currentAlias = data.alias?.trim() || "";
|
||||||
if (!currentAlias) {
|
if (!currentAlias) {
|
||||||
let aliasValue = data.destination;
|
let aliasValue = data.destination;
|
||||||
if (data.destination.toLowerCase() === "localhost") {
|
if (data.destination?.toLowerCase() === "localhost") {
|
||||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||||
}
|
}
|
||||||
data = { ...data, alias: aliasValue };
|
data = { ...data, alias: aliasValue };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "ssh" mode maps to "host" in the backend with SSH settings
|
||||||
|
const backendMode = data.mode === "ssh" ? "host" : data.mode;
|
||||||
|
|
||||||
await api.post(`/site-resource/${resource.id}`, {
|
await api.post(`/site-resource/${resource.id}`, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
siteIds: data.siteIds,
|
siteIds: data.siteIds,
|
||||||
mode: data.mode,
|
mode: backendMode,
|
||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
destination: data.destination,
|
destination: data.destination,
|
||||||
...(data.mode === "http" && {
|
...(data.mode === "http" && {
|
||||||
scheme: data.scheme,
|
scheme: data.scheme,
|
||||||
ssl: data.ssl ?? false,
|
ssl: data.ssl ?? false,
|
||||||
destinationPort: data.httpHttpsPort ?? null,
|
destinationPort: data.destinationPort ?? null,
|
||||||
domainId: data.httpConfigDomainId
|
domainId: data.httpConfigDomainId
|
||||||
? data.httpConfigDomainId
|
? data.httpConfigDomainId
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -95,7 +100,24 @@ export default function EditInternalResourceDialog({
|
|||||||
authDaemonPort: data.authDaemonPort || null
|
authDaemonPort: data.authDaemonPort || null
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
...((data.mode === "host" || data.mode === "cidr") && {
|
...(data.mode === "ssh" && {
|
||||||
|
alias:
|
||||||
|
data.alias &&
|
||||||
|
typeof data.alias === "string" &&
|
||||||
|
data.alias.trim()
|
||||||
|
? data.alias
|
||||||
|
: null,
|
||||||
|
pamMode: data.pamMode ?? undefined,
|
||||||
|
...(data.authDaemonMode != null && {
|
||||||
|
authDaemonMode: data.authDaemonMode
|
||||||
|
}),
|
||||||
|
...(data.authDaemonMode === "remote" && {
|
||||||
|
authDaemonPort: data.authDaemonPort || null
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
...((data.mode === "host" ||
|
||||||
|
data.mode === "ssh" ||
|
||||||
|
data.mode === "cidr") && {
|
||||||
tcpPortRangeString: data.tcpPortRangeString,
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
udpPortRangeString: data.udpPortRangeString,
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
disableIcmp: data.disableIcmp ?? false
|
disableIcmp: data.disableIcmp ?? false
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@
|
|||||||
import type { SidebarNavSection } from "@app/app/navigation";
|
import type { SidebarNavSection } from "@app/app/navigation";
|
||||||
import { OrgSelector } from "@app/components/OrgSelector";
|
import { OrgSelector } from "@app/components/OrgSelector";
|
||||||
import { SidebarNav } from "@app/components/SidebarNav";
|
import { SidebarNav } from "@app/components/SidebarNav";
|
||||||
import SupporterStatus from "@app/components/SupporterStatus";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -258,11 +257,6 @@ export function LayoutSidebar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{build === "oss" && (
|
|
||||||
<div className="px-4">
|
|
||||||
<SupporterStatus isCollapsed={isSidebarCollapsed} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{build === "saas" && (
|
{build === "saas" && (
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<SidebarSupportButton
|
<SidebarSupportButton
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ type Resource = {
|
|||||||
domain: string;
|
domain: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
protected: boolean;
|
protected: boolean;
|
||||||
protocol: string;
|
mode: string; // "http", "tcp", "udp", "rdp", "vnc", "ssh"
|
||||||
// Auth method fields
|
// Auth method fields
|
||||||
sso?: boolean;
|
sso?: boolean;
|
||||||
password?: boolean;
|
password?: boolean;
|
||||||
@@ -64,7 +64,6 @@ type SiteResource = {
|
|||||||
name: string;
|
name: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
protocol: string | null;
|
|
||||||
ssl: boolean;
|
ssl: boolean;
|
||||||
fullDomain: string | null;
|
fullDomain: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -882,21 +881,6 @@ export default function MemberResourcesPortal({
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{siteResource.protocol && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">
|
|
||||||
{t(
|
|
||||||
"protocol"
|
|
||||||
)}
|
|
||||||
:
|
|
||||||
</span>
|
|
||||||
<span className="ml-2 text-muted-foreground uppercase">
|
|
||||||
{
|
|
||||||
siteResource.protocol
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{t(
|
{t(
|
||||||
@@ -954,7 +938,7 @@ export default function MemberResourcesPortal({
|
|||||||
siteResource.fullDomain ? (
|
siteResource.fullDomain ? (
|
||||||
/* HTTP mode - show as clickable link */
|
/* HTTP mode - show as clickable link */
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`}
|
text={`${siteResource.ssl ? "https" : (siteResource.mode ?? "http")}://${siteResource.fullDomain}`}
|
||||||
isLink={true}
|
isLink={true}
|
||||||
/>
|
/>
|
||||||
) : siteResource.alias ? (
|
) : siteResource.alias ? (
|
||||||
@@ -1037,7 +1021,7 @@ export default function MemberResourcesPortal({
|
|||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(
|
window.open(
|
||||||
`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`,
|
`${siteResource.ssl ? "https" : (siteResource.mode ?? "http")}://${siteResource.fullDomain}`,
|
||||||
"_blank"
|
"_blank"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
|
|||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { build } from "@server/build";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import Disable2FaForm from "./Disable2FaForm";
|
import Disable2FaForm from "./Disable2FaForm";
|
||||||
@@ -27,7 +26,6 @@ import SecurityKeyForm from "./SecurityKeyForm";
|
|||||||
import Enable2FaDialog from "./Enable2FaDialog";
|
import Enable2FaDialog from "./Enable2FaDialog";
|
||||||
import ChangePasswordDialog from "./ChangePasswordDialog";
|
import ChangePasswordDialog from "./ChangePasswordDialog";
|
||||||
import ViewDevicesDialog from "./ViewDevicesDialog";
|
import ViewDevicesDialog from "./ViewDevicesDialog";
|
||||||
import SupporterStatus from "./SupporterStatus";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import LocaleSwitcher from "@app/components/LocaleSwitcher";
|
import LocaleSwitcher from "@app/components/LocaleSwitcher";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|||||||
@@ -90,9 +90,8 @@ export type ResourceRow = {
|
|||||||
name: string;
|
name: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
mode: string | null;
|
||||||
authState: string;
|
authState: string;
|
||||||
http: boolean;
|
|
||||||
protocol: string;
|
|
||||||
proxyPort: number | null;
|
proxyPort: number | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
domainId?: string;
|
domainId?: string;
|
||||||
@@ -366,11 +365,11 @@ export default function ProxyResourcesTable({
|
|||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{resourceRow.http
|
{resourceRow.mode == "http"
|
||||||
? resourceRow.ssl
|
? resourceRow.ssl
|
||||||
? "HTTPS"
|
? "HTTPS"
|
||||||
: "HTTP"
|
: "HTTP"
|
||||||
: resourceRow.protocol.toUpperCase()}
|
: resourceRow.mode?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -413,6 +412,9 @@ export default function ProxyResourcesTable({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
|
if (resourceRow.mode !== "http") {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<TargetStatusCell
|
<TargetStatusCell
|
||||||
targets={resourceRow.targets}
|
targets={resourceRow.targets}
|
||||||
@@ -441,6 +443,9 @@ export default function ProxyResourcesTable({
|
|||||||
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
|
if (resourceRow.mode !== "http") {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
|
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
|
||||||
);
|
);
|
||||||
@@ -453,7 +458,11 @@ export default function ProxyResourcesTable({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
|
|
||||||
if (!resourceRow.http) {
|
if (
|
||||||
|
!["http", "ssh", "rdp", "vnc"].includes(
|
||||||
|
resourceRow.mode || ""
|
||||||
|
)
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
@@ -941,7 +950,7 @@ function ResourceEnabledForm({
|
|||||||
resource,
|
resource,
|
||||||
onToggleResourceEnabled
|
onToggleResourceEnabled
|
||||||
}: ResourceEnabledFormProps) {
|
}: ResourceEnabledFormProps) {
|
||||||
const enabled = resource.http
|
const enabled = ["http", "ssh", "rdp", "vnc"].includes(resource.mode || "")
|
||||||
? !!resource.domainId && resource.enabled
|
? !!resource.domainId && resource.enabled
|
||||||
: resource.enabled;
|
: resource.enabled;
|
||||||
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
|
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
|
||||||
@@ -959,7 +968,10 @@ function ResourceEnabledForm({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={optimisticEnabled}
|
checked={optimisticEnabled}
|
||||||
disabled={
|
disabled={
|
||||||
(resource.http && !resource.domainId) ||
|
(["http", "ssh", "rdp", "vnc"].includes(
|
||||||
|
resource.mode || ""
|
||||||
|
) &&
|
||||||
|
!resource.domainId) ||
|
||||||
optimisticEnabled !== enabled
|
optimisticEnabled !== enabled
|
||||||
}
|
}
|
||||||
name="enabled"
|
name="enabled"
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||||||
import {
|
import {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
Eye,
|
|
||||||
EyeOff,
|
EyeOff,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle
|
||||||
Clock
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
@@ -32,12 +30,43 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
|
|
||||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||||
|
|
||||||
|
const showCertificate = !!(
|
||||||
|
["http", "ssh", "rdp", "vnc"].includes(resource.mode) &&
|
||||||
|
resource.domainId &&
|
||||||
|
resource.fullDomain &&
|
||||||
|
build != "oss"
|
||||||
|
);
|
||||||
|
const showType = !!(
|
||||||
|
["http", "ssh", "rdp", "vnc"].includes(resource.mode) && resource.mode
|
||||||
|
);
|
||||||
|
const showHealth =
|
||||||
|
!["ssh", "rdp", "vnc"].includes(resource.mode || "") &&
|
||||||
|
!!resource.health &&
|
||||||
|
resource.health !== "unknown";
|
||||||
|
const showVisibility = !resource.enabled;
|
||||||
|
|
||||||
|
const numSections = [
|
||||||
|
true, // URL or Protocol
|
||||||
|
true, // Authentication or Port
|
||||||
|
showType,
|
||||||
|
showCertificate,
|
||||||
|
showHealth,
|
||||||
|
showVisibility
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{/* 4 cols because of the certs */}
|
<InfoSections cols={numSections}>
|
||||||
<InfoSections cols={resource.http && build != "oss" ? 5 : 4}>
|
{/* <InfoSection>
|
||||||
{resource.http ? (
|
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<span className="inline-flex items-center">
|
||||||
|
{resource.niceId}
|
||||||
|
</span>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection> */}
|
||||||
|
{["http", "ssh", "rdp", "vnc"].includes(resource.mode) ? (
|
||||||
<>
|
<>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||||
@@ -54,6 +83,18 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
|
{showType && (
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("type")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<span className="inline-flex items-center">
|
||||||
|
{resource.mode!.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
)}
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("authentication")}
|
{t("authentication")}
|
||||||
@@ -76,24 +117,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
{/* {isEnabled && (
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>Socket</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{isAvailable ? (
|
|
||||||
<span className="flex items-center space-x-2">
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
||||||
<span>Online</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center space-x-2">
|
|
||||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
|
||||||
<span>Offline</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
)} */}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -103,7 +126,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<span className="inline-flex items-center">
|
<span className="inline-flex items-center">
|
||||||
{resource.protocol.toUpperCase()}
|
{resource.mode?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
@@ -141,74 +164,69 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
{/* </InfoSectionContent> */}
|
{/* </InfoSectionContent> */}
|
||||||
{/* </InfoSection> */}
|
{/* </InfoSection> */}
|
||||||
{/* Certificate Status Column */}
|
{/* Certificate Status Column */}
|
||||||
{resource.http &&
|
{showCertificate && (
|
||||||
resource.domainId &&
|
<InfoSection>
|
||||||
resource.fullDomain &&
|
<InfoSectionTitle>
|
||||||
build != "oss" && (
|
{t("certificateStatus", {
|
||||||
<InfoSection>
|
defaultValue: "Certificate"
|
||||||
<InfoSectionTitle>
|
})}
|
||||||
{t("certificateStatus", {
|
</InfoSectionTitle>
|
||||||
defaultValue: "Certificate"
|
<InfoSectionContent>
|
||||||
})}
|
<CertificateStatus
|
||||||
</InfoSectionTitle>
|
orgId={resource.orgId}
|
||||||
<InfoSectionContent>
|
domainId={resource.domainId!}
|
||||||
<CertificateStatus
|
fullDomain={resource.fullDomain!}
|
||||||
orgId={resource.orgId}
|
autoFetch={true}
|
||||||
domainId={resource.domainId}
|
showLabel={false}
|
||||||
fullDomain={resource.fullDomain}
|
polling={true}
|
||||||
autoFetch={true}
|
/>
|
||||||
showLabel={false}
|
</InfoSectionContent>
|
||||||
polling={true}
|
</InfoSection>
|
||||||
/>
|
)}
|
||||||
</InfoSectionContent>
|
{showHealth && (
|
||||||
</InfoSection>
|
<InfoSection>
|
||||||
)}
|
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
||||||
<InfoSection>
|
<InfoSectionContent>
|
||||||
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
{resource.health === "healthy" && (
|
||||||
<InfoSectionContent>
|
<div className="flex items-center space-x-2">
|
||||||
{resource.health === "healthy" && (
|
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||||
<div className="flex items-center space-x-2">
|
<span>
|
||||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
|
{t("resourcesTableHealthy")}
|
||||||
<span>{t("resourcesTableHealthy")}</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{resource.health === "degraded" && (
|
{resource.health === "degraded" && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
|
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
|
||||||
<span>{t("resourcesTableDegraded")}</span>
|
<span>
|
||||||
</div>
|
{t("resourcesTableDegraded")}
|
||||||
)}
|
</span>
|
||||||
{resource.health === "unhealthy" && (
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
)}
|
||||||
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
|
{resource.health === "unhealthy" && (
|
||||||
<span>{t("resourcesTableUnhealthy")}</span>
|
<div className="flex items-center space-x-2">
|
||||||
</div>
|
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
|
||||||
)}
|
<span>
|
||||||
{(!resource.health ||
|
{t("resourcesTableUnhealthy")}
|
||||||
resource.health === "unknown") && (
|
</span>
|
||||||
<div className="flex items-center space-x-2">
|
</div>
|
||||||
<Clock className="w-4 h-4 flex-shrink-0" />
|
)}
|
||||||
<span>{t("resourcesTableUnknown")}</span>
|
</InfoSectionContent>
|
||||||
</div>
|
</InfoSection>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
{showVisibility && (
|
||||||
</InfoSection>
|
<InfoSection>
|
||||||
<InfoSection>
|
<InfoSectionTitle>
|
||||||
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
|
{t("visibility")}
|
||||||
<InfoSectionContent>
|
</InfoSectionTitle>
|
||||||
{resource.enabled ? (
|
<InfoSectionContent>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Eye className="w-4 h-4 flex-shrink-0 text-green-500" />
|
|
||||||
<span>{t("enabled")}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
|
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
|
||||||
<span>{t("disabled")}</span>
|
<span>{t("disabled")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</InfoSectionContent>
|
||||||
</InfoSectionContent>
|
</InfoSection>
|
||||||
</InfoSection>
|
)}
|
||||||
</InfoSections>
|
</InfoSections>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -22,13 +22,24 @@ export function SettingsSectionHeader({
|
|||||||
|
|
||||||
export function SettingsSectionForm({
|
export function SettingsSectionForm({
|
||||||
children,
|
children,
|
||||||
className
|
className,
|
||||||
|
variant = "compact"
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
variant?: "half" | "compact";
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("max-w-xl space-y-4", className)}>{children}</div>
|
<div
|
||||||
|
className={cn(
|
||||||
|
variant === "half"
|
||||||
|
? "max-w-3xl space-y-4"
|
||||||
|
: "max-w-xl space-y-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ function isSafeUrlForLink(href: string): boolean {
|
|||||||
const OVERVIEW_META_CLASS = "w-full min-w-0 text-muted-foreground text-sm";
|
const OVERVIEW_META_CLASS = "w-full min-w-0 text-muted-foreground text-sm";
|
||||||
|
|
||||||
function publicProtocolLabel(r: PublicResourceRow): string {
|
function publicProtocolLabel(r: PublicResourceRow): string {
|
||||||
if (r.http) {
|
if (r.mode == "http") {
|
||||||
return r.ssl ? "HTTPS" : "HTTP";
|
return r.ssl ? "HTTPS" : "HTTP";
|
||||||
}
|
}
|
||||||
const p = (r.protocol || "").toLowerCase();
|
const p = (r.mode || "").toLowerCase();
|
||||||
if (p === "tcp") return "TCP";
|
if (p === "tcp") return "TCP";
|
||||||
if (p === "udp") return "UDP";
|
if (p === "udp") return "UDP";
|
||||||
return (r.protocol || "—").toUpperCase();
|
return (r.mode || "—").toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function PublicResourceMeta({ resource: r }: { resource: PublicResourceRow }) {
|
function PublicResourceMeta({ resource: r }: { resource: PublicResourceRow }) {
|
||||||
@@ -68,12 +68,13 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
|
|||||||
const modeLabel: Record<SiteResourceRow["mode"], string> = {
|
const modeLabel: Record<SiteResourceRow["mode"], string> = {
|
||||||
host: t("editInternalResourceDialogModeHost"),
|
host: t("editInternalResourceDialogModeHost"),
|
||||||
cidr: t("editInternalResourceDialogModeCidr"),
|
cidr: t("editInternalResourceDialogModeCidr"),
|
||||||
http: t("editInternalResourceDialogModeHttp")
|
http: t("editInternalResourceDialogModeHttp"),
|
||||||
|
ssh: t("editInternalResourceDialogModeSsh")
|
||||||
};
|
};
|
||||||
const dest = formatSiteResourceDestinationDisplay({
|
const dest = formatSiteResourceDestinationDisplay({
|
||||||
mode: row.mode,
|
mode: row.mode,
|
||||||
destination: row.destination,
|
destination: row.destination,
|
||||||
httpHttpsPort: row.destinationPort ?? null,
|
destinationPort: row.destinationPort ?? null,
|
||||||
scheme: row.scheme
|
scheme: row.scheme
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
@@ -90,7 +91,7 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
|
|||||||
|
|
||||||
function PublicAccessMethod({ resource: r }: { resource: PublicResourceRow }) {
|
function PublicAccessMethod({ resource: r }: { resource: PublicResourceRow }) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
if (!r.http) {
|
if (!["http", "ssh", "rdp", "vnc"].includes(r.mode || "")) {
|
||||||
return (
|
return (
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={r.proxyPort?.toString() ?? ""}
|
text={r.proxyPort?.toString() ?? ""}
|
||||||
@@ -149,7 +150,7 @@ function PrivateAccessMethod({ row }: { row: SiteResourceRow }) {
|
|||||||
const dest = formatSiteResourceDestinationDisplay({
|
const dest = formatSiteResourceDestinationDisplay({
|
||||||
mode: row.mode,
|
mode: row.mode,
|
||||||
destination: row.destination,
|
destination: row.destination,
|
||||||
httpHttpsPort: row.destinationPort,
|
destinationPort: row.destinationPort,
|
||||||
scheme: row.scheme
|
scheme: row.scheme
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user