mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-27 18:49:51 +00:00
Compare commits
122 Commits
fix-logoUr
...
rdp-ssh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
464d4990df | ||
|
|
cb90672573 | ||
|
|
e19b6ebc82 | ||
|
|
5a6de12f74 | ||
|
|
6e6c91a27c | ||
|
|
aa7004b2ff | ||
|
|
eca87b66f0 | ||
|
|
cc8c89eeae | ||
|
|
6ea4aa1920 | ||
|
|
f12451b8f9 | ||
|
|
0d4bb65a92 | ||
|
|
d47ad9ac40 | ||
|
|
f81ae24ba7 | ||
|
|
df1e28aabd | ||
|
|
4c1e1daf07 | ||
|
|
7c54df7ed1 | ||
|
|
9d77fcc457 | ||
|
|
454449ec8a | ||
|
|
fe67e8e384 | ||
|
|
715b957660 | ||
|
|
f1e4bf8d36 | ||
|
|
23ca3efbf4 | ||
|
|
0f9100fd3a | ||
|
|
c47c411161 | ||
|
|
e88e262abe | ||
|
|
832d45e32b | ||
|
|
69e3ac3cd4 | ||
|
|
50865f4265 | ||
|
|
0d1a8d9695 | ||
|
|
5d8486dd7f | ||
|
|
3c25932787 | ||
|
|
1d0e1eb126 | ||
|
|
57c0dc8618 | ||
|
|
526a147570 | ||
|
|
0938997548 | ||
|
|
0876b482f8 | ||
|
|
d558c31f88 | ||
|
|
6010515da0 | ||
|
|
868bcd8e34 | ||
|
|
20c4904965 | ||
|
|
5a5536b38c | ||
|
|
53e2296de8 | ||
|
|
d2423919e9 | ||
|
|
2250fcd177 | ||
|
|
2a33256d17 | ||
|
|
117aa750f8 | ||
|
|
15f161274f | ||
|
|
09779aca3e | ||
|
|
1d1f7cecf4 | ||
|
|
dc00668cbe | ||
|
|
57701e13eb | ||
|
|
46545cb003 | ||
|
|
6cacc9b83f | ||
|
|
1f1791feb7 | ||
|
|
2d9c082607 | ||
|
|
7968c4357b | ||
|
|
25c08e7279 | ||
|
|
987b5d580e | ||
|
|
cb75ffc3b7 | ||
|
|
540f0a754d | ||
|
|
0f9a6fd968 | ||
|
|
82112abc34 | ||
|
|
75b5afd544 | ||
|
|
00e1675f7b | ||
|
|
2ddbdf977b | ||
|
|
4c8f0cc9ec | ||
|
|
e822b681cd | ||
|
|
68d7b0a416 | ||
|
|
43546c84eb | ||
|
|
eac36ee442 | ||
|
|
9a88394efe | ||
|
|
173562654b | ||
|
|
e1583a58aa | ||
|
|
8f7e5ab1ed | ||
|
|
4334480675 | ||
|
|
6aa406927a | ||
|
|
5b50024712 | ||
|
|
7d922ac95f | ||
|
|
795a3d351e | ||
|
|
4b4c86b4b7 | ||
|
|
013af49137 | ||
|
|
a6ae9290f2 | ||
|
|
de70d72e0d | ||
|
|
4e07e9c52c | ||
|
|
743621eb25 | ||
|
|
943923ff4b | ||
|
|
3f17f1a468 | ||
|
|
436996a43d | ||
|
|
d42b6076d2 | ||
|
|
89cc99f915 | ||
|
|
ce746a2a21 | ||
|
|
7120ab4b22 | ||
|
|
12e777b32e | ||
|
|
9378103ddd | ||
|
|
ec794d5de2 | ||
|
|
12b18a3e8c | ||
|
|
91e8a13e59 | ||
|
|
931ba0f540 | ||
|
|
d321d7275c | ||
|
|
3855486a00 | ||
|
|
ab494521b1 | ||
|
|
549e1ead1d | ||
|
|
a0759a79a1 | ||
|
|
14e1a119d3 | ||
|
|
6e066d38b0 | ||
|
|
21f72639b6 | ||
|
|
8a0c2031d4 | ||
|
|
56d3a466e5 | ||
|
|
563e505cc1 | ||
|
|
c44c02b8ba | ||
|
|
b9ab35a05b | ||
|
|
2fd519e102 | ||
|
|
a63c1ec364 | ||
|
|
e61ef2ca2a | ||
|
|
39b09b7f3f | ||
|
|
840cc214e3 | ||
|
|
72524db52d | ||
|
|
ab8fc11ab3 | ||
|
|
1831ca4e75 | ||
|
|
0d04cc365f | ||
|
|
09baf2f32e | ||
|
|
3253d60900 |
110
cloud
Normal file
110
cloud
Normal file
@@ -0,0 +1,110 @@
|
||||
git push origin -d 1.11.0-s.0
|
||||
git push origin -d 1.11.0-s.1
|
||||
git push origin -d 1.11.0-s.2
|
||||
git push origin -d 1.11.0-s.3
|
||||
git push origin -d 1.11.0-s.4
|
||||
git push origin -d 1.11.0-s.5
|
||||
git push origin -d 1.11.1-s.0
|
||||
git push origin -d 1.12.0-s.0
|
||||
git push origin -d 1.12.2-s.0
|
||||
git push origin -d 1.12.2-s.1
|
||||
git push origin -d 1.12.2-s.2
|
||||
git push origin -d 1.12.2-s.3
|
||||
git push origin -d 1.12.2-s.4
|
||||
git push origin -d 1.12.2-s.5
|
||||
git push origin -d 1.13.0.s.0
|
||||
git push origin -d 1.13.1-s.0
|
||||
git push origin -d 1.14.0-s.2
|
||||
git push origin -d 1.14.1-s.0
|
||||
git push origin -d 1.14.1-s.1
|
||||
git push origin -d 1.14.1-s.2
|
||||
git push origin -d 1.14.1-s.3
|
||||
git push origin -d 1.15.0-s.0
|
||||
git push origin -d 1.15.0-s.1
|
||||
git push origin -d 1.15.0-s.2
|
||||
git push origin -d 1.15.0-s.3
|
||||
git push origin -d 1.15.0-s.4
|
||||
git push origin -d 1.15.0-s.5
|
||||
git push origin -d 1.15.1-s.0
|
||||
git push origin -d 1.15.1-s.1
|
||||
git push origin -d 1.15.3-s.0
|
||||
git push origin -d 1.15.3-s.1
|
||||
git push origin -d 1.15.4-s.0
|
||||
git push origin -d 1.15.4-s.1
|
||||
git push origin -d 1.15.4-s.10
|
||||
git push origin -d 1.15.4-s.2
|
||||
git push origin -d 1.15.4-s.3
|
||||
git push origin -d 1.15.4-s.4
|
||||
git push origin -d 1.15.4-s.5
|
||||
git push origin -d 1.15.4-s.6
|
||||
git push origin -d 1.15.4-s.7
|
||||
git push origin -d 1.15.4-s.8
|
||||
git push origin -d 1.15.4-s.9
|
||||
git push origin -d 1.16.0-s.0
|
||||
git push origin -d 1.16.0-s.1
|
||||
git push origin -d 1.16.1-s.0
|
||||
git push origin -d 1.16.1-s.1
|
||||
git push origin -d 1.16.2-s.0
|
||||
git push origin -d 1.16.2-s.1
|
||||
git push origin -d 1.16.2-s.10
|
||||
git push origin -d 1.16.2-s.11
|
||||
git push origin -d 1.16.2-s.12
|
||||
git push origin -d 1.16.2-s.13
|
||||
git push origin -d 1.16.2-s.14
|
||||
git push origin -d 1.16.2-s.15
|
||||
git push origin -d 1.16.2-s.16
|
||||
git push origin -d 1.16.2-s.17
|
||||
git push origin -d 1.16.2-s.18
|
||||
git push origin -d 1.16.2-s.19
|
||||
git push origin -d 1.16.2-s.2
|
||||
git push origin -d 1.16.2-s.20
|
||||
git push origin -d 1.16.2-s.21
|
||||
git push origin -d 1.16.2-s.22
|
||||
git push origin -d 1.16.2-s.3
|
||||
git push origin -d 1.16.2-s.4
|
||||
git push origin -d 1.16.2-s.5
|
||||
git push origin -d 1.16.2-s.6
|
||||
git push origin -d 1.16.2-s.7
|
||||
git push origin -d 1.16.2-s.8
|
||||
git push origin -d 1.16.2-s.9
|
||||
git push origin -d 1.17.0-s.0
|
||||
git push origin -d 1.17.0-s.1
|
||||
git push origin -d 1.17.0-s.2
|
||||
git push origin -d 1.17.0-s.3
|
||||
git push origin -d 1.17.0-s.4
|
||||
git push origin -d 1.17.1-s.0
|
||||
git push origin -d 1.17.1-s.1
|
||||
git push origin -d 1.17.1-s.2
|
||||
git push origin -d 1.17.1-s.3
|
||||
git push origin -d 1.17.1-s.4
|
||||
git push origin -d 1.17.1-s.5
|
||||
git push origin -d 1.17.1-s.6
|
||||
git push origin -d 1.17.1-s.7
|
||||
git push origin -d 1.18.0-s.0
|
||||
git push origin -d 1.18.0-s.1
|
||||
git push origin -d 1.18.0-s.2
|
||||
git push origin -d 1.18.1-s.0
|
||||
git push origin -d 1.18.1-s.1
|
||||
git push origin -d 1.18.1-s.2
|
||||
git push origin -d 1.18.1-s.3
|
||||
git push origin -d 1.18.1-s.4
|
||||
git push origin -d 1.18.1-s.5
|
||||
git push origin -d 1.18.1-s.6
|
||||
git push origin -d 1.18.1-s.7
|
||||
git push origin -d 1.18.2-s.0
|
||||
git push origin -d 1.18.2-s.1
|
||||
git push origin -d 1.18.2-s.2
|
||||
git push origin -d 1.18.2-s.3
|
||||
git push origin -d 1.18.2-s.4
|
||||
git push origin -d 1.18.2-s.5
|
||||
git push origin -d 1.18.3-s.0
|
||||
git push origin -d 1.18.3-s.1
|
||||
git push origin -d 1.18.3-s.2
|
||||
git push origin -d 1.18.3-s.3
|
||||
git push origin -d 1.18.4-s.0
|
||||
git push origin -d 1.18.4-s.1
|
||||
git push origin -d 1.18.4-s.2
|
||||
git push origin -d 1.18.4-s.3
|
||||
git push origin -d 1.18.4-s.4
|
||||
git push origin -d 1.18.4-s.5
|
||||
git push origin -d 1.18.4-s.6
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Метод",
|
||||
"editInternalResourceDialogEnableSsl": "Активирайте TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Метод",
|
||||
"createInternalResourceDialogScheme": "Метод",
|
||||
"createInternalResourceDialogEnableSsl": "Активирайте TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Schéma",
|
||||
"editInternalResourceDialogEnableSsl": "Povolit SSL",
|
||||
"editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Schéma",
|
||||
"createInternalResourceDialogScheme": "Schéma",
|
||||
"createInternalResourceDialogEnableSsl": "Povolit SSL",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Schema",
|
||||
"editInternalResourceDialogEnableSsl": "TLS aktivieren",
|
||||
"editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Schema",
|
||||
"createInternalResourceDialogScheme": "Schema",
|
||||
"createInternalResourceDialogEnableSsl": "TLS aktivieren",
|
||||
|
||||
@@ -220,8 +220,9 @@
|
||||
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||
"resourceCreate": "Create Resource",
|
||||
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
||||
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
|
||||
"resourceSeeAll": "See All Resources",
|
||||
"resourceInfo": "Resource Information",
|
||||
"resourceCreateGeneral": "General",
|
||||
"resourceNameDescription": "This is the display name for the resource.",
|
||||
"siteSelect": "Select site",
|
||||
"siteSearch": "Search site",
|
||||
@@ -231,12 +232,15 @@
|
||||
"noCountryFound": "No country found.",
|
||||
"siteSelectionDescription": "This site will provide connectivity to the target.",
|
||||
"resourceType": "Resource Type",
|
||||
"resourceTypeDescription": "Determine how to access the resource",
|
||||
"resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This can’t be changed later.",
|
||||
"resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
|
||||
"resourceHTTPSSettings": "HTTPS Settings",
|
||||
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
|
||||
"resourcePortDescription": "The external port on the Pangolin instance or node where the resource will be accessible.",
|
||||
"domainType": "Domain Type",
|
||||
"subdomain": "Subdomain",
|
||||
"baseDomain": "Base Domain",
|
||||
"configure": "Configure",
|
||||
"subdomnainDescription": "The subdomain where the resource will be accessible.",
|
||||
"resourceRawSettings": "TCP/UDP Settings",
|
||||
"resourceRawSettingsDescription": "Configure how the resource will be accessed over TCP/UDP",
|
||||
@@ -255,6 +259,23 @@
|
||||
"resourceGoTo": "Go to Resource",
|
||||
"resourceDelete": "Delete Resource",
|
||||
"resourceDeleteConfirm": "Confirm Delete Resource",
|
||||
"labelDelete": "Delete Label",
|
||||
"labelAdd": "Add Label",
|
||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
||||
"labelNameField": "Label Name",
|
||||
"labelColorField": "Label Color",
|
||||
"labelPlaceholder": "Ex: homelab",
|
||||
"labelCreate": "Create Label",
|
||||
"createLabelDialogTitle": "Create Label",
|
||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
||||
"labelEdit": "Edit Label",
|
||||
"editLabelDialogTitle": "Update Label",
|
||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
||||
"labelDeleteConfirm": "Confirm Delete Label",
|
||||
"labelErrorDelete": "Failed to delete label",
|
||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
||||
"visibility": "Visibility",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
@@ -1140,6 +1161,15 @@
|
||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||
"idpErrorNotFound": "IdP not found",
|
||||
"inviteInvalid": "Invalid Invite",
|
||||
"labels": "Labels",
|
||||
"orgLabelsDescription": "Manage labels in this organization.",
|
||||
"addLabels": "Add labels",
|
||||
"siteLabelsTab": "Labels",
|
||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||
"labelsNotFound": "Labels not found",
|
||||
"labelSearch": "Search labels",
|
||||
"selectColor": "Select color",
|
||||
"createNewLabel": "Create new org label \"{label}\"",
|
||||
"inviteInvalidDescription": "The invite link is invalid.",
|
||||
"inviteErrorWrongUser": "Invite is not for this user",
|
||||
"inviteErrorUserNotExists": "User does not exist. Please create an account first.",
|
||||
@@ -1846,6 +1876,7 @@
|
||||
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
|
||||
"billingCurrentKeys": "Current Keys",
|
||||
"billingModifyCurrentPlan": "Modify Current Plan",
|
||||
"billingManageLicenseSubscriptionDescription": "Manage your subscription for paid self-hosted license keys and download invoices.",
|
||||
"billingConfirmUpgrade": "Confirm Upgrade",
|
||||
"billingConfirmDowngrade": "Confirm Downgrade",
|
||||
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
|
||||
@@ -1943,7 +1974,36 @@
|
||||
"timeIsInSeconds": "Time is in seconds",
|
||||
"requireDeviceApproval": "Require Device Approvals",
|
||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||
"sshAccess": "SSH Access",
|
||||
"sshSettings": "SSH Settings",
|
||||
"rdpSettings": "RDP Settings",
|
||||
"vncSettings": "VNC Settings",
|
||||
"sshServer": "SSH Server",
|
||||
"rdpServer": "RDP Server",
|
||||
"vncServer": "VNC Server",
|
||||
"sshServerDescription": "Set up the authentication method, daemon location, and server destination",
|
||||
"rdpServerDescription": "Configure the destination and port of the RDP server",
|
||||
"vncServerDescription": "Configure the destination and port of the VNC server",
|
||||
"sshServerMode": "Mode",
|
||||
"sshServerModeStandard": "Standard SSH Server",
|
||||
"sshServerModePangolin": "Pangolin SSH",
|
||||
"sshServerModeStandardDescription": "Routes commands over network to an SSH server such as OpenSSH.",
|
||||
"sshServerModeNative": "Native SSH Server",
|
||||
"sshServerModeNativeDescription": "Executes commands directly on the host via the Site Connector. No network config required.",
|
||||
"sshAuthenticationMethod": "Authentication Method",
|
||||
"sshAuthMethodManual": "Manual Authentication",
|
||||
"sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.",
|
||||
"sshAuthMethodAutomated": "Automated Provisioning",
|
||||
"sshAuthMethodAutomatedDescription": "Automatically creates users, groups, and sudo permissions on host.",
|
||||
"sshAuthDaemonLocation": "Auth Daemon Location",
|
||||
"sshDaemonLocationSiteDescription": "Executes locally on the machine hosting the site connector.",
|
||||
"sshDaemonLocationRemote": "On Remote Host",
|
||||
"sshDaemonLocationRemoteDescription": "Executes on a separate target machine on the same network.",
|
||||
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
||||
"sshDaemonPort": "Daemon Port",
|
||||
"sshServerDestination": "Server Destination",
|
||||
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
|
||||
"destination": "Destination",
|
||||
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||
"roleAllowSsh": "Allow SSH",
|
||||
"roleAllowSshAllow": "Allow",
|
||||
"roleAllowSshDisallow": "Disallow",
|
||||
@@ -2049,6 +2109,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Scheme",
|
||||
"editInternalResourceDialogEnableSsl": "Enable TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
|
||||
@@ -2098,6 +2159,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Scheme",
|
||||
"createInternalResourceDialogScheme": "Scheme",
|
||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
||||
@@ -2937,7 +2999,7 @@
|
||||
"learnMore": "Learn more",
|
||||
"backToHome": "Go back to home",
|
||||
"needToSignInToOrg": "Need to use your organization's identity provider?",
|
||||
"maintenanceMode": "Maintenance Mode",
|
||||
"maintenanceMode": "Maintenance Page",
|
||||
"maintenanceModeDescription": "Display a maintenance page to visitors",
|
||||
"maintenanceModeType": "Maintenance Mode Type",
|
||||
"showMaintenancePage": "Show a maintenance page to visitors",
|
||||
@@ -2967,6 +3029,7 @@
|
||||
"maintenanceScreenEstimatedCompletion": "Estimated Completion:",
|
||||
"createInternalResourceDialogDestinationRequired": "Destination is required",
|
||||
"available": "Available",
|
||||
"disabledResourceDescription": "When disabled, the resource will be inaccessible by everyone.",
|
||||
"archived": "Archived",
|
||||
"noArchivedDevices": "No archived devices found",
|
||||
"deviceArchived": "Device archived",
|
||||
@@ -3296,5 +3359,6 @@
|
||||
"memberPortalResourceDisabled": "Resource Disabled",
|
||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||
"memberPortalPrevious": "Previous",
|
||||
"memberPortalNext": "Next"
|
||||
"memberPortalNext": "Next",
|
||||
"httpSettings": "HTTP Settings"
|
||||
}
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Esquema",
|
||||
"editInternalResourceDialogEnableSsl": "Activar TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Esquema",
|
||||
"createInternalResourceDialogScheme": "Esquema",
|
||||
"createInternalResourceDialogEnableSsl": "Activar TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Méthode HTTP",
|
||||
"editInternalResourceDialogEnableSsl": "Activer TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Méthode HTTP",
|
||||
"createInternalResourceDialogScheme": "Méthode HTTP",
|
||||
"createInternalResourceDialogEnableSsl": "Activer TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Metodo HTTP",
|
||||
"editInternalResourceDialogEnableSsl": "Abilitare TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Metodo HTTP",
|
||||
"createInternalResourceDialogScheme": "Metodo HTTP",
|
||||
"createInternalResourceDialogEnableSsl": "Abilitare TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "스킴",
|
||||
"editInternalResourceDialogEnableSsl": "TLS 활성화",
|
||||
"editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "스킴",
|
||||
"createInternalResourceDialogScheme": "스킴",
|
||||
"createInternalResourceDialogEnableSsl": "TLS 활성화",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Skjema",
|
||||
"editInternalResourceDialogEnableSsl": "Aktiver TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Skjema",
|
||||
"createInternalResourceDialogScheme": "Skjema",
|
||||
"createInternalResourceDialogEnableSsl": "Aktiver TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Schema",
|
||||
"editInternalResourceDialogEnableSsl": "TLS inschakelen",
|
||||
"editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Schema",
|
||||
"createInternalResourceDialogScheme": "Schema",
|
||||
"createInternalResourceDialogEnableSsl": "TLS inschakelen",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Schemat",
|
||||
"editInternalResourceDialogEnableSsl": "Włącz TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Schemat",
|
||||
"createInternalResourceDialogScheme": "Schemat",
|
||||
"createInternalResourceDialogEnableSsl": "Włącz TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Esquema",
|
||||
"editInternalResourceDialogEnableSsl": "Ativar TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Esquema",
|
||||
"createInternalResourceDialogScheme": "Esquema",
|
||||
"createInternalResourceDialogEnableSsl": "Ativar TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "СИДР",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Схема",
|
||||
"editInternalResourceDialogEnableSsl": "Включить TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "СИДР",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Схема",
|
||||
"createInternalResourceDialogScheme": "Схема",
|
||||
"createInternalResourceDialogEnableSsl": "Включить TLS",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "Şema",
|
||||
"editInternalResourceDialogEnableSsl": "TLS Etkinleştir",
|
||||
"editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "Şema",
|
||||
"createInternalResourceDialogScheme": "Şema",
|
||||
"createInternalResourceDialogEnableSsl": "TLS'yi Etkinleştir",
|
||||
|
||||
@@ -2049,6 +2049,7 @@
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeSsh": "SSH",
|
||||
"editInternalResourceDialogScheme": "方案",
|
||||
"editInternalResourceDialogEnableSsl": "启用 TLS",
|
||||
"editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
|
||||
@@ -2098,6 +2099,7 @@
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"scheme": "方案",
|
||||
"createInternalResourceDialogScheme": "方案",
|
||||
"createInternalResourceDialogEnableSsl": "启用 TLS",
|
||||
|
||||
@@ -5,6 +5,7 @@ const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false,
|
||||
transpilePackages: ["@novnc/novnc"],
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true
|
||||
},
|
||||
|
||||
102
package-lock.json
generated
102
package-lock.json
generated
@@ -11,11 +11,14 @@
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
||||
"@aws-sdk/client-s3": "3.1011.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",
|
||||
"@faker-js/faker": "10.3.0",
|
||||
"@headlessui/react": "2.2.9",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@node-rs/argon2": "2.0.2",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
@@ -44,6 +47,9 @@
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.15.0",
|
||||
"better-sqlite3": "11.9.1",
|
||||
@@ -1058,7 +1064,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1460,6 +1465,16 @@
|
||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@devolutions/iron-remote-desktop": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA=="
|
||||
},
|
||||
"node_modules/@devolutions/iron-remote-desktop-rdp": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
||||
"integrity": "sha512-O0YVpOJDwUzekH3N2QKj+48WP+56wI0sj4VmaJkGoW5XgyAj2ONn2k3i+vk17Eavx+Vg6vAg3lwYRAOK4kKIDQ=="
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz",
|
||||
@@ -2354,7 +2369,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2377,7 +2391,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2400,7 +2413,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2417,7 +2429,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2434,7 +2445,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2451,7 +2461,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2468,7 +2477,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2485,7 +2493,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2502,7 +2509,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2519,7 +2525,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2536,7 +2541,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2553,7 +2557,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2576,7 +2579,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2599,7 +2601,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2622,7 +2623,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2645,7 +2645,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2668,7 +2667,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2691,7 +2689,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2714,7 +2711,6 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -2734,7 +2730,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2754,7 +2749,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2774,7 +2768,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3034,7 +3027,6 @@
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
@@ -3654,6 +3646,12 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@novnc/novnc": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
|
||||
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
"node_modules/@oslojs/asn1": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
|
||||
@@ -6981,7 +6979,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
||||
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -8442,7 +8439,6 @@
|
||||
"version": "5.90.21",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.20"
|
||||
},
|
||||
@@ -8558,7 +8554,6 @@
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -8906,7 +8901,6 @@
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -9002,7 +8996,6 @@
|
||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -9030,7 +9023,6 @@
|
||||
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -9056,7 +9048,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -9067,7 +9058,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -9154,7 +9144,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
@@ -9228,7 +9219,6 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -9683,6 +9673,27 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -9702,7 +9713,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -10152,7 +10162,6 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@@ -10224,7 +10233,6 @@
|
||||
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@@ -10353,7 +10361,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -11260,7 +11267,6 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -11701,6 +11707,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
@@ -12335,7 +12342,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -12421,7 +12427,6 @@
|
||||
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
@@ -12558,7 +12563,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -12952,7 +12956,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -15370,6 +15373,7 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -15380,6 +15384,7 @@
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
@@ -15468,7 +15473,6 @@
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
|
||||
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.15",
|
||||
"@swc/helpers": "0.5.15",
|
||||
@@ -16428,7 +16432,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
@@ -16936,7 +16939,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -16968,7 +16970,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -17261,7 +17262,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -18723,8 +18723,7 @@
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
@@ -19199,7 +19198,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -19627,7 +19625,6 @@
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.8",
|
||||
@@ -19834,7 +19831,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -34,11 +34,14 @@
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
||||
"@aws-sdk/client-s3": "3.1011.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",
|
||||
"@faker-js/faker": "10.3.0",
|
||||
"@headlessui/react": "2.2.9",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@node-rs/argon2": "2.0.2",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
@@ -67,6 +70,9 @@
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.15.0",
|
||||
"better-sqlite3": "11.9.1",
|
||||
|
||||
@@ -148,11 +148,22 @@ export enum ActionsEnum {
|
||||
updateAlertRule = "updateAlertRule",
|
||||
deleteAlertRule = "deleteAlertRule",
|
||||
listAlertRules = "listAlertRules",
|
||||
listOrgLabels = "listOrgLabels",
|
||||
createOrgLabel = "createOrgLabel",
|
||||
updateOrgLabel = "updateOrgLabel",
|
||||
deleteOrgLabel = "deleteOrgLabel",
|
||||
attachLabelToItem = "attachLabelToItem",
|
||||
detachLabelFromItem = "detachLabelFromItem",
|
||||
getAlertRule = "getAlertRule",
|
||||
createHealthCheck = "createHealthCheck",
|
||||
updateHealthCheck = "updateHealthCheck",
|
||||
deleteHealthCheck = "deleteHealthCheck",
|
||||
listHealthChecks = "listHealthChecks"
|
||||
listHealthChecks = "listHealthChecks",
|
||||
createBrowserGatewayTarget = "createBrowserGatewayTarget",
|
||||
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
|
||||
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
|
||||
getBrowserGatewayTarget = "getBrowserGatewayTarget",
|
||||
listBrowserGatewayTargets = "listBrowserGatewayTargets"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -580,6 +580,24 @@ export const trialNotifications = pgTable("trialNotifications", {
|
||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
|
||||
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
authToken: varchar("authToken").notNull(),
|
||||
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
|
||||
destination: varchar("destination").notNull(),
|
||||
destinationPort: integer("destinationPort").notNull()
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -627,3 +645,6 @@ export type AlertEmailRecipients = InferSelectModel<
|
||||
>;
|
||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type BrowserGatewayTarget = InferSelectModel<
|
||||
typeof browserGatewayTarget
|
||||
>;
|
||||
|
||||
@@ -147,7 +147,6 @@ export const resources = pgTable("resources", {
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||
|
||||
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
@@ -159,9 +158,100 @@ export const resources = pgTable("resources", {
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: boolean("wildcard").notNull().default(false)
|
||||
wildcard: boolean("wildcard").notNull().default(false),
|
||||
browserAccessType: text("browserAccessType").default("http"), // 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", {
|
||||
labelId: serial("labelId").primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
color: varchar("color").notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const siteLabels = pgTable(
|
||||
"siteLabels",
|
||||
{
|
||||
siteLabelId: serial("siteLabelId").primaryKey(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
|
||||
);
|
||||
|
||||
export const resourceLabels = pgTable(
|
||||
"resourceLabels",
|
||||
{
|
||||
resourceLabelId: serial("resourceLabelId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
|
||||
);
|
||||
|
||||
export const siteResourceLabels = pgTable(
|
||||
"siteResourceLabels",
|
||||
{
|
||||
siteResourceLabelId: serial("siteResourceLabelId").primaryKey(),
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.references(() => siteResources.siteResourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
|
||||
);
|
||||
|
||||
export const clientLabels = pgTable(
|
||||
"clientLabels",
|
||||
{
|
||||
clientLabelId: serial("clientLabelId").primaryKey(),
|
||||
clientId: integer("clientId")
|
||||
.references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
||||
);
|
||||
|
||||
export const targets = pgTable("targets", {
|
||||
targetId: serial("targetId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
@@ -196,9 +286,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}).notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
name: varchar("name"),
|
||||
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
||||
hcPath: varchar("hcPath"),
|
||||
@@ -254,11 +346,11 @@ export const siteResources = pgTable("siteResources", {
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||
mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
|
||||
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
||||
destination: varchar("destination"), // ip, cidr, hostname; validate against the mode
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
alias: varchar("alias"),
|
||||
aliasAddress: varchar("aliasAddress"),
|
||||
@@ -266,8 +358,11 @@ export const siteResources = pgTable("siteResources", {
|
||||
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
|
||||
disableIcmp: boolean("disableIcmp").notNull().default(false),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
pamMode: varchar("pamMode", { length: 32 })
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$type<"site" | "remote">()
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
@@ -1097,19 +1192,30 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
|
||||
complete: boolean("complete").notNull().default(false)
|
||||
});
|
||||
|
||||
export const statusHistory = pgTable("statusHistory", {
|
||||
id: serial("id").primaryKey(),
|
||||
entityType: varchar("entityType").notNull(),
|
||||
entityId: integer("entityId").notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: varchar("status").notNull(),
|
||||
timestamp: integer("timestamp").notNull(),
|
||||
}, (table) => [
|
||||
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||
]);
|
||||
export const statusHistory = pgTable(
|
||||
"statusHistory",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
entityType: varchar("entityType").notNull(),
|
||||
entityId: integer("entityId").notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: varchar("status").notNull(),
|
||||
timestamp: integer("timestamp").notNull()
|
||||
},
|
||||
(table) => [
|
||||
index("idx_statusHistory_entity").on(
|
||||
table.entityType,
|
||||
table.entityId,
|
||||
table.timestamp
|
||||
),
|
||||
index("idx_statusHistory_org_timestamp").on(
|
||||
table.orgId,
|
||||
table.timestamp
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
@@ -1179,3 +1285,4 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
>;
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type Label = InferSelectModel<typeof labels>;
|
||||
|
||||
@@ -588,6 +588,26 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
||||
sentAt: integer("sentAt").notNull()
|
||||
});
|
||||
|
||||
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
|
||||
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
authToken: text("authToken").notNull(),
|
||||
type: text("type").notNull(), // "ssh", "rdp", "vnc"
|
||||
destination: text("destination").notNull(),
|
||||
destinationPort: integer("destinationPort").notNull()
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -627,3 +647,6 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type BrowserGatewayTarget = InferSelectModel<
|
||||
typeof browserGatewayTarget
|
||||
>;
|
||||
|
||||
@@ -180,9 +180,106 @@ export const resources = sqliteTable("resources", {
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||
browserAccessType: text("browserAccessType").default("http"), // 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", {
|
||||
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
color: text("color").notNull(),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const siteLabels = sqliteTable(
|
||||
"siteLabels",
|
||||
{
|
||||
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
|
||||
);
|
||||
|
||||
export const resourceLabels = sqliteTable(
|
||||
"resourceLabels",
|
||||
{
|
||||
resourceLabelId: integer("resourceLabelId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
|
||||
);
|
||||
|
||||
export const siteResourceLabels = sqliteTable(
|
||||
"siteResourceLabels",
|
||||
{
|
||||
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
siteResourceId: integer("siteResourceId")
|
||||
.references(() => siteResources.siteResourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
|
||||
);
|
||||
|
||||
export const clientLabels = sqliteTable(
|
||||
"clientLabels",
|
||||
{
|
||||
clientLabelId: integer("clientLabelId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
clientId: integer("clientId")
|
||||
.references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
labelId: integer("labelId")
|
||||
.references(() => labels.labelId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
},
|
||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
||||
);
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||
resourceId: integer("resourceId")
|
||||
@@ -219,9 +316,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}).notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
name: text("name"),
|
||||
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
@@ -281,11 +380,11 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
|
||||
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
destination: text("destination").notNull(), // ip, cidr, hostname
|
||||
destination: text("destination"), // ip, cidr, hostname
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
alias: text("alias"),
|
||||
aliasAddress: text("aliasAddress"),
|
||||
@@ -295,8 +394,11 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
.notNull()
|
||||
.default(false),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
pamMode: text("pamMode")
|
||||
.$type<"passthrough" | "push">()
|
||||
.default("passthrough"),
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote">()
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
@@ -1196,19 +1298,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
||||
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export const statusHistory = sqliteTable("statusHistory", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||
timestamp: integer("timestamp").notNull(), // unix epoch seconds
|
||||
}, (table) => [
|
||||
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||
]);
|
||||
export const statusHistory = sqliteTable(
|
||||
"statusHistory",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||
timestamp: integer("timestamp").notNull() // unix epoch seconds
|
||||
},
|
||||
(table) => [
|
||||
index("idx_statusHistory_entity").on(
|
||||
table.entityType,
|
||||
table.entityId,
|
||||
table.timestamp
|
||||
),
|
||||
index("idx_statusHistory_org_timestamp").on(
|
||||
table.orgId,
|
||||
table.timestamp
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
@@ -1278,3 +1391,4 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type Label = InferSelectModel<typeof labels>;
|
||||
|
||||
@@ -24,10 +24,12 @@ export enum TierFeature {
|
||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||
AlertingRules = "alertingRules",
|
||||
WildcardSubdomain = "wildcardSubdomain"
|
||||
WildcardSubdomain = "wildcardSubdomain",
|
||||
Labels = "labels"
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||
|
||||
@@ -475,6 +475,8 @@ export function generateRemoteSubnets(
|
||||
): string[] {
|
||||
const remoteSubnets = allSiteResources
|
||||
.filter((sr) => {
|
||||
if (!sr.destination) return false;
|
||||
|
||||
if (sr.mode === "cidr") {
|
||||
// check if its a valid CIDR using zod
|
||||
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
|
||||
@@ -496,7 +498,7 @@ export function generateRemoteSubnets(
|
||||
}
|
||||
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
||||
})
|
||||
.filter((subnet) => subnet !== ""); // Remove empty strings just to be safe
|
||||
.filter((subnet): subnet is string => subnet !== "" && subnet !== null); // Remove invalid values just to be safe
|
||||
// remove duplicates
|
||||
return Array.from(new Set(remoteSubnets));
|
||||
}
|
||||
@@ -581,7 +583,7 @@ export function generateSubnetProxyTargets(
|
||||
targets.push({
|
||||
sourcePrefix: clientPrefix,
|
||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||
rewriteTo: destination,
|
||||
rewriteTo: destination!,
|
||||
portRange,
|
||||
disableIcmp
|
||||
});
|
||||
@@ -589,7 +591,7 @@ export function generateSubnetProxyTargets(
|
||||
} else if (siteResource.mode == "cidr") {
|
||||
targets.push({
|
||||
sourcePrefix: clientPrefix,
|
||||
destPrefix: siteResource.destination,
|
||||
destPrefix: siteResource.destination!,
|
||||
portRange,
|
||||
disableIcmp
|
||||
});
|
||||
@@ -671,7 +673,7 @@ export async function generateSubnetProxyTargetV2(
|
||||
targets.push({
|
||||
sourcePrefixes: [],
|
||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||
rewriteTo: destination,
|
||||
rewriteTo: destination!,
|
||||
portRange,
|
||||
disableIcmp,
|
||||
resourceId: siteResource.siteResourceId
|
||||
@@ -680,7 +682,7 @@ export async function generateSubnetProxyTargetV2(
|
||||
} else if (siteResource.mode == "cidr") {
|
||||
targets.push({
|
||||
sourcePrefixes: [],
|
||||
destPrefix: siteResource.destination,
|
||||
destPrefix: siteResource.destination!,
|
||||
portRange,
|
||||
disableIcmp,
|
||||
resourceId: siteResource.siteResourceId
|
||||
@@ -738,7 +740,7 @@ export async function generateSubnetProxyTargetV2(
|
||||
protocol: siteResource.ssl ? "https" : "http",
|
||||
httpTargets: [
|
||||
{
|
||||
destAddr: siteResource.destination,
|
||||
destAddr: siteResource.destination!,
|
||||
destPort: siteResource.destinationPort,
|
||||
scheme: siteResource.scheme
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
userOrgRoles,
|
||||
userSiteResources
|
||||
} from "@server/db";
|
||||
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||
|
||||
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
||||
import {
|
||||
@@ -39,11 +39,6 @@ import {
|
||||
removePeerData,
|
||||
removeTargets as removeSubnetProxyTargets
|
||||
} from "@server/routers/client/targets";
|
||||
import { lockManager } from "#dynamic/lib/lock";
|
||||
|
||||
// TTL for rebuild-association locks. These functions can fan out into many
|
||||
// peer/proxy updates, so give them a generous window.
|
||||
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
|
||||
|
||||
export async function getClientSiteResourceAccess(
|
||||
siteResource: SiteResource,
|
||||
@@ -166,23 +161,6 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
pubKey: string | null;
|
||||
subnet: string | null;
|
||||
}[];
|
||||
}> {
|
||||
return await lockManager.withLock(
|
||||
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
||||
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
|
||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||
);
|
||||
}
|
||||
|
||||
async function rebuildClientAssociationsFromSiteResourceImpl(
|
||||
siteResource: SiteResource,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<{
|
||||
mergedAllClients: {
|
||||
clientId: number;
|
||||
pubKey: string | null;
|
||||
subnet: string | null;
|
||||
}[];
|
||||
}> {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
||||
@@ -561,29 +539,6 @@ async function handleMessagesForSiteClients(
|
||||
}
|
||||
}
|
||||
|
||||
// get the number of sites on each of these clients so we can log it and make decisions about whether to send messages based on it
|
||||
const clientSiteCounts: Record<number, number> = {};
|
||||
if (clientsToProcess.size > 0) {
|
||||
const clientIdsToProcess = Array.from(clientsToProcess.keys());
|
||||
const siteCounts = await trx
|
||||
.select({
|
||||
clientId: clientSitesAssociationsCache.clientId,
|
||||
siteCount: count(clientSitesAssociationsCache.siteId)
|
||||
})
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(
|
||||
inArray(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clientIdsToProcess
|
||||
)
|
||||
)
|
||||
.groupBy(clientSitesAssociationsCache.clientId);
|
||||
|
||||
for (const row of siteCounts) {
|
||||
clientSiteCounts[row.clientId] = Number(row.siteCount);
|
||||
}
|
||||
}
|
||||
|
||||
for (const client of clientsToProcess.values()) {
|
||||
// UPDATE THE NEWT
|
||||
if (!client.subnet || !client.pubKey) {
|
||||
@@ -627,14 +582,7 @@ async function handleMessagesForSiteClients(
|
||||
}
|
||||
|
||||
if (isAdd) {
|
||||
if (clientSiteCounts[client.clientId] > 250) {
|
||||
// skip adding the peer if we have more than 250 sites because we are in jit mode anyway
|
||||
logger.info(
|
||||
`rebuildClientAssociations: Client ${client.clientId} has ${clientSiteCounts[client.clientId]} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: if we are in jit mode here should we really be sending this?
|
||||
await initPeerAddHandshake(
|
||||
// this will kick off the add peer process for the client
|
||||
client.clientId,
|
||||
@@ -652,24 +600,9 @@ async function handleMessagesForSiteClients(
|
||||
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
||||
}
|
||||
|
||||
Promise.all(exitNodeJobs).catch((error) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
Promise.all(newtJobs).catch((error) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error updating Newt peers for site ${site.siteId}:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
Promise.all(olmJobs).catch((error) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error updating Olm peers for site ${site.siteId}:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
await Promise.all(exitNodeJobs);
|
||||
await Promise.all(newtJobs); // do the servers first to make sure they are ready?
|
||||
await Promise.all(olmJobs);
|
||||
}
|
||||
|
||||
interface PeerDestination {
|
||||
@@ -890,6 +823,9 @@ async function handleSubnetProxyTargetUpdates(
|
||||
}
|
||||
|
||||
for (const client of removedClients) {
|
||||
if (!siteResource.destination) {
|
||||
continue;
|
||||
}
|
||||
// Check if this client still has access to another resource
|
||||
// on this specific site with the same destination. We scope
|
||||
// by siteId (via siteNetworks) rather than networkId because
|
||||
@@ -952,17 +888,6 @@ async function handleSubnetProxyTargetUpdates(
|
||||
export async function rebuildClientAssociationsFromClient(
|
||||
client: Client,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
return await lockManager.withLock(
|
||||
`rebuild-client-associations:client:${client.clientId}`,
|
||||
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||
);
|
||||
}
|
||||
|
||||
async function rebuildClientAssociationsFromClientImpl(
|
||||
client: Client,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
let newSiteResourceIds: number[] = [];
|
||||
|
||||
@@ -1235,12 +1160,6 @@ async function handleMessagesForClientSites(
|
||||
const olmJobs: Promise<any>[] = [];
|
||||
const exitNodeJobs: Promise<any>[] = [];
|
||||
|
||||
const totalSitesOnClient = await trx
|
||||
.select({ count: count(clientSitesAssociationsCache.siteId) })
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId))
|
||||
.then((rows) => Number(rows[0].count));
|
||||
|
||||
for (const siteData of sitesData) {
|
||||
const site = siteData.sites;
|
||||
const exitNode = siteData.exitNodes;
|
||||
@@ -1301,14 +1220,7 @@ async function handleMessagesForClientSites(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (totalSitesOnClient > 250) {
|
||||
// skip adding the site if we have more than 250 because we are in jit mode anyway
|
||||
logger.info(
|
||||
`rebuildClientAssociations: Client ${client.clientId} has ${totalSitesOnClient} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: if we are in jit mode here should we really be sending this?
|
||||
await initPeerAddHandshake(
|
||||
// this will kick off the add peer process for the client
|
||||
client.clientId,
|
||||
@@ -1336,24 +1248,9 @@ async function handleMessagesForClientSites(
|
||||
);
|
||||
}
|
||||
|
||||
Promise.all(exitNodeJobs).catch((error) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
Promise.all(newtJobs).catch((error) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error updating Newt peers for client ${client.clientId}:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
Promise.all(olmJobs).catch((error) => {
|
||||
logger.error(
|
||||
`rebuildClientAssociations: Error updating Olm peers for client ${client.clientId}:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
await Promise.all(exitNodeJobs);
|
||||
await Promise.all(newtJobs);
|
||||
await Promise.all(olmJobs);
|
||||
}
|
||||
|
||||
async function handleMessagesForClientResources(
|
||||
@@ -1563,6 +1460,9 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
|
||||
try {
|
||||
if (!resource.destination) {
|
||||
continue;
|
||||
}
|
||||
// Check if this client still has access to another resource
|
||||
// on this specific site with the same destination. We scope
|
||||
// by siteId (via siteNetworks) rather than networkId because
|
||||
@@ -1634,195 +1534,3 @@ async function handleMessagesForClientResources(
|
||||
|
||||
await Promise.all([...proxyJobs, ...olmJobs]);
|
||||
}
|
||||
|
||||
export type ClientAssociationsCacheVerification = {
|
||||
clientId: number;
|
||||
consistent: boolean;
|
||||
// What permissions say the cache should contain
|
||||
expectedSiteResourceIds: number[];
|
||||
expectedSiteIds: number[];
|
||||
// What the cache currently contains
|
||||
actualSiteResourceIds: number[];
|
||||
actualSiteIds: number[];
|
||||
// Diff
|
||||
missingSiteResourceIds: number[]; // present in expected, missing from cache
|
||||
extraSiteResourceIds: number[]; // present in cache, not in expected
|
||||
missingSiteIds: number[];
|
||||
extraSiteIds: number[];
|
||||
};
|
||||
|
||||
// verifyClientAssociationsCache walks the same permission-derivation logic as
|
||||
// rebuildClientAssociationsFromClient but does NOT modify the database. It
|
||||
// returns the expected vs actual cache contents and a boolean indicating
|
||||
// whether the cache is in sync with what permissions imply.
|
||||
export async function verifyClientAssociationsCache(
|
||||
client: Client,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<ClientAssociationsCacheVerification> {
|
||||
let newSiteResourceIds: number[] = [];
|
||||
|
||||
// 1. Direct client associations
|
||||
const directSiteResources = await trx
|
||||
.select({ siteResourceId: clientSiteResources.siteResourceId })
|
||||
.from(clientSiteResources)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(siteResources.siteResourceId, clientSiteResources.siteResourceId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(clientSiteResources.clientId, client.clientId),
|
||||
eq(siteResources.orgId, client.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
newSiteResourceIds.push(
|
||||
...directSiteResources.map((r) => r.siteResourceId)
|
||||
);
|
||||
|
||||
// 2. User-based and role-based access (if client has a userId)
|
||||
if (client.userId) {
|
||||
const userSiteResourceIds = await trx
|
||||
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||
.from(userSiteResources)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
siteResources.siteResourceId,
|
||||
userSiteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(userSiteResources.userId, client.userId),
|
||||
eq(siteResources.orgId, client.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
newSiteResourceIds.push(
|
||||
...userSiteResourceIds.map((r) => r.siteResourceId)
|
||||
);
|
||||
|
||||
const roleIds = await trx
|
||||
.select({ roleId: userOrgRoles.roleId })
|
||||
.from(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, client.userId),
|
||||
eq(userOrgRoles.orgId, client.orgId)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.map((row) => row.roleId));
|
||||
|
||||
if (roleIds.length > 0) {
|
||||
const roleSiteResourceIds = await trx
|
||||
.select({ siteResourceId: roleSiteResources.siteResourceId })
|
||||
.from(roleSiteResources)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
siteResources.siteResourceId,
|
||||
roleSiteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(roleSiteResources.roleId, roleIds),
|
||||
eq(siteResources.orgId, client.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
newSiteResourceIds.push(
|
||||
...roleSiteResourceIds.map((r) => r.siteResourceId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
newSiteResourceIds = Array.from(new Set(newSiteResourceIds));
|
||||
|
||||
const newSiteResources =
|
||||
newSiteResourceIds.length > 0
|
||||
? await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
inArray(siteResources.siteResourceId, newSiteResourceIds)
|
||||
)
|
||||
: [];
|
||||
|
||||
const networkIds = Array.from(
|
||||
new Set(
|
||||
newSiteResources
|
||||
.map((sr) => sr.networkId)
|
||||
.filter((id): id is number => id !== null)
|
||||
)
|
||||
);
|
||||
const newSiteIds =
|
||||
networkIds.length > 0
|
||||
? await trx
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(inArray(siteNetworks.networkId, networkIds))
|
||||
.then((rows) =>
|
||||
Array.from(new Set(rows.map((r) => r.siteId)))
|
||||
)
|
||||
: [];
|
||||
|
||||
// Read the existing cache state
|
||||
const existingResourceAssociations = await trx
|
||||
.select({
|
||||
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId
|
||||
})
|
||||
.from(clientSiteResourcesAssociationsCache)
|
||||
.where(
|
||||
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
|
||||
);
|
||||
const existingSiteResourceIds = existingResourceAssociations.map(
|
||||
(r) => r.siteResourceId
|
||||
);
|
||||
|
||||
const existingSiteAssociations = await trx
|
||||
.select({ siteId: clientSitesAssociationsCache.siteId })
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||
const existingSiteIds = existingSiteAssociations.map((s) => s.siteId);
|
||||
|
||||
const expectedSiteResourceSet = new Set(newSiteResourceIds);
|
||||
const actualSiteResourceSet = new Set(existingSiteResourceIds);
|
||||
const expectedSiteSet = new Set(newSiteIds);
|
||||
const actualSiteSet = new Set(existingSiteIds);
|
||||
|
||||
const missingSiteResourceIds = newSiteResourceIds.filter(
|
||||
(id) => !actualSiteResourceSet.has(id)
|
||||
);
|
||||
const extraSiteResourceIds = existingSiteResourceIds.filter(
|
||||
(id) => !expectedSiteResourceSet.has(id)
|
||||
);
|
||||
const missingSiteIds = newSiteIds.filter((id) => !actualSiteSet.has(id));
|
||||
const extraSiteIds = existingSiteIds.filter(
|
||||
(id) => !expectedSiteSet.has(id)
|
||||
);
|
||||
|
||||
const consistent =
|
||||
missingSiteResourceIds.length === 0 &&
|
||||
extraSiteResourceIds.length === 0 &&
|
||||
missingSiteIds.length === 0 &&
|
||||
extraSiteIds.length === 0;
|
||||
|
||||
return {
|
||||
clientId: client.clientId,
|
||||
consistent,
|
||||
expectedSiteResourceIds: Array.from(expectedSiteResourceSet).sort(
|
||||
(a, b) => a - b
|
||||
),
|
||||
expectedSiteIds: Array.from(expectedSiteSet).sort((a, b) => a - b),
|
||||
actualSiteResourceIds: Array.from(actualSiteResourceSet).sort(
|
||||
(a, b) => a - b
|
||||
),
|
||||
actualSiteIds: Array.from(actualSiteSet).sort((a, b) => a - b),
|
||||
missingSiteResourceIds: missingSiteResourceIds.sort((a, b) => a - b),
|
||||
extraSiteResourceIds: extraSiteResourceIds.sort((a, b) => a - b),
|
||||
missingSiteIds: missingSiteIds.sort((a, b) => a - b),
|
||||
extraSiteIds: extraSiteIds.sort((a, b) => a - b)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -780,9 +780,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||
);
|
||||
// logger.debug(
|
||||
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||
// );
|
||||
|
||||
for (const domain of allDomains) {
|
||||
try {
|
||||
|
||||
@@ -24,7 +24,8 @@ import { LogStreamingManager } from "./LogStreamingManager";
|
||||
*/
|
||||
export const logStreamingManager = new LogStreamingManager();
|
||||
|
||||
if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here
|
||||
if (build !== "saas") {
|
||||
// this is handled separately in the saas build, so we don't want to start it here
|
||||
logStreamingManager.start();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
certificates,
|
||||
db,
|
||||
domainNamespaces,
|
||||
@@ -277,10 +278,119 @@ export async function getTraefikConfig(
|
||||
});
|
||||
});
|
||||
|
||||
// Query browser gateway targets for this exit node
|
||||
const browserGatewayRows = await db
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
resourceName: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
wildcard: resources.wildcard,
|
||||
domainCertResolver: domains.certResolver,
|
||||
preferWildcardCert: domains.preferWildcardCert,
|
||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
||||
// Browser gateway target fields
|
||||
browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId,
|
||||
bgType: browserGatewayTarget.type,
|
||||
// Site fields
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.subnet,
|
||||
siteExitNodeId: sites.exitNodeId
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resources.resourceId, browserGatewayTarget.resourceId)
|
||||
)
|
||||
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
||||
.leftJoin(
|
||||
domainNamespaces,
|
||||
eq(domainNamespaces.domainId, resources.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.enabled, true),
|
||||
or(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
and(
|
||||
isNull(sites.exitNodeId),
|
||||
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
||||
eq(sites.type, "local"),
|
||||
sql`(${build != "saas" ? 1 : 0} = 1)`
|
||||
)
|
||||
),
|
||||
inArray(sites.type, siteTypes)
|
||||
)
|
||||
);
|
||||
|
||||
// Group browser gateway targets by resource
|
||||
type BrowserGatewayResourceEntry = {
|
||||
resourceId: number;
|
||||
name: string;
|
||||
fullDomain: string | null;
|
||||
ssl: boolean | null;
|
||||
subdomain: string | null;
|
||||
domainId: string | null;
|
||||
enabled: boolean | null;
|
||||
wildcard: boolean | null;
|
||||
domainCertResolver: string | null;
|
||||
preferWildcardCert: boolean | null;
|
||||
targets: {
|
||||
browserGatewayTargetId: number;
|
||||
bgType: string;
|
||||
siteId: number;
|
||||
siteType: string;
|
||||
siteOnline: boolean | null;
|
||||
subnet: string | null;
|
||||
siteExitNodeId: number | null;
|
||||
}[];
|
||||
};
|
||||
const browserGatewayResourcesMap = new Map<
|
||||
number,
|
||||
BrowserGatewayResourceEntry
|
||||
>();
|
||||
|
||||
for (const row of browserGatewayRows) {
|
||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||
continue;
|
||||
}
|
||||
if (!browserGatewayResourcesMap.has(row.resourceId)) {
|
||||
browserGatewayResourcesMap.set(row.resourceId, {
|
||||
resourceId: row.resourceId,
|
||||
name: sanitize(row.resourceName) || "",
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
subdomain: row.subdomain,
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
wildcard: row.wildcard,
|
||||
domainCertResolver: row.domainCertResolver,
|
||||
preferWildcardCert: row.preferWildcardCert,
|
||||
targets: []
|
||||
});
|
||||
}
|
||||
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
|
||||
browserGatewayTargetId: row.browserGatewayTargetId,
|
||||
bgType: row.bgType,
|
||||
siteId: row.siteId,
|
||||
siteType: row.siteType,
|
||||
siteOnline: row.siteOnline,
|
||||
subnet: row.subnet,
|
||||
siteExitNodeId: row.siteExitNodeId
|
||||
});
|
||||
}
|
||||
|
||||
let siteResourcesWithFullDomain: {
|
||||
siteResourceId: number;
|
||||
fullDomain: string | null;
|
||||
mode: "http" | "host" | "cidr";
|
||||
mode: "http" | "host" | "cidr" | "ssh";
|
||||
}[] = [];
|
||||
if (build == "enterprise") {
|
||||
// we dont want to do this on the cloud
|
||||
@@ -324,6 +434,12 @@ export async function getTraefikConfig(
|
||||
domains.add(sr.fullDomain);
|
||||
}
|
||||
}
|
||||
// Include browser gateway resource domains
|
||||
for (const bgResource of browserGatewayResourcesMap.values()) {
|
||||
if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) {
|
||||
domains.add(bgResource.fullDomain);
|
||||
}
|
||||
}
|
||||
// get the valid certs for these domains
|
||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||
@@ -589,7 +705,7 @@ export async function getTraefikConfig(
|
||||
resource.ssl ? entrypointHttps : entrypointHttp
|
||||
],
|
||||
service: maintenanceServiceName,
|
||||
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `,
|
||||
priority: 2001,
|
||||
...(resource.ssl ? { tls } : {})
|
||||
};
|
||||
@@ -925,6 +1041,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
|
||||
// Traefik generates TLS certificates for those domains even when no
|
||||
// matching resource exists yet.
|
||||
@@ -1040,7 +1335,7 @@ export async function getTraefikConfig(
|
||||
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
||||
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
||||
service: siteResourceServiceName,
|
||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
priority: 101,
|
||||
tls
|
||||
};
|
||||
@@ -1143,7 +1438,7 @@ export async function getTraefikConfig(
|
||||
config.getRawConfig().traefik.https_entrypoint
|
||||
],
|
||||
service: "landing-service",
|
||||
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
priority: 203,
|
||||
tls: tls
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
if (build != "saas") {
|
||||
if (build !== "saas") {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
18
server/private/routers/browserGatewayTarget/index.ts
Normal file
18
server/private/routers/browserGatewayTarget/index.ts
Normal file
@@ -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,7 +31,8 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||
import * as alertRule from "#private/routers/alertRule";
|
||||
import * as healthChecks from "#private/routers/healthChecks";
|
||||
import * as client from "@server/routers/client";
|
||||
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
||||
import * as labels from "#private/routers/labels";
|
||||
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
@@ -733,6 +734,59 @@ authenticated.get(
|
||||
alertRule.getAlertRule
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/labels",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyValidSubscription(tierMatrix.labels),
|
||||
verifyUserHasAction(ActionsEnum.listOrgLabels),
|
||||
labels.listOrgLabels
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/labels",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyValidSubscription(tierMatrix.labels),
|
||||
verifyUserHasAction(ActionsEnum.createOrgLabel),
|
||||
labels.createOrgLabel
|
||||
);
|
||||
|
||||
authenticated.patch(
|
||||
"/org/:orgId/label/:labelId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyValidSubscription(tierMatrix.labels),
|
||||
verifyUserHasAction(ActionsEnum.updateOrgLabel),
|
||||
labels.updateOrgLabel
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/label/:labelId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
|
||||
labels.deleteOrgLabel
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/label/:labelId/attach",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyValidSubscription(tierMatrix.labels),
|
||||
verifyUserHasAction(ActionsEnum.attachLabelToItem),
|
||||
labels.attachLabelToItem
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/label/:labelId/detach",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyValidSubscription(tierMatrix.labels),
|
||||
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
|
||||
labels.detachLabelFromItem
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/health-checks",
|
||||
verifyValidLicense,
|
||||
@@ -777,14 +831,47 @@ authenticated.get(
|
||||
healthChecks.getHealthCheckStatusHistory
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
||||
browserGatewayTarget.createBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/client/:clientId/verify-associations-cache",
|
||||
verifyClientAccess,
|
||||
client.verifyClientAssociationsCache
|
||||
"/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(
|
||||
"/client/:clientId/rebuild-associations-cache",
|
||||
verifyClientAccess,
|
||||
client.rebuildClientAssociationsCacheRoute
|
||||
"/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 alertEvents from "#private/routers/alertEvents";
|
||||
import * as certificates from "#private/routers/certificates";
|
||||
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
||||
|
||||
import {
|
||||
verifyApiKeyHasAction,
|
||||
@@ -215,3 +216,43 @@ authenticated.delete(
|
||||
logActionAudit(ActionsEnum.removeUserRole),
|
||||
user.removeUserRole
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
||||
browserGatewayTarget.createBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
|
||||
browserGatewayTarget.listBrowserGatewayTargets
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
|
||||
browserGatewayTarget.getBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
||||
browserGatewayTarget.updateBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
||||
browserGatewayTarget.deleteBrowserGatewayTarget
|
||||
);
|
||||
|
||||
224
server/private/routers/labels/attachLabelToItem.ts
Normal file
224
server/private/routers/labels/attachLabelToItem.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* 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 {
|
||||
clients,
|
||||
clientLabels,
|
||||
db,
|
||||
labels,
|
||||
resourceLabels,
|
||||
resources,
|
||||
siteLabels,
|
||||
siteResourceLabels,
|
||||
siteResources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const attachLabelBodySchema = z.strictObject({
|
||||
siteId: z.number().int().optional(),
|
||||
resourceId: z.number().int().optional(),
|
||||
siteResourceId: z.number().int().optional(),
|
||||
clientId: z.number().int().optional()
|
||||
});
|
||||
|
||||
export async function attachLabelToItem(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, labelId } = parsedParams.data;
|
||||
|
||||
const parsedBody = attachLabelBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, resourceId, siteResourceId, clientId } =
|
||||
parsedBody.data;
|
||||
|
||||
if (!siteId && !resourceId && !siteResourceId && !clientId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(labels)
|
||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Label with Id ${labelId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (siteId) {
|
||||
const siteCount = await db.$count(
|
||||
sites,
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
);
|
||||
|
||||
if (siteCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with Id ${siteId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||
await db
|
||||
.insert(siteLabels)
|
||||
.values({
|
||||
labelId,
|
||||
siteId
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
const resourceCount = await db.$count(
|
||||
resources,
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with Id ${resourceId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||
await db
|
||||
.insert(resourceLabels)
|
||||
.values({
|
||||
labelId,
|
||||
resourceId
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
if (siteResourceId) {
|
||||
const resourceCount = await db.$count(
|
||||
siteResources,
|
||||
and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`SiteResource with Id ${siteResourceId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||
await db
|
||||
.insert(siteResourceLabels)
|
||||
.values({
|
||||
labelId,
|
||||
siteResourceId
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
const clientCount = await db.$count(
|
||||
clients,
|
||||
and(
|
||||
eq(clients.clientId, clientId),
|
||||
eq(clients.orgId, orgId),
|
||||
isNull(clients.userId)
|
||||
)
|
||||
);
|
||||
|
||||
if (clientCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Client with Id ${clientId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
||||
await db
|
||||
.insert(clientLabels)
|
||||
.values({
|
||||
labelId,
|
||||
clientId
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Label attached successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
149
server/private/routers/labels/createOrgLabel.ts
Normal file
149
server/private/routers/labels/createOrgLabel.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 {
|
||||
db,
|
||||
labels,
|
||||
resourceLabels,
|
||||
resources,
|
||||
siteLabels,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
name: z.string().nonempty(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
||||
.nonempty(),
|
||||
siteId: z.number().int().optional(),
|
||||
resourceId: z.number().int().optional()
|
||||
});
|
||||
|
||||
export async function createOrgLabel(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { name, color, siteId, resourceId } = parsedBody.data;
|
||||
|
||||
if (siteId) {
|
||||
const siteCount = await db.$count(
|
||||
sites,
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
);
|
||||
|
||||
if (siteCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Site with Id ${siteId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
const resourceCount = await db.$count(
|
||||
resources,
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Resource with Id ${resourceId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const label = await db.transaction(async (tx) => {
|
||||
const [label] = await tx
|
||||
.insert(labels)
|
||||
.values({
|
||||
name,
|
||||
color,
|
||||
orgId
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (siteId) {
|
||||
await tx.insert(siteLabels).values({
|
||||
siteId,
|
||||
labelId: label.labelId
|
||||
});
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
await tx.insert(resourceLabels).values({
|
||||
resourceId,
|
||||
labelId: label.labelId
|
||||
});
|
||||
}
|
||||
return label;
|
||||
});
|
||||
|
||||
return response<CreateOrEditLabelResponse>(res, {
|
||||
data: { label },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Org Label created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
72
server/private/routers/labels/deleteOrgLabel.ts
Normal file
72
server/private/routers/labels/deleteOrgLabel.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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 { db, labels } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
export async function deleteOrgLabel(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, labelId } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(labels)
|
||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||
|
||||
if (!existing) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(labels)
|
||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Label deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
224
server/private/routers/labels/detachLabelFromItem.ts
Normal file
224
server/private/routers/labels/detachLabelFromItem.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* 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 {
|
||||
clients,
|
||||
clientLabels,
|
||||
db,
|
||||
labels,
|
||||
resourceLabels,
|
||||
resources,
|
||||
siteLabels,
|
||||
siteResourceLabels,
|
||||
siteResources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const detachLabelBodySchema = z.strictObject({
|
||||
siteId: z.number().int().optional(),
|
||||
resourceId: z.number().int().optional(),
|
||||
siteResourceId: z.number().int().optional(),
|
||||
clientId: z.number().int().optional()
|
||||
});
|
||||
|
||||
export async function detachLabelFromItem(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, labelId } = parsedParams.data;
|
||||
|
||||
const parsedBody = detachLabelBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, resourceId, siteResourceId, clientId } =
|
||||
parsedBody.data;
|
||||
|
||||
if (!siteId && !resourceId && !siteResourceId && !clientId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(labels)
|
||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Label with Id ${labelId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (siteId) {
|
||||
const siteCount = await db.$count(
|
||||
sites,
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
);
|
||||
|
||||
if (siteCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with Id ${siteId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(siteLabels)
|
||||
.where(
|
||||
and(
|
||||
eq(siteLabels.labelId, labelId),
|
||||
eq(siteLabels.siteId, siteId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceId) {
|
||||
const resourceCount = await db.$count(
|
||||
resources,
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with Id ${resourceId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(resourceLabels)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceLabels.labelId, labelId),
|
||||
eq(resourceLabels.resourceId, resourceId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (siteResourceId) {
|
||||
const resourceCount = await db.$count(
|
||||
siteResources,
|
||||
and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`SiteResource with Id ${siteResourceId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(siteResourceLabels)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResourceLabels.labelId, labelId),
|
||||
eq(siteResourceLabels.siteResourceId, siteResourceId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
const clientCount = await db.$count(
|
||||
clients,
|
||||
and(
|
||||
eq(clients.clientId, clientId),
|
||||
eq(clients.orgId, orgId),
|
||||
isNull(clients.userId)
|
||||
)
|
||||
);
|
||||
|
||||
if (clientCount === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Client with Id ${clientId} doesn't exist.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(clientLabels)
|
||||
.where(
|
||||
and(
|
||||
eq(clientLabels.labelId, labelId),
|
||||
eq(clientLabels.clientId, clientId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Label detached successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
19
server/private/routers/labels/index.ts
Normal file
19
server/private/routers/labels/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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 "./listOrgLabels";
|
||||
export * from "./createOrgLabel";
|
||||
export * from "./updateOrgLabel";
|
||||
export * from "./attachLabelToItem";
|
||||
export * from "./detachLabelFromItem";
|
||||
export * from "./deleteOrgLabel";
|
||||
155
server/private/routers/labels/listOrgLabels.ts
Normal file
155
server/private/routers/labels/listOrgLabels.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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 { db, labels } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, asc, eq, like, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const listLabelsSchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional()
|
||||
});
|
||||
|
||||
function queryLabelsBase() {
|
||||
return db
|
||||
.select({
|
||||
labelId: labels.labelId,
|
||||
name: labels.name,
|
||||
color: labels.color
|
||||
})
|
||||
.from(labels);
|
||||
}
|
||||
|
||||
export async function listOrgLabels(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listLabelsSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { pageSize, page, query } = parsedQuery.data;
|
||||
|
||||
const conditions = [and(eq(labels.orgId, orgId))];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
like(
|
||||
sql`LOWER(${labels.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = queryLabelsBase().where(and(...conditions));
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(
|
||||
queryLabelsBase()
|
||||
.where(and(...conditions))
|
||||
.as("filtered_labels")
|
||||
);
|
||||
|
||||
const labelListQuery = baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(labels.name));
|
||||
|
||||
const [totalCount, rows] = await Promise.all([
|
||||
countQuery,
|
||||
labelListQuery
|
||||
]);
|
||||
|
||||
return response<ListOrgLabelsResponse>(res, {
|
||||
data: {
|
||||
labels: rows,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Labels retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
101
server/private/routers/labels/updateOrgLabel.ts
Normal file
101
server/private/routers/labels/updateOrgLabel.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 { db, labels } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const updateLabelBodySchema = z.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
||||
.nonempty()
|
||||
});
|
||||
|
||||
export async function updateOrgLabel(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, labelId } = parsedParams.data;
|
||||
|
||||
const parsedBody = updateLabelBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(labels)
|
||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||
|
||||
if (!existing) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
|
||||
}
|
||||
|
||||
const { name, color } = parsedBody.data;
|
||||
|
||||
const [label] = await db
|
||||
.update(labels)
|
||||
.set({
|
||||
name,
|
||||
color
|
||||
})
|
||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)))
|
||||
.returning();
|
||||
|
||||
return response<CreateOrEditLabelResponse>(res, {
|
||||
data: {
|
||||
label
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Label updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { eq, InferInsertModel } from "drizzle-orm";
|
||||
import { build } from "@server/build";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import config from "#private/lib/config";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
@@ -34,9 +35,78 @@ const paramsSchema = z.strictObject({
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
logoUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val === "" ? null : val)),
|
||||
.union([
|
||||
z.literal(""),
|
||||
z
|
||||
.string()
|
||||
.superRefine(async (urlOrPath, ctx) => {
|
||||
const parseResult = z.url().safeParse(urlOrPath);
|
||||
if (!parseResult.success) {
|
||||
if (build !== "enterprise") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be a valid URL"
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
validateLocalPath(urlOrPath);
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||
});
|
||||
} finally {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(urlOrPath, {
|
||||
method: "HEAD"
|
||||
}).catch(() => {
|
||||
// If HEAD fails (CORS or method not allowed), try GET
|
||||
return fetch(urlOrPath, { method: "GET" });
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Failed to load image. Please check that the URL is accessible.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType =
|
||||
response.headers.get("content-type") ?? "";
|
||||
if (!contentType.startsWith("image/")) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
let errorMessage =
|
||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
||||
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
errorMessage =
|
||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = `Error verifying URL: ${error.message}`;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: errorMessage
|
||||
});
|
||||
}
|
||||
})
|
||||
])
|
||||
.transform((val) => (val === "" ? null : val))
|
||||
.nullish(),
|
||||
logoWidth: z.coerce.number<number>().min(1),
|
||||
logoHeight: z.coerce.number<number>().min(1),
|
||||
resourceTitle: z.string(),
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
roundTripMessageTracker,
|
||||
siteResources,
|
||||
siteNetworks,
|
||||
userOrgs
|
||||
userOrgs,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
||||
@@ -48,7 +49,8 @@ const bodySchema = z
|
||||
.strictObject({
|
||||
publicKey: z.string().nonempty(),
|
||||
resourceId: z.number().int().positive().optional(),
|
||||
resource: z.string().nonempty().optional() // this is either the nice id or the alias
|
||||
resource: z.string().nonempty().optional(), // this is either the nice id or the alias
|
||||
username: z.string().nonempty().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -63,19 +65,19 @@ const bodySchema = z
|
||||
);
|
||||
|
||||
export type SignSshKeyResponse = {
|
||||
certificate: string;
|
||||
certificate?: string;
|
||||
messageIds: number[];
|
||||
messageId: number;
|
||||
messageId?: number;
|
||||
sshUsername: string;
|
||||
sshHost: string;
|
||||
resourceId: number;
|
||||
siteIds: number[];
|
||||
siteId: number;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
validAfter: string;
|
||||
validBefore: string;
|
||||
expiresIn: number;
|
||||
keyId?: string;
|
||||
validPrincipals?: string[];
|
||||
validAfter?: string;
|
||||
validBefore?: string;
|
||||
expiresIn?: number;
|
||||
};
|
||||
|
||||
// registry.registerPath({
|
||||
@@ -126,7 +128,8 @@ export async function signSshKey(
|
||||
const {
|
||||
publicKey,
|
||||
resourceId,
|
||||
resource: resourceQueryString
|
||||
resource: resourceQueryString,
|
||||
username
|
||||
} = parsedBody.data;
|
||||
const userId = req.user?.userId;
|
||||
const roleIds = req.userOrgRoleIds ?? [];
|
||||
@@ -174,101 +177,6 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
let usernameToUse;
|
||||
if (!userOrg.pamUsername) {
|
||||
if (req.user?.email) {
|
||||
// Extract username from email (first part before @)
|
||||
usernameToUse = req.user?.email
|
||||
.split("@")[0]
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Unable to extract username from email"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (req.user?.username) {
|
||||
usernameToUse = req.user.username;
|
||||
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username is not valid for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User does not have a valid email or username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// prefix with p-
|
||||
usernameToUse = `p-${usernameToUse}`;
|
||||
|
||||
// check if we have a existing user in this org with the same
|
||||
const [existingUserWithSameName] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, usernameToUse)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUserWithSameName) {
|
||||
let foundUniqueUsername = false;
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
||||
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, candidateUsername)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
usernameToUse = candidateUsername;
|
||||
foundUniqueUsername = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundUniqueUsername) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Unable to generate a unique username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userOrgs)
|
||||
.set({ pamUsername: usernameToUse })
|
||||
.where(
|
||||
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
|
||||
);
|
||||
} else {
|
||||
usernameToUse = userOrg.pamUsername;
|
||||
}
|
||||
|
||||
// Get and decrypt the org's CA keys
|
||||
const caKeys = await getOrgCAKeys(
|
||||
orgId,
|
||||
@@ -361,90 +269,303 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
sshSudoCommands: roles.sshSudoCommands,
|
||||
sshUnixGroups: roles.sshUnixGroups,
|
||||
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||
sshSudoMode: roles.sshSudoMode
|
||||
})
|
||||
.from(roles)
|
||||
.innerJoin(
|
||||
roleSiteResources,
|
||||
eq(roleSiteResources.roleId, roles.roleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(
|
||||
roleSiteResources.siteResourceId,
|
||||
resource.siteResourceId
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const parsedSudoCommands: string[] = [];
|
||||
const parsedGroupsSet = new Set<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
|
||||
const sitesFromNetworks = await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId!));
|
||||
|
||||
const siteIds = sites.map((site) => site.siteId);
|
||||
const siteIds = sitesFromNetworks.map((site) => site.siteId);
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
// only valid for 5 minutes
|
||||
const validFor = 300n;
|
||||
let expiresIn: number | undefined;
|
||||
let messageIds: number[] = [];
|
||||
let cert:
|
||||
| {
|
||||
certificate: string;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
validAfter: Date;
|
||||
validBefore: Date;
|
||||
}
|
||||
| undefined;
|
||||
// if the pam mode is push then we generate the user's pam username and use that or pull it from the userOrgs table
|
||||
// if the mode is passthrough then just use what was provided because the user will log in themselves
|
||||
let usernameToUse;
|
||||
if (resource.pamMode === "push") {
|
||||
if (!userOrg.pamUsername) {
|
||||
if (req.user?.email) {
|
||||
// Extract username from email (first part before @)
|
||||
usernameToUse = req.user?.email
|
||||
.split("@")[0]
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Unable to extract username from email"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (req.user?.username) {
|
||||
usernameToUse = req.user.username;
|
||||
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||
usernameToUse = usernameToUse.replace(
|
||||
/[^a-zA-Z0-9_-]/g,
|
||||
"-"
|
||||
);
|
||||
if (!usernameToUse) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Username is not valid for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"User does not have a valid email or username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||
keyId: `${usernameToUse}@${resource.niceId}`,
|
||||
validPrincipals: [usernameToUse, resource.niceId],
|
||||
validAfter: now - 60n, // Start 1 min ago for clock skew
|
||||
validBefore: now + validFor
|
||||
});
|
||||
// prefix with p-
|
||||
usernameToUse = `p-${usernameToUse}`;
|
||||
|
||||
// check if we have a existing user in this org with the same
|
||||
const [existingUserWithSameName] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, usernameToUse)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUserWithSameName) {
|
||||
let foundUniqueUsername = false;
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
||||
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.pamUsername, candidateUsername)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
usernameToUse = candidateUsername;
|
||||
foundUniqueUsername = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundUniqueUsername) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Unable to generate a unique username for SSH certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(userOrgs)
|
||||
.set({ pamUsername: usernameToUse })
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.userId, userId)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
usernameToUse = userOrg.pamUsername;
|
||||
}
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
sshSudoCommands: roles.sshSudoCommands,
|
||||
sshUnixGroups: roles.sshUnixGroups,
|
||||
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||
sshSudoMode: roles.sshSudoMode
|
||||
})
|
||||
.from(roles)
|
||||
.innerJoin(
|
||||
roleSiteResources,
|
||||
eq(roleSiteResources.roleId, roles.roleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(
|
||||
roleSiteResources.siteResourceId,
|
||||
resource.siteResourceId
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const parsedSudoCommands: string[] = [];
|
||||
const parsedGroupsSet = new Set<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
|
||||
const [newt] = await db
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteIds[0]))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
@@ -453,54 +574,26 @@ export async function signSshKey(
|
||||
);
|
||||
}
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!message) {
|
||||
if (!site.address) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
"Site address not configured, unable to determine SSH host when in native mode"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
messageIds.push(message.messageId);
|
||||
|
||||
await sendToClient(newt.newtId, {
|
||||
type: `newt/pam/connection`,
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
orgId: orgId,
|
||||
agentPort: resource.authDaemonPort ?? 22123,
|
||||
externalAuthDaemon: resource.authDaemonMode === "remote",
|
||||
agentHost: resource.destination,
|
||||
caCert: caKeys.publicKeyOpenSSH,
|
||||
username: usernameToUse,
|
||||
niceId: resource.niceId,
|
||||
metadata: {
|
||||
sudoMode: sudoMode,
|
||||
sudoCommands: parsedSudoCommands,
|
||||
homedir: homedir,
|
||||
groups: parsedGroups
|
||||
}
|
||||
}
|
||||
});
|
||||
// its the address but split off the cidr if there is one
|
||||
sshHost = site.address.split("/")[0];
|
||||
}
|
||||
|
||||
const expiresIn = Number(validFor); // seconds
|
||||
|
||||
let sshHost;
|
||||
if (resource.alias && resource.alias != "") {
|
||||
sshHost = resource.alias;
|
||||
} else {
|
||||
sshHost = resource.destination;
|
||||
if (!sshHost) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Unable to determine SSH host for the resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await logsDb.insert(actionAuditLog).values({
|
||||
@@ -527,7 +620,7 @@ export async function signSshKey(
|
||||
: undefined,
|
||||
metadata: {
|
||||
resourceName: resource.name,
|
||||
siteId: siteIds[0],
|
||||
siteIds: siteIds,
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost
|
||||
},
|
||||
@@ -537,18 +630,18 @@ export async function signSshKey(
|
||||
|
||||
return response<SignSshKeyResponse>(res, {
|
||||
data: {
|
||||
certificate: cert.certificate,
|
||||
certificate: cert?.certificate,
|
||||
messageIds: messageIds,
|
||||
messageId: messageIds[0], // just pick the first one for backward compatibility
|
||||
messageId: messageIds[0], // just pick the first one for backward compatibility with older olms
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost,
|
||||
sshHost: sshHost, // just pick the first one for backward compatibility with older olms
|
||||
resourceId: resource.siteResourceId,
|
||||
siteIds: siteIds,
|
||||
siteId: siteIds[0], // just pick the first one for backward compatibility
|
||||
keyId: cert.keyId,
|
||||
validPrincipals: cert.validPrincipals,
|
||||
validAfter: cert.validAfter.toISOString(),
|
||||
validBefore: cert.validBefore.toISOString(),
|
||||
siteId: siteIds[0], // just pick the first one for backward compatibility with older olms
|
||||
keyId: cert?.keyId,
|
||||
validPrincipals: cert?.validPrincipals,
|
||||
validAfter: cert?.validAfter.toISOString(),
|
||||
validBefore: cert?.validBefore.toISOString(),
|
||||
expiresIn
|
||||
},
|
||||
success: true,
|
||||
|
||||
@@ -10,5 +10,3 @@ export * from "./listUserDevices";
|
||||
export * from "./updateClient";
|
||||
export * from "./getClient";
|
||||
export * from "./createUserClient";
|
||||
export * from "./verifyClientAssociationsCache";
|
||||
export * from "./rebuildClientAssociationsCacheRoute";
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import {
|
||||
clientLabels,
|
||||
clients,
|
||||
clientSitesAssociationsCache,
|
||||
currentFingerprint,
|
||||
db,
|
||||
labels,
|
||||
olms,
|
||||
orgs,
|
||||
roleClients,
|
||||
sites,
|
||||
userClients,
|
||||
users
|
||||
users,
|
||||
type Label
|
||||
} from "@server/db";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -169,6 +174,7 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
|
||||
siteNiceId: string | null;
|
||||
}>;
|
||||
olmUpdateAvailable?: boolean;
|
||||
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
|
||||
};
|
||||
|
||||
type OlmWithUpdateAvailable = ClientWithSites;
|
||||
@@ -255,6 +261,11 @@ export async function listClients(
|
||||
(client) => client.clientId
|
||||
);
|
||||
|
||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.labels
|
||||
);
|
||||
|
||||
// Get client count with filter
|
||||
const conditions = [
|
||||
and(
|
||||
@@ -288,18 +299,29 @@ export async function listClients(
|
||||
}
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${clients.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${clients.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
like(sql`LOWER(${clients.name})`, q),
|
||||
like(sql`LOWER(${clients.niceId})`, q)
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
queryList.push(
|
||||
inArray(
|
||||
clients.clientId,
|
||||
db
|
||||
.select({ id: clientLabels.clientId })
|
||||
.from(clientLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, clientLabels.labelId)
|
||||
)
|
||||
.where(like(sql`LOWER(${labels.name})`, q))
|
||||
)
|
||||
)
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
conditions.push(or(...queryList));
|
||||
}
|
||||
|
||||
const baseQuery = queryClientsBase().where(and(...conditions));
|
||||
@@ -326,6 +348,30 @@ export async function listClients(
|
||||
const clientIds = clientsList.map((client) => client.clientId);
|
||||
const siteAssociations = await getSiteAssociations(clientIds);
|
||||
|
||||
let labelsForClients: Array<{
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
clientId: number;
|
||||
}> = [];
|
||||
|
||||
if (isLabelFeatureEnabled && clientIds.length > 0) {
|
||||
labelsForClients = await db
|
||||
.select({
|
||||
labelId: labels.labelId,
|
||||
name: labels.name,
|
||||
color: labels.color,
|
||||
clientId: clientLabels.clientId
|
||||
})
|
||||
.from(labels)
|
||||
.innerJoin(
|
||||
clientLabels,
|
||||
eq(clientLabels.labelId, labels.labelId)
|
||||
)
|
||||
.where(inArray(clientLabels.clientId, clientIds))
|
||||
.orderBy(asc(clientLabels.clientLabelId));
|
||||
}
|
||||
|
||||
// Group site associations by client ID
|
||||
const sitesByClient = siteAssociations.reduce(
|
||||
(acc, association) => {
|
||||
@@ -353,7 +399,10 @@ export async function listClients(
|
||||
const clientsWithSites = clientsList.map((client) => {
|
||||
return {
|
||||
...client,
|
||||
sites: sitesByClient[client.clientId] || []
|
||||
sites: sitesByClient[client.clientId] || [],
|
||||
labels: labelsForClients.filter(
|
||||
(l) => l.clientId === client.clientId
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { clients } 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 logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/client/{clientId}/rebuild-associations-cache",
|
||||
description:
|
||||
"Rebuild the client's site/site-resource association cache based on current permissions.",
|
||||
tags: [OpenAPITags.Client],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function rebuildClientAssociationsCacheRoute(
|
||||
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 { clientId } = parsedParams.data;
|
||||
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.clientId, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (!client) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Client with ID ${clientId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await rebuildClientAssociationsFromClient(client);
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Client association cache rebuilt successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to rebuild client association cache"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { clients } 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 logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { verifyClientAssociationsCache as verifyClientAssociationsCacheLib } from "@server/lib/rebuildClientAssociations";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/client/{clientId}/verify-associations-cache",
|
||||
description:
|
||||
"Read-only check of whether the client's site/site-resource association cache matches what the current permissions imply.",
|
||||
tags: [OpenAPITags.Client],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function verifyClientAssociationsCache(
|
||||
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 { clientId } = parsedParams.data;
|
||||
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.clientId, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (!client) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Client with ID ${clientId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const report = await verifyClientAssociationsCacheLib(client);
|
||||
|
||||
return response(res, {
|
||||
data: report,
|
||||
success: true,
|
||||
error: false,
|
||||
message: report.consistent
|
||||
? "Client association cache is consistent"
|
||||
: "Client association cache is INCONSISTENT",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to verify client association cache"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ internalRouter.get("/idp", idp.listIdps);
|
||||
|
||||
internalRouter.get("/idp/:idpId", idp.getIdp);
|
||||
|
||||
internalRouter.get("/resource/browser-target", resource.getBrowserTarget);
|
||||
|
||||
// Gerbil routes
|
||||
const gerbilRouter = Router();
|
||||
internalRouter.use("/gerbil", gerbilRouter);
|
||||
|
||||
10
server/routers/labels/types.ts
Normal file
10
server/routers/labels/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Label } from "@server/db";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
|
||||
export type ListOrgLabelsResponse = PaginatedResponse<{
|
||||
labels: Omit<Label, "orgId">[];
|
||||
}>;
|
||||
|
||||
export type CreateOrEditLabelResponse = {
|
||||
label: Label;
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
clients,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
clientSitesAssociationsCache,
|
||||
@@ -16,6 +18,7 @@ import logger from "@server/logger";
|
||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
formatEndpoint,
|
||||
generateSubnetProxyTargetV2,
|
||||
@@ -233,6 +236,11 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
.from(targetHealthCheck)
|
||||
.where(eq(targetHealthCheck.siteId, siteId));
|
||||
|
||||
const allBrowserGatewayTargets = await db
|
||||
.select()
|
||||
.from(browserGatewayTarget)
|
||||
.where(eq(browserGatewayTarget.siteId, siteId));
|
||||
|
||||
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||
(acc, target) => {
|
||||
// Filter out invalid targets
|
||||
@@ -304,9 +312,22 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
(target) => target !== null
|
||||
);
|
||||
|
||||
const serverSecret = config.getRawConfig().server.secret!;
|
||||
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
|
||||
const decryptAuthToken = decrypt(t.authToken, serverSecret);
|
||||
return {
|
||||
id: t.browserGatewayTargetId,
|
||||
type: t.type,
|
||||
destination: t.destination,
|
||||
destinationPort: t.destinationPort,
|
||||
authToken: decryptAuthToken
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
validHealthCheckTargets,
|
||||
tcpTargets,
|
||||
udpTargets
|
||||
udpTargets,
|
||||
browserGatewayTargets
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,8 +43,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
|
||||
const siteId = newt.siteId;
|
||||
|
||||
const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } =
|
||||
message.data;
|
||||
const {
|
||||
publicKey,
|
||||
pingResults,
|
||||
newtVersion,
|
||||
backwardsCompatible,
|
||||
chainId
|
||||
} = message.data;
|
||||
if (!publicKey) {
|
||||
logger.warn("Public key not provided");
|
||||
return;
|
||||
@@ -191,8 +196,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
.where(eq(newts.newtId, newt.newtId));
|
||||
}
|
||||
|
||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
||||
const {
|
||||
tcpTargets,
|
||||
udpTargets,
|
||||
validHealthCheckTargets,
|
||||
browserGatewayTargets
|
||||
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
||||
|
||||
logger.debug(
|
||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||
@@ -212,6 +221,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
tcp: tcpTargets
|
||||
},
|
||||
healthCheckTargets: validHealthCheckTargets,
|
||||
browserGatewayTargets: browserGatewayTargets,
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,8 +9,12 @@ import {
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
|
||||
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||
await buildTargetConfigurationForNewtClient(site.siteId);
|
||||
const {
|
||||
tcpTargets,
|
||||
udpTargets,
|
||||
validHealthCheckTargets,
|
||||
browserGatewayTargets
|
||||
} = await buildTargetConfigurationForNewtClient(site.siteId);
|
||||
|
||||
let exitNode: ExitNode | undefined;
|
||||
if (site.exitNodeId) {
|
||||
@@ -36,7 +40,8 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
||||
},
|
||||
healthCheckTargets: validHealthCheckTargets,
|
||||
peers: peers,
|
||||
clientTargets: targets
|
||||
clientTargets: targets,
|
||||
browserGatewayTargets: browserGatewayTargets
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Target, TargetHealthCheck } from "@server/db";
|
||||
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import logger from "@server/logger";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export async function addTargets(
|
||||
newtId: string,
|
||||
@@ -239,3 +241,55 @@ export async function removeTargets(
|
||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendBrowserGatewayTargets(
|
||||
newtId: string,
|
||||
targets: BrowserGatewayTarget[],
|
||||
version?: string | null
|
||||
) {
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const payload = targets.map((t) => {
|
||||
const decryptAuthToken = decrypt(
|
||||
t.authToken,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
return {
|
||||
id: t.browserGatewayTargetId,
|
||||
resourceId: t.resourceId,
|
||||
siteId: t.siteId,
|
||||
type: t.type,
|
||||
destination: t.destination,
|
||||
destinationPort: t.destinationPort,
|
||||
authToken: decryptAuthToken
|
||||
};
|
||||
});
|
||||
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: "newt/browsergateway/add",
|
||||
data: {
|
||||
targets: payload
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeBrowserGatewayTarget(
|
||||
newtId: string,
|
||||
browserGatewayTargetId: number,
|
||||
version?: string | null
|
||||
) {
|
||||
await sendToClient(
|
||||
newtId,
|
||||
{
|
||||
type: "newt/browsergateway/remove",
|
||||
data: {
|
||||
ids: [browserGatewayTargetId]
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { build } from "@server/build";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { getUniqueResourceName } from "@server/db/names";
|
||||
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
||||
import {
|
||||
validateAndConstructDomain,
|
||||
checkWildcardDomainConflict
|
||||
} from "@server/lib/domainUtils";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
@@ -40,7 +43,12 @@ const createHttpResourceSchema = z
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
domainId: z.string(),
|
||||
stickySession: z.boolean().optional(),
|
||||
postAuthPath: z.string().nullable().optional()
|
||||
postAuthPath: z.string().nullable().optional(),
|
||||
browserAccessType: z.enum(["http", "ssh", "rdp", "vnc"]).optional(),
|
||||
// SSH Settings
|
||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||
authDaemonPort: z.int().positive().optional(),
|
||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -198,7 +206,15 @@ async function createHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
const { name, domainId, postAuthPath } = parsedBody.data;
|
||||
const {
|
||||
name,
|
||||
domainId,
|
||||
postAuthPath,
|
||||
browserAccessType,
|
||||
authDaemonPort,
|
||||
authDaemonMode,
|
||||
pamMode
|
||||
} = parsedBody.data;
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
const stickySession = parsedBody.data.stickySession;
|
||||
|
||||
@@ -323,6 +339,10 @@ async function createHttpResource(
|
||||
name,
|
||||
subdomain: finalSubdomain,
|
||||
http: true,
|
||||
browserAccessType: browserAccessType,
|
||||
pamMode: pamMode,
|
||||
authDaemonMode: authDaemonMode,
|
||||
authDaemonPort: authDaemonPort,
|
||||
protocol: "tcp",
|
||||
ssl: true,
|
||||
stickySession: stickySession,
|
||||
|
||||
109
server/routers/resource/getBrowserTarget.ts
Normal file
109
server/routers/resource/getBrowserTarget.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { browserGatewayTarget, db } from "@server/db";
|
||||
import { resources, targets } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const getBrowserTargetSchema = z
|
||||
.object({
|
||||
fullDomain: z.string().min(1, "fullDomain is required")
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type GetBrowserTargetResponse = {
|
||||
ip: string;
|
||||
port: number;
|
||||
authToken: string;
|
||||
orgId: string;
|
||||
resourceId: number;
|
||||
niceId: string;
|
||||
pamMode: "passthrough" | "push" | null;
|
||||
authDaemonMode: "site" | "remote" | "native" | null;
|
||||
};
|
||||
|
||||
export async function getBrowserTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsed = getBrowserTargetSchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsed.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { fullDomain } = parsed.data;
|
||||
|
||||
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
|
||||
|
||||
const [browserTarget] = await db
|
||||
.select({
|
||||
destination: browserGatewayTarget.destination,
|
||||
destinationPort: browserGatewayTarget.destinationPort,
|
||||
authToken: browserGatewayTarget.authToken,
|
||||
resourceId: resources.resourceId,
|
||||
niceId: resources.niceId,
|
||||
orgId: resources.orgId,
|
||||
pamMode: resources.pamMode,
|
||||
authDaemonMode: resources.authDaemonMode
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(browserGatewayTarget.resourceId, resources.resourceId)
|
||||
)
|
||||
.where(eq(resources.fullDomain, fullDomain))
|
||||
.limit(1);
|
||||
|
||||
const decryptedAuthToken = decrypt(
|
||||
browserTarget.authToken,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
if (!browserTarget) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"No resource found for this domain"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetBrowserTargetResponse>(res, {
|
||||
data: {
|
||||
ip: browserTarget.destination,
|
||||
port: browserTarget.destinationPort,
|
||||
authToken: decryptedAuthToken,
|
||||
pamMode: browserTarget.pamMode,
|
||||
authDaemonMode: browserTarget.authDaemonMode,
|
||||
orgId: browserTarget.orgId,
|
||||
resourceId: browserTarget.resourceId,
|
||||
niceId: browserTarget.niceId
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser target retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred while retrieving the browser target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
|
||||
export * from "./listAllResourceNames";
|
||||
export * from "./removeEmailFromResourceWhitelist";
|
||||
export * from "./getStatusHistory";
|
||||
export * from "./getBrowserTarget";
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
db,
|
||||
labels,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourceLabels,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resources,
|
||||
@@ -9,8 +12,11 @@ import {
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
targets,
|
||||
userResources
|
||||
userResources,
|
||||
type Label
|
||||
} from "@server/db";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -140,6 +146,7 @@ export type ResourceWithTargets = {
|
||||
headerAuthId: number | null;
|
||||
wildcard: boolean;
|
||||
health: string | null;
|
||||
browserAccessType: string | null;
|
||||
targets: Array<{
|
||||
targetId: number;
|
||||
ip: string;
|
||||
@@ -154,6 +161,7 @@ export type ResourceWithTargets = {
|
||||
siteNiceId: string;
|
||||
online?: boolean; // undefined for local sites
|
||||
}>;
|
||||
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
||||
};
|
||||
|
||||
function queryResourcesBase() {
|
||||
@@ -177,7 +185,8 @@ function queryResourcesBase() {
|
||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||
headerAuthExtendedCompatibilityId:
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
||||
health: resources.health
|
||||
health: resources.health,
|
||||
browserAccessType: resources.browserAccessType
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
@@ -288,6 +297,11 @@ export async function listResources(
|
||||
);
|
||||
}
|
||||
|
||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.labels
|
||||
);
|
||||
|
||||
let accessibleResources: Array<{ resourceId: number }>;
|
||||
if (req.user) {
|
||||
accessibleResources = await db
|
||||
@@ -325,24 +339,6 @@ export async function listResources(
|
||||
)
|
||||
];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${resources.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${resources.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${resources.fullDomain})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (typeof enabled !== "undefined") {
|
||||
conditions.push(eq(resources.enabled, enabled));
|
||||
}
|
||||
@@ -386,6 +382,32 @@ export async function listResources(
|
||||
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
||||
}
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
like(sql`LOWER(${resources.name})`, q),
|
||||
like(sql`LOWER(${resources.niceId})`, q),
|
||||
like(sql`LOWER(${resources.fullDomain})`, q)
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
queryList.push(
|
||||
inArray(
|
||||
resources.resourceId,
|
||||
db
|
||||
.select({ id: resourceLabels.resourceId })
|
||||
.from(resourceLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, resourceLabels.labelId)
|
||||
)
|
||||
.where(like(sql`LOWER(${labels.name})`, q))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
conditions.push(or(...queryList));
|
||||
}
|
||||
|
||||
const baseQuery = queryResourcesBase().where(and(...conditions));
|
||||
|
||||
@@ -407,6 +429,36 @@ export async function listResources(
|
||||
]);
|
||||
|
||||
const resourceIdList = rows.map((row) => row.resourceId);
|
||||
|
||||
let labelsForResources: Array<{
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
resourceId: number;
|
||||
}> = [];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
labelsForResources =
|
||||
resourceIdList.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
labelId: labels.labelId,
|
||||
name: labels.name,
|
||||
color: labels.color,
|
||||
resourceId: resourceLabels.resourceId
|
||||
})
|
||||
.from(labels)
|
||||
.innerJoin(
|
||||
resourceLabels,
|
||||
eq(resourceLabels.labelId, labels.labelId)
|
||||
)
|
||||
.where(
|
||||
inArray(resourceLabels.resourceId, resourceIdList)
|
||||
)
|
||||
.orderBy(asc(resourceLabels.resourceLabelId));
|
||||
}
|
||||
|
||||
const allResourceTargets =
|
||||
resourceIdList.length === 0
|
||||
? []
|
||||
@@ -433,6 +485,30 @@ export async function listResources(
|
||||
)
|
||||
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
||||
|
||||
const allBgTargetSites =
|
||||
resourceIdList.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
resourceId: browserGatewayTarget.resourceId,
|
||||
siteId: browserGatewayTarget.siteId,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteOnline: sites.online,
|
||||
siteType: sites.type
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.where(
|
||||
inArray(
|
||||
browserGatewayTarget.resourceId,
|
||||
resourceIdList
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
sites,
|
||||
eq(sites.siteId, browserGatewayTarget.siteId)
|
||||
);
|
||||
|
||||
// avoids TS issues with reduce/never[]
|
||||
const map = new Map<number, ResourceWithTargets>();
|
||||
|
||||
@@ -453,12 +529,16 @@ export async function listResources(
|
||||
protocol: row.protocol,
|
||||
proxyPort: row.proxyPort,
|
||||
wildcard: row.wildcard,
|
||||
browserAccessType: row.browserAccessType,
|
||||
enabled: row.enabled,
|
||||
domainId: row.domainId,
|
||||
headerAuthId: row.headerAuthId,
|
||||
health: row.health ?? null,
|
||||
targets: [],
|
||||
sites: []
|
||||
sites: [],
|
||||
labels: labelsForResources.filter(
|
||||
(l) => l.resourceId === row.resourceId
|
||||
)
|
||||
};
|
||||
map.set(row.resourceId, entry);
|
||||
}
|
||||
@@ -493,6 +573,21 @@ export async function listResources(
|
||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||
});
|
||||
}
|
||||
const bgRaw = allBgTargetSites.filter(
|
||||
(t) => t.resourceId === entry.resourceId
|
||||
);
|
||||
for (const t of bgRaw) {
|
||||
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
|
||||
continue;
|
||||
}
|
||||
const isLocal = t.siteType === "local";
|
||||
siteById.set(t.siteId, {
|
||||
siteId: t.siteId,
|
||||
siteName: t.siteName ?? "",
|
||||
siteNiceId: t.siteNiceId ?? "",
|
||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||
});
|
||||
}
|
||||
entry.sites = Array.from(siteById.values());
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
import { registry } from "@server/openApi";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
||||
import {
|
||||
validateAndConstructDomain,
|
||||
checkWildcardDomainConflict
|
||||
} from "@server/lib/domainUtils";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
@@ -68,7 +71,12 @@ const updateHttpResourceBodySchema = z
|
||||
maintenanceTitle: z.string().max(255).nullable().optional(),
|
||||
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
||||
postAuthPath: z.string().nullable().optional()
|
||||
postAuthPath: z.string().nullable().optional(),
|
||||
browserAccessType: z.enum(["http", "ssh", "rdp", "vnc"]).optional(),
|
||||
// SSH settings
|
||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||
authDaemonPort: z.int().min(1).max(65535).nullable().optional()
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
error: "At least one field must be provided for update"
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
siteResources,
|
||||
targets,
|
||||
sites,
|
||||
userSites
|
||||
userSites,
|
||||
labels,
|
||||
siteLabels,
|
||||
type Label
|
||||
} from "@server/db";
|
||||
import cache from "#dynamic/lib/cache";
|
||||
import response from "@server/lib/response";
|
||||
@@ -23,6 +26,8 @@ import createHttpError from "http-errors";
|
||||
import semver from "semver";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
// Stale-while-revalidate: keeps the last successfully fetched version so that
|
||||
// a transient network failure / timeout does not flip every site back to
|
||||
@@ -187,7 +192,7 @@ const listSitesSchema = z.object({
|
||||
|
||||
function querySitesBase() {
|
||||
return db
|
||||
.select({
|
||||
.selectDistinct({
|
||||
siteId: sites.siteId,
|
||||
niceId: sites.niceId,
|
||||
name: sites.name,
|
||||
@@ -233,6 +238,7 @@ type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
|
||||
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
|
||||
online?: SiteRowBase["online"]; // undefined for local sites
|
||||
newtUpdateAvailable?: boolean;
|
||||
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
||||
};
|
||||
|
||||
export type ListSitesResponse = PaginatedResponse<{
|
||||
@@ -308,6 +314,11 @@ export async function listSites(
|
||||
.where(eq(sites.orgId, orgId));
|
||||
}
|
||||
|
||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.labels
|
||||
);
|
||||
|
||||
const { pageSize, page, query, sort_by, order, online, status } =
|
||||
parsedQuery.data;
|
||||
|
||||
@@ -319,33 +330,43 @@ export async function listSites(
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
];
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${sites.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${sites.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof online !== "undefined") {
|
||||
conditions.push(eq(sites.online, online));
|
||||
}
|
||||
if (typeof status !== "undefined") {
|
||||
conditions.push(eq(sites.status, status));
|
||||
}
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
like(sql`LOWER(${sites.name})`, q),
|
||||
like(sql`LOWER(${sites.niceId})`, q)
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
queryList.push(
|
||||
inArray(
|
||||
sites.siteId,
|
||||
db
|
||||
.select({ id: siteLabels.siteId })
|
||||
.from(siteLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, siteLabels.labelId)
|
||||
)
|
||||
.where(like(sql`LOWER(${labels.name})`, q))
|
||||
)
|
||||
);
|
||||
}
|
||||
conditions.push(or(...queryList));
|
||||
}
|
||||
|
||||
const baseQuery = querySitesBase().where(and(...conditions));
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(
|
||||
querySitesBase()
|
||||
.where(and(...conditions))
|
||||
.as("filtered_sites")
|
||||
querySitesBase().where(and(...conditions)).as("filtered_sites")
|
||||
);
|
||||
|
||||
const siteListQuery = baseQuery
|
||||
@@ -367,11 +388,46 @@ export async function listSites(
|
||||
// Get latest version asynchronously without blocking the response
|
||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||
|
||||
const siteIds = rows.map((site) => site.siteId);
|
||||
|
||||
let labelsForSites: Array<{
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
siteId: number;
|
||||
}> = [];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
labelsForSites =
|
||||
siteIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
labelId: labels.labelId,
|
||||
name: labels.name,
|
||||
color: labels.color,
|
||||
siteId: siteLabels.siteId
|
||||
})
|
||||
.from(labels)
|
||||
.innerJoin(
|
||||
siteLabels,
|
||||
eq(siteLabels.labelId, labels.labelId)
|
||||
)
|
||||
.where(inArray(siteLabels.siteId, siteIds))
|
||||
.orderBy(asc(siteLabels.siteLabelId));
|
||||
}
|
||||
|
||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||
// Initially set to false, will be updated if version check succeeds
|
||||
siteWithUpdate.newtUpdateAvailable = false;
|
||||
return siteWithUpdate;
|
||||
|
||||
// associate labels
|
||||
const labelsForSite = labelsForSites.filter(
|
||||
(label) => label.siteId === site.siteId
|
||||
);
|
||||
|
||||
return { ...siteWithUpdate, labels: labelsForSite };
|
||||
});
|
||||
|
||||
// Try to get the latest version, but don't block if it fails
|
||||
|
||||
@@ -44,14 +44,14 @@ const createSiteResourceSchema = z
|
||||
name: z.string().min(1).max(255),
|
||||
niceId: z.string().optional(),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
mode: z.enum(["host", "cidr", "http"]),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
||||
ssl: z.boolean().optional(), // only used for http mode
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
siteIds: z.array(z.int()).optional(),
|
||||
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
||||
// proxyPort: z.int().positive().optional(),
|
||||
destinationPort: z.int().positive().optional(),
|
||||
destination: z.string().min(1),
|
||||
destination: z.string().min(1).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
alias: z
|
||||
.string()
|
||||
@@ -67,14 +67,18 @@ const createSiteResourceSchema = z
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
disableIcmp: z.boolean().optional(),
|
||||
authDaemonPort: z.int().positive().optional(),
|
||||
authDaemonMode: z.enum(["site", "remote"]).optional(),
|
||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
|
||||
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode === "host") {
|
||||
if (
|
||||
(data.mode === "host" || data.mode === "ssh") &&
|
||||
data.destination
|
||||
) {
|
||||
// Check if it's a valid IP address using zod (v4 or v6)
|
||||
const isValidIP = z
|
||||
// .union([z.ipv4(), z.ipv6()])
|
||||
@@ -116,13 +120,24 @@ const createSiteResourceSchema = z
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode !== "http") return true;
|
||||
return (
|
||||
data.scheme !== undefined &&
|
||||
data.destinationPort !== undefined &&
|
||||
data.destinationPort >= 1 &&
|
||||
data.destinationPort <= 65535
|
||||
);
|
||||
if (data.mode === "http") {
|
||||
return (
|
||||
data.scheme !== undefined &&
|
||||
data.scheme !== null &&
|
||||
data.destinationPort !== undefined &&
|
||||
data.destinationPort !== null &&
|
||||
data.destinationPort >= 1 &&
|
||||
data.destinationPort <= 65535
|
||||
);
|
||||
} else if (data.mode === "ssh") {
|
||||
// just check the destinationPort
|
||||
return (
|
||||
data.destinationPort === undefined ||
|
||||
(data.destinationPort !== null &&
|
||||
data.destinationPort >= 1 &&
|
||||
data.destinationPort <= 65535)
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
message:
|
||||
@@ -212,6 +227,7 @@ export async function createSiteResource(
|
||||
disableIcmp,
|
||||
authDaemonPort,
|
||||
authDaemonMode,
|
||||
pamMode,
|
||||
domainId,
|
||||
subdomain
|
||||
} = parsedBody.data;
|
||||
@@ -276,8 +292,8 @@ export async function createSiteResource(
|
||||
.safeParse(destination).success;
|
||||
if (
|
||||
isIp &&
|
||||
(isIpInCidr(destination, org.subnet) ||
|
||||
isIpInCidr(destination, org.utilitySubnet))
|
||||
(isIpInCidr(destination!, org.subnet) ||
|
||||
isIpInCidr(destination!, org.utilitySubnet))
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -389,6 +405,15 @@ export async function createSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
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!,
|
||||
@@ -397,16 +422,18 @@ export async function createSiteResource(
|
||||
mode,
|
||||
ssl,
|
||||
networkId: network.networkId,
|
||||
destination,
|
||||
destination: destination, // the ssh can be null
|
||||
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
|
||||
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
|
||||
@@ -416,6 +443,7 @@ export async function createSiteResource(
|
||||
insertValues.authDaemonPort = authDaemonPort;
|
||||
if (authDaemonMode !== undefined)
|
||||
insertValues.authDaemonMode = authDaemonMode;
|
||||
if (pamMode !== undefined) insertValues.pamMode = pamMode;
|
||||
}
|
||||
[newSiteResource] = await trx
|
||||
.insert(siteResources)
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
|
||||
import {
|
||||
db,
|
||||
DB_TYPE,
|
||||
Label,
|
||||
SiteResource,
|
||||
siteNetworks,
|
||||
siteResourceLabels,
|
||||
siteResources,
|
||||
sites,
|
||||
labels
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -9,6 +19,8 @@ import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -69,16 +81,11 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
}),
|
||||
siteId: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.openapi({
|
||||
type: "integer",
|
||||
description:
|
||||
"When set, only site resources associated with this site (via network) are returned"
|
||||
})
|
||||
siteId: z.coerce.number<string>().int().positive().optional().openapi({
|
||||
type: "integer",
|
||||
description:
|
||||
"When set, only site resources associated with this site (via network) are returned"
|
||||
})
|
||||
});
|
||||
|
||||
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||
@@ -88,6 +95,7 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||
siteNames: string[];
|
||||
siteNiceIds: string[];
|
||||
siteAddresses: (string | null)[];
|
||||
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
|
||||
})[];
|
||||
}>;
|
||||
|
||||
@@ -234,6 +242,11 @@ export async function listAllSiteResourcesByOrg(
|
||||
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
||||
parsedQuery.data;
|
||||
|
||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.labels
|
||||
);
|
||||
|
||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||
|
||||
if (siteId != null) {
|
||||
@@ -258,41 +271,41 @@ export async function listAllSiteResourcesByOrg(
|
||||
inArray(siteResources.siteResourceId, resourcesForSite)
|
||||
);
|
||||
}
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${siteResources.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.destination})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.alias})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.aliasAddress})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${sites.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (mode) {
|
||||
conditions.push(eq(siteResources.mode, mode));
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
like(sql`LOWER(${siteResources.name})`, q),
|
||||
like(sql`LOWER(${siteResources.niceId})`, q),
|
||||
like(sql`LOWER(${siteResources.destination})`, q),
|
||||
like(sql`LOWER(${siteResources.alias})`, q),
|
||||
like(sql`LOWER(${siteResources.aliasAddress})`, q),
|
||||
like(sql`LOWER(${sites.name})`, q)
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
queryList.push(
|
||||
inArray(
|
||||
siteResources.siteResourceId,
|
||||
db
|
||||
.select({ id: siteResourceLabels.siteResourceId })
|
||||
.from(siteResourceLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, siteResourceLabels.labelId)
|
||||
)
|
||||
.where(like(sql`LOWER(${labels.name})`, q))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
conditions.push(or(...queryList));
|
||||
}
|
||||
|
||||
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(
|
||||
@@ -315,11 +328,51 @@ export async function listAllSiteResourcesByOrg(
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
|
||||
const siteResourcesList = siteResourcesRaw.map(
|
||||
transformSiteResourceRow
|
||||
);
|
||||
|
||||
const siteResourceIdList = siteResourcesList.map(
|
||||
(r) => r.siteResourceId
|
||||
);
|
||||
|
||||
let labelsForSiteResources: Array<{
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
siteResourceId: number;
|
||||
}> = [];
|
||||
|
||||
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
||||
labelsForSiteResources = await db
|
||||
.select({
|
||||
labelId: labels.labelId,
|
||||
name: labels.name,
|
||||
color: labels.color,
|
||||
siteResourceId: siteResourceLabels.siteResourceId
|
||||
})
|
||||
.from(labels)
|
||||
.innerJoin(
|
||||
siteResourceLabels,
|
||||
eq(siteResourceLabels.labelId, labels.labelId)
|
||||
)
|
||||
.where(
|
||||
inArray(
|
||||
siteResourceLabels.siteResourceId,
|
||||
siteResourceIdList
|
||||
)
|
||||
)
|
||||
.orderBy(asc(siteResourceLabels.siteResourceLabelId));
|
||||
}
|
||||
|
||||
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||
data: {
|
||||
siteResources: siteResourcesList,
|
||||
siteResources: siteResourcesList.map((r) => ({
|
||||
...r,
|
||||
labels: labelsForSiteResources.filter(
|
||||
(l) => l.siteResourceId === r.siteResourceId
|
||||
)
|
||||
})),
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
@@ -340,4 +393,4 @@ export async function listAllSiteResourcesByOrg(
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ const updateSiteResourceSchema = z
|
||||
)
|
||||
.optional(),
|
||||
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
||||
mode: z.enum(["host", "cidr", "http"]).optional(),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh"]).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).nullish(),
|
||||
// proxyPort: z.int().positive().nullish(),
|
||||
@@ -77,14 +77,18 @@ const updateSiteResourceSchema = z
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
disableIcmp: z.boolean().optional(),
|
||||
authDaemonPort: z.int().positive().nullish(),
|
||||
authDaemonMode: z.enum(["site", "remote"]).optional(),
|
||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||
domainId: z.string().optional(),
|
||||
subdomain: z.string().optional()
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode === "host" && data.destination) {
|
||||
if (
|
||||
(data.mode === "host" || data.mode == "ssh") &&
|
||||
data.destination
|
||||
) {
|
||||
const isValidIP = z
|
||||
// .union([z.ipv4(), z.ipv6()])
|
||||
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
||||
@@ -125,15 +129,24 @@ const updateSiteResourceSchema = z
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode !== "http") return true;
|
||||
return (
|
||||
data.scheme !== undefined &&
|
||||
data.scheme !== null &&
|
||||
data.destinationPort !== undefined &&
|
||||
data.destinationPort !== null &&
|
||||
data.destinationPort >= 1 &&
|
||||
data.destinationPort <= 65535
|
||||
);
|
||||
if (data.mode === "http") {
|
||||
return (
|
||||
data.scheme !== undefined &&
|
||||
data.scheme !== null &&
|
||||
data.destinationPort !== undefined &&
|
||||
data.destinationPort !== null &&
|
||||
data.destinationPort >= 1 &&
|
||||
data.destinationPort <= 65535
|
||||
);
|
||||
} else if (data.mode === "ssh") {
|
||||
// just check the destinationPort
|
||||
return (
|
||||
data.destinationPort === undefined ||
|
||||
(data.destinationPort !== null &&
|
||||
data.destinationPort >= 1 &&
|
||||
data.destinationPort <= 65535)
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
message:
|
||||
@@ -222,6 +235,7 @@ export async function updateSiteResource(
|
||||
disableIcmp,
|
||||
authDaemonPort,
|
||||
authDaemonMode,
|
||||
pamMode,
|
||||
domainId,
|
||||
subdomain
|
||||
} = parsedBody.data;
|
||||
@@ -430,16 +444,30 @@ export async function updateSiteResource(
|
||||
const sshPamSet =
|
||||
isLicensedSshPam &&
|
||||
(authDaemonPort !== undefined ||
|
||||
authDaemonMode !== undefined)
|
||||
authDaemonMode !== undefined ||
|
||||
pamMode !== undefined)
|
||||
? {
|
||||
...(authDaemonPort !== undefined && {
|
||||
authDaemonPort
|
||||
}),
|
||||
...(authDaemonMode !== undefined && {
|
||||
authDaemonMode
|
||||
}),
|
||||
...(pamMode !== undefined && {
|
||||
pamMode
|
||||
})
|
||||
}
|
||||
: {};
|
||||
|
||||
let tcpPortRangeStringAdjusted = tcpPortRangeString;
|
||||
if (mode === "http") {
|
||||
tcpPortRangeStringAdjusted = "443,80";
|
||||
} else if (mode === "ssh") {
|
||||
tcpPortRangeStringAdjusted = destinationPort
|
||||
? destinationPort.toString()
|
||||
: "22";
|
||||
}
|
||||
|
||||
[updatedSiteResource] = await trx
|
||||
.update(siteResources)
|
||||
.set({
|
||||
@@ -452,12 +480,14 @@ export async function updateSiteResource(
|
||||
destinationPort,
|
||||
enabled,
|
||||
alias: alias ? alias.trim() : null,
|
||||
tcpPortRangeString:
|
||||
mode == "http" ? "443,80" : tcpPortRangeString,
|
||||
tcpPortRangeString: tcpPortRangeStringAdjusted,
|
||||
udpPortRangeString:
|
||||
mode == "http" ? "" : udpPortRangeString,
|
||||
mode == "http" || mode == "ssh"
|
||||
? ""
|
||||
: udpPortRangeString,
|
||||
disableIcmp:
|
||||
disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false
|
||||
disableIcmp ||
|
||||
(mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false
|
||||
domainId,
|
||||
subdomain: finalSubdomain,
|
||||
fullDomain,
|
||||
@@ -554,13 +584,17 @@ export async function updateSiteResource(
|
||||
const sshPamSet =
|
||||
isLicensedSshPam &&
|
||||
(authDaemonPort !== undefined ||
|
||||
authDaemonMode !== undefined)
|
||||
authDaemonMode !== undefined ||
|
||||
pamMode !== undefined)
|
||||
? {
|
||||
...(authDaemonPort !== undefined && {
|
||||
authDaemonPort
|
||||
}),
|
||||
...(authDaemonMode !== undefined && {
|
||||
authDaemonMode
|
||||
}),
|
||||
...(pamMode !== undefined && {
|
||||
pamMode
|
||||
})
|
||||
}
|
||||
: {};
|
||||
@@ -832,6 +866,10 @@ export async function handleMessagingForUpdatedSiteResource(
|
||||
for (const client of mergedAllClients) {
|
||||
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
|
||||
// todo: optimize this query if needed
|
||||
if (!existingSiteResource.destination) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldDestinationStillInUseSites = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
|
||||
@@ -1352,6 +1352,12 @@ export default function BillingPage() {
|
||||
{t("billingModifyCurrentPlan") ||
|
||||
"Modify Current Plan"}
|
||||
</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>
|
||||
</SettingsSectionBody>
|
||||
|
||||
63
src/app/[orgId]/settings/(private)/labels/page.tsx
Normal file
63
src/app/[orgId]/settings/(private)/labels/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||
import { AxiosResponse } from "axios";
|
||||
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Labels"
|
||||
};
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LabelsPage({ params, searchParams }: Props) {
|
||||
const { orgId } = await params;
|
||||
|
||||
const searchParamsObj = new URLSearchParams(await searchParams);
|
||||
|
||||
let labels: ListOrgLabelsResponse["labels"] = [];
|
||||
let pagination: ListOrgLabelsResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListOrgLabelsResponse>>(
|
||||
`/org/${orgId}/labels?${searchParamsObj.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const responseData = res.data.data;
|
||||
labels = responseData.labels;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("labels")}
|
||||
description={t("orgLabelsDescription")}
|
||||
/>
|
||||
|
||||
<OrgLabelsTable
|
||||
labels={labels}
|
||||
orgId={orgId}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
agent: client.agent,
|
||||
archived: client.archived || false,
|
||||
blocked: client.blocked || false,
|
||||
approvalState: client.approvalState ?? "approved"
|
||||
approvalState: client.approvalState ?? "approved",
|
||||
labels: client.labels ?? []
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -153,65 +153,6 @@ export default function GeneralPage() {
|
||||
const [approvalId, setApprovalId] = useState<number | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
const [cacheCheck, setCacheCheck] = useState<null | {
|
||||
consistent: boolean;
|
||||
missingSiteResourceIds: number[];
|
||||
extraSiteResourceIds: number[];
|
||||
missingSiteIds: number[];
|
||||
extraSiteIds: number[];
|
||||
expectedSiteResourceIds: number[];
|
||||
actualSiteResourceIds: number[];
|
||||
expectedSiteIds: number[];
|
||||
actualSiteIds: number[];
|
||||
}>(null);
|
||||
const [isCheckingCache, setIsCheckingCache] = useState(false);
|
||||
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
|
||||
|
||||
const handleRebuildCache = async () => {
|
||||
if (!client.clientId) return;
|
||||
setIsRebuildingCache(true);
|
||||
try {
|
||||
await api.post(
|
||||
`/client/${client.clientId}/rebuild-associations-cache`
|
||||
);
|
||||
// Re-verify after rebuild so the result refreshes
|
||||
const res = await api.get(
|
||||
`/client/${client.clientId}/verify-associations-cache`
|
||||
);
|
||||
setCacheCheck(res.data.data);
|
||||
toast({
|
||||
title: "Cache rebuilt",
|
||||
description: "Association cache rebuilt successfully."
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Rebuild failed",
|
||||
description: formatAxiosError(e, "Failed to rebuild cache")
|
||||
});
|
||||
} finally {
|
||||
setIsRebuildingCache(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCache = async () => {
|
||||
if (!client.clientId) return;
|
||||
setIsCheckingCache(true);
|
||||
try {
|
||||
const res = await api.get(
|
||||
`/client/${client.clientId}/verify-associations-cache`
|
||||
);
|
||||
setCacheCheck(res.data.data);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Cache check failed",
|
||||
description: formatAxiosError(e, "Failed to verify cache")
|
||||
});
|
||||
} finally {
|
||||
setIsCheckingCache(false);
|
||||
}
|
||||
};
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const showApprovalFeatures =
|
||||
@@ -903,75 +844,6 @@ export default function GeneralPage() {
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Hidden cache verification — subtle button, dev/admin diagnostic */}
|
||||
<div className="mt-8 flex flex-col gap-2 items-start opacity-30 hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVerifyCache}
|
||||
disabled={isCheckingCache}
|
||||
className="text-xs text-muted-foreground underline disabled:opacity-50"
|
||||
title="Verify the client's site association cache against current permissions (read-only)"
|
||||
>
|
||||
{isCheckingCache
|
||||
? "Checking cache…"
|
||||
: "Verify association cache"}
|
||||
</button>
|
||||
{cacheCheck && (
|
||||
<div
|
||||
className={
|
||||
"text-xs rounded border px-2 py-1 " +
|
||||
(cacheCheck.consistent
|
||||
? "border-green-600 text-green-700"
|
||||
: "border-red-600 text-red-700")
|
||||
}
|
||||
>
|
||||
{cacheCheck.consistent ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Cache is consistent
|
||||
</span>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 font-semibold">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Cache is INCONSISTENT
|
||||
</div>
|
||||
<div>
|
||||
Missing site resources: [
|
||||
{cacheCheck.missingSiteResourceIds.join(
|
||||
", "
|
||||
)}
|
||||
]
|
||||
</div>
|
||||
<div>
|
||||
Extra site resources: [
|
||||
{cacheCheck.extraSiteResourceIds.join(", ")}
|
||||
]
|
||||
</div>
|
||||
<div>
|
||||
Missing sites: [
|
||||
{cacheCheck.missingSiteIds.join(", ")}]
|
||||
</div>
|
||||
<div>
|
||||
Extra sites: [
|
||||
{cacheCheck.extraSiteIds.join(", ")}]
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRebuildCache}
|
||||
disabled={isRebuildingCache}
|
||||
className="mt-1 text-xs underline font-semibold disabled:opacity-50"
|
||||
>
|
||||
{isRebuildingCache
|
||||
? "Rebuilding…"
|
||||
: "Rebuild cache now"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
@@ -294,7 +294,7 @@ export default function ConnectionLogsPage() {
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("Failed to filter logs"),
|
||||
description: formatAxiosError(error),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -56,7 +56,9 @@ export default async function ClientResourcesPage(
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
|
||||
const siteIdParam = parsePositiveInt(
|
||||
searchParams.get("siteId") ?? undefined
|
||||
);
|
||||
|
||||
let initialFilterSite: {
|
||||
siteId: number;
|
||||
@@ -106,7 +108,10 @@ export default async function ClientResourcesPage(
|
||||
siteNiceId: siteResource.siteNiceIds[idx],
|
||||
online: siteResource.siteOnlines[idx]
|
||||
})),
|
||||
mode: siteResource.mode,
|
||||
mode:
|
||||
siteResource.pamMode && siteResource.mode === "host"
|
||||
? "ssh"
|
||||
: siteResource.mode,
|
||||
scheme: siteResource.scheme,
|
||||
ssl: siteResource.ssl,
|
||||
siteNames: siteResource.siteNames,
|
||||
@@ -115,7 +120,7 @@ export default async function ClientResourcesPage(
|
||||
// proxyPort: siteResource.proxyPort,
|
||||
siteIds: siteResource.siteIds,
|
||||
destination: siteResource.destination,
|
||||
httpHttpsPort: siteResource.destinationPort ?? null,
|
||||
destinationPort: siteResource.destinationPort ?? null,
|
||||
alias: siteResource.alias || null,
|
||||
aliasAddress: siteResource.aliasAddress || null,
|
||||
siteNiceIds: siteResource.siteNiceIds,
|
||||
@@ -125,9 +130,11 @@ export default async function ClientResourcesPage(
|
||||
disableIcmp: siteResource.disableIcmp || false,
|
||||
authDaemonMode: siteResource.authDaemonMode ?? null,
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null,
|
||||
pamMode: siteResource.pamMode ?? null,
|
||||
subdomain: siteResource.subdomain ?? null,
|
||||
domainId: siteResource.domainId ?? null,
|
||||
fullDomain: siteResource.fullDomain ?? null
|
||||
fullDomain: siteResource.fullDomain ?? null,
|
||||
labels: siteResource.labels ?? []
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,15 +3,7 @@
|
||||
import HealthCheckCredenza from "@/components/HealthCheckCredenza";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { HeadersInput } from "@app/components/HeadersInput";
|
||||
import {
|
||||
PathMatchDisplay,
|
||||
PathMatchModal,
|
||||
@@ -20,25 +12,12 @@ import {
|
||||
} from "@app/components/PathMatchRenameModal";
|
||||
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -55,17 +34,13 @@ import {
|
||||
} from "@app/components/ui/tooltip";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { build } from "@server/build";
|
||||
import { tlsNameSchema } from "@server/lib/schemas";
|
||||
import { type GetResourceResponse } from "@server/routers/resource";
|
||||
import type { ListSitesResponse } from "@server/routers/site";
|
||||
import { CreateTargetResponse } from "@server/routers/target";
|
||||
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
||||
import { ArrayElement } from "@server/types/ArrayElement";
|
||||
@@ -80,33 +55,18 @@ import {
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Plus,
|
||||
Settings
|
||||
} from "lucide-react";
|
||||
import { ExternalLink, Info, Plus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
use,
|
||||
useActionState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
const targetsSettingsSchema = z.object({
|
||||
stickySession: z.boolean()
|
||||
});
|
||||
|
||||
type LocalTarget = Omit<
|
||||
export type LocalTarget = Omit<
|
||||
ArrayElement<ListTargetsResponse["targets"]> & {
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
@@ -115,67 +75,43 @@ type LocalTarget = Omit<
|
||||
"protocol"
|
||||
>;
|
||||
|
||||
export default function ReverseProxyTargetsPage(props: {
|
||||
params: Promise<{ resourceId: number; orgId: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
|
||||
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
|
||||
resourceQueries.resourceTargets({
|
||||
resourceId: resource.resourceId
|
||||
})
|
||||
);
|
||||
|
||||
if (isLoadingTargets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
interface ProxyResourceTargetsFormProps {
|
||||
orgId: string;
|
||||
isHttp: boolean;
|
||||
initialTargets?: LocalTarget[];
|
||||
/** Edit mode: when provided, shows a save button and polls for health status */
|
||||
resource?: GetResourceResponse;
|
||||
updateResource?: ResourceContextType["updateResource"];
|
||||
/** Create mode: called whenever the targets list changes */
|
||||
onChange?: (targets: LocalTarget[]) => void;
|
||||
}
|
||||
|
||||
function ProxyResourceTargetsForm({
|
||||
export function ProxyResourceTargetsForm({
|
||||
orgId,
|
||||
initialTargets,
|
||||
resource
|
||||
}: {
|
||||
initialTargets: LocalTarget[];
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
}) {
|
||||
isHttp,
|
||||
initialTargets = [],
|
||||
resource,
|
||||
updateResource,
|
||||
onChange
|
||||
}: ProxyResourceTargetsFormProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
|
||||
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({
|
||||
...resourceQueries.resourceTargets({
|
||||
resourceId: resource.resourceId
|
||||
resourceId: resource?.resourceId ?? 0
|
||||
}),
|
||||
refetchInterval: 10_000
|
||||
refetchInterval: 10_000,
|
||||
enabled: !!resource
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -194,6 +130,7 @@ function ProxyResourceTargetsForm({
|
||||
})
|
||||
);
|
||||
}, [polledTargets]);
|
||||
|
||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
||||
new Map()
|
||||
);
|
||||
@@ -201,14 +138,17 @@ function ProxyResourceTargetsForm({
|
||||
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
||||
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) => {
|
||||
if (dockerStates.has(siteId)) {
|
||||
return; // Already initialized
|
||||
return;
|
||||
}
|
||||
|
||||
const dockerManager = new DockerManager(api, siteId);
|
||||
const dockerState = await dockerManager.initializeDocker();
|
||||
|
||||
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
||||
};
|
||||
|
||||
@@ -216,7 +156,6 @@ function ProxyResourceTargetsForm({
|
||||
async (siteId: number) => {
|
||||
const dockerManager = new DockerManager(api, siteId);
|
||||
const containers = await dockerManager.fetchContainers();
|
||||
|
||||
setDockerStates((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existingState = newMap.get(siteId);
|
||||
@@ -250,8 +189,6 @@ function ProxyResourceTargetsForm({
|
||||
return false;
|
||||
});
|
||||
|
||||
const isHttp = resource.http;
|
||||
|
||||
const removeTarget = useCallback((targetId: number) => {
|
||||
setTargets((prevTargets) => {
|
||||
const targetToRemove = prevTargets.find(
|
||||
@@ -270,6 +207,42 @@ function ProxyResourceTargetsForm({
|
||||
})
|
||||
);
|
||||
|
||||
// Browser-gateway targets (edit mode only)
|
||||
const { data: bgTargetsResponse } = useQuery({
|
||||
queryKey: ["browserGatewayTargets", resource?.resourceId, orgId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(
|
||||
`/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets`
|
||||
);
|
||||
return res.data.data as {
|
||||
targets: Array<{
|
||||
browserGatewayTargetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
type: string;
|
||||
destination: string;
|
||||
destinationPort: number;
|
||||
}>;
|
||||
};
|
||||
},
|
||||
enabled: !!resource
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgTargetsResponse?.targets?.length) return;
|
||||
const bgt = bgTargetsResponse.targets[0];
|
||||
setBgDestination(bgt.destination);
|
||||
setBgDestinationPort(String(bgt.destinationPort));
|
||||
setBgSiteId(bgt.siteId);
|
||||
setBgTargetId(bgt.browserGatewayTargetId);
|
||||
}, [bgTargetsResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sites.length > 0 && bgSiteId === null) {
|
||||
setBgSiteId(sites[0].siteId);
|
||||
}
|
||||
}, [sites, bgSiteId]);
|
||||
|
||||
const updateTarget = useCallback(
|
||||
(targetId: number, data: Partial<LocalTarget>) => {
|
||||
setTargets((prevTargets) => {
|
||||
@@ -356,7 +329,7 @@ function ProxyResourceTargetsForm({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
{row.original.siteType === "newt" ? (
|
||||
<Button
|
||||
@@ -375,7 +348,6 @@ function ProxyResourceTargetsForm({
|
||||
{getStatusText(status)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
@@ -404,9 +376,15 @@ function ProxyResourceTargetsForm({
|
||||
pathMatchType: row.original.pathMatchType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(row.original.targetId,
|
||||
config.path === null && config.pathMatchType === null
|
||||
? { ...config, rewritePath: null, rewritePathType: null }
|
||||
updateTarget(
|
||||
row.original.targetId,
|
||||
config.path === null &&
|
||||
config.pathMatchType === null
|
||||
? {
|
||||
...config,
|
||||
rewritePath: null,
|
||||
rewritePathType: null
|
||||
}
|
||||
: config
|
||||
)
|
||||
}
|
||||
@@ -432,9 +410,15 @@ function ProxyResourceTargetsForm({
|
||||
pathMatchType: row.original.pathMatchType
|
||||
}}
|
||||
onChange={(config) =>
|
||||
updateTarget(row.original.targetId,
|
||||
config.path === null && config.pathMatchType === null
|
||||
? { ...config, rewritePath: null, rewritePathType: null }
|
||||
updateTarget(
|
||||
row.original.targetId,
|
||||
config.path === null &&
|
||||
config.pathMatchType === null
|
||||
? {
|
||||
...config,
|
||||
rewritePath: null,
|
||||
rewritePathType: null
|
||||
}
|
||||
: config
|
||||
)
|
||||
}
|
||||
@@ -587,20 +571,19 @@ function ProxyResourceTargetsForm({
|
||||
};
|
||||
|
||||
if (isAdvancedMode) {
|
||||
const columns = [
|
||||
const cols = [
|
||||
addressColumn,
|
||||
healthCheckColumn,
|
||||
enabledColumn,
|
||||
actionsColumn
|
||||
];
|
||||
|
||||
// Only include path-related columns for HTTP resources
|
||||
if (isHttp) {
|
||||
columns.unshift(matchPathColumn);
|
||||
columns.splice(3, 0, rewritePathColumn, priorityColumn);
|
||||
cols.unshift(matchPathColumn);
|
||||
cols.splice(3, 0, rewritePathColumn, priorityColumn);
|
||||
}
|
||||
|
||||
return columns;
|
||||
return cols;
|
||||
} else {
|
||||
return [
|
||||
addressColumn,
|
||||
@@ -622,22 +605,20 @@ function ProxyResourceTargetsForm({
|
||||
]);
|
||||
|
||||
function addNewTarget() {
|
||||
const isHttp = resource.http;
|
||||
|
||||
const newTarget: LocalTarget = {
|
||||
targetId: -Date.now(), // Use negative timestamp as temporary ID
|
||||
targetId: -Date.now(),
|
||||
ip: "",
|
||||
method: isHttp ? "http" : null,
|
||||
port: 0,
|
||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||
siteName: sites.length > 0 ? sites[0].name : "",
|
||||
path: isHttp ? null : null,
|
||||
pathMatchType: isHttp ? null : null,
|
||||
rewritePath: isHttp ? null : null,
|
||||
rewritePathType: isHttp ? null : null,
|
||||
priority: isHttp ? 100 : 100,
|
||||
path: null,
|
||||
pathMatchType: null,
|
||||
rewritePath: null,
|
||||
rewritePathType: null,
|
||||
priority: 100,
|
||||
enabled: true,
|
||||
resourceId: resource.resourceId,
|
||||
resourceId: resource?.resourceId ?? 0,
|
||||
hcEnabled: false,
|
||||
hcPath: null,
|
||||
hcMethod: null,
|
||||
@@ -694,7 +675,6 @@ function ProxyResourceTargetsForm({
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -704,7 +684,6 @@ function ProxyResourceTargetsForm({
|
||||
}
|
||||
}, [sites]);
|
||||
|
||||
// Save advanced mode preference to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(
|
||||
@@ -717,7 +696,8 @@ function ProxyResourceTargetsForm({
|
||||
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
||||
|
||||
async function saveTargets() {
|
||||
// Validate that no targets have blank IPs or invalid ports
|
||||
if (!resource) return;
|
||||
|
||||
const targetsWithInvalidFields = targets.filter(
|
||||
(target) =>
|
||||
!target.ip ||
|
||||
@@ -726,7 +706,6 @@ function ProxyResourceTargetsForm({
|
||||
target.port <= 0 ||
|
||||
isNaN(target.port)
|
||||
);
|
||||
console.log(targetsWithInvalidFields);
|
||||
if (targetsWithInvalidFields.length > 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -743,7 +722,6 @@ function ProxyResourceTargetsForm({
|
||||
)
|
||||
);
|
||||
|
||||
// Save targets
|
||||
for (const target of targets) {
|
||||
const data: any = {
|
||||
ip: target.ip,
|
||||
@@ -769,8 +747,7 @@ function ProxyResourceTargetsForm({
|
||||
hcUnhealthyThreshold: target.hcUnhealthyThreshold || null
|
||||
};
|
||||
|
||||
// Only include path-related fields for HTTP resources
|
||||
if (resource.http) {
|
||||
if (isHttp) {
|
||||
data.path = target.path;
|
||||
data.pathMatchType = target.pathMatchType;
|
||||
data.rewritePath = target.rewritePath;
|
||||
@@ -791,12 +768,14 @@ function ProxyResourceTargetsForm({
|
||||
}
|
||||
|
||||
toast({
|
||||
title: targets.length === 0
|
||||
? t("targetTargetsCleared")
|
||||
: t("settingsUpdated"),
|
||||
description: targets.length === 0
|
||||
? t("targetTargetsClearedDescription")
|
||||
: t("settingsUpdatedDescription")
|
||||
title:
|
||||
targets.length === 0
|
||||
? t("targetTargetsCleared")
|
||||
: t("settingsUpdated"),
|
||||
description:
|
||||
targets.length === 0
|
||||
? t("targetTargetsClearedDescription")
|
||||
: t("settingsUpdatedDescription")
|
||||
});
|
||||
|
||||
setTargetsToRemove([]);
|
||||
@@ -918,9 +897,6 @@ function ProxyResourceTargetsForm({
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
{/* <TableCaption> */}
|
||||
{/* {t('targetNoOneDescription')} */}
|
||||
{/* </TableCaption> */}
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -978,15 +954,18 @@ function ProxyResourceTargetsForm({
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
|
||||
<form className="self-end mt-4" action={formAction}>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveResourceTargets")}
|
||||
</Button>
|
||||
</form>
|
||||
{/* Save button — only shown in edit mode */}
|
||||
{resource && (
|
||||
<form className="self-end mt-4" action={formAction}>
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveResourceTargets")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -507,7 +507,9 @@ export default function GeneralForm() {
|
||||
name: data.name,
|
||||
niceId: data.niceId,
|
||||
subdomain: data.subdomain
|
||||
? toASCII(finalizeSubdomainSanitize(data.subdomain, true))
|
||||
? toASCII(
|
||||
finalizeSubdomainSanitize(data.subdomain, true)
|
||||
)
|
||||
: undefined,
|
||||
domainId: data.domainId,
|
||||
proxyPort: data.proxyPort
|
||||
@@ -555,13 +557,15 @@ export default function GeneralForm() {
|
||||
return (
|
||||
<>
|
||||
<SettingsContainer>
|
||||
{resource?.resourceId && resource?.orgId && (
|
||||
<UptimeAlertSection
|
||||
orgId={resource.orgId}
|
||||
resourceId={resource.resourceId}
|
||||
startingName={resource.name}
|
||||
/>
|
||||
)}
|
||||
{resource?.resourceId &&
|
||||
resource?.orgId &&
|
||||
resource.browserAccessType == "http" && (
|
||||
<UptimeAlertSection
|
||||
orgId={resource.orgId}
|
||||
resourceId={resource.resourceId}
|
||||
startingName={resource.name}
|
||||
/>
|
||||
)}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
@@ -580,43 +584,44 @@ export default function GeneralForm() {
|
||||
className="space-y-4"
|
||||
id="general-settings-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="niceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("identifier")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"enterIdentifier"
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="niceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("identifier")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"enterIdentifier"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!resource.http && (
|
||||
<>
|
||||
@@ -726,28 +731,31 @@ export default function GeneralForm() {
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={() => (
|
||||
<FormItem className="col-span-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="enable-resource"
|
||||
defaultChecked={
|
||||
resource.enabled
|
||||
}
|
||||
label={t(
|
||||
"resourceEnable"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="enable-resource"
|
||||
defaultChecked={
|
||||
resource.enabled
|
||||
}
|
||||
label={t(
|
||||
"resourceEnable"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) =>
|
||||
form.setValue(
|
||||
"enabled",
|
||||
val
|
||||
) =>
|
||||
form.setValue(
|
||||
"enabled",
|
||||
val
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"disabledResourceDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
651
src/app/[orgId]/settings/resources/proxy/[niceId]/http/page.tsx
Normal file
651
src/app/[orgId]/settings/resources/proxy/[niceId]/http/page.tsx
Normal file
@@ -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={resource.http}
|
||||
initialTargets={remoteTargets}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
/>
|
||||
|
||||
{resource.http && (
|
||||
<ProxyResourceHttpForm
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!resource.http && resource.protocol == "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,8 +86,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t("proxy"),
|
||||
href: `/{orgId}/settings/resources/proxy/{niceId}/proxy`
|
||||
title: t(`${resource.browserAccessType}Settings`),
|
||||
href: `/{orgId}/settings/resources/proxy/{niceId}/${resource.browserAccessType}`
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -10,6 +10,6 @@ export default async function ResourcePage(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(
|
||||
`/${params.orgId}/settings/resources/proxy/${params.niceId}/proxy`
|
||||
`/${params.orgId}/settings/resources/proxy/${params.niceId}/general`
|
||||
);
|
||||
}
|
||||
|
||||
250
src/app/[orgId]/settings/resources/proxy/[niceId]/rdp/page.tsx
Normal file
250
src/app/[orgId]/settings/resources/proxy/[niceId]/rdp/page.tsx
Normal file
@@ -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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
@@ -36,7 +34,6 @@ import {
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
@@ -55,18 +52,11 @@ import {
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { ArrowUpDown, Check, InfoIcon, X, ChevronsUpDown } from "lucide-react";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import {
|
||||
isValidCIDR,
|
||||
@@ -78,7 +68,11 @@ import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { COUNTRIES } from "@server/db/countries";
|
||||
import { MAJOR_ASNS } from "@server/db/asns";
|
||||
import { REGIONS, getRegionNameById, isValidRegionId } from "@server/db/regions";
|
||||
import {
|
||||
REGIONS,
|
||||
getRegionNameById,
|
||||
isValidRegionId
|
||||
} from "@server/db/regions";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -109,25 +103,23 @@ type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
||||
export default function ResourceRules(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [rules, setRules] = useState<LocalRule[]>([]);
|
||||
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false);
|
||||
const [rulesEnabled, setRulesEnabled] = useState(
|
||||
resource.applyRules ?? false
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRulesEnabled(resource.applyRules);
|
||||
}, [resource.applyRules]);
|
||||
|
||||
const [openCountrySelect, setOpenCountrySelect] = useState(false);
|
||||
const [countrySelectValue, setCountrySelectValue] = useState("");
|
||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
||||
useState(false);
|
||||
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] =
|
||||
useState(false);
|
||||
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false);
|
||||
const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] =
|
||||
useState(false);
|
||||
const router = useRouter();
|
||||
@@ -157,7 +149,10 @@ export default function ResourceRules(props: {
|
||||
resolver: zodResolver(addRuleSchema),
|
||||
defaultValues: {
|
||||
action: "ACCEPT",
|
||||
match: "PATH",
|
||||
match:
|
||||
resource.http && resource.browserAccessType == "http"
|
||||
? "PATH"
|
||||
: "IP",
|
||||
value: ""
|
||||
}
|
||||
});
|
||||
@@ -270,16 +265,12 @@ export default function ResourceRules(props: {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
data.match === "REGION" &&
|
||||
!isValidRegionId(data.value)
|
||||
) {
|
||||
if (data.match === "REGION" && !isValidRegionId(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("rulesErrorInvalidRegion"),
|
||||
description:
|
||||
t("rulesErrorInvalidRegionDescription") ||
|
||||
"Invalid region."
|
||||
t("rulesErrorInvalidRegionDescription") || "Invalid region."
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -564,12 +555,24 @@ export default function ResourceRules(props: {
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
onValueChange={(
|
||||
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION"
|
||||
value:
|
||||
| "CIDR"
|
||||
| "IP"
|
||||
| "PATH"
|
||||
| "COUNTRY"
|
||||
| "ASN"
|
||||
| "REGION"
|
||||
) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
match: 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 +580,12 @@ export default function ResourceRules(props: {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
||||
{resource.http &&
|
||||
resource.browserAccessType == "http" && (
|
||||
<SelectItem value="PATH">
|
||||
{RuleMatch.PATH}
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
||||
{isMaxmindAvailable && (
|
||||
@@ -669,14 +677,14 @@ export default function ResourceRules(props: {
|
||||
>
|
||||
{row.original.value
|
||||
? (() => {
|
||||
const found = MAJOR_ASNS.find(
|
||||
const found = MAJOR_ASNS.find(
|
||||
(asn) =>
|
||||
asn.code ===
|
||||
row.original.value
|
||||
);
|
||||
return found
|
||||
? `${found.name} (${row.original.value})`
|
||||
: `Custom (${row.original.value})`;
|
||||
);
|
||||
return found
|
||||
? `${found.name} (${row.original.value})`
|
||||
: `Custom (${row.original.value})`;
|
||||
})()
|
||||
: "Select ASN"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
@@ -755,7 +763,9 @@ export default function ResourceRules(props: {
|
||||
className="min-w-[200px] justify-between"
|
||||
>
|
||||
{(() => {
|
||||
const regionName = getRegionNameById(row.original.value);
|
||||
const regionName = getRegionNameById(
|
||||
row.original.value
|
||||
);
|
||||
if (!regionName) {
|
||||
return t("selectRegion");
|
||||
}
|
||||
@@ -774,7 +784,10 @@ export default function ResourceRules(props: {
|
||||
{t("noRegionFound")}
|
||||
</CommandEmpty>
|
||||
{REGIONS.map((continent) => (
|
||||
<CommandGroup key={continent.id} heading={t(continent.name)}>
|
||||
<CommandGroup
|
||||
key={continent.id}
|
||||
heading={t(continent.name)}
|
||||
>
|
||||
<CommandItem
|
||||
value={continent.id}
|
||||
keywords={[
|
||||
@@ -790,38 +803,48 @@ export default function ResourceRules(props: {
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
row.original.value === continent.id
|
||||
row.original.value ===
|
||||
continent.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(continent.name)} ({continent.id})
|
||||
{t(continent.name)} (
|
||||
{continent.id})
|
||||
</CommandItem>
|
||||
{continent.includes.map((subregion) => (
|
||||
<CommandItem
|
||||
key={subregion.id}
|
||||
value={subregion.id}
|
||||
keywords={[
|
||||
t(subregion.name),
|
||||
subregion.id
|
||||
]}
|
||||
onSelect={() => {
|
||||
updateRule(
|
||||
row.original.ruleId,
|
||||
{ value: subregion.id }
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
row.original.value === subregion.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(subregion.name)} ({subregion.id})
|
||||
</CommandItem>
|
||||
))}
|
||||
{continent.includes.map(
|
||||
(subregion) => (
|
||||
<CommandItem
|
||||
key={subregion.id}
|
||||
value={subregion.id}
|
||||
keywords={[
|
||||
t(subregion.name),
|
||||
subregion.id
|
||||
]}
|
||||
onSelect={() => {
|
||||
updateRule(
|
||||
row.original
|
||||
.ruleId,
|
||||
{
|
||||
value: subregion.id
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
row.original
|
||||
.value ===
|
||||
subregion.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(subregion.name)} (
|
||||
{subregion.id})
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
@@ -1018,13 +1041,15 @@ export default function ResourceRules(props: {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resource.http && (
|
||||
<SelectItem value="PATH">
|
||||
{
|
||||
RuleMatch.PATH
|
||||
}
|
||||
</SelectItem>
|
||||
)}
|
||||
{resource.http &&
|
||||
resource.browserAccessType ==
|
||||
"http" && (
|
||||
<SelectItem value="PATH">
|
||||
{
|
||||
RuleMatch.PATH
|
||||
}
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="IP">
|
||||
{RuleMatch.IP}
|
||||
</SelectItem>
|
||||
@@ -1311,8 +1336,8 @@ export default function ResourceRules(props: {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : addRuleForm.watch(
|
||||
"match"
|
||||
) === "REGION" ? (
|
||||
"match"
|
||||
) === "REGION" ? (
|
||||
<Popover
|
||||
open={
|
||||
openAddRuleRegionSelect
|
||||
@@ -1334,8 +1359,16 @@ export default function ResourceRules(props: {
|
||||
>
|
||||
{field.value
|
||||
? (() => {
|
||||
const regionName = getRegionNameById(field.value);
|
||||
const translatedName = regionName ? t(regionName) : field.value;
|
||||
const regionName =
|
||||
getRegionNameById(
|
||||
field.value
|
||||
);
|
||||
const translatedName =
|
||||
regionName
|
||||
? t(
|
||||
regionName
|
||||
)
|
||||
: field.value;
|
||||
return `${translatedName} (${field.value})`;
|
||||
})()
|
||||
: t(
|
||||
@@ -1357,43 +1390,31 @@ export default function ResourceRules(props: {
|
||||
"noRegionFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{REGIONS.map((continent) => (
|
||||
<CommandGroup key={continent.id} heading={t(continent.name)}>
|
||||
<CommandItem
|
||||
value={continent.id}
|
||||
keywords={[
|
||||
t(continent.name),
|
||||
{REGIONS.map(
|
||||
(
|
||||
continent
|
||||
) => (
|
||||
<CommandGroup
|
||||
key={
|
||||
continent.id
|
||||
]}
|
||||
onSelect={() => {
|
||||
field.onChange(
|
||||
continent.id
|
||||
);
|
||||
setOpenAddRuleRegionSelect(
|
||||
false
|
||||
);
|
||||
}}
|
||||
}
|
||||
heading={t(
|
||||
continent.name
|
||||
)}
|
||||
>
|
||||
<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
|
||||
key={subregion.id}
|
||||
value={subregion.id}
|
||||
value={
|
||||
continent.id
|
||||
}
|
||||
keywords={[
|
||||
t(subregion.name),
|
||||
subregion.id
|
||||
t(
|
||||
continent.name
|
||||
),
|
||||
continent.id
|
||||
]}
|
||||
onSelect={() => {
|
||||
field.onChange(
|
||||
subregion.id
|
||||
continent.id
|
||||
);
|
||||
setOpenAddRuleRegionSelect(
|
||||
false
|
||||
@@ -1402,16 +1423,71 @@ export default function ResourceRules(props: {
|
||||
>
|
||||
<Check
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
field.value === subregion.id
|
||||
field.value ===
|
||||
continent.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
{t(subregion.name)} ({subregion.id})
|
||||
{t(
|
||||
continent.name
|
||||
)}{" "}
|
||||
(
|
||||
{
|
||||
continent.id
|
||||
}
|
||||
|
||||
)
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
{continent.includes.map(
|
||||
(
|
||||
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>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
524
src/app/[orgId]/settings/resources/proxy/[niceId]/ssh/page.tsx
Normal file
524
src/app/[orgId]/settings/resources/proxy/[niceId]/ssh/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
248
src/app/[orgId]/settings/resources/proxy/[niceId]/vnc/page.tsx
Normal file
248
src/app/[orgId]/settings/resources/proxy/[niceId]/vnc/page.tsx
Normal file
@@ -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
@@ -111,6 +111,7 @@ export default async function ProxyResourcesPage(
|
||||
protocol: resource.protocol,
|
||||
proxyPort: resource.proxyPort,
|
||||
http: resource.http,
|
||||
labels: resource.labels,
|
||||
authState: !resource.http
|
||||
? "none"
|
||||
: resource.sso ||
|
||||
@@ -125,6 +126,7 @@ export default async function ProxyResourcesPage(
|
||||
fullDomain: resource.fullDomain ?? null,
|
||||
ssl: resource.ssl,
|
||||
wildcard: resource.wildcard,
|
||||
browserAccessType: resource.browserAccessType,
|
||||
targets: resource.targets?.map((target) => ({
|
||||
targetId: target.targetId,
|
||||
ip: target.ip,
|
||||
|
||||
@@ -60,6 +60,7 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||
return {
|
||||
name: site.name,
|
||||
id: site.siteId,
|
||||
labels: site.labels,
|
||||
nice: site.niceId.toString(),
|
||||
address: site.address?.split("/")[0],
|
||||
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
||||
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -23,6 +23,7 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
SquareMousePointer,
|
||||
TagIcon,
|
||||
TicketCheck,
|
||||
Unplug,
|
||||
User,
|
||||
@@ -99,7 +100,7 @@ export const orgNavSections = (
|
||||
href: "/{orgId}/settings/domains",
|
||||
icon: <Globe className="size-4 flex-none" />
|
||||
},
|
||||
...(build == "saas"
|
||||
...(build === "saas"
|
||||
? [
|
||||
{
|
||||
title: "sidebarRemoteExitNodes",
|
||||
@@ -237,10 +238,19 @@ export const orgNavSections = (
|
||||
title: "sidebarApiKeys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
}
|
||||
},
|
||||
...(build !== "oss"
|
||||
? [
|
||||
{
|
||||
title: "labels",
|
||||
href: "/{orgId}/settings/labels",
|
||||
icon: <TagIcon className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
...(build == "saas" && options?.isPrimaryOrg
|
||||
...(build === "saas" && options?.isPrimaryOrg
|
||||
? [
|
||||
{
|
||||
title: "sidebarBillingAndLicenses",
|
||||
|
||||
522
src/app/rdp/RdpClient.tsx
Normal file
522
src/app/rdp/RdpClient.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import type {
|
||||
UserInteraction,
|
||||
IronError,
|
||||
FileTransferProvider
|
||||
} from "@devolutions/iron-remote-desktop/dist";
|
||||
import type {
|
||||
RdpFileTransferProvider,
|
||||
FileInfo
|
||||
} from "@devolutions/iron-remote-desktop-rdp/dist";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
|
||||
declare module "react" {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"iron-remote-desktop": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement> & {
|
||||
scale?: string;
|
||||
verbose?: string;
|
||||
flexcenter?: string;
|
||||
module?: unknown;
|
||||
},
|
||||
HTMLElement
|
||||
>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
username: string;
|
||||
password: string;
|
||||
domain: string;
|
||||
kdcProxyUrl: string;
|
||||
pcb: string;
|
||||
enableClipboard: boolean;
|
||||
};
|
||||
|
||||
const isIronError = (error: unknown): error is IronError => {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
typeof (error as IronError).backtrace === "function" &&
|
||||
typeof (error as IronError).kind === "function"
|
||||
);
|
||||
};
|
||||
|
||||
export default function RdpClient({
|
||||
target,
|
||||
error
|
||||
}: {
|
||||
target: GetBrowserTargetResponse | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
const STORAGE_KEY = "pangolin_rdp_credentials";
|
||||
|
||||
const [form, setForm] = useState<FormState>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) return JSON.parse(saved) as FormState;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
username: "",
|
||||
password: "",
|
||||
domain: "",
|
||||
kdcProxyUrl: "",
|
||||
pcb: "",
|
||||
enableClipboard: true
|
||||
};
|
||||
});
|
||||
|
||||
const [showLogin, setShowLogin] = useState(true);
|
||||
const [moduleReady, setModuleReady] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [unicodeMode, setUnicodeMode] = useState(false);
|
||||
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
|
||||
|
||||
const userInteractionRef = useRef<UserInteraction | null>(null);
|
||||
const backendRef = useRef<unknown>(null);
|
||||
// Holds the RdpFileTransferProvider constructor so we can create a fresh
|
||||
// instance per session (avoids stale upload state across reconnects).
|
||||
const fileTransferClassRef = useRef<typeof RdpFileTransferProvider | null>(
|
||||
null
|
||||
);
|
||||
// Active session's provider instance; replaced on each connect.
|
||||
const fileTransferRef = useRef<RdpFileTransferProvider | null>(null);
|
||||
const extensionsRef = useRef<{
|
||||
displayControl: (enable: boolean) => unknown;
|
||||
preConnectionBlob: (pcb: string) => unknown;
|
||||
kdcProxyUrl: (url: string) => unknown;
|
||||
} | null>(null);
|
||||
|
||||
// Load the iron-remote-desktop modules client-side and register the
|
||||
// `<iron-remote-desktop>` custom element.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [coreMod, rdpMod] = await Promise.all([
|
||||
import("@devolutions/iron-remote-desktop/dist"),
|
||||
import("@devolutions/iron-remote-desktop-rdp/dist")
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
await rdpMod.init("INFO");
|
||||
|
||||
backendRef.current = rdpMod.Backend;
|
||||
extensionsRef.current = {
|
||||
displayControl: rdpMod.displayControl,
|
||||
preConnectionBlob: rdpMod.preConnectionBlob,
|
||||
kdcProxyUrl: rdpMod.kdcProxyUrl
|
||||
};
|
||||
|
||||
// Store the class; a fresh instance is created per session.
|
||||
fileTransferClassRef.current =
|
||||
rdpMod.RdpFileTransferProvider as unknown as typeof RdpFileTransferProvider;
|
||||
|
||||
// Importing the package registers the custom element as a side
|
||||
// effect. Touch the default export to avoid tree-shaking.
|
||||
void coreMod;
|
||||
|
||||
setModuleReady(true);
|
||||
})().catch((err) => {
|
||||
console.error("Failed to load iron-remote-desktop modules", err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to load RDP module",
|
||||
description: `${err}`
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Attach the "ready" listener synchronously the moment the custom
|
||||
// element mounts. The custom element dispatches `ready` from its own
|
||||
// `onMount`, so a deferred useEffect can race and miss it.
|
||||
const remoteElementRef = (el: HTMLElement | null) => {
|
||||
if (!el) return;
|
||||
const onReady = (e: Event) => {
|
||||
const event = e as CustomEvent;
|
||||
userInteractionRef.current = event.detail.irgUserInteraction;
|
||||
};
|
||||
el.addEventListener("ready", onReady);
|
||||
};
|
||||
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const startSession = async () => {
|
||||
setConnecting(true);
|
||||
const userInteraction = userInteractionRef.current;
|
||||
const exts = extensionsRef.current;
|
||||
if (!userInteraction || !exts) {
|
||||
setConnecting(false);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Not ready",
|
||||
description: "RDP module is still initializing"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
userInteraction.setEnableClipboard(form.enableClipboard);
|
||||
|
||||
// Dispose any previous session's provider and create a fresh one so
|
||||
// there is no stale upload state from a prior connection.
|
||||
fileTransferRef.current?.dispose();
|
||||
const ProviderClass = fileTransferClassRef.current;
|
||||
const fileTransfer = ProviderClass ? new ProviderClass() : null;
|
||||
fileTransferRef.current = fileTransfer;
|
||||
|
||||
if (fileTransfer) {
|
||||
// Auto-download files when the remote copies them to clipboard.
|
||||
fileTransfer.on("files-available", (files: FileInfo[]) => {
|
||||
const downloadable = files.filter((f) => !f.isDirectory);
|
||||
if (downloadable.length === 0) return;
|
||||
toast({
|
||||
title: `Downloading ${downloadable.length} file(s) from remote…`
|
||||
});
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.isDirectory) continue;
|
||||
const { completion } = fileTransfer.downloadFile(file, i);
|
||||
completion
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `Download failed: ${file.name}`,
|
||||
description: `${err}`
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Notify when individual uploads complete (remote pasted a file).
|
||||
fileTransfer.on("upload-complete", (file: File) => {
|
||||
toast({ title: `Uploaded: ${file.name}` });
|
||||
});
|
||||
|
||||
// Register with the web component so CLIPRDR extensions are
|
||||
// wired up before connect() builds the session.
|
||||
userInteraction.enableFileTransfer(
|
||||
fileTransfer as unknown as FileTransferProvider
|
||||
);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
setConnecting(false);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "No target",
|
||||
description: "No connection target available"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const destination = `${target.ip}:${target.port}`;
|
||||
|
||||
const builder = userInteraction
|
||||
.configBuilder()
|
||||
.withUsername(form.username)
|
||||
.withPassword(form.password)
|
||||
.withDestination(destination)
|
||||
.withProxyAddress(
|
||||
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
|
||||
)
|
||||
.withServerDomain(form.domain)
|
||||
.withAuthToken(target.authToken)
|
||||
.withDesktopSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
})
|
||||
.withExtension(exts.displayControl(true));
|
||||
|
||||
if (form.pcb !== "") {
|
||||
builder.withExtension(exts.preConnectionBlob(form.pcb));
|
||||
}
|
||||
if (form.kdcProxyUrl !== "") {
|
||||
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionInfo = await userInteraction.connect(builder.build());
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setConnecting(false);
|
||||
setShowLogin(false);
|
||||
userInteraction.setVisibility(true);
|
||||
|
||||
const termInfo = await sessionInfo.run();
|
||||
fileTransferRef.current?.dispose();
|
||||
fileTransferRef.current = null;
|
||||
setShowLogin(true);
|
||||
} catch (err) {
|
||||
setConnecting(false);
|
||||
setShowLogin(true);
|
||||
if (isIronError(err)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Connection failed",
|
||||
description: err.backtrace()
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Connection failed",
|
||||
description: `${err}`
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ui = () => userInteractionRef.current;
|
||||
|
||||
const toggleCursorKind = () => {
|
||||
const u = ui();
|
||||
if (!u) return;
|
||||
if (cursorOverrideActive) {
|
||||
u.setCursorStyleOverride(null);
|
||||
} else {
|
||||
u.setCursorStyleOverride('url("crosshair.png") 7 7, default');
|
||||
}
|
||||
setCursorOverrideActive((v) => !v);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{showLogin && (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="mb-4 text-2xl font-semibold">RDP</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Domain" id="domain">
|
||||
<Input
|
||||
id="domain"
|
||||
value={form.domain}
|
||||
onChange={(e) =>
|
||||
update("domain", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Username" id="username">
|
||||
<Input
|
||||
id="username"
|
||||
value={form.username}
|
||||
onChange={(e) =>
|
||||
update("username", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Password" id="password">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
update("password", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{/*
|
||||
<Field label="Pre Connection Blob (optional)" id="pcb">
|
||||
<Input
|
||||
id="pcb"
|
||||
value={form.pcb}
|
||||
onChange={(e) => update("pcb", e.target.value)}
|
||||
/>
|
||||
</Field> */}
|
||||
|
||||
{/* <Field
|
||||
label="KDC Proxy URL (optional)"
|
||||
id="kdcProxyUrl"
|
||||
>
|
||||
<Input
|
||||
id="kdcProxyUrl"
|
||||
value={form.kdcProxyUrl}
|
||||
onChange={(e) =>
|
||||
update("kdcProxyUrl", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field> */}
|
||||
{/* <div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable_clipboard"
|
||||
checked={form.enableClipboard}
|
||||
onCheckedChange={(checked) =>
|
||||
update("enableClipboard", checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="enable_clipboard">
|
||||
Enable Clipboard
|
||||
</Label>
|
||||
</div> */}
|
||||
<Button
|
||||
onClick={startSession}
|
||||
disabled={!moduleReady}
|
||||
loading={connecting}
|
||||
className="w-full"
|
||||
>
|
||||
{moduleReady ? "Connect" : "Loading module..."}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex h-screen flex-col bg-neutral-900"
|
||||
style={{ display: showLogin ? "none" : "flex" }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => ui()?.setScale(1)}
|
||||
>
|
||||
Fit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => ui()?.setScale(2)}
|
||||
>
|
||||
Full
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => ui()?.setScale(3)}
|
||||
>
|
||||
Real
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => ui()?.ctrlAltDel()}
|
||||
>
|
||||
Ctrl+Alt+Del
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => ui()?.metaKey()}
|
||||
>
|
||||
Meta
|
||||
</Button>
|
||||
{/* <Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={toggleCursorKind}
|
||||
>
|
||||
Toggle cursor
|
||||
</Button> */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
const ft = fileTransferRef.current;
|
||||
if (!ft) return;
|
||||
const files = await ft.showFilePicker({
|
||||
multiple: true
|
||||
});
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
ft.uploadFiles(files);
|
||||
toast({
|
||||
title: "Files ready to paste",
|
||||
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Upload failed",
|
||||
description: `${err}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upload files
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
ui()?.shutdown();
|
||||
setShowLogin(true);
|
||||
}}
|
||||
>
|
||||
Terminate
|
||||
</Button>
|
||||
<label className="ml-2 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unicodeMode}
|
||||
onChange={(e) => {
|
||||
setUnicodeMode(e.target.checked);
|
||||
ui()?.setKeyboardUnicodeMode(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
Unicode keyboard mode
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{moduleReady && (
|
||||
<iron-remote-desktop
|
||||
ref={remoteElementRef}
|
||||
verbose="true"
|
||||
scale="fit"
|
||||
flexcenter="true"
|
||||
module={backendRef.current}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
id,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/app/rdp/page.tsx
Normal file
33
src/app/rdp/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { headers } from "next/headers";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
import RdpClient from "./RdpClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "RDP"
|
||||
};
|
||||
|
||||
export default async function RdpPage() {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get("host") || "";
|
||||
const hostname = host.split(":")[0];
|
||||
|
||||
let target: GetBrowserTargetResponse | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||
);
|
||||
target = res.data.data;
|
||||
console.log("Fetched browser target:", target);
|
||||
} catch (error) {
|
||||
console.error("Error fetching browser target:", error);
|
||||
error = "No resource found for this domain";
|
||||
}
|
||||
|
||||
return <RdpClient target={target} error={error} />;
|
||||
}
|
||||
453
src/app/ssh/SshClient.tsx
Normal file
453
src/app/ssh/SshClient.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
"use client";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { SignSshKeyResponse } from "@server/private/routers/ssh";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
|
||||
type FormState = {
|
||||
username: string;
|
||||
password: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
type ConnectCredentials = {
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
};
|
||||
|
||||
export default function SshClient({
|
||||
target,
|
||||
error,
|
||||
signedKeyData,
|
||||
privateKey: signedPrivateKey
|
||||
}: {
|
||||
target: GetBrowserTargetResponse | null;
|
||||
error: string | null;
|
||||
signedKeyData?: SignSshKeyResponse | null;
|
||||
privateKey?: string | null;
|
||||
}) {
|
||||
const STORAGE_KEY = "pangolin_ssh_credentials";
|
||||
|
||||
const [form, setForm] = useState<FormState>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) return JSON.parse(saved) as FormState;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { username: "", password: "", privateKey: "" };
|
||||
});
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result;
|
||||
if (typeof text === "string") {
|
||||
setForm((prev) => ({ ...prev, privateKey: text }));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset input so the same file can be re-selected if needed.
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
|
||||
const fitAddonRef = useRef<import("@xterm/addon-fit").FitAddon | null>(
|
||||
null
|
||||
);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// Mount the terminal div once connected.
|
||||
useEffect(() => {
|
||||
if (!connected || !terminalRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] =
|
||||
await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
import("@xterm/addon-web-links")
|
||||
]);
|
||||
if (cancelled || !terminalRef.current) return;
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
||||
theme: {
|
||||
background: "#0d0d0d",
|
||||
foreground: "#f0f0f0"
|
||||
},
|
||||
scrollback: 5000
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
terminal.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
xtermRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// Send user keystrokes to the WebSocket.
|
||||
terminal.onData((data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "data", data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Send resize events.
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({ type: "resize", cols, rows })
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Send the initial size once the terminal is rendered.
|
||||
const { cols, rows } = terminal;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({ type: "resize", cols, rows })
|
||||
);
|
||||
}
|
||||
})().catch(console.error);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connected]);
|
||||
|
||||
// Refit terminal when the window resizes.
|
||||
useEffect(() => {
|
||||
const onResize = () => fitAddonRef.current?.fit();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
wsRef.current?.close();
|
||||
xtermRef.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-connect when signed key data is provided (push PAM mode).
|
||||
useEffect(() => {
|
||||
if (signedKeyData && signedPrivateKey && target) {
|
||||
connect({
|
||||
username: signedKeyData.sshUsername,
|
||||
privateKey: signedPrivateKey,
|
||||
certificate: signedKeyData.certificate
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function connect(override?: ConnectCredentials) {
|
||||
setConnectError(null);
|
||||
setConnecting(true);
|
||||
|
||||
if (!target) {
|
||||
setConnectError("No target specified");
|
||||
setConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const username = override?.username ?? form.username;
|
||||
const password = override?.password ?? form.password;
|
||||
const privateKey = override?.privateKey ?? form.privateKey;
|
||||
const certificate = override?.certificate;
|
||||
|
||||
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
|
||||
const url = new URL(proxyAddress);
|
||||
url.searchParams.set("host", target.ip ?? "");
|
||||
url.searchParams.set("port", String(target.port ?? 22));
|
||||
url.searchParams.set("username", username);
|
||||
url.searchParams.set("authToken", target.authToken ?? "");
|
||||
|
||||
const ws = new WebSocket(url.toString(), ["ssh"]);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send credentials as the first frame so the proxy can complete
|
||||
// SSH authentication before piping pty data.
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth",
|
||||
password,
|
||||
privateKey,
|
||||
certificate
|
||||
})
|
||||
);
|
||||
if (!override) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setConnecting(false);
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
if (typeof evt.data === "string") {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data as string) as {
|
||||
type: string;
|
||||
data?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (msg.type === "data" && msg.data) {
|
||||
xtermRef.current?.write(msg.data);
|
||||
} else if (msg.type === "error") {
|
||||
xtermRef.current?.writeln(
|
||||
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
xtermRef.current?.write(evt.data);
|
||||
}
|
||||
} else if (evt.data instanceof Blob) {
|
||||
evt.data.text().then((t) => xtermRef.current?.write(t));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnecting(false);
|
||||
setConnected(false);
|
||||
setConnectError("WebSocket connection failed");
|
||||
};
|
||||
|
||||
ws.onclose = (evt) => {
|
||||
setConnecting(false);
|
||||
setConnected(false);
|
||||
xtermRef.current?.writeln(
|
||||
`\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
wsRef.current?.close();
|
||||
xtermRef.current?.dispose();
|
||||
xtermRef.current = null;
|
||||
setConnected(false);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// In push mode, show a connecting/connected state without the login form.
|
||||
if (signedKeyData && signedPrivateKey) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{!connected && (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">
|
||||
{connectError
|
||||
? connectError
|
||||
: connecting
|
||||
? "Connecting…"
|
||||
: "Initializing…"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{connected && (
|
||||
<div className="flex h-screen flex-col bg-neutral-900">
|
||||
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={disconnect}
|
||||
>
|
||||
Terminate
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ minHeight: 0 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{!connected && (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="mb-4 text-2xl font-semibold">SSH</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Username" id="username">
|
||||
<Input
|
||||
id="username"
|
||||
value={form.username}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
username: e.target.value
|
||||
})
|
||||
}
|
||||
placeholder="root"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Password" id="password">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
password: e.target.value
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
form.privateKey
|
||||
? "Optional with key auth"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Private Key (optional)" id="privateKey">
|
||||
<Textarea
|
||||
id="privateKey"
|
||||
value={form.privateKey}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
privateKey: e.target.value
|
||||
})
|
||||
}
|
||||
placeholder="Paste your private key here (PEM format)…"
|
||||
rows={5}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
Upload key file
|
||||
</Button>
|
||||
{form.privateKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline"
|
||||
onClick={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
privateKey: ""
|
||||
}))
|
||||
}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pem,.key,.pub,*"
|
||||
onChange={handleKeyFile}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{connectError && (
|
||||
<p className="text-destructive text-sm">
|
||||
{connectError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => connect()}
|
||||
loading={connecting}
|
||||
disabled={
|
||||
!form.username ||
|
||||
(!form.password && !form.privateKey)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{connecting ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connected && (
|
||||
<div className="flex h-screen flex-col bg-neutral-900">
|
||||
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={disconnect}
|
||||
>
|
||||
Terminate
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ minHeight: 0 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
id,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/app/ssh/page.tsx
Normal file
92
src/app/ssh/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { headers } from "next/headers";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
import SshClient from "./SshClient";
|
||||
import { SignSshKeyResponse } from "@server/private/routers/ssh";
|
||||
import crypto from "crypto";
|
||||
|
||||
function generateEphemeralKeyPair(): {
|
||||
privateKeyPem: string;
|
||||
publicKeyOpenSSH: string;
|
||||
} {
|
||||
const { publicKey: pubKeyObj, privateKey: privKeyObj } =
|
||||
crypto.generateKeyPairSync("ed25519");
|
||||
|
||||
const privateKeyPem = privKeyObj.export({
|
||||
type: "pkcs8",
|
||||
format: "pem"
|
||||
}) as string;
|
||||
|
||||
// Build OpenSSH wire format: uint32-length-prefixed strings
|
||||
const pubKeyDer = pubKeyObj.export({
|
||||
type: "spki",
|
||||
format: "der"
|
||||
}) as Buffer;
|
||||
const rawPubKey = pubKeyDer.subarray(pubKeyDer.length - 32); // last 32 bytes are the Ed25519 key
|
||||
|
||||
function encodeField(b: Buffer): Buffer {
|
||||
const len = Buffer.allocUnsafe(4);
|
||||
len.writeUInt32BE(b.length, 0);
|
||||
return Buffer.concat([len, b]);
|
||||
}
|
||||
|
||||
const keyBlob = Buffer.concat([
|
||||
encodeField(Buffer.from("ssh-ed25519")),
|
||||
encodeField(rawPubKey)
|
||||
]);
|
||||
const publicKeyOpenSSH = `ssh-ed25519 ${keyBlob.toString("base64")}`;
|
||||
|
||||
return { privateKeyPem, publicKeyOpenSSH };
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "SSH"
|
||||
};
|
||||
|
||||
export default async function SshPage() {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get("host") || "";
|
||||
const hostname = host.split(":")[0];
|
||||
|
||||
let target: GetBrowserTargetResponse | null = null;
|
||||
let signedKeyData: SignSshKeyResponse | null = null;
|
||||
let privateKey: string | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||
);
|
||||
target = res.data.data;
|
||||
|
||||
if (target.pamMode === "push") {
|
||||
const { privateKeyPem, publicKeyOpenSSH } =
|
||||
generateEphemeralKeyPair();
|
||||
privateKey = privateKeyPem;
|
||||
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
|
||||
`/org/${target.orgId}/ssh/sign-key`,
|
||||
{
|
||||
publicKey: publicKeyOpenSSH,
|
||||
resource: target.niceId
|
||||
}
|
||||
);
|
||||
signedKeyData = res.data.data;
|
||||
console.log("Received signed SSH key:", signedKeyData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching browser target:", error);
|
||||
error = "No resource found for this domain";
|
||||
}
|
||||
|
||||
return (
|
||||
<SshClient
|
||||
target={target}
|
||||
error={error}
|
||||
signedKeyData={signedKeyData}
|
||||
privateKey={privateKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
245
src/app/vnc/VncClient.tsx
Normal file
245
src/app/vnc/VncClient.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
|
||||
type FormState = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export default function VncClient({
|
||||
target,
|
||||
error
|
||||
}: {
|
||||
target: GetBrowserTargetResponse | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
const STORAGE_KEY = "pangolin_vnc_credentials";
|
||||
|
||||
const [form, setForm] = useState<FormState>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) return JSON.parse(saved) as FormState;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { password: "" };
|
||||
});
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rfbRef = useRef<any>(null);
|
||||
const screenRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Disconnect and clean up the RFB instance.
|
||||
const disconnect = () => {
|
||||
if (rfbRef.current) {
|
||||
rfbRef.current.disconnect();
|
||||
rfbRef.current = null;
|
||||
}
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
// Clean up on unmount.
|
||||
useEffect(() => {
|
||||
return () => disconnect();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const connect = async () => {
|
||||
if (!target) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "No target",
|
||||
description: "No resource target is available"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!screenRef.current) return;
|
||||
|
||||
// Disconnect any existing session first.
|
||||
disconnect();
|
||||
|
||||
// noVNC has no ESM default export — import the module dynamically to
|
||||
// keep it out of the server bundle, then grab the default export.
|
||||
let RFB: new (
|
||||
target: HTMLElement,
|
||||
url: string,
|
||||
options?: Record<string, unknown>
|
||||
) => unknown;
|
||||
try {
|
||||
// @ts-expect-error — @novnc/novnc ships plain JS with no bundled types
|
||||
const mod = await import("@novnc/novnc");
|
||||
RFB = mod.default ?? mod;
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to load noVNC",
|
||||
description: `${err}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the proxy WebSocket URL:
|
||||
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
|
||||
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
|
||||
const base = proxyAddress.replace(/\/$/, "");
|
||||
const params = new URLSearchParams({
|
||||
host: target.ip,
|
||||
port: String(target.port),
|
||||
authToken: target.authToken
|
||||
});
|
||||
const wsUrl = `${base}?${params.toString()}`;
|
||||
|
||||
// Clear the container so noVNC gets a clean mount point.
|
||||
screenRef.current.innerHTML = "";
|
||||
|
||||
const options: Record<string, unknown> = {};
|
||||
if (form.password) {
|
||||
options.credentials = { password: form.password };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rfb: any = new RFB(screenRef.current, wsUrl, options);
|
||||
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = true;
|
||||
|
||||
rfb.addEventListener("connect", () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
rfb.addEventListener(
|
||||
"disconnect",
|
||||
(e: { detail: { clean: boolean } }) => {
|
||||
rfbRef.current = null;
|
||||
setConnected(false);
|
||||
}
|
||||
);
|
||||
|
||||
rfb.addEventListener(
|
||||
"securityfailure",
|
||||
(e: { detail: { status: number; reason?: string } }) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Authentication failed",
|
||||
description: e.detail.reason ?? `Status ${e.detail.status}`
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
rfbRef.current = rfb;
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{!connected && (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="mb-4 text-2xl font-semibold">VNC</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Password (optional)" id="password">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
update("password", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button onClick={connect} className="w-full">
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex h-screen flex-col bg-neutral-900"
|
||||
style={{ display: connected ? "flex" : "none" }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (rfbRef.current) {
|
||||
rfbRef.current.sendCtrlAltDel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ctrl+Alt+Del
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
?.readText()
|
||||
.then((text) => {
|
||||
rfbRef.current?.clipboardPasteFrom(text);
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
>
|
||||
Paste clipboard
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={disconnect}
|
||||
>
|
||||
Terminate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* noVNC mounts a <canvas> inside this div */}
|
||||
<div
|
||||
ref={screenRef}
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ background: "#000" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
id,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/app/vnc/page.tsx
Normal file
32
src/app/vnc/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { headers } from "next/headers";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
import VncClient from "./VncClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "VNC"
|
||||
};
|
||||
|
||||
export default async function VncPage() {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get("host") || "";
|
||||
const hostname = host.split(":")[0];
|
||||
|
||||
let target: GetBrowserTargetResponse | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||
);
|
||||
target = res.data.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching browser target:", error);
|
||||
error = "No resource found for this domain";
|
||||
}
|
||||
|
||||
return <VncClient target={target} error={error} />;
|
||||
}
|
||||
@@ -44,11 +44,77 @@ export type AuthPageCustomizationProps = {
|
||||
};
|
||||
|
||||
const AuthPageFormSchema = z.object({
|
||||
logoUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val === "" ? undefined : val)),
|
||||
logoUrl: z.union([
|
||||
z.literal(""),
|
||||
z.string().superRefine(async (urlOrPath, ctx) => {
|
||||
const parseResult = z.url().safeParse(urlOrPath);
|
||||
if (!parseResult.success) {
|
||||
if (build !== "enterprise") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be a valid URL"
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
validateLocalPath(urlOrPath);
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||
});
|
||||
} finally {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(urlOrPath, {
|
||||
method: "HEAD"
|
||||
}).catch(() => {
|
||||
// If HEAD fails (CORS or method not allowed), try GET
|
||||
return fetch(urlOrPath, { method: "GET" });
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `Failed to load image. Please check that the URL is accessible.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.startsWith("image/")) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
let errorMessage =
|
||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
||||
|
||||
if (
|
||||
error instanceof TypeError &&
|
||||
error.message.includes("fetch")
|
||||
) {
|
||||
errorMessage =
|
||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = `Error verifying URL: ${error.message}`;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: errorMessage
|
||||
});
|
||||
}
|
||||
})
|
||||
]),
|
||||
logoWidth: z.coerce.number<number>().min(1),
|
||||
logoHeight: z.coerce.number<number>().min(1),
|
||||
orgTitle: z.string().optional(),
|
||||
|
||||
146
src/components/BrowserGatewayTargetForm.tsx
Normal file
146
src/components/BrowserGatewayTargetForm.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
MultiSitesSelector,
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "./multi-site-selector";
|
||||
import { SitesSelector, type Selectedsite } from "./site-selector";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
type SingleSiteProps = {
|
||||
multiSite?: false;
|
||||
selectedSite: Selectedsite | null;
|
||||
onSiteChange: (site: Selectedsite | null) => void;
|
||||
};
|
||||
|
||||
type MultiSiteProps = {
|
||||
multiSite: true;
|
||||
selectedSites: Selectedsite[];
|
||||
onSitesChange: (sites: Selectedsite[]) => void;
|
||||
};
|
||||
|
||||
export type BrowserGatewayTargetFormProps = {
|
||||
orgId: string;
|
||||
destination: string;
|
||||
defaultPort: number;
|
||||
destinationPort: string;
|
||||
onDestinationChange: (v: string) => void;
|
||||
onDestinationPortChange: (v: string) => void;
|
||||
learnMoreHref?: string;
|
||||
} & (SingleSiteProps | MultiSiteProps);
|
||||
|
||||
export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
|
||||
const t = useTranslations();
|
||||
const [siteOpen, setSiteOpen] = useState(false);
|
||||
|
||||
const siteSelector =
|
||||
props.multiSite === true ? (
|
||||
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
props.selectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<MultiSitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSites={props.selectedSites}
|
||||
onSelectionChange={props.onSitesChange}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{props.selectedSite?.name ?? t("siteSelect")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<SitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSite={props.selectedSite}
|
||||
onSelectSite={(site) => {
|
||||
props.onSiteChange(site);
|
||||
setSiteOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">
|
||||
{t("sites")}
|
||||
</label>
|
||||
{siteSelector}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">
|
||||
{t("destination")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="192.168.1.1"
|
||||
value={props.destination}
|
||||
onChange={(e) =>
|
||||
props.onDestinationChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">{t("port")}</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={props.defaultPort.toString()}
|
||||
value={props.destinationPort}
|
||||
onChange={(e) =>
|
||||
props.onDestinationPortChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{props.multiSite === true && props.selectedSites.length > 1 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("bgTargetMultiSiteDisclaimer")}{" "}
|
||||
<a
|
||||
href={
|
||||
props.learnMoreHref ??
|
||||
"https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
@@ -30,13 +29,21 @@ import {
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
Funnel,
|
||||
MoreHorizontal
|
||||
MoreHorizontal,
|
||||
PlusIcon
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import {
|
||||
startTransition,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useOptimistic,
|
||||
useState,
|
||||
useTransition
|
||||
} from "react";
|
||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
@@ -53,6 +60,10 @@ import {
|
||||
} from "@app/components/ResourceSitesStatusCell";
|
||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||
import { build } from "@server/build";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { LabelBadge } from "./label-badge";
|
||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
||||
|
||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||
|
||||
@@ -66,31 +77,37 @@ export type InternalResourceRow = {
|
||||
siteIds: number[];
|
||||
siteNiceIds: string[];
|
||||
// mode: "host" | "cidr" | "port";
|
||||
mode: "host" | "cidr" | "http";
|
||||
mode: "host" | "cidr" | "http" | "ssh";
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
// protocol: string | null;
|
||||
// proxyPort: number | null;
|
||||
destination: string;
|
||||
httpHttpsPort: number | null;
|
||||
destination: string | null;
|
||||
destinationPort: number | null;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
niceId: string;
|
||||
tcpPortRangeString: string | null;
|
||||
udpPortRangeString: string | null;
|
||||
disableIcmp: boolean;
|
||||
authDaemonMode?: "site" | "remote" | null;
|
||||
authDaemonMode?: "site" | "remote" | "native" | null;
|
||||
authDaemonPort?: number | null;
|
||||
pamMode?: "passthrough" | "push" | null;
|
||||
subdomain?: string | null;
|
||||
domainId?: string | null;
|
||||
fullDomain?: string | null;
|
||||
labels?: Array<{
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function formatDestinationDisplay(row: InternalResourceRow): string {
|
||||
return formatSiteResourceDestinationDisplay({
|
||||
mode: row.mode,
|
||||
destination: row.destination,
|
||||
httpHttpsPort: row.httpHttpsPort,
|
||||
destinationPort: row.destinationPort,
|
||||
scheme: row.scheme
|
||||
});
|
||||
}
|
||||
@@ -141,7 +158,10 @@ export default function ClientResourcesTable({
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
@@ -167,7 +187,7 @@ export default function ClientResourcesTable({
|
||||
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
||||
|
||||
const refreshData = () => {
|
||||
startTransition(() => {
|
||||
startRefreshTransition(() => {
|
||||
try {
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
@@ -185,8 +205,8 @@ export default function ClientResourcesTable({
|
||||
siteId: number
|
||||
) => {
|
||||
try {
|
||||
await api.delete(`/site-resource/${resourceId}`).then(() => {
|
||||
startTransition(() => {
|
||||
startTransition(async () => {
|
||||
await api.delete(`/site-resource/${resourceId}`).then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
@@ -254,296 +274,338 @@ export default function ClientResourcesTable({
|
||||
);
|
||||
}
|
||||
|
||||
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
const internalColumns = useMemo<
|
||||
ExtendedColumnDef<InternalResourceRow>[]
|
||||
>(() => {
|
||||
const cols: ExtendedColumnDef<InternalResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "niceId",
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("identifier")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.niceId || "-"}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "sites",
|
||||
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
||||
friendlyName: t("sites"),
|
||||
header: () => (
|
||||
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||
!selectedSite && "text-muted-foreground"
|
||||
)}
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{t("sites")}
|
||||
<Funnel className="size-4 flex-none" />
|
||||
{selectedSite && (
|
||||
<Badge
|
||||
className="truncate max-w-[10rem]"
|
||||
variant="secondary"
|
||||
>
|
||||
{selectedSite.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={dataTableFilterPopoverContentClassName}
|
||||
align="start"
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "niceId",
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("identifier")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.niceId || "-"}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "sites",
|
||||
accessorFn: (row) =>
|
||||
row.sites.map((s) => s.siteName).join(", "),
|
||||
friendlyName: t("sites"),
|
||||
header: () => (
|
||||
<Popover
|
||||
open={siteFilterOpen}
|
||||
onOpenChange={setSiteFilterOpen}
|
||||
>
|
||||
<div className="border-b p-1">
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start font-normal"
|
||||
onClick={clearSiteFilter}
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||
!selectedSite && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t("standaloneHcFilterAnySite")}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{t("sites")}
|
||||
<Funnel className="size-4 flex-none" />
|
||||
{selectedSite && (
|
||||
<Badge
|
||||
className="truncate max-w-[10rem]"
|
||||
variant="secondary"
|
||||
>
|
||||
{selectedSite.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSite}
|
||||
onSelectSite={onPickSite}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={dataTableFilterPopoverContentClassName}
|
||||
align="start"
|
||||
>
|
||||
<div className="border-b p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start font-normal"
|
||||
onClick={clearSiteFilter}
|
||||
>
|
||||
{t("standaloneHcFilterAnySite")}
|
||||
</Button>
|
||||
</div>
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSite}
|
||||
onSelectSite={onPickSite}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<ResourceSitesStatusCell
|
||||
orgId={resourceRow.orgId}
|
||||
resourceSites={resourceRow.sites}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<ResourceSitesStatusCell
|
||||
orgId={resourceRow.orgId}
|
||||
resourceSites={resourceRow.sites}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mode",
|
||||
friendlyName: t("editInternalResourceDialogMode"),
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{
|
||||
value: "host",
|
||||
label: t("editInternalResourceDialogModeHost")
|
||||
},
|
||||
{
|
||||
value: "cidr",
|
||||
label: t("editInternalResourceDialogModeCidr")
|
||||
},
|
||||
{
|
||||
value: "http",
|
||||
label: t("editInternalResourceDialogModeHttp")
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mode",
|
||||
friendlyName: t("editInternalResourceDialogMode"),
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{
|
||||
value: "host",
|
||||
label: t("editInternalResourceDialogModeHost")
|
||||
},
|
||||
{
|
||||
value: "cidr",
|
||||
label: t("editInternalResourceDialogModeCidr")
|
||||
},
|
||||
{
|
||||
value: "http",
|
||||
label: t("editInternalResourceDialogModeHttp")
|
||||
},
|
||||
{
|
||||
value: "ssh",
|
||||
label: t("editInternalResourceDialogModeSsh")
|
||||
}
|
||||
]}
|
||||
selectedValue={searchParams.get("mode") ?? undefined}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("mode", value)
|
||||
}
|
||||
]}
|
||||
selectedValue={searchParams.get("mode") ?? undefined}
|
||||
onValueChange={(value) => handleFilterChange("mode", value)}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("editInternalResourceDialogMode")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const modeLabels: Record<
|
||||
"host" | "cidr" | "port" | "http",
|
||||
string
|
||||
> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
port: t("editInternalResourceDialogModePort"),
|
||||
http: t("editInternalResourceDialogModeHttp")
|
||||
};
|
||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "destination",
|
||||
friendlyName: t("resourcesTableDestination"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("resourcesTableDestination")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const display = formatDestinationDisplay(resourceRow);
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={display}
|
||||
isLink={false}
|
||||
displayText={display}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("editInternalResourceDialogMode")}
|
||||
className="p-3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "alias",
|
||||
friendlyName: t("resourcesTableAlias"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("resourcesTableAlias")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
if (resourceRow.mode === "host" && resourceRow.alias) {
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const modeLabels: Record<
|
||||
"host" | "cidr" | "port" | "http" | "ssh",
|
||||
string
|
||||
> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
port: t("editInternalResourceDialogModePort"),
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
};
|
||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "destination",
|
||||
friendlyName: t("resourcesTableDestination"),
|
||||
header: () => (
|
||||
<span className="p-3">
|
||||
{t("resourcesTableDestination")}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const display = formatDestinationDisplay(resourceRow);
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.alias}
|
||||
text={display}
|
||||
isLink={false}
|
||||
displayText={resourceRow.alias}
|
||||
displayText={display}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (resourceRow.mode === "http") {
|
||||
const domainId = resourceRow.domainId;
|
||||
const fullDomain = resourceRow.fullDomain;
|
||||
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
|
||||
const did =
|
||||
build !== "oss" &&
|
||||
resourceRow.ssl &&
|
||||
domainId != null &&
|
||||
domainId !== "" &&
|
||||
fullDomain != null &&
|
||||
fullDomain !== "";
|
||||
},
|
||||
{
|
||||
accessorKey: "alias",
|
||||
friendlyName: t("resourcesTableAlias"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("resourcesTableAlias")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
if (resourceRow.mode === "host" && resourceRow.alias) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.alias}
|
||||
isLink={false}
|
||||
displayText={resourceRow.alias}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (resourceRow.mode === "http") {
|
||||
const domainId = resourceRow.domainId;
|
||||
const fullDomain = resourceRow.fullDomain;
|
||||
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
|
||||
const did =
|
||||
build !== "oss" &&
|
||||
resourceRow.ssl &&
|
||||
domainId != null &&
|
||||
domainId !== "" &&
|
||||
fullDomain != null &&
|
||||
fullDomain !== "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{did ? (
|
||||
<ResourceAccessCertIndicator
|
||||
orgId={resourceRow.orgId}
|
||||
domainId={domainId}
|
||||
fullDomain={fullDomain}
|
||||
/>
|
||||
) : null}
|
||||
<div className="">
|
||||
<CopyToClipboard
|
||||
text={url}
|
||||
isLink={isSafeUrlForLink(url)}
|
||||
displayText={url}
|
||||
/>
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{did ? (
|
||||
<ResourceAccessCertIndicator
|
||||
orgId={resourceRow.orgId}
|
||||
domainId={domainId}
|
||||
fullDomain={fullDomain}
|
||||
/>
|
||||
) : null}
|
||||
<div className="">
|
||||
<CopyToClipboard
|
||||
text={url}
|
||||
isLink={isSafeUrlForLink(url)}
|
||||
displayText={url}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span>-</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "aliasAddress",
|
||||
friendlyName: t("resourcesTableAliasAddress"),
|
||||
enableHiding: true,
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<span>{t("resourcesTableAliasAddress")}</span>
|
||||
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return resourceRow.aliasAddress ? (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.aliasAddress}
|
||||
isLink={false}
|
||||
displayText={resourceRow.aliasAddress}
|
||||
/>
|
||||
) : (
|
||||
<span>-</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedInternalResource(
|
||||
resourceRow
|
||||
);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setEditingResource(resourceRow);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span>-</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "aliasAddress",
|
||||
friendlyName: t("resourcesTableAliasAddress"),
|
||||
enableHiding: true,
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<span>{t("resourcesTableAliasAddress")}</span>
|
||||
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return resourceRow.aliasAddress ? (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.aliasAddress}
|
||||
isLink={false}
|
||||
displayText={resourceRow.aliasAddress}
|
||||
];
|
||||
|
||||
if (isLabelFeatureEnabled) {
|
||||
cols.splice(cols.length - 1, 0, {
|
||||
id: "labels",
|
||||
accessorKey: "labels",
|
||||
header: () => (
|
||||
<span className="p-3 text-end w-full inline-block">
|
||||
{t("labels")}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
||||
<ClientResourceLabelCell
|
||||
resource={row.original}
|
||||
orgId={orgId}
|
||||
/>
|
||||
) : (
|
||||
<span>-</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedInternalResource(
|
||||
resourceRow
|
||||
);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setEditingResource(resourceRow);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
];
|
||||
|
||||
return cols;
|
||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
@@ -638,7 +700,8 @@ export default function ClientResourcesTable({
|
||||
enableColumnVisibility
|
||||
columnVisibility={{
|
||||
niceId: false,
|
||||
aliasAddress: false
|
||||
aliasAddress: false,
|
||||
labels: false
|
||||
}}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
@@ -674,3 +737,101 @@ export default function ClientResourcesTable({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ClientResourceLabelCellProps = {
|
||||
resource: InternalResourceRow;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
function ClientResourceLabelCell({
|
||||
resource,
|
||||
orgId
|
||||
}: ClientResourceLabelCellProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const labels = resource.labels ?? [];
|
||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
||||
|
||||
function toggleResourceLabel(
|
||||
label: SelectedLabel,
|
||||
action: "attach" | "detach"
|
||||
) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
if (action === "attach") {
|
||||
setOptimisticLabels([...optimisticLabels, label]);
|
||||
await api.put(
|
||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
||||
{ siteResourceId: resource.id }
|
||||
);
|
||||
} else {
|
||||
setOptimisticLabels(
|
||||
optimisticLabels.filter(
|
||||
(lb) => lb.labelId !== label.labelId
|
||||
)
|
||||
);
|
||||
await api.put(
|
||||
`/org/${orgId}/label/${label.labelId}/detach`,
|
||||
{ siteResourceId: resource.id }
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e, t("errorOccurred")),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
|
||||
{optimisticLabels.slice(0, 3).map((label) => (
|
||||
<LabelBadge
|
||||
key={label.labelId}
|
||||
onClick={() => setIsPopoverOpen(true)}
|
||||
{...label}
|
||||
/>
|
||||
))}
|
||||
{optimisticLabels.length > 3 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"inline-flex gap-1 items-center",
|
||||
"rounded-full text-sm cursor-pointer",
|
||||
"px-1.5 py-0 h-auto"
|
||||
)}
|
||||
onClick={() => setIsPopoverOpen(true)}
|
||||
>
|
||||
+{optimisticLabels.length - 3}
|
||||
</Button>
|
||||
)}
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="p-1 size-auto rounded-full"
|
||||
title={t("addLabels")}
|
||||
>
|
||||
<span className="sr-only">{t("addLabels")}</span>
|
||||
<PlusIcon className="size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="p-0 w-full">
|
||||
<LabelsSelector
|
||||
orgId={orgId}
|
||||
selectedLabels={optimisticLabels}
|
||||
toggleLabel={toggleResourceLabel}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
cleanForFQDN,
|
||||
InternalResourceForm,
|
||||
@@ -39,39 +39,44 @@ export default function CreateInternalResourceDialog({
|
||||
}: CreateInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
async function handleSubmit(values: InternalResourceFormValues) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (
|
||||
(data.mode === "host" || data.mode === "http") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
if (data.destination.toLowerCase() === "localhost") {
|
||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||
function handleSubmit(values: InternalResourceFormValues) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (
|
||||
(data.mode === "host" ||
|
||||
data.mode === "http" ||
|
||||
data.mode === "ssh") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
if (data.destination?.toLowerCase() === "localhost") {
|
||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||
}
|
||||
data = { ...data, alias: aliasValue };
|
||||
}
|
||||
data = { ...data, alias: aliasValue };
|
||||
}
|
||||
}
|
||||
|
||||
await api.put<AxiosResponse<{ data: { siteResourceId: number } }>>(
|
||||
`/org/${orgId}/site-resource`,
|
||||
{
|
||||
// "ssh" mode maps to "host" in the backend with SSH settings
|
||||
const backendMode = data.mode === "ssh" ? "host" : data.mode;
|
||||
|
||||
await api.put<
|
||||
AxiosResponse<{ data: { siteResourceId: number } }>
|
||||
>(`/org/${orgId}/site-resource`, {
|
||||
name: data.name,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
mode: backendMode,
|
||||
destination: data.destination,
|
||||
enabled: true,
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.httpHttpsPort ?? undefined,
|
||||
destinationPort: data.destinationPort ?? undefined,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
@@ -94,7 +99,25 @@ export default function CreateInternalResourceDialog({
|
||||
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,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
@@ -106,32 +129,30 @@ export default function CreateInternalResourceDialog({
|
||||
clientIds: data.clients
|
||||
? data.clients.map((c) => parseInt(c.id))
|
||||
: []
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
102
src/components/CreateOrgLabelDialog.tsx
Normal file
102
src/components/CreateOrgLabelDialog.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTransition } from "react";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "./Credenza";
|
||||
import { OrgLabelForm } from "./OrgLabelForm";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export type CreateOrgLabelDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
orgId: string;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export function CreateOrgLabelDialog({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
onSuccess
|
||||
}: CreateOrgLabelDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
async function createOrgLabel(data: { name: string; color: string }) {
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<CreateOrEditLabelResponse>
|
||||
>(`/org/${orgId}/labels`, data);
|
||||
|
||||
if (res.status === 201) {
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("labelCreateSuccessMessage")
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e, t("errorOccurred")),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="md:max-w-md">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("createLabelDialogTitle")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("createLabelDialogDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<OrgLabelForm
|
||||
onSubmit={(data) => {
|
||||
startTransition(async () => createOrgLabel(data));
|
||||
}}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="org-label-form"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("labelCreate")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -51,29 +51,34 @@ export default function EditInternalResourceDialog({
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (
|
||||
(data.mode === "host" || data.mode === "http") &&
|
||||
(data.mode === "host" ||
|
||||
data.mode === "http" ||
|
||||
data.mode === "ssh") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
if (data.destination.toLowerCase() === "localhost") {
|
||||
if (data.destination?.toLowerCase() === "localhost") {
|
||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||
}
|
||||
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}`, {
|
||||
name: data.name,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
mode: backendMode,
|
||||
niceId: data.niceId,
|
||||
destination: data.destination,
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.httpHttpsPort ?? null,
|
||||
destinationPort: data.destinationPort ?? null,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
@@ -95,7 +100,24 @@ export default function EditInternalResourceDialog({
|
||||
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,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
|
||||
109
src/components/EditOrgLabelDialog.tsx
Normal file
109
src/components/EditOrgLabelDialog.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTransition } from "react";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "./Credenza";
|
||||
import { OrgLabelForm } from "./OrgLabelForm";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export type EditOrgLabelDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
orgId: string;
|
||||
onSuccess?: () => void;
|
||||
label: {
|
||||
name: string;
|
||||
color: string;
|
||||
labelId: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function EditOrgLabelDialog({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
onSuccess,
|
||||
label
|
||||
}: EditOrgLabelDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
async function editOrgLabel(data: { name: string; color: string }) {
|
||||
try {
|
||||
const res = await api.patch<
|
||||
AxiosResponse<CreateOrEditLabelResponse>
|
||||
>(`/org/${orgId}/label/${label.labelId}`, data);
|
||||
|
||||
if (res.status === 200) {
|
||||
setOpen(false);
|
||||
onSuccess?.();
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("labelEditSuccessMessage")
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e, t("errorOccurred")),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="md:max-w-md">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("editLabelDialogTitle")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("editLabelDialogDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<OrgLabelForm
|
||||
defaultValue={label}
|
||||
onSubmit={(data) => {
|
||||
startTransition(async () => editOrgLabel(data));
|
||||
}}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="org-label-form"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("labelEdit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user