mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-01 00:41:55 +02:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eca87b66f0 | |||
| cc8c89eeae | |||
| 6ea4aa1920 | |||
| f12451b8f9 | |||
| 0d4bb65a92 | |||
| d47ad9ac40 | |||
| f81ae24ba7 | |||
| df1e28aabd | |||
| 4c1e1daf07 | |||
| 7c54df7ed1 | |||
| 9d77fcc457 | |||
| 454449ec8a | |||
| fe67e8e384 | |||
| 715b957660 | |||
| f1e4bf8d36 | |||
| 23ca3efbf4 | |||
| 0f9100fd3a | |||
| 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 | |||
| 987b5d580e | |||
| cb75ffc3b7 | |||
| 540f0a754d | |||
| 0f9a6fd968 | |||
| 82112abc34 | |||
| 75b5afd544 | |||
| 00e1675f7b | |||
| 2ddbdf977b | |||
| 4c8f0cc9ec | |||
| e822b681cd | |||
| e1583a58aa | |||
| 7d922ac95f | |||
| 795a3d351e | |||
| 4b4c86b4b7 | |||
| 013af49137 | |||
| a6ae9290f2 | |||
| de70d72e0d | |||
| 4e07e9c52c | |||
| 743621eb25 | |||
| 943923ff4b | |||
| 3f17f1a468 | |||
| 436996a43d | |||
| d42b6076d2 | |||
| 89cc99f915 |
@@ -14,13 +14,12 @@ body:
|
||||
label: Environment
|
||||
description: Please fill out the relevant details below for your environment.
|
||||
value: |
|
||||
- OS Type & Version:
|
||||
- OS Type & Version: (e.g., Ubuntu 22.04)
|
||||
- Pangolin Version:
|
||||
- Edition (Community or Enterprise):
|
||||
- Gerbil Version:
|
||||
- Traefik Version:
|
||||
- Newt Version:
|
||||
- Client Version:
|
||||
- Olm Version: (if applicable)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
import { APP_PATH } from "./server/lib/consts";
|
||||
import { APP_PATH } from "@server/lib/consts";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
import path from "path";
|
||||
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/term v0.42.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -33,6 +33,6 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
|
||||
+4
-4
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
+41
-31
@@ -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",
|
||||
@@ -1164,9 +1168,6 @@
|
||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||
"labelsNotFound": "Labels not found",
|
||||
"labelSearch": "Search labels",
|
||||
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
||||
"accessLabelFilterClear": "Clear label filters",
|
||||
"selectColor": "Select color",
|
||||
"createNewLabel": "Create new org label \"{label}\"",
|
||||
"inviteInvalidDescription": "The invite link is invalid.",
|
||||
@@ -1354,29 +1355,6 @@
|
||||
"otpAuthBack": "Back to Password",
|
||||
"navbar": "Navigation Menu",
|
||||
"navbarDescription": "Main navigation menu for the application",
|
||||
"commandPaletteTitle": "Command palette",
|
||||
"commandPaletteDescription": "Search for pages, organizations, resources, and actions",
|
||||
"commandPaletteSearchPlaceholder": "Search pages, resources, actions...",
|
||||
"commandPaletteNoResults": "No results found.",
|
||||
"commandPaletteSearching": "Searching...",
|
||||
"commandPaletteNavigation": "Navigation",
|
||||
"commandPaletteOrganizations": "Organizations",
|
||||
"commandPaletteSites": "Sites",
|
||||
"commandPaletteResources": "Resources",
|
||||
"commandPaletteUsers": "Users",
|
||||
"commandPaletteClients": "Machine clients",
|
||||
"commandPaletteActions": "Actions",
|
||||
"commandPaletteCreateSite": "Create site",
|
||||
"commandPaletteCreateProxyResource": "Create public resource",
|
||||
"commandPaletteCreateUser": "Create user",
|
||||
"commandPaletteCreateApiKey": "Create API key",
|
||||
"commandPaletteCreateMachineClient": "Create machine client",
|
||||
"commandPaletteCreateAlertRule": "Create alert rule",
|
||||
"commandPaletteCreateIdentityProvider": "Create identity provider",
|
||||
"commandPaletteToggleTheme": "Toggle theme",
|
||||
"commandPaletteChooseOrganization": "Choose organization",
|
||||
"commandPaletteShortcutMac": "⌘K",
|
||||
"commandPaletteShortcutWindows": "Ctrl K",
|
||||
"navbarDocsLink": "Documentation",
|
||||
"otpErrorEnable": "Unable to enable 2FA",
|
||||
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
||||
@@ -1672,7 +1650,6 @@
|
||||
"certificateStatus": "Certificate",
|
||||
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
||||
"loading": "Loading",
|
||||
"loadingEllipsis": "Loading...",
|
||||
"loadingAnalytics": "Loading Analytics",
|
||||
"restart": "Restart",
|
||||
"domains": "Domains",
|
||||
@@ -1899,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.",
|
||||
@@ -1996,6 +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.",
|
||||
"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.",
|
||||
"sshAccess": "SSH Access",
|
||||
"roleAllowSsh": "Allow SSH",
|
||||
"roleAllowSshAllow": "Allow",
|
||||
@@ -2990,7 +2998,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",
|
||||
@@ -3020,6 +3028,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",
|
||||
@@ -3349,5 +3358,6 @@
|
||||
"memberPortalResourceDisabled": "Resource Disabled",
|
||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||
"memberPortalPrevious": "Previous",
|
||||
"memberPortalNext": "Next"
|
||||
"memberPortalNext": "Next",
|
||||
"httpSettings": "HTTP Settings"
|
||||
}
|
||||
|
||||
+7
-1
@@ -5,7 +5,13 @@ const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false,
|
||||
reactCompiler: true,
|
||||
transpilePackages: ["@novnc/novnc"],
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true
|
||||
},
|
||||
experimental: {
|
||||
reactCompiler: true
|
||||
},
|
||||
output: "standalone"
|
||||
};
|
||||
|
||||
|
||||
Generated
+3397
-2191
File diff suppressed because it is too large
Load Diff
+56
-52
@@ -32,13 +32,16 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||
"@aws-sdk/client-s3": "3.1047.0",
|
||||
"@faker-js/faker": "10.4.0",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@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",
|
||||
@@ -59,17 +62,19 @@
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@react-email/body": "0.3.0",
|
||||
"@react-email/components": "1.0.12",
|
||||
"@react-email/render": "2.0.8",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@react-email/components": "1.0.8",
|
||||
"@react-email/render": "2.0.4",
|
||||
"@react-email/tailwind": "2.0.5",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.0",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.100.10",
|
||||
"@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.16.1",
|
||||
"axios": "1.15.0",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -81,76 +86,76 @@
|
||||
"d3": "7.9.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.5.2",
|
||||
"express-rate-limit": "8.3.0",
|
||||
"glob": "13.0.6",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.10.1",
|
||||
"ioredis": "5.10.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "0.577.0",
|
||||
"maxmind": "5.0.6",
|
||||
"maxmind": "5.0.5",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.6",
|
||||
"next-intl": "4.12.0",
|
||||
"next": "15.5.15",
|
||||
"next-intl": "4.8.3",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "8.0.7",
|
||||
"nodemailer": "8.0.5",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.20.0",
|
||||
"posthog-node": "5.34.1",
|
||||
"posthog-node": "5.28.0",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react-dom": "19.2.4",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.75.0",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"recharts": "2.15.4",
|
||||
"reodotdev": "1.1.0",
|
||||
"resend": "6.12.3",
|
||||
"semver": "7.8.0",
|
||||
"resend": "6.9.2",
|
||||
"semver": "7.7.4",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "20.4.1",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
"uuid": "14.0.0",
|
||||
"use-debounce": "10.1.0",
|
||||
"uuid": "13.0.0",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.20.1",
|
||||
"yaml": "2.9.0",
|
||||
"ws": "8.19.0",
|
||||
"yaml": "2.8.3",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "4.4.3",
|
||||
"zod": "4.3.6",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.66.0",
|
||||
"@dotenvx/dotenvx": "1.54.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.1.4",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.10",
|
||||
"@react-email/preview-server": "5.2.10",
|
||||
"@tailwindcss/postcss": "4.2.2",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/d3": "7.4.3",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/express-session": "1.19.0",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "25.8.0",
|
||||
"@types/nodemailer": "8.0.0",
|
||||
"@types/node": "25.3.5",
|
||||
"@types/nodemailer": "7.0.11",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/pg": "8.18.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
@@ -161,22 +166,21 @@
|
||||
"@types/yargs": "17.0.35",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.0",
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.3.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"postcss": "8.5.14",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.1.4",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsc-alias": "1.8.17",
|
||||
"tsx": "4.22.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.59.3"
|
||||
"esbuild": "0.27.4",
|
||||
"esbuild-node-externals": "1.20.1",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"postcss": "8.5.8",
|
||||
"prettier": "3.8.1",
|
||||
"react-email": "5.2.10",
|
||||
"tailwindcss": "4.2.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.56.1"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.0",
|
||||
"dompurify": "3.4.0",
|
||||
"postcss": "8.5.14"
|
||||
"esbuild": "0.27.4",
|
||||
"dompurify": "3.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,12 @@ export enum ActionsEnum {
|
||||
createHealthCheck = "createHealthCheck",
|
||||
updateHealthCheck = "updateHealthCheck",
|
||||
deleteHealthCheck = "deleteHealthCheck",
|
||||
listHealthChecks = "listHealthChecks"
|
||||
listHealthChecks = "listHealthChecks",
|
||||
createBrowserGatewayTarget = "createBrowserGatewayTarget",
|
||||
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
|
||||
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
|
||||
getBrowserGatewayTarget = "getBrowserGatewayTarget",
|
||||
listBrowserGatewayTargets = "listBrowserGatewayTargets"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -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,7 +158,15 @@ export const resources = pgTable("resources", {
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: boolean("wildcard").notNull().default(false)
|
||||
wildcard: boolean("wildcard").notNull().default(false),
|
||||
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", {
|
||||
@@ -351,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"
|
||||
|
||||
@@ -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,7 +180,15 @@ export const resources = sqliteTable("resources", {
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||
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", {
|
||||
@@ -386,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"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#! /usr/bin/env node
|
||||
import "./extendZod";
|
||||
import "./extendZod.ts";
|
||||
|
||||
import { runSetupFunctions } from "./setup";
|
||||
import { createApiServer } from "./apiServer";
|
||||
|
||||
@@ -154,19 +154,8 @@ class AdaptiveCache {
|
||||
keys(): string[] {
|
||||
return localCache.keys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keys with a specific prefix
|
||||
* @param prefix - Key prefix to match
|
||||
* @returns Array of matching keys
|
||||
*/
|
||||
async keysWithPrefix(prefix: string): Promise<string[]> {
|
||||
const allKeys = localCache.keys();
|
||||
return allKeys.filter((key) => key.startsWith(prefix));
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const cache = new AdaptiveCache();
|
||||
export const regionalCache = cache; // Alias for compatability with the private version
|
||||
export default cache;
|
||||
|
||||
@@ -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 {
|
||||
@@ -952,17 +885,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 +1157,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 +1217,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 +1245,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(
|
||||
@@ -1634,195 +1528,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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export function getFirstString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && typeof value[0] === "string") {
|
||||
return value[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { db, logsDb, statusHistory } from "@server/db";
|
||||
import { and, eq, gte, asc } from "drizzle-orm";
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import cache from "@server/lib/cache";
|
||||
|
||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function invalidateStatusHistoryCache(
|
||||
entityId: number
|
||||
): Promise<void> {
|
||||
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
||||
const keys = await cache.keysWithPrefix(prefix);
|
||||
const keys = cache.keys().filter((k) => k.startsWith(prefix));
|
||||
if (keys.length > 0) {
|
||||
await cache.del(keys);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { resourceAccessToken, resources, apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyApiKeyAccessTokenAccess(
|
||||
req: Request,
|
||||
@@ -13,7 +12,7 @@ export async function verifyApiKeyAccessTokenAccess(
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const accessTokenId = getFirstString(req.params.accessTokenId);
|
||||
const accessTokenId = req.params.accessTokenId;
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
@@ -21,12 +20,6 @@ export async function verifyApiKeyAccessTokenAccess(
|
||||
);
|
||||
}
|
||||
|
||||
if (!accessTokenId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [accessToken] = await db
|
||||
.select()
|
||||
.from(resourceAccessToken)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { apiKeys, apiKeyOrg } from "@server/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyApiKeyApiKeyAccess(
|
||||
req: Request,
|
||||
@@ -15,10 +14,8 @@ export async function verifyApiKeyApiKeyAccess(
|
||||
const { apiKey: callerApiKey } = req;
|
||||
|
||||
const apiKeyId =
|
||||
getFirstString(req.params.apiKeyId) ||
|
||||
getFirstString(req.body.apiKeyId) ||
|
||||
getFirstString(req.query.apiKeyId);
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!callerApiKey) {
|
||||
return next(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { db, domains, orgDomains, apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyApiKeyDomainAccess(
|
||||
req: Request,
|
||||
@@ -13,10 +12,8 @@ export async function verifyApiKeyDomainAccess(
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const domainId =
|
||||
getFirstString(req.params.domainId) ||
|
||||
getFirstString(req.body.domainId) ||
|
||||
getFirstString(req.query.domainId);
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
req.params.domainId || req.body.domainId || req.query.domainId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
@@ -30,12 +27,6 @@ export async function verifyApiKeyDomainAccess(
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
// Root keys can access any domain in any org
|
||||
return next();
|
||||
|
||||
@@ -4,7 +4,6 @@ import { idp, idpOrg, apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyApiKeyIdpAccess(
|
||||
req: Request,
|
||||
@@ -13,12 +12,8 @@ export async function verifyApiKeyIdpAccess(
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const idpIdRaw =
|
||||
getFirstString(req.params.idpId) ||
|
||||
getFirstString(req.body.idpId) ||
|
||||
getFirstString(req.query.idpId);
|
||||
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
@@ -32,7 +27,7 @@ export async function verifyApiKeyIdpAccess(
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isNaN(idpId)) {
|
||||
if (!idpId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyApiKeyOrgAccess(
|
||||
req: Request,
|
||||
@@ -13,7 +12,7 @@ export async function verifyApiKeyOrgAccess(
|
||||
) {
|
||||
try {
|
||||
const apiKeyId = req.apiKey?.apiKeyId;
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!apiKeyId) {
|
||||
return next(
|
||||
@@ -46,7 +45,7 @@ export async function verifyApiKeyOrgAccess(
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { siteResources, apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyApiKeySiteResourceAccess(
|
||||
req: Request,
|
||||
@@ -13,8 +12,7 @@ export async function verifyApiKeySiteResourceAccess(
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const siteResourceIdRaw = getFirstString(req.params.siteResourceId);
|
||||
const siteResourceId = Number.parseInt(siteResourceIdRaw ?? "", 10);
|
||||
const siteResourceId = parseInt(req.params.siteResourceId);
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
@@ -22,7 +20,7 @@ export async function verifyApiKeySiteResourceAccess(
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isNaN(siteResourceId)) {
|
||||
if (!siteResourceId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { resources, targets, apiKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyApiKeyTargetAccess(
|
||||
req: Request,
|
||||
@@ -13,8 +12,7 @@ export async function verifyApiKeyTargetAccess(
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const targetIdRaw = getFirstString(req.params.targetId);
|
||||
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
|
||||
const targetId = parseInt(req.params.targetId);
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
@@ -22,7 +20,7 @@ export async function verifyApiKeyTargetAccess(
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isNaN(targetId)) {
|
||||
if (isNaN(targetId)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyAccessTokenAccess(
|
||||
req: Request,
|
||||
@@ -15,7 +14,7 @@ export async function verifyAccessTokenAccess(
|
||||
next: NextFunction
|
||||
) {
|
||||
const userId = req.user!.userId;
|
||||
const accessTokenId = getFirstString(req.params.accessTokenId);
|
||||
const accessTokenId = req.params.accessTokenId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
@@ -23,12 +22,6 @@ export async function verifyAccessTokenAccess(
|
||||
);
|
||||
}
|
||||
|
||||
if (!accessTokenId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [accessToken] = await db
|
||||
.select()
|
||||
.from(resourceAccessToken)
|
||||
@@ -94,7 +87,7 @@ export async function verifyAccessTokenAccess(
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
return next(
|
||||
next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
|
||||
@@ -6,7 +6,6 @@ import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyApiKeyAccess(
|
||||
req: Request,
|
||||
@@ -15,24 +14,9 @@ export async function verifyApiKeyAccess(
|
||||
) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const apiKeyIdFromParams = getFirstString(req.params?.apiKeyId);
|
||||
const apiKeyIdFromBody = getFirstString(req.body?.apiKeyId);
|
||||
|
||||
if (
|
||||
apiKeyIdFromParams &&
|
||||
apiKeyIdFromBody &&
|
||||
apiKeyIdFromParams !== apiKeyIdFromBody
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"API key ID provided in both URL and body with different values"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const apiKeyId = apiKeyIdFromParams || apiKeyIdFromBody;
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const apiKeyId =
|
||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
@@ -120,7 +104,10 @@ export async function verifyApiKeyAccess(
|
||||
}
|
||||
}
|
||||
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
||||
req.userOrg.userId,
|
||||
orgId
|
||||
);
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyDomainAccess(
|
||||
req: Request,
|
||||
@@ -15,8 +14,9 @@ export async function verifyDomainAccess(
|
||||
) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const domainId = getFirstString(req.params.domainId);
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const domainId =
|
||||
req.params.domainId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
@@ -62,7 +62,10 @@ export async function verifyDomainAccess(
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
req.userOrg = userOrgRole[0];
|
||||
|
||||
@@ -3,7 +3,6 @@ import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { build } from "@server/build";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyLimits(
|
||||
req: Request,
|
||||
@@ -14,10 +13,7 @@ export async function verifyLimits(
|
||||
return next();
|
||||
}
|
||||
|
||||
const orgId =
|
||||
req.userOrgId ||
|
||||
req.apiKeyOrg?.orgId ||
|
||||
getFirstString(req.params.orgId);
|
||||
const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail
|
||||
|
||||
@@ -6,7 +6,6 @@ import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyOrgAccess(
|
||||
req: Request,
|
||||
@@ -14,7 +13,7 @@ export async function verifyOrgAccess(
|
||||
next: NextFunction
|
||||
) {
|
||||
const userId = req.user!.userId;
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
db,
|
||||
userOrgs,
|
||||
siteProvisioningKeys,
|
||||
siteProvisioningKeyOrg
|
||||
} from "@server/db";
|
||||
import { db, userOrgs, siteProvisioningKeys, siteProvisioningKeyOrg } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifySiteProvisioningKeyAccess(
|
||||
req: Request,
|
||||
@@ -19,10 +13,8 @@ export async function verifySiteProvisioningKeyAccess(
|
||||
) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const siteProvisioningKeyId = getFirstString(
|
||||
req.params.siteProvisioningKeyId
|
||||
);
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const siteProvisioningKeyId = req.params.siteProvisioningKeyId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
@@ -88,7 +80,10 @@ export async function verifySiteProvisioningKeyAccess(
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, row.siteProvisioningKeyOrg.orgId)
|
||||
eq(
|
||||
userOrgs.orgId,
|
||||
row.siteProvisioningKeyOrg.orgId
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
@@ -7,7 +7,6 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyTargetAccess(
|
||||
req: Request,
|
||||
@@ -15,8 +14,7 @@ export async function verifyTargetAccess(
|
||||
next: NextFunction
|
||||
) {
|
||||
const userId = req.user!.userId;
|
||||
const targetIdRaw = getFirstString(req.params.targetId);
|
||||
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
|
||||
const targetId = parseInt(req.params.targetId);
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
|
||||
@@ -4,7 +4,6 @@ import { userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyUserIsOrgOwner(
|
||||
req: Request,
|
||||
@@ -12,7 +11,7 @@ export async function verifyUserIsOrgOwner(
|
||||
next: NextFunction
|
||||
) {
|
||||
const userId = req.user!.userId;
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-145
@@ -13,7 +13,7 @@
|
||||
|
||||
import NodeCache from "node-cache";
|
||||
import logger from "@server/logger";
|
||||
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
||||
import { redisManager } from "@server/private/lib/redis";
|
||||
|
||||
// Create local cache with maxKeys limit to prevent memory leaks
|
||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||
@@ -298,147 +298,3 @@ class AdaptiveCache {
|
||||
// Export singleton instance
|
||||
export const cache = new AdaptiveCache();
|
||||
export default cache;
|
||||
|
||||
/**
|
||||
* Regional adaptive cache backed by the in-cluster Redis instance.
|
||||
* Falls back to a local NodeCache when the regional Redis is unavailable.
|
||||
* Use this for data that is regional in nature (e.g. status history) so
|
||||
* reads are served from the same cluster the user is hitting.
|
||||
*/
|
||||
const regionalLocalCache = new NodeCache({
|
||||
stdTTL: 3600,
|
||||
checkperiod: 120,
|
||||
maxKeys: 10000
|
||||
});
|
||||
|
||||
class RegionalAdaptiveCache {
|
||||
private useRedis(): boolean {
|
||||
return (
|
||||
regionalRedisManager.isRedisEnabled() &&
|
||||
regionalRedisManager.getHealthStatus().isHealthy
|
||||
);
|
||||
}
|
||||
|
||||
async set(key: string, value: any, ttl?: number): Promise<boolean> {
|
||||
const effectiveTtl = ttl === 0 ? undefined : ttl;
|
||||
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
|
||||
|
||||
if (this.useRedis()) {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
const success = await regionalRedisManager.set(
|
||||
key,
|
||||
serialized,
|
||||
redisTtl
|
||||
);
|
||||
if (success) {
|
||||
logger.debug(`[regional] Set key in Redis: ${key}`);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[regional] Redis set error for key ${key}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
|
||||
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
|
||||
return success;
|
||||
}
|
||||
|
||||
async get<T = any>(key: string): Promise<T | undefined> {
|
||||
if (this.useRedis()) {
|
||||
try {
|
||||
const value = await regionalRedisManager.get(key);
|
||||
if (value !== null) {
|
||||
logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[regional] Redis get error for key ${key}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const value = regionalLocalCache.get<T>(key);
|
||||
if (value !== undefined) {
|
||||
logger.debug(`[regional] Cache hit in local cache: ${key}`);
|
||||
} else {
|
||||
logger.debug(`[regional] Cache miss in local cache: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async del(key: string | string[]): Promise<number> {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
let deletedCount = 0;
|
||||
|
||||
if (this.useRedis()) {
|
||||
try {
|
||||
for (const k of keys) {
|
||||
const success = await regionalRedisManager.del(k);
|
||||
if (success) {
|
||||
deletedCount++;
|
||||
logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
||||
}
|
||||
}
|
||||
if (deletedCount === keys.length) return deletedCount;
|
||||
deletedCount = 0;
|
||||
} catch (error) {
|
||||
logger.error(`[regional] Redis del error:`, error);
|
||||
deletedCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of keys) {
|
||||
const count = regionalLocalCache.del(k);
|
||||
if (count > 0) {
|
||||
deletedCount++;
|
||||
logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
||||
}
|
||||
}
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
if (this.useRedis()) {
|
||||
try {
|
||||
const value = await regionalRedisManager.get(key);
|
||||
return value !== null;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[regional] Redis has error for key ${key}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
return regionalLocalCache.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns keys matching the given prefix from whichever backend is active.
|
||||
* Redis uses a KEYS scan; local cache filters in-memory keys.
|
||||
*/
|
||||
async keysWithPrefix(prefix: string): Promise<string[]> {
|
||||
if (this.useRedis()) {
|
||||
try {
|
||||
return await regionalRedisManager.keys(`${prefix}*`);
|
||||
} catch (error) {
|
||||
logger.error(`[regional] Redis keys error:`, error);
|
||||
}
|
||||
}
|
||||
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
|
||||
}
|
||||
|
||||
getCurrentBackend(): "redis" | "local" {
|
||||
return this.useRedis() ? "redis" : "local";
|
||||
}
|
||||
}
|
||||
|
||||
export const regionalCache = new RegionalAdaptiveCache();
|
||||
|
||||
@@ -73,25 +73,6 @@ export const privateConfigSchema = z
|
||||
.object({
|
||||
rejectUnauthorized: z.boolean().optional().default(true)
|
||||
})
|
||||
.optional(),
|
||||
regional_redis: z
|
||||
.object({
|
||||
host: z.string(),
|
||||
port: portSchema,
|
||||
password: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("REGIONAL_REDIS_PASSWORD")),
|
||||
db: z.int().nonnegative().optional().default(0),
|
||||
tls: z
|
||||
.object({
|
||||
rejectUnauthorized: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional(),
|
||||
|
||||
+4
-164
@@ -109,14 +109,14 @@ class RedisManager {
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db
|
||||
};
|
||||
|
||||
|
||||
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||
if (redisConfig.tls) {
|
||||
opts.tls = {
|
||||
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
@@ -135,14 +135,14 @@ class RedisManager {
|
||||
password: replica.password,
|
||||
db: replica.db || redisConfig.db
|
||||
};
|
||||
|
||||
|
||||
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
|
||||
if (redisConfig.tls) {
|
||||
opts.tls = {
|
||||
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
@@ -855,163 +855,3 @@ class RedisManager {
|
||||
export const redisManager = new RedisManager();
|
||||
export const redis = redisManager.getClient();
|
||||
export default redisManager;
|
||||
|
||||
/**
|
||||
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
|
||||
* Connects only when `redis.regional_redis` is present in the private config
|
||||
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
|
||||
* caching of regionally-scoped data.
|
||||
*/
|
||||
class RegionalRedisManager {
|
||||
private writeClient: Redis | null = null;
|
||||
private readClient: Redis | null = null;
|
||||
private isEnabled: boolean = false;
|
||||
private isHealthy: boolean = false;
|
||||
private connectionTimeout: number = 5000;
|
||||
private commandTimeout: number = 5000;
|
||||
|
||||
constructor() {
|
||||
if (build === "oss") return;
|
||||
|
||||
const cfg = privateConfig.getRawPrivateConfig();
|
||||
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
|
||||
|
||||
this.isEnabled = true;
|
||||
this.initializeClients();
|
||||
}
|
||||
|
||||
private getConfig(): RedisOptions {
|
||||
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
|
||||
const opts: RedisOptions = {
|
||||
host: r.host,
|
||||
port: r.port,
|
||||
password: r.password,
|
||||
db: r.db
|
||||
};
|
||||
if (r.tls) {
|
||||
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
private initializeClients(): void {
|
||||
const cfg = this.getConfig();
|
||||
const baseOpts = {
|
||||
...cfg,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
keepAlive: 10000,
|
||||
connectTimeout: this.connectionTimeout,
|
||||
commandTimeout: this.commandTimeout
|
||||
};
|
||||
|
||||
try {
|
||||
this.writeClient = new Redis(baseOpts);
|
||||
// redis-1 (replica) handles reads; fall back to primary if not resolvable
|
||||
this.readClient = new Redis({
|
||||
...baseOpts,
|
||||
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
|
||||
// Derive replica hostname from the headless service pattern:
|
||||
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
|
||||
// If it doesn't look like a k8s service, just use the same host
|
||||
return h + rest;
|
||||
})
|
||||
});
|
||||
|
||||
// For simplicity use same host for both; callers can always read from primary
|
||||
// The real replica routing is handled by the StatefulSet headless service
|
||||
this.readClient = this.writeClient;
|
||||
|
||||
this.writeClient.on("ready", () => {
|
||||
logger.info("Regional Redis client ready");
|
||||
this.isHealthy = true;
|
||||
});
|
||||
this.writeClient.on("error", (err) => {
|
||||
logger.error("Regional Redis client error:", err);
|
||||
this.isHealthy = false;
|
||||
});
|
||||
this.writeClient.on("reconnecting", () => {
|
||||
logger.info("Regional Redis client reconnecting...");
|
||||
this.isHealthy = false;
|
||||
});
|
||||
|
||||
logger.info("Regional Redis client initialized");
|
||||
} catch (error) {
|
||||
logger.error("Failed to initialize regional Redis client:", error);
|
||||
this.isEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public isRedisEnabled(): boolean {
|
||||
return this.isEnabled && this.writeClient !== null && this.isHealthy;
|
||||
}
|
||||
|
||||
public getHealthStatus() {
|
||||
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
|
||||
}
|
||||
|
||||
public async set(
|
||||
key: string,
|
||||
value: string,
|
||||
ttl?: number
|
||||
): Promise<boolean> {
|
||||
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||
try {
|
||||
if (ttl) {
|
||||
await this.writeClient.setex(key, ttl, value);
|
||||
} else {
|
||||
await this.writeClient.set(key, value);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Regional Redis SET error:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string | null> {
|
||||
if (!this.isRedisEnabled() || !this.readClient) return null;
|
||||
try {
|
||||
return await this.readClient.get(key);
|
||||
} catch (error) {
|
||||
logger.error("Regional Redis GET error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async del(key: string): Promise<boolean> {
|
||||
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||
try {
|
||||
await this.writeClient.del(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("Regional Redis DEL error:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async keys(pattern: string): Promise<string[]> {
|
||||
if (!this.isRedisEnabled() || !this.readClient) return [];
|
||||
try {
|
||||
return await this.readClient.keys(pattern);
|
||||
} catch (error) {
|
||||
logger.error("Regional Redis KEYS error:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
try {
|
||||
if (this.writeClient) {
|
||||
await this.writeClient.quit();
|
||||
this.writeClient = null;
|
||||
}
|
||||
this.readClient = null;
|
||||
logger.info("Regional Redis client disconnected");
|
||||
} catch (error) {
|
||||
logger.error("Error disconnecting regional Redis client:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const regionalRedisManager = new RegionalRedisManager();
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
certificates,
|
||||
db,
|
||||
domainNamespaces,
|
||||
@@ -277,6 +278,115 @@ 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;
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -19,7 +19,6 @@ import { eq, and } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyCertificateAccess(
|
||||
req: Request,
|
||||
@@ -28,43 +27,11 @@ export async function verifyCertificateAccess(
|
||||
) {
|
||||
try {
|
||||
// Assume user/org access is already verified
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
|
||||
const certIdFromParams = getFirstString(req.params?.certId);
|
||||
const certIdFromBody = getFirstString(req.body?.certId);
|
||||
|
||||
if (
|
||||
certIdFromParams &&
|
||||
certIdFromBody &&
|
||||
certIdFromParams !== certIdFromBody
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Certificate ID provided in both URL and body with different values"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const certId = certIdFromParams || certIdFromBody;
|
||||
|
||||
const domainIdFromParams = getFirstString(req.params?.domainId);
|
||||
const domainIdFromBody = getFirstString(req.body?.domainId);
|
||||
|
||||
if (
|
||||
domainIdFromParams &&
|
||||
domainIdFromBody &&
|
||||
domainIdFromParams !== domainIdFromBody
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Domain ID provided in both URL and body with different values"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let domainId = domainIdFromParams || domainIdFromBody;
|
||||
const orgId = req.params.orgId;
|
||||
const certId =
|
||||
req.params.certId || req.body?.certId || req.query?.certId;
|
||||
let domainId =
|
||||
req.params.domainId || req.body?.domainId || req.query?.domainId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
@@ -98,7 +65,7 @@ export async function verifyCertificateAccess(
|
||||
);
|
||||
}
|
||||
|
||||
domainId = cert.domainId ?? undefined;
|
||||
domainId = cert.domainId;
|
||||
if (!domainId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
|
||||
@@ -17,7 +17,6 @@ import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyIdpAccess(
|
||||
req: Request,
|
||||
@@ -26,12 +25,8 @@ export async function verifyIdpAccess(
|
||||
) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const idpIdRaw =
|
||||
getFirstString(req.params.idpId) ||
|
||||
getFirstString(req.body?.idpId) ||
|
||||
getFirstString(req.query?.idpId);
|
||||
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
|
||||
const orgId = req.params.orgId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
@@ -45,7 +40,7 @@ export async function verifyIdpAccess(
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isNaN(idpId)) {
|
||||
if (!idpId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyRemoteExitNodeAccess(
|
||||
req: Request,
|
||||
@@ -26,11 +25,11 @@ export async function verifyRemoteExitNodeAccess(
|
||||
next: NextFunction
|
||||
) {
|
||||
const userId = req.user!.userId; // Assuming you have user information in the request
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const orgId = req.params.orgId;
|
||||
const remoteExitNodeId =
|
||||
getFirstString(req.params.remoteExitNodeId) ||
|
||||
getFirstString(req.body?.remoteExitNodeId) ||
|
||||
getFirstString(req.query?.remoteExitNodeId);
|
||||
req.params.remoteExitNodeId ||
|
||||
req.body.remoteExitNodeId ||
|
||||
req.query.remoteExitNodeId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
@@ -38,15 +37,6 @@ export async function verifyRemoteExitNodeAccess(
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId || !remoteExitNodeId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid organization or remote exit node ID"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const [remoteExitNode] = await db
|
||||
.select()
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
newts,
|
||||
resources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
siteId: z.number().int().positive(),
|
||||
type: z.enum(["ssh", "rdp", "vnc"]),
|
||||
destination: z.string().nonempty(),
|
||||
destinationPort: z.number().int().min(1).max(65535)
|
||||
});
|
||||
|
||||
export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target",
|
||||
description: "Create a browser gateway target for a resource.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, resourceId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${siteId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const plainToken = generateId(48);
|
||||
const encryptedToken = encrypt(
|
||||
plainToken,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
const [record] = await db
|
||||
.insert(browserGatewayTarget)
|
||||
.values({
|
||||
resourceId,
|
||||
siteId,
|
||||
type,
|
||||
destination,
|
||||
destinationPort,
|
||||
authToken: encryptedToken
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (site.type === "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await sendBrowserGatewayTargets(
|
||||
newt.newtId,
|
||||
[record],
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}`
|
||||
);
|
||||
|
||||
return response<CreateBrowserGatewayTargetResponse>(res, {
|
||||
data: record,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { browserGatewayTarget, db, newts, sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
browserGatewayTargetId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||
description: "Delete a browser gateway target.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function deleteBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ bgt: browserGatewayTarget, site: sites })
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(browserGatewayTarget)
|
||||
.where(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
)
|
||||
);
|
||||
|
||||
if (existing.site.type === "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, existing.bgt.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await removeBrowserGatewayTarget(
|
||||
newt.newtId,
|
||||
browserGatewayTargetId,
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`);
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to delete browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
browserGatewayTargetId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||
description: "Get a browser gateway target.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||
|
||||
const [result] = await db
|
||||
.select({ bgt: browserGatewayTarget })
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!result) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetBrowserGatewayTargetResponse>(res, {
|
||||
data: result.bgt,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to retrieve browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./createBrowserGatewayTarget";
|
||||
export * from "./updateBrowserGatewayTarget";
|
||||
export * from "./deleteBrowserGatewayTarget";
|
||||
export * from "./getBrowserGatewayTarget";
|
||||
export * from "./listBrowserGatewayTargets";
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
resources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
const querySchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
});
|
||||
|
||||
export type ListBrowserGatewayTargetsResponse = {
|
||||
targets: BrowserGatewayTarget[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets",
|
||||
description: "List browser gateway targets for a resource.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: querySchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listBrowserGatewayTargets(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, resourceId } = parsedParams.data;
|
||||
|
||||
const parsedQuery = querySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const targets = await db
|
||||
.select()
|
||||
.from(browserGatewayTarget)
|
||||
.where(eq(browserGatewayTarget.resourceId, resourceId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return response<ListBrowserGatewayTargetsResponse>(res, {
|
||||
data: {
|
||||
targets: targets,
|
||||
total: targets.length,
|
||||
limit,
|
||||
offset
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway targets retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to list browser gateway targets"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
newts,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
browserGatewayTargetId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
siteId: z.number().int().positive().optional(),
|
||||
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
|
||||
destination: z.string().nonempty().optional(),
|
||||
destinationPort: z.number().int().min(1).max(65535).optional()
|
||||
});
|
||||
|
||||
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||
description: "Update a browser gateway target.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function updateBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ bgt: browserGatewayTarget, site: sites })
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updateValues: Partial<BrowserGatewayTarget> = {};
|
||||
if (siteId !== undefined) updateValues.siteId = siteId;
|
||||
if (type !== undefined) updateValues.type = type;
|
||||
if (destination !== undefined) updateValues.destination = destination;
|
||||
if (destinationPort !== undefined)
|
||||
updateValues.destinationPort = destinationPort;
|
||||
|
||||
const [updated] = await db
|
||||
.update(browserGatewayTarget)
|
||||
.set(updateValues)
|
||||
.where(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
const targetSiteId = siteId ?? existing.bgt.siteId;
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, targetSiteId))
|
||||
.limit(1);
|
||||
|
||||
if (site && site.type === "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, targetSiteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await sendBrowserGatewayTargets(
|
||||
newt.newtId,
|
||||
[updated],
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
|
||||
|
||||
return response<UpdateBrowserGatewayTargetResponse>(res, {
|
||||
data: updated,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to update browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,8 +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 browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
||||
import * as labels from "#private/routers/labels";
|
||||
import * as client from "@server/routers/client";
|
||||
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
@@ -831,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,44 +16,40 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { response as sendResponse } from "@server/lib/response";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
||||
|
||||
export interface CreateNewLicenseResponse {
|
||||
data: Data;
|
||||
success: boolean;
|
||||
error: boolean;
|
||||
message: string;
|
||||
status: number;
|
||||
data: Data
|
||||
success: boolean
|
||||
error: boolean
|
||||
message: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
licenseKey: LicenseKey;
|
||||
licenseKey: LicenseKey
|
||||
}
|
||||
|
||||
export interface LicenseKey {
|
||||
id: number;
|
||||
instanceName: any;
|
||||
instanceId: string;
|
||||
licenseKey: string;
|
||||
tier: string;
|
||||
type: string;
|
||||
quantity: number;
|
||||
quantity_2: number;
|
||||
isValid: boolean;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
paidFor: boolean;
|
||||
orgId: string;
|
||||
metadata: string;
|
||||
id: number
|
||||
instanceName: any
|
||||
instanceId: string
|
||||
licenseKey: string
|
||||
tier: string
|
||||
type: string
|
||||
quantity: number
|
||||
quantity_2: number
|
||||
isValid: boolean
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
paidFor: boolean
|
||||
orgId: string
|
||||
metadata: string
|
||||
}
|
||||
|
||||
export async function createNewLicense(
|
||||
orgId: string,
|
||||
licenseData: any
|
||||
): Promise<CreateNewLicenseResponse> {
|
||||
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
||||
@@ -84,7 +80,7 @@ export async function generateNewLicense(
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const { orgId } = req.params;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
|
||||
@@ -16,7 +16,6 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { response as sendResponse } from "@server/lib/response";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import {
|
||||
GeneratedLicenseKey,
|
||||
@@ -56,7 +55,7 @@ export async function listSaasLicenseKeys(
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const { orgId } = req.params;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
} 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,
|
||||
|
||||
@@ -25,7 +25,6 @@ import { UserType } from "@server/types/UserTypes";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
import { verifyTotpCode } from "@server/auth/totp";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
// The RP ID is the domain name of your application
|
||||
const rpID = (() => {
|
||||
@@ -407,12 +406,7 @@ export async function deleteSecurityKey(
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const encodedCredentialId = getFirstString(req.params.credentialId);
|
||||
if (!encodedCredentialId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid credential ID")
|
||||
);
|
||||
}
|
||||
const { credentialId: encodedCredentialId } = req.params;
|
||||
const credentialId = decodeURIComponent(encodedCredentialId);
|
||||
const user = req.user as User;
|
||||
|
||||
|
||||
@@ -10,5 +10,3 @@ export * from "./listUserDevices";
|
||||
export * from "./updateClient";
|
||||
export * from "./getClient";
|
||||
export * from "./createUserClient";
|
||||
export * from "./verifyClientAssociationsCache";
|
||||
export * from "./rebuildClientAssociationsCacheRoute";
|
||||
|
||||
@@ -118,27 +118,7 @@ const listClientsSchema = z.object({
|
||||
description:
|
||||
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
|
||||
})
|
||||
),
|
||||
labels: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
// the array is returned as this
|
||||
if (typeof val === "string") {
|
||||
return val.split(",");
|
||||
}
|
||||
return undefined;
|
||||
}, z.array(z.string()))
|
||||
.optional()
|
||||
.catch([])
|
||||
.openapi({
|
||||
type: "array",
|
||||
description: "Filter by client labels"
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
function queryClientsBase() {
|
||||
@@ -230,16 +210,8 @@ export async function listClients(
|
||||
)
|
||||
);
|
||||
}
|
||||
const {
|
||||
page,
|
||||
pageSize,
|
||||
online,
|
||||
query,
|
||||
status,
|
||||
sort_by,
|
||||
order,
|
||||
labels: labelFilter
|
||||
} = parsedQuery.data;
|
||||
const { page, pageSize, online, query, status, sort_by, order } =
|
||||
parsedQuery.data;
|
||||
|
||||
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
@@ -326,22 +298,6 @@ export async function listClients(
|
||||
conditions.push(or(...filterAggregates));
|
||||
}
|
||||
|
||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
clients.clientId,
|
||||
db
|
||||
.select({ id: clientLabels.clientId })
|
||||
.from(clientLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, clientLabels.labelId)
|
||||
)
|
||||
.where(inArray(labels.name, labelFilter))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { domain } from "zod/v4/core/regexes";
|
||||
|
||||
const getDomainSchema = z.strictObject({
|
||||
domainId: z.string().optional(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { response } from "@server/lib/response";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function getUserResources(
|
||||
req: Request,
|
||||
@@ -27,7 +26,7 @@ export async function getUserResources(
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
const { orgId } = req.params;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
@@ -36,12 +35,6 @@ export async function getUserResources(
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
// Check user is in organization and get their role IDs
|
||||
const [userOrg] = await db
|
||||
.select()
|
||||
|
||||
@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
|
||||
export * from "./listAllResourceNames";
|
||||
export * from "./removeEmailFromResourceWhitelist";
|
||||
export * from "./getStatusHistory";
|
||||
export * from "./getBrowserTarget";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
db,
|
||||
labels,
|
||||
resourceHeaderAuth,
|
||||
@@ -71,7 +72,7 @@ const listResourcesSchema = z.object({
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.literal("name")
|
||||
.enum(["name"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
@@ -123,27 +124,7 @@ const listResourcesSchema = z.object({
|
||||
type: "integer",
|
||||
description:
|
||||
"When set, only resources that have at least one target on this site are returned"
|
||||
}),
|
||||
labels: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
// the array is returned as this
|
||||
if (typeof val === "string") {
|
||||
return val.split(",");
|
||||
}
|
||||
return undefined;
|
||||
}, z.array(z.string()))
|
||||
.optional()
|
||||
.catch([])
|
||||
.openapi({
|
||||
type: "array",
|
||||
description: "Filter by resource labels"
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
// grouped by resource with targets[])
|
||||
@@ -165,6 +146,7 @@ export type ResourceWithTargets = {
|
||||
headerAuthId: number | null;
|
||||
wildcard: boolean;
|
||||
health: string | null;
|
||||
browserAccessType: string | null;
|
||||
targets: Array<{
|
||||
targetId: number;
|
||||
ip: string;
|
||||
@@ -203,7 +185,8 @@ function queryResourcesBase() {
|
||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||
headerAuthExtendedCompatibilityId:
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
||||
health: resources.health
|
||||
health: resources.health,
|
||||
browserAccessType: resources.browserAccessType
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
@@ -281,8 +264,7 @@ export async function listResources(
|
||||
healthStatus,
|
||||
sort_by,
|
||||
order,
|
||||
siteId,
|
||||
labels: labelFilter
|
||||
siteId
|
||||
} = parsedQuery.data;
|
||||
|
||||
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||
@@ -400,23 +382,6 @@ export async function listResources(
|
||||
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
||||
}
|
||||
|
||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
resources.resourceId,
|
||||
db
|
||||
.select({ id: resourceLabels.resourceId })
|
||||
.from(resourceLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, resourceLabels.labelId)
|
||||
)
|
||||
.where(inArray(labels.name, labelFilter))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
@@ -520,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>();
|
||||
|
||||
@@ -540,6 +529,7 @@ 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,
|
||||
@@ -583,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"
|
||||
|
||||
@@ -187,26 +187,6 @@ const listSitesSchema = z.object({
|
||||
type: "string",
|
||||
enum: ["pending", "approved"],
|
||||
description: "Filter by site status"
|
||||
}),
|
||||
labels: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
// the array is returned as this
|
||||
if (typeof val === "string") {
|
||||
return val.split(",");
|
||||
}
|
||||
return undefined;
|
||||
}, z.array(z.string()))
|
||||
.optional()
|
||||
.catch([])
|
||||
.openapi({
|
||||
type: "array",
|
||||
description: "Filter by site labels"
|
||||
})
|
||||
});
|
||||
|
||||
@@ -339,16 +319,8 @@ export async function listSites(
|
||||
tierMatrix.labels
|
||||
);
|
||||
|
||||
const {
|
||||
pageSize,
|
||||
page,
|
||||
query,
|
||||
sort_by,
|
||||
order,
|
||||
online,
|
||||
status,
|
||||
labels: labelFilter
|
||||
} = parsedQuery.data;
|
||||
const { pageSize, page, query, sort_by, order, online, status } =
|
||||
parsedQuery.data;
|
||||
|
||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||
|
||||
@@ -365,23 +337,6 @@ export async function listSites(
|
||||
if (typeof status !== "undefined") {
|
||||
conditions.push(eq(sites.status, status));
|
||||
}
|
||||
|
||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
sites.siteId,
|
||||
db
|
||||
.select({ id: siteLabels.siteId })
|
||||
.from(siteLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, siteLabels.labelId)
|
||||
)
|
||||
.where(inArray(labels.name, labelFilter))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
@@ -411,9 +366,7 @@ export async function listSites(
|
||||
|
||||
// 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
|
||||
|
||||
@@ -68,6 +68,7 @@ const createSiteResourceSchema = z
|
||||
disableIcmp: z.boolean().optional(),
|
||||
authDaemonPort: z.int().positive().optional(),
|
||||
authDaemonMode: z.enum(["site", "remote"]).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
|
||||
})
|
||||
|
||||
@@ -85,27 +85,7 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||
type: "integer",
|
||||
description:
|
||||
"When set, only site resources associated with this site (via network) are returned"
|
||||
}),
|
||||
labels: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
// the array is returned as this
|
||||
if (typeof val === "string") {
|
||||
return val.split(",");
|
||||
}
|
||||
return undefined;
|
||||
}, z.array(z.string()))
|
||||
.optional()
|
||||
.catch([])
|
||||
.openapi({
|
||||
type: "array",
|
||||
description: "Filter by resource labels"
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||
@@ -259,16 +239,8 @@ export async function listAllSiteResourcesByOrg(
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const {
|
||||
page,
|
||||
pageSize,
|
||||
query,
|
||||
mode,
|
||||
sort_by,
|
||||
order,
|
||||
siteId,
|
||||
labels: labelFilter
|
||||
} = parsedQuery.data;
|
||||
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
||||
parsedQuery.data;
|
||||
|
||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
@@ -304,22 +276,6 @@ export async function listAllSiteResourcesByOrg(
|
||||
conditions.push(eq(siteResources.mode, mode));
|
||||
}
|
||||
|
||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
siteResources.siteResourceId,
|
||||
db
|
||||
.select({ id: siteResourceLabels.siteResourceId })
|
||||
.from(siteResourceLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, siteResourceLabels.labelId)
|
||||
)
|
||||
.where(inArray(labels.name, labelFilter))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useTransition, useMemo } from "react";
|
||||
import { useState, useRef, useEffect, useTransition } from "react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -20,9 +20,6 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const router = useRouter();
|
||||
@@ -33,8 +30,23 @@ export default function GeneralPage() {
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
const [filterAttributes, setFilterAttributes] = useState<{
|
||||
actors: string[];
|
||||
resources: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
locations: string[];
|
||||
}>({
|
||||
actors: [],
|
||||
resources: [],
|
||||
locations: []
|
||||
});
|
||||
|
||||
// Filter states - unified object for all filters
|
||||
const [filters, setFilters] = useState<{
|
||||
action?: string;
|
||||
type?: string;
|
||||
@@ -49,21 +61,40 @@ export default function GeneralPage() {
|
||||
actor: searchParams.get("actor") || undefined
|
||||
});
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Initialize page size from storage or default
|
||||
const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: { date: new Date(startParam) },
|
||||
endDate: { date: new Date(endParam) }
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastWeek = getSevenDaysAgo();
|
||||
|
||||
return {
|
||||
startDate: { date: getSevenDaysAgo() },
|
||||
endDate: { date: new Date() }
|
||||
startDate: {
|
||||
date: lastWeek
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -72,95 +103,75 @@ export default function GeneralPage() {
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
const queryFilters = useMemo(() => {
|
||||
let timeStart: string | undefined;
|
||||
let timeEnd: string | undefined;
|
||||
|
||||
if (dateRange.startDate?.date) {
|
||||
const dt = new Date(dateRange.startDate.date);
|
||||
if (dateRange.startDate.time) {
|
||||
const [h, m, s] = dateRange.startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
}
|
||||
timeStart = dt.toISOString();
|
||||
}
|
||||
|
||||
if (dateRange.endDate?.date) {
|
||||
const dt = new Date(dateRange.endDate.date);
|
||||
if (dateRange.endDate.time) {
|
||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
} else {
|
||||
const now = new Date();
|
||||
dt.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
timeEnd = dt.toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
timeStart,
|
||||
timeEnd,
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
...filters,
|
||||
resourceId: filters.resourceId
|
||||
? Number(filters.resourceId)
|
||||
: undefined
|
||||
};
|
||||
}, [dateRange, currentPage, pageSize, filters]);
|
||||
|
||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||
...logQueries.access({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
}),
|
||||
enabled: isPaidUser(tierMatrix.accessLogs) && build !== "oss"
|
||||
});
|
||||
|
||||
const rows = isLoading ? generateSampleAccessLogs() : (data?.log ?? []);
|
||||
const totalCount = data?.pagination?.total ?? 0;
|
||||
const filterAttributes = data?.filterAttributes ?? {
|
||||
actors: [],
|
||||
resources: [],
|
||||
locations: []
|
||||
};
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
const handleDateRangeChange = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
updateUrlParamsForAllFilters(newFilters);
|
||||
|
||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
0,
|
||||
pageSize,
|
||||
newFilters
|
||||
);
|
||||
};
|
||||
|
||||
const updateUrlParamsForAllFilters = (
|
||||
@@ -182,8 +193,114 @@ export default function GeneralPage() {
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const queryDateTime = async (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue,
|
||||
page: number = currentPage,
|
||||
size: number = pageSize,
|
||||
filtersParam?: {
|
||||
action?: string;
|
||||
type?: string;
|
||||
resourceId?: string;
|
||||
location?: string;
|
||||
actor?: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
if (!isPaidUser(tierMatrix.accessLogs) || build === "oss") {
|
||||
console.log(
|
||||
"Access denied: subscription inactive or license locked"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Use the provided filters or fall back to current state
|
||||
const activeFilters = filtersParam || filters;
|
||||
|
||||
// Convert the date/time values to API parameters
|
||||
const params: any = {
|
||||
limit: size,
|
||||
offset: page * size,
|
||||
...activeFilters
|
||||
};
|
||||
|
||||
if (startDate?.date) {
|
||||
const startDateTime = new Date(startDate.date);
|
||||
if (startDate.time) {
|
||||
const [hours, minutes, seconds] = startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||
}
|
||||
params.timeStart = startDateTime.toISOString();
|
||||
}
|
||||
|
||||
if (endDate?.date) {
|
||||
const endDateTime = new Date(endDate.date);
|
||||
if (endDate.time) {
|
||||
const [hours, minutes, seconds] = endDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||
} else {
|
||||
// If no time is specified, set to NOW
|
||||
const now = new Date();
|
||||
endDateTime.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
params.timeEnd = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
const res = await api.get(`/org/${orgId}/logs/access`, { params });
|
||||
if (res.status === 200) {
|
||||
setRows(res.data.data.log || []);
|
||||
setTotalCount(res.data.data.pagination?.total || 0);
|
||||
setFilterAttributes(res.data.data.filterAttributes);
|
||||
console.log("Fetched logs:", res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("Failed to filter logs"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Refresh data with current date range and pagination
|
||||
await queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
currentPage,
|
||||
pageSize
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
// Prepare query params for export
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
@@ -199,6 +316,7 @@ export default function GeneralPage() {
|
||||
params
|
||||
});
|
||||
|
||||
// Create a URL for the blob and trigger a download
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -216,6 +334,7 @@ export default function GeneralPage() {
|
||||
const data = error.response.data;
|
||||
|
||||
if (data instanceof Blob && data.type === "application/json") {
|
||||
// Parse the Blob as JSON
|
||||
const text = await data.text();
|
||||
const errorData = JSON.parse(text);
|
||||
apiErrorMessage = errorData.message;
|
||||
@@ -232,7 +351,7 @@ export default function GeneralPage() {
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -247,7 +366,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("action")}</span>
|
||||
@@ -260,6 +379,7 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("action", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -276,11 +396,13 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: () => t("ip")
|
||||
header: ({ column }) => {
|
||||
return t("ip");
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "location",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("location")}</span>
|
||||
@@ -295,6 +417,7 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("location", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -319,7 +442,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("resource")}</span>
|
||||
@@ -332,6 +455,7 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("resourceId", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -357,7 +481,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("type")}</span>
|
||||
@@ -376,6 +500,7 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("type", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -393,7 +518,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("actor")}</span>
|
||||
@@ -406,6 +531,7 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -433,12 +559,16 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "actorId",
|
||||
header: () => t("actorId"),
|
||||
cell: ({ row }) => (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId || "-"}
|
||||
</span>
|
||||
)
|
||||
header: ({ column }) => {
|
||||
return t("actorId");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -484,10 +614,13 @@ export default function GeneralPage() {
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("accessLogs")}
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isFetching}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
|
||||
// !isPaidUser(tierMatrix.accessLogs) || build === "oss"
|
||||
// }
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
@@ -497,12 +630,14 @@ export default function GeneralPage() {
|
||||
id: "timestamp",
|
||||
desc: true
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
||||
@@ -510,41 +645,3 @@ export default function GeneralPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
||||
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
||||
const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"];
|
||||
const actors = [
|
||||
"alice@example.com",
|
||||
"bob@example.com",
|
||||
"carol@example.com",
|
||||
null
|
||||
];
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const action = Math.random() > 0.3;
|
||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||
|
||||
return {
|
||||
timestamp: Math.floor(
|
||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||
),
|
||||
action,
|
||||
orgId: "sample-org",
|
||||
actorType: actor ? "user" : null,
|
||||
actor,
|
||||
actorId: actor ? `user-${i}` : null,
|
||||
resourceId: Math.floor(Math.random() * 5) + 1,
|
||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||
resourceName: `Resource ${(i % 3) + 1}`,
|
||||
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||
location: locations[Math.floor(Math.random() * locations.length)],
|
||||
userAgent: "Mozilla/5.0",
|
||||
metadata: null,
|
||||
type: types[Math.floor(Math.random() * types.length)]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,17 +10,14 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import { Key, User } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const router = useRouter();
|
||||
@@ -31,8 +28,18 @@ export default function GeneralPage() {
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
const [filterAttributes, setFilterAttributes] = useState<{
|
||||
actors: string[];
|
||||
actions: string[];
|
||||
}>({
|
||||
actors: [],
|
||||
actions: []
|
||||
});
|
||||
|
||||
// Filter states - unified object for all filters
|
||||
const [filters, setFilters] = useState<{
|
||||
action?: string;
|
||||
actor?: string;
|
||||
@@ -41,21 +48,40 @@ export default function GeneralPage() {
|
||||
actor: searchParams.get("actor") || undefined
|
||||
});
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Initialize page size from storage or default
|
||||
const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: { date: new Date(startParam) },
|
||||
endDate: { date: new Date(endParam) }
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastWeek = getSevenDaysAgo();
|
||||
|
||||
return {
|
||||
startDate: { date: getSevenDaysAgo() },
|
||||
endDate: { date: new Date() }
|
||||
startDate: {
|
||||
date: lastWeek
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -64,90 +90,78 @@ export default function GeneralPage() {
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
const queryFilters = useMemo(() => {
|
||||
let timeStart: string | undefined;
|
||||
let timeEnd: string | undefined;
|
||||
|
||||
if (dateRange.startDate?.date) {
|
||||
const dt = new Date(dateRange.startDate.date);
|
||||
if (dateRange.startDate.time) {
|
||||
const [h, m, s] = dateRange.startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
}
|
||||
timeStart = dt.toISOString();
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
if (build === "oss") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dateRange.endDate?.date) {
|
||||
const dt = new Date(dateRange.endDate.date);
|
||||
if (dateRange.endDate.time) {
|
||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
} else {
|
||||
const now = new Date();
|
||||
dt.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
timeEnd = dt.toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
timeStart,
|
||||
timeEnd,
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
...filters
|
||||
};
|
||||
}, [dateRange, currentPage, pageSize, filters]);
|
||||
|
||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||
...logQueries.action({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
}),
|
||||
enabled: isPaidUser(tierMatrix.actionLogs) && build !== "oss"
|
||||
});
|
||||
|
||||
const rows = isLoading ? generateSampleActionLogs() : (data?.log ?? []);
|
||||
const totalCount = data?.pagination?.total ?? 0;
|
||||
const filterAttributes = {
|
||||
actors: data?.filterAttributes?.actors ?? []
|
||||
};
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
const handleDateRangeChange = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
updateUrlParamsForAllFilters(newFilters);
|
||||
|
||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
0,
|
||||
pageSize,
|
||||
newFilters
|
||||
);
|
||||
};
|
||||
|
||||
const updateUrlParamsForAllFilters = (
|
||||
@@ -169,8 +183,110 @@ export default function GeneralPage() {
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const queryDateTime = async (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue,
|
||||
page: number = currentPage,
|
||||
size: number = pageSize,
|
||||
filtersParam?: {
|
||||
action?: string;
|
||||
actor?: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
if (!isPaidUser(tierMatrix.actionLogs)) {
|
||||
console.log(
|
||||
"Access denied: subscription inactive or license locked"
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Use the provided filters or fall back to current state
|
||||
const activeFilters = filtersParam || filters;
|
||||
|
||||
// Convert the date/time values to API parameters
|
||||
const params: any = {
|
||||
limit: size,
|
||||
offset: page * size,
|
||||
...activeFilters
|
||||
};
|
||||
|
||||
if (startDate?.date) {
|
||||
const startDateTime = new Date(startDate.date);
|
||||
if (startDate.time) {
|
||||
const [hours, minutes, seconds] = startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||
}
|
||||
params.timeStart = startDateTime.toISOString();
|
||||
}
|
||||
|
||||
if (endDate?.date) {
|
||||
const endDateTime = new Date(endDate.date);
|
||||
if (endDate.time) {
|
||||
const [hours, minutes, seconds] = endDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||
} else {
|
||||
// If no time is specified, set to NOW
|
||||
const now = new Date();
|
||||
endDateTime.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
params.timeEnd = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
const res = await api.get(`/org/${orgId}/logs/action`, { params });
|
||||
if (res.status === 200) {
|
||||
setRows(res.data.data.log || []);
|
||||
setTotalCount(res.data.data.pagination?.total || 0);
|
||||
setFilterAttributes(res.data.data.filterAttributes);
|
||||
console.log("Fetched logs:", res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("Failed to filter logs"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Refresh data with current date range and pagination
|
||||
await queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
currentPage,
|
||||
pageSize
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
// Prepare query params for export
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
@@ -186,6 +302,7 @@ export default function GeneralPage() {
|
||||
params
|
||||
});
|
||||
|
||||
// Create a URL for the blob and trigger a download
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -203,6 +320,7 @@ export default function GeneralPage() {
|
||||
const data = error.response.data;
|
||||
|
||||
if (data instanceof Blob && data.type === "application/json") {
|
||||
// Parse the Blob as JSON
|
||||
const text = await data.text();
|
||||
const errorData = JSON.parse(text);
|
||||
apiErrorMessage = errorData.message;
|
||||
@@ -219,7 +337,7 @@ export default function GeneralPage() {
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -234,16 +352,22 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("action")}</span>
|
||||
<ColumnFilter
|
||||
options={[]}
|
||||
options={filterAttributes.actions.map((action) => ({
|
||||
label:
|
||||
action.charAt(0).toUpperCase() +
|
||||
action.slice(1),
|
||||
value: action
|
||||
}))}
|
||||
selectedValue={filters.action}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("action", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -261,7 +385,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("actor")}</span>
|
||||
@@ -274,6 +398,7 @@ export default function GeneralPage() {
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
@@ -295,7 +420,7 @@ export default function GeneralPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "actorId",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return t("actorId");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -344,9 +469,12 @@ export default function GeneralPage() {
|
||||
title={t("actionLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="action"
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isFetching}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onExport={() => startTransition(exportData)}
|
||||
// isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan
|
||||
// !isPaidUser(tierMatrix.logExport) || build === "oss"
|
||||
// }
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
@@ -357,12 +485,14 @@ export default function GeneralPage() {
|
||||
id: "timestamp",
|
||||
desc: true
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
||||
@@ -370,39 +500,3 @@ export default function GeneralPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function generateSampleActionLogs(): QueryActionAuditLogResponse["log"] {
|
||||
const actions = [
|
||||
"createResource",
|
||||
"deleteResource",
|
||||
"updateResource",
|
||||
"createSite",
|
||||
"deleteSite",
|
||||
"inviteUser",
|
||||
"removeUser"
|
||||
];
|
||||
const actors = [
|
||||
"alice@example.com",
|
||||
"bob@example.com",
|
||||
"carol@example.com"
|
||||
];
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||
|
||||
return {
|
||||
timestamp: Math.floor(
|
||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||
),
|
||||
action: actions[Math.floor(Math.random() * actions.length)],
|
||||
orgId: "sample-org",
|
||||
actorType: "user",
|
||||
actor,
|
||||
actorId: `user-${i}`,
|
||||
metadata: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,20 +9,26 @@ 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 { logQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { QueryConnectionAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import axios from "axios";
|
||||
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes === null || bytes === undefined) return "-";
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const value = bytes / Math.pow(1024, i);
|
||||
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: number, endedAt: number | null): string {
|
||||
if (endedAt === null || endedAt === undefined) return "Active";
|
||||
@@ -48,8 +54,24 @@ export default function ConnectionLogsPage() {
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
const [filterAttributes, setFilterAttributes] = useState<{
|
||||
protocols: string[];
|
||||
destAddrs: string[];
|
||||
clients: { id: number; name: string }[];
|
||||
resources: { id: number; name: string | null }[];
|
||||
users: { id: string; email: string | null }[];
|
||||
}>({
|
||||
protocols: [],
|
||||
destAddrs: [],
|
||||
clients: [],
|
||||
resources: [],
|
||||
users: []
|
||||
});
|
||||
|
||||
// Filter states - unified object for all filters
|
||||
const [filters, setFilters] = useState<{
|
||||
protocol?: string;
|
||||
destAddr?: string;
|
||||
@@ -64,24 +86,43 @@ export default function ConnectionLogsPage() {
|
||||
userId: searchParams.get("userId") || undefined
|
||||
});
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Initialize page size from storage or default
|
||||
const [pageSize, setPageSize] = useStoredPageSize(
|
||||
"connection-audit-logs",
|
||||
20
|
||||
);
|
||||
|
||||
// Set default date range to last 7 days
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: { date: new Date(startParam) },
|
||||
endDate: { date: new Date(endParam) }
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastWeek = getSevenDaysAgo();
|
||||
|
||||
return {
|
||||
startDate: { date: getSevenDaysAgo() },
|
||||
endDate: { date: new Date() }
|
||||
startDate: {
|
||||
date: lastWeek
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -90,100 +131,78 @@ export default function ConnectionLogsPage() {
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
const queryFilters = useMemo(() => {
|
||||
let timeStart: string | undefined;
|
||||
let timeEnd: string | undefined;
|
||||
|
||||
if (dateRange.startDate?.date) {
|
||||
const dt = new Date(dateRange.startDate.date);
|
||||
if (dateRange.startDate.time) {
|
||||
const [h, m, s] = dateRange.startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
}
|
||||
timeStart = dt.toISOString();
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
if (build === "oss") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dateRange.endDate?.date) {
|
||||
const dt = new Date(dateRange.endDate.date);
|
||||
if (dateRange.endDate.time) {
|
||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
} else {
|
||||
const now = new Date();
|
||||
dt.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
timeEnd = dt.toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
timeStart,
|
||||
timeEnd,
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
...filters,
|
||||
clientId: filters.clientId ? Number(filters.clientId) : undefined,
|
||||
siteResourceId: filters.siteResourceId
|
||||
? Number(filters.siteResourceId)
|
||||
: undefined
|
||||
};
|
||||
}, [dateRange, currentPage, pageSize, filters]);
|
||||
|
||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||
...logQueries.connection({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
}),
|
||||
enabled: isPaidUser(tierMatrix.connectionLogs) && build !== "oss"
|
||||
});
|
||||
|
||||
const rows = isLoading
|
||||
? generateSampleConnectionLogs()
|
||||
: (data?.log ?? []);
|
||||
const totalCount = data?.pagination?.total ?? 0;
|
||||
const filterAttributes = data?.filterAttributes ?? {
|
||||
protocols: [],
|
||||
destAddrs: [],
|
||||
clients: [],
|
||||
resources: [],
|
||||
users: []
|
||||
};
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
const handleDateRangeChange = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
updateUrlParamsForAllFilters(newFilters);
|
||||
|
||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
0,
|
||||
pageSize,
|
||||
newFilters
|
||||
);
|
||||
};
|
||||
|
||||
const updateUrlParamsForAllFilters = (
|
||||
@@ -205,8 +224,109 @@ export default function ConnectionLogsPage() {
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const queryDateTime = async (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue,
|
||||
page: number = currentPage,
|
||||
size: number = pageSize,
|
||||
filtersParam?: typeof filters
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
if (!isPaidUser(tierMatrix.connectionLogs)) {
|
||||
console.log(
|
||||
"Access denied: subscription inactive or license locked"
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Use the provided filters or fall back to current state
|
||||
const activeFilters = filtersParam || filters;
|
||||
|
||||
// Convert the date/time values to API parameters
|
||||
const params: any = {
|
||||
limit: size,
|
||||
offset: page * size,
|
||||
...activeFilters
|
||||
};
|
||||
|
||||
if (startDate?.date) {
|
||||
const startDateTime = new Date(startDate.date);
|
||||
if (startDate.time) {
|
||||
const [hours, minutes, seconds] = startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||
}
|
||||
params.timeStart = startDateTime.toISOString();
|
||||
}
|
||||
|
||||
if (endDate?.date) {
|
||||
const endDateTime = new Date(endDate.date);
|
||||
if (endDate.time) {
|
||||
const [hours, minutes, seconds] = endDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||
} else {
|
||||
// If no time is specified, set to NOW
|
||||
const now = new Date();
|
||||
endDateTime.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
params.timeEnd = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
const res = await api.get(`/org/${orgId}/logs/connection`, {
|
||||
params
|
||||
});
|
||||
if (res.status === 200) {
|
||||
setRows(res.data.data.log || []);
|
||||
setTotalCount(res.data.data.pagination?.total || 0);
|
||||
setFilterAttributes(res.data.data.filterAttributes);
|
||||
console.log("Fetched connection logs:", res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(error),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Refresh data with current date range and pagination
|
||||
await queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
currentPage,
|
||||
pageSize
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
// Prepare query params for export
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
@@ -225,6 +345,7 @@ export default function ConnectionLogsPage() {
|
||||
}
|
||||
);
|
||||
|
||||
// Create a URL for the blob and trigger a download
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
@@ -242,6 +363,7 @@ export default function ConnectionLogsPage() {
|
||||
const data = error.response.data;
|
||||
|
||||
if (data instanceof Blob && data.type === "application/json") {
|
||||
// Parse the Blob as JSON
|
||||
const text = await data.text();
|
||||
const errorData = JSON.parse(text);
|
||||
apiErrorMessage = errorData.message;
|
||||
@@ -258,7 +380,7 @@ export default function ConnectionLogsPage() {
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "startedAt",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -273,7 +395,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("protocol")}</span>
|
||||
@@ -304,7 +426,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("resource")}</span>
|
||||
@@ -345,7 +467,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "clientName",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("client")}</span>
|
||||
@@ -388,7 +510,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "userEmail",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("user")}</span>
|
||||
@@ -421,7 +543,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "sourceAddr",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return t("sourceAddress");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -434,7 +556,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "destAddr",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("destinationAddress")}</span>
|
||||
@@ -463,7 +585,7 @@ export default function ConnectionLogsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: "duration",
|
||||
header: () => {
|
||||
header: ({ column }) => {
|
||||
return t("duration");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -484,6 +606,9 @@ export default function ConnectionLogsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||
<div className="space-y-2">
|
||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||
Connection Details
|
||||
</div>*/}
|
||||
<div>
|
||||
<strong>Session ID:</strong>{" "}
|
||||
<span className="font-mono">
|
||||
@@ -508,6 +633,18 @@ export default function ConnectionLogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||
Resource & Site
|
||||
</div>*/}
|
||||
{/*<div>
|
||||
<strong>Resource:</strong>{" "}
|
||||
{row.resourceName ?? "-"}
|
||||
{row.resourceNiceId && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({row.resourceNiceId})
|
||||
</span>
|
||||
)}
|
||||
</div>*/}
|
||||
<div>
|
||||
<strong>Client Endpoint:</strong>{" "}
|
||||
<span className="font-mono">
|
||||
@@ -543,8 +680,30 @@ export default function ConnectionLogsPage() {
|
||||
<strong>Duration:</strong>{" "}
|
||||
{formatDuration(row.startedAt, row.endedAt)}
|
||||
</div>
|
||||
{/*<div>
|
||||
<strong>Resource ID:</strong>{" "}
|
||||
{row.siteResourceId ?? "-"}
|
||||
</div>*/}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
||||
Client & Transfer
|
||||
</div>*/}
|
||||
{/*<div>
|
||||
<strong>Bytes Sent (TX):</strong>{" "}
|
||||
{formatBytes(row.bytesTx)}
|
||||
</div>*/}
|
||||
{/*<div>
|
||||
<strong>Bytes Received (RX):</strong>{" "}
|
||||
{formatBytes(row.bytesRx)}
|
||||
</div>*/}
|
||||
{/*<div>
|
||||
<strong>Total Transfer:</strong>{" "}
|
||||
{formatBytes(
|
||||
(row.bytesTx ?? 0) + (row.bytesRx ?? 0)
|
||||
)}
|
||||
</div>*/}
|
||||
</div>
|
||||
<div className="space-y-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -565,8 +724,8 @@ export default function ConnectionLogsPage() {
|
||||
title={t("connectionLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="protocol"
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isFetching}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
@@ -578,12 +737,14 @@ export default function ConnectionLogsPage() {
|
||||
id: "startedAt",
|
||||
desc: true
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
@@ -593,49 +754,3 @@ export default function ConnectionLogsPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function generateSampleConnectionLogs(): QueryConnectionAuditLogResponse["log"] {
|
||||
const protocols = ["tcp", "udp", "icmp"];
|
||||
const destAddrs = [
|
||||
"10.0.0.1:22",
|
||||
"10.0.0.2:80",
|
||||
"10.0.0.3:443",
|
||||
"192.168.1.10:3306"
|
||||
];
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const startedAt = Math.floor(
|
||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||
);
|
||||
const active = Math.random() > 0.3;
|
||||
|
||||
return {
|
||||
sessionId: `session-${i}`,
|
||||
siteResourceId: (i % 3) + 1,
|
||||
orgId: "sample-org",
|
||||
siteId: 1,
|
||||
clientId: (i % 4) + 1,
|
||||
clientEndpoint: `10.0.0.${i + 1}:51820`,
|
||||
userId: i % 2 === 0 ? `user-${i}` : null,
|
||||
sourceAddr: `192.168.1.${i + 1}:${40000 + i}`,
|
||||
destAddr: destAddrs[Math.floor(Math.random() * destAddrs.length)],
|
||||
protocol:
|
||||
protocols[Math.floor(Math.random() * protocols.length)],
|
||||
startedAt,
|
||||
endedAt: active ? null : startedAt + Math.floor(Math.random() * 3600),
|
||||
bytesTx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
||||
bytesRx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
||||
resourceName: `Resource ${(i % 3) + 1}`,
|
||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||
siteName: "Sample Site",
|
||||
siteNiceId: "sample-site",
|
||||
clientName: `Client ${(i % 4) + 1}`,
|
||||
clientNiceId: `client-${(i % 4) + 1}`,
|
||||
clientType: i % 2 === 0 ? "user" : "machine",
|
||||
userEmail: i % 2 === 0 ? `user${i}@example.com` : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,17 +9,14 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { build } from "@server/build";
|
||||
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const router = useRouter();
|
||||
@@ -28,11 +25,36 @@ export default function GeneralPage() {
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Initialize page size from storage or default
|
||||
const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20);
|
||||
|
||||
const [filterAttributes, setFilterAttributes] = useState<{
|
||||
actors: string[];
|
||||
resources: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
locations: string[];
|
||||
hosts: string[];
|
||||
paths: string[];
|
||||
}>({
|
||||
actors: [],
|
||||
resources: [],
|
||||
locations: [],
|
||||
hosts: [],
|
||||
paths: []
|
||||
});
|
||||
|
||||
// Filter states - unified object for all filters
|
||||
const [filters, setFilters] = useState<{
|
||||
action?: string;
|
||||
resourceId?: string;
|
||||
@@ -53,18 +75,32 @@ export default function GeneralPage() {
|
||||
path: searchParams.get("path") || undefined
|
||||
});
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: { date: new Date(startParam) },
|
||||
endDate: { date: new Date(endParam) }
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastWeek = getSevenDaysAgo();
|
||||
|
||||
return {
|
||||
startDate: { date: getSevenDaysAgo() },
|
||||
endDate: { date: new Date() }
|
||||
startDate: {
|
||||
date: lastWeek
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -73,97 +109,80 @@ export default function GeneralPage() {
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
const queryFilters = useMemo(() => {
|
||||
let timeStart: string | undefined;
|
||||
let timeEnd: string | undefined;
|
||||
|
||||
if (dateRange.startDate?.date) {
|
||||
const dt = new Date(dateRange.startDate.date);
|
||||
if (dateRange.startDate.time) {
|
||||
const [h, m, s] = dateRange.startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
}
|
||||
timeStart = dt.toISOString();
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
if (build === "oss") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dateRange.endDate?.date) {
|
||||
const dt = new Date(dateRange.endDate.date);
|
||||
if (dateRange.endDate.time) {
|
||||
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
|
||||
dt.setHours(h, m, s || 0);
|
||||
} else {
|
||||
const now = new Date();
|
||||
dt.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
timeEnd = dt.toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
timeStart,
|
||||
timeEnd,
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
...filters,
|
||||
resourceId: filters.resourceId
|
||||
? Number(filters.resourceId)
|
||||
: undefined
|
||||
};
|
||||
}, [dateRange, currentPage, pageSize, filters]);
|
||||
|
||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||
...logQueries.requests({
|
||||
orgId: orgId as string,
|
||||
filters: queryFilters
|
||||
}),
|
||||
enabled: build !== "oss"
|
||||
});
|
||||
|
||||
const rows = isLoading ? generateSampleRequestLogs() : (data?.log ?? []);
|
||||
const totalCount = data?.pagination?.total ?? 0;
|
||||
const filterAttributes = data?.filterAttributes ?? {
|
||||
actors: [],
|
||||
resources: [],
|
||||
locations: [],
|
||||
hosts: [],
|
||||
paths: []
|
||||
};
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
const handleDateRangeChange = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
const newFilters = { ...filters, [filterType]: value };
|
||||
console.log(`${filterType} filter changed:`, value);
|
||||
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
updateUrlParamsForAllFilters(newFilters);
|
||||
|
||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
0,
|
||||
pageSize,
|
||||
newFilters
|
||||
);
|
||||
};
|
||||
|
||||
const updateUrlParamsForAllFilters = (
|
||||
@@ -185,6 +204,101 @@ export default function GeneralPage() {
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const queryDateTime = async (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue,
|
||||
page: number = currentPage,
|
||||
size: number = pageSize,
|
||||
filtersParam?: {
|
||||
action?: string;
|
||||
type?: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Use the provided filters or fall back to current state
|
||||
const activeFilters = filtersParam || filters;
|
||||
|
||||
// Convert the date/time values to API parameters
|
||||
const params: any = {
|
||||
limit: size,
|
||||
offset: page * size,
|
||||
...activeFilters
|
||||
};
|
||||
|
||||
if (startDate?.date) {
|
||||
const startDateTime = new Date(startDate.date);
|
||||
if (startDate.time) {
|
||||
const [hours, minutes, seconds] = startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||
}
|
||||
params.timeStart = startDateTime.toISOString();
|
||||
}
|
||||
|
||||
if (endDate?.date) {
|
||||
const endDateTime = new Date(endDate.date);
|
||||
if (endDate.time) {
|
||||
const [hours, minutes, seconds] = endDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||
} else {
|
||||
// If no time is specified, set to NOW
|
||||
const now = new Date();
|
||||
endDateTime.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
params.timeEnd = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
const res = await api.get(`/org/${orgId}/logs/request`, { params });
|
||||
if (res.status === 200) {
|
||||
setRows(res.data.data.log || []);
|
||||
setTotalCount(res.data.data.pagination?.total || 0);
|
||||
setFilterAttributes(res.data.data.filterAttributes);
|
||||
console.log("Fetched logs:", res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("Failed to filter logs"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Refresh data with current date range and pagination
|
||||
await queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
currentPage,
|
||||
pageSize
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
// Prepare query params for export
|
||||
@@ -667,8 +781,8 @@ export default function GeneralPage() {
|
||||
title={t("requestLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="host"
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isFetching}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onExport={() => startTransition(exportData)}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
@@ -680,6 +794,7 @@ export default function GeneralPage() {
|
||||
id: "timestamp",
|
||||
desc: true
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
@@ -693,63 +808,3 @@ export default function GeneralPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function generateSampleRequestLogs(): QueryRequestAuditLogResponse["log"] {
|
||||
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
||||
const paths = [
|
||||
"/api/v1/users",
|
||||
"/dashboard",
|
||||
"/settings",
|
||||
"/health",
|
||||
"/metrics"
|
||||
];
|
||||
const hosts = ["app.example.com", "api.example.com", "admin.example.com"];
|
||||
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
||||
const allowedReasons = [100, 101, 102, 103, 104, 105, 106, 107, 108];
|
||||
const deniedReasons = [201, 202, 203, 204, 205, 299];
|
||||
const actors = [
|
||||
"alice@example.com",
|
||||
"bob@example.com",
|
||||
"carol@example.com",
|
||||
null
|
||||
];
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const action = Math.random() > 0.3;
|
||||
const reason = action
|
||||
? allowedReasons[Math.floor(Math.random() * allowedReasons.length)]
|
||||
: deniedReasons[Math.floor(Math.random() * deniedReasons.length)];
|
||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||
|
||||
return {
|
||||
timestamp: Math.floor(
|
||||
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||
),
|
||||
action,
|
||||
reason,
|
||||
orgId: "sample-org",
|
||||
actorType: actor ? "user" : null,
|
||||
actor,
|
||||
actorId: actor ? `user-${i}` : null,
|
||||
resourceId: Math.floor(Math.random() * 5) + 1,
|
||||
siteResourceId: null,
|
||||
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||
resourceName: `Resource ${(i % 3) + 1}`,
|
||||
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||
location: locations[Math.floor(Math.random() * locations.length)],
|
||||
userAgent: "Mozilla/5.0",
|
||||
metadata: null,
|
||||
headers: null,
|
||||
query: null,
|
||||
originalRequestURL: null,
|
||||
scheme: "https",
|
||||
host: hosts[Math.floor(Math.random() * hosts.length)],
|
||||
path: paths[Math.floor(Math.random() * paths.length)],
|
||||
method: methods[Math.floor(Math.random() * methods.length)],
|
||||
tls: true
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+123
-641
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
|
||||
import SetResourcePasswordForm from "components/SetResourcePasswordForm";
|
||||
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
import {
|
||||
SitesSelector,
|
||||
type Selectedsite
|
||||
} from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { use, useActionState, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { GetResourceResponse } from "@server/routers/resource";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
|
||||
type ExistingTarget = {
|
||||
browserGatewayTargetId: number;
|
||||
siteId: number;
|
||||
};
|
||||
|
||||
const sshFormSchema = z.object({
|
||||
authDaemonPort: z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: "Port must be between 1 and 65535" }
|
||||
)
|
||||
});
|
||||
|
||||
export default function SshSettingsPage(props: {
|
||||
params: Promise<{ orgId: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SshServerForm
|
||||
orgId={params.orgId}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SshServerForm({
|
||||
orgId,
|
||||
resource,
|
||||
updateResource
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
|
||||
const isNativeInitially = resource.authDaemonMode === "native";
|
||||
|
||||
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||
isNativeInitially ? "native" : "standard"
|
||||
);
|
||||
const isNative = sshServerMode === "native";
|
||||
|
||||
const [pamMode, setPamMode] = useState<"passthrough" | "push">(
|
||||
(resource.pamMode as "passthrough" | "push") || "passthrough"
|
||||
);
|
||||
|
||||
const [standardDaemonLocation, setStandardDaemonLocation] = useState<
|
||||
"site" | "remote"
|
||||
>(
|
||||
isNativeInitially
|
||||
? "site"
|
||||
: (resource.authDaemonMode as "site" | "remote") || "site"
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(sshFormSchema),
|
||||
defaultValues: {
|
||||
authDaemonPort: (resource as any).authDaemonPort
|
||||
? String((resource as any).authDaemonPort)
|
||||
: "22123"
|
||||
}
|
||||
});
|
||||
|
||||
// Standard mode: multi-site
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [selectedSite, setSelectedSite] = useState<Selectedsite | null>(null);
|
||||
const [bgDestination, setBgDestination] = useState("");
|
||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
// Native mode: single site
|
||||
const [selectedNativeSite, setSelectedNativeSite] =
|
||||
useState<Selectedsite | null>(null);
|
||||
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||
useState<ExistingTarget | null>(null);
|
||||
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
|
||||
|
||||
const { data: bgTargetsResponse } = useQuery({
|
||||
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||
);
|
||||
return res.data.data as {
|
||||
targets: Array<{
|
||||
browserGatewayTargetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
siteName?: string;
|
||||
type: string;
|
||||
destination: string;
|
||||
destinationPort: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgTargetsResponse?.targets?.length) return;
|
||||
const targets = bgTargetsResponse.targets;
|
||||
const first = targets[0];
|
||||
if (isNativeInitially) {
|
||||
setSelectedNativeSite({
|
||||
siteId: first.siteId,
|
||||
name: first.siteName ?? String(first.siteId),
|
||||
type: "newt" as const
|
||||
});
|
||||
setNativeExistingTarget({
|
||||
browserGatewayTargetId: first.browserGatewayTargetId,
|
||||
siteId: first.siteId
|
||||
});
|
||||
} else {
|
||||
setBgDestination(first.destination);
|
||||
setBgDestinationPort(String(first.destinationPort));
|
||||
setExistingTargets(
|
||||
targets.map((t) => ({
|
||||
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||
siteId: t.siteId
|
||||
}))
|
||||
);
|
||||
setSelectedSites(
|
||||
targets.map((t) => ({
|
||||
siteId: t.siteId,
|
||||
name: t.siteName ?? String(t.siteId),
|
||||
type: "newt" as const
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [bgTargetsResponse]);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||
|
||||
async function save() {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const effectiveMode = isNative ? "native" : standardDaemonLocation;
|
||||
const portVal = form.getValues().authDaemonPort;
|
||||
const effectivePort =
|
||||
!isNative && standardDaemonLocation === "remote" && portVal
|
||||
? Number(portVal)
|
||||
: null;
|
||||
|
||||
try {
|
||||
await api.post(`/resource/${resource.resourceId}`, {
|
||||
pamMode,
|
||||
authDaemonMode: effectiveMode,
|
||||
authDaemonPort: effectivePort
|
||||
});
|
||||
|
||||
updateResource({
|
||||
...resource,
|
||||
pamMode,
|
||||
authDaemonMode: effectiveMode
|
||||
});
|
||||
|
||||
if (isNative) {
|
||||
if (selectedNativeSite) {
|
||||
if (nativeExistingTarget) {
|
||||
await api.post(
|
||||
`/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`,
|
||||
{
|
||||
type: "ssh",
|
||||
destination: "localhost",
|
||||
destinationPort: 22,
|
||||
siteId: selectedNativeSite.siteId
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const res = await api.put(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||
{
|
||||
siteId: selectedNativeSite.siteId,
|
||||
type: "ssh",
|
||||
destination: "localhost",
|
||||
destinationPort: 22
|
||||
}
|
||||
);
|
||||
setNativeExistingTarget({
|
||||
browserGatewayTargetId:
|
||||
res.data.data.browserGatewayTargetId,
|
||||
siteId: selectedNativeSite.siteId
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (bgDestination && bgDestinationPort) {
|
||||
const selectedSiteIds = new Set(
|
||||
selectedSites.map((s) => s.siteId)
|
||||
);
|
||||
const existingSiteIds = new Set(
|
||||
existingTargets.map((t) => t.siteId)
|
||||
);
|
||||
|
||||
const toDelete = existingTargets.filter(
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toDelete.map((t) =>
|
||||
api.delete(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||
{
|
||||
type: "ssh",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort),
|
||||
siteId: t.siteId
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const toCreate = selectedSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
const created = await Promise.all(
|
||||
toCreate.map((s) =>
|
||||
api.put(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||
{
|
||||
siteId: s.siteId,
|
||||
type: "ssh",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort)
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const newTargets: ExistingTarget[] = created.map(
|
||||
(res, i) => ({
|
||||
browserGatewayTargetId:
|
||||
res.data.data.browserGatewayTargetId,
|
||||
siteId: toCreate[i].siteId
|
||||
})
|
||||
);
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("settingsUpdated"),
|
||||
description: t("settingsUpdatedDescription")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
|
||||
{
|
||||
id: "passthrough",
|
||||
title: t("sshAuthMethodManual"),
|
||||
description: t("sshAuthMethodManualDescription")
|
||||
},
|
||||
{
|
||||
id: "push",
|
||||
title: t("sshAuthMethodAutomated"),
|
||||
description: t("sshAuthMethodAutomatedDescription")
|
||||
}
|
||||
];
|
||||
|
||||
const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [
|
||||
{
|
||||
id: "site",
|
||||
title: t("internalResourceAuthDaemonSite"),
|
||||
description: t("sshDaemonLocationSiteDescription")
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
title: t("sshDaemonLocationRemote"),
|
||||
description: t("sshDaemonLocationRemoteDescription")
|
||||
}
|
||||
];
|
||||
|
||||
const showDaemonLocation = !isNative && pamMode === "push";
|
||||
const showDaemonPort =
|
||||
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t("sshServer")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("sshServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("sshServerMode")}
|
||||
</p>
|
||||
<Badge variant="secondary">
|
||||
{sshServerMode == "standard"
|
||||
? t("sshServerModeStandard")
|
||||
: t("sshServerModePangolin")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("sshAuthenticationMethod")}
|
||||
</p>
|
||||
<StrategySelect<"passthrough" | "push">
|
||||
value={pamMode}
|
||||
options={authMethodOptions}
|
||||
onChange={setPamMode}
|
||||
cols={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showDaemonLocation && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("sshAuthDaemonLocation")}
|
||||
</p>
|
||||
<StrategySelect<"site" | "remote">
|
||||
value={standardDaemonLocation}
|
||||
options={daemonLocationOptions}
|
||||
onChange={setStandardDaemonLocation}
|
||||
cols={2}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshDaemonDisclaimer")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDaemonPort && (
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshDaemonPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
|
||||
{t("sshServerDestination")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshServerDestinationDescription")}
|
||||
</p>
|
||||
</div>
|
||||
{isNative ? (
|
||||
<Popover
|
||||
open={nativeSiteOpen}
|
||||
onOpenChange={setNativeSiteOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full max-w-xs justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedNativeSite?.name ??
|
||||
t("siteSelect")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedNativeSite}
|
||||
onSelectSite={(site) => {
|
||||
setSelectedNativeSite(site);
|
||||
setNativeSiteOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : standardDaemonLocation !== "site" ? (
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId}
|
||||
multiSite={true}
|
||||
selectedSites={selectedSites}
|
||||
onSitesChange={setSelectedSites}
|
||||
destination={bgDestination}
|
||||
destinationPort={bgDestinationPort}
|
||||
onDestinationChange={setBgDestination}
|
||||
onDestinationPortChange={setBgDestinationPort}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
) : (
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId}
|
||||
multiSite={false}
|
||||
selectedSite={selectedSite}
|
||||
onSiteChange={setSelectedSite}
|
||||
destination={bgDestination}
|
||||
destinationPort={bgDestinationPort}
|
||||
onDestinationChange={setBgDestination}
|
||||
onDestinationPortChange={setBgDestinationPort}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<form action={formAction} className="flex justify-end mt-4">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
import { type Selectedsite } from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { use, useActionState, useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { GetResourceResponse } from "@server/routers/resource";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
|
||||
type ExistingTarget = {
|
||||
browserGatewayTargetId: number;
|
||||
siteId: number;
|
||||
};
|
||||
|
||||
const sshFormSchema = z.object({
|
||||
authDaemonPort: z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: "Port must be between 1 and 65535" }
|
||||
)
|
||||
});
|
||||
|
||||
export default function SshSettingsPage(props: {
|
||||
params: Promise<{ orgId: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SshServerForm
|
||||
orgId={params.orgId}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SshServerForm({
|
||||
orgId,
|
||||
resource,
|
||||
updateResource
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
|
||||
// Standard mode: multi-site
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [bgDestination, setBgDestination] = useState("");
|
||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
// Native mode: single site
|
||||
const [selectedNativeSite, setSelectedNativeSite] =
|
||||
useState<Selectedsite | null>(null);
|
||||
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||
useState<ExistingTarget | null>(null);
|
||||
|
||||
const { data: bgTargetsResponse } = useQuery({
|
||||
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||
);
|
||||
return res.data.data as {
|
||||
targets: Array<{
|
||||
browserGatewayTargetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
siteName?: string;
|
||||
type: string;
|
||||
destination: string;
|
||||
destinationPort: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgTargetsResponse?.targets?.length) return;
|
||||
const targets = bgTargetsResponse.targets;
|
||||
const first = targets[0];
|
||||
|
||||
setBgDestination(first.destination);
|
||||
setBgDestinationPort(String(first.destinationPort));
|
||||
setExistingTargets(
|
||||
targets.map((t) => ({
|
||||
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||
siteId: t.siteId
|
||||
}))
|
||||
);
|
||||
setSelectedSites(
|
||||
targets.map((t) => ({
|
||||
siteId: t.siteId,
|
||||
name: t.siteName ?? String(t.siteId),
|
||||
type: "newt" as const
|
||||
}))
|
||||
);
|
||||
}, [bgTargetsResponse]);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
if (bgDestination && bgDestinationPort) {
|
||||
const selectedSiteIds = new Set(
|
||||
selectedSites.map((s) => s.siteId)
|
||||
);
|
||||
const existingSiteIds = new Set(
|
||||
existingTargets.map((t) => t.siteId)
|
||||
);
|
||||
|
||||
const toDelete = existingTargets.filter(
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toDelete.map((t) =>
|
||||
api.delete(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||
{
|
||||
type: "vnc",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort),
|
||||
siteId: t.siteId
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const toCreate = selectedSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
const created = await Promise.all(
|
||||
toCreate.map((s) =>
|
||||
api.put(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||
{
|
||||
siteId: s.siteId,
|
||||
type: "vnc",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort)
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||
browserGatewayTargetId:
|
||||
res.data.data.browserGatewayTargetId,
|
||||
siteId: toCreate[i].siteId
|
||||
}));
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("settingsUpdated"),
|
||||
description: t("settingsUpdatedDescription")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{t("vncServer")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("vncServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId}
|
||||
multiSite={true}
|
||||
selectedSites={selectedSites}
|
||||
onSitesChange={setSelectedSites}
|
||||
destination={bgDestination}
|
||||
destinationPort={bgDestinationPort}
|
||||
onDestinationChange={setBgDestination}
|
||||
onDestinationPortChange={setBgDestinationPort}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
|
||||
defaultPort={5900}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<form action={formAction} className="flex justify-end mt-4">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -126,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,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+1
-3
@@ -21,11 +21,9 @@ export default async function Page(props: {
|
||||
searchParams: Promise<{
|
||||
redirect: string | undefined;
|
||||
t: string | undefined;
|
||||
orgs?: string | undefined;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.searchParams; // this is needed to prevent static optimization
|
||||
const showOrgPicker = params.orgs === "1";
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
@@ -108,7 +106,7 @@ export default async function Page(props: {
|
||||
}
|
||||
}
|
||||
|
||||
if (targetOrgId && !showOrgPicker) {
|
||||
if (targetOrgId) {
|
||||
return <RedirectToOrg targetOrgId={targetOrgId} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import type {
|
||||
UserInteraction,
|
||||
IronError,
|
||||
FileTransferProvider
|
||||
} from "@devolutions/iron-remote-desktop/dist";
|
||||
import type {
|
||||
RdpFileTransferProvider,
|
||||
FileInfo
|
||||
} from "@devolutions/iron-remote-desktop-rdp/dist";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
|
||||
declare module "react" {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"iron-remote-desktop": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement> & {
|
||||
scale?: string;
|
||||
verbose?: string;
|
||||
flexcenter?: string;
|
||||
module?: unknown;
|
||||
},
|
||||
HTMLElement
|
||||
>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
username: string;
|
||||
password: string;
|
||||
domain: string;
|
||||
kdcProxyUrl: string;
|
||||
pcb: string;
|
||||
enableClipboard: boolean;
|
||||
};
|
||||
|
||||
const isIronError = (error: unknown): error is IronError => {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
typeof (error as IronError).backtrace === "function" &&
|
||||
typeof (error as IronError).kind === "function"
|
||||
);
|
||||
};
|
||||
|
||||
export default function RdpClient({
|
||||
target,
|
||||
error
|
||||
}: {
|
||||
target: GetBrowserTargetResponse | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
const STORAGE_KEY = "pangolin_rdp_credentials";
|
||||
|
||||
const [form, setForm] = useState<FormState>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) return JSON.parse(saved) as FormState;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
username: "",
|
||||
password: "",
|
||||
domain: "",
|
||||
kdcProxyUrl: "",
|
||||
pcb: "",
|
||||
enableClipboard: true
|
||||
};
|
||||
});
|
||||
|
||||
const [showLogin, setShowLogin] = useState(true);
|
||||
const [moduleReady, setModuleReady] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [unicodeMode, setUnicodeMode] = useState(false);
|
||||
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
|
||||
|
||||
const userInteractionRef = useRef<UserInteraction | null>(null);
|
||||
const backendRef = useRef<unknown>(null);
|
||||
// Holds the RdpFileTransferProvider constructor so we can create a fresh
|
||||
// instance per session (avoids stale upload state across reconnects).
|
||||
const fileTransferClassRef = useRef<typeof RdpFileTransferProvider | null>(
|
||||
null
|
||||
);
|
||||
// Active session's provider instance; replaced on each connect.
|
||||
const fileTransferRef = useRef<RdpFileTransferProvider | null>(null);
|
||||
const extensionsRef = useRef<{
|
||||
displayControl: (enable: boolean) => unknown;
|
||||
preConnectionBlob: (pcb: string) => unknown;
|
||||
kdcProxyUrl: (url: string) => unknown;
|
||||
} | null>(null);
|
||||
|
||||
// Load the iron-remote-desktop modules client-side and register the
|
||||
// `<iron-remote-desktop>` custom element.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [coreMod, rdpMod] = await Promise.all([
|
||||
import("@devolutions/iron-remote-desktop/dist"),
|
||||
import("@devolutions/iron-remote-desktop-rdp/dist")
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
await rdpMod.init("INFO");
|
||||
|
||||
backendRef.current = rdpMod.Backend;
|
||||
extensionsRef.current = {
|
||||
displayControl: rdpMod.displayControl,
|
||||
preConnectionBlob: rdpMod.preConnectionBlob,
|
||||
kdcProxyUrl: rdpMod.kdcProxyUrl
|
||||
};
|
||||
|
||||
// Store the class; a fresh instance is created per session.
|
||||
fileTransferClassRef.current =
|
||||
rdpMod.RdpFileTransferProvider as unknown as typeof RdpFileTransferProvider;
|
||||
|
||||
// Importing the package registers the custom element as a side
|
||||
// effect. Touch the default export to avoid tree-shaking.
|
||||
void coreMod;
|
||||
|
||||
setModuleReady(true);
|
||||
})().catch((err) => {
|
||||
console.error("Failed to load iron-remote-desktop modules", err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to load RDP module",
|
||||
description: `${err}`
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Attach the "ready" listener synchronously the moment the custom
|
||||
// element mounts. The custom element dispatches `ready` from its own
|
||||
// `onMount`, so a deferred useEffect can race and miss it.
|
||||
const remoteElementRef = (el: HTMLElement | null) => {
|
||||
if (!el) return;
|
||||
const onReady = (e: Event) => {
|
||||
const event = e as CustomEvent;
|
||||
userInteractionRef.current = event.detail.irgUserInteraction;
|
||||
};
|
||||
el.addEventListener("ready", onReady);
|
||||
};
|
||||
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const startSession = async () => {
|
||||
setConnecting(true);
|
||||
const userInteraction = userInteractionRef.current;
|
||||
const exts = extensionsRef.current;
|
||||
if (!userInteraction || !exts) {
|
||||
setConnecting(false);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Not ready",
|
||||
description: "RDP module is still initializing"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
userInteraction.setEnableClipboard(form.enableClipboard);
|
||||
|
||||
// Dispose any previous session's provider and create a fresh one so
|
||||
// there is no stale upload state from a prior connection.
|
||||
fileTransferRef.current?.dispose();
|
||||
const ProviderClass = fileTransferClassRef.current;
|
||||
const fileTransfer = ProviderClass ? new ProviderClass() : null;
|
||||
fileTransferRef.current = fileTransfer;
|
||||
|
||||
if (fileTransfer) {
|
||||
// Auto-download files when the remote copies them to clipboard.
|
||||
fileTransfer.on("files-available", (files: FileInfo[]) => {
|
||||
const downloadable = files.filter((f) => !f.isDirectory);
|
||||
if (downloadable.length === 0) return;
|
||||
toast({
|
||||
title: `Downloading ${downloadable.length} file(s) from remote…`
|
||||
});
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.isDirectory) continue;
|
||||
const { completion } = fileTransfer.downloadFile(file, i);
|
||||
completion
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `Download failed: ${file.name}`,
|
||||
description: `${err}`
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Notify when individual uploads complete (remote pasted a file).
|
||||
fileTransfer.on("upload-complete", (file: File) => {
|
||||
toast({ title: `Uploaded: ${file.name}` });
|
||||
});
|
||||
|
||||
// Register with the web component so CLIPRDR extensions are
|
||||
// wired up before connect() builds the session.
|
||||
userInteraction.enableFileTransfer(
|
||||
fileTransfer as unknown as FileTransferProvider
|
||||
);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
setConnecting(false);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "No target",
|
||||
description: "No connection target available"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const destination = `${target.ip}:${target.port}`;
|
||||
|
||||
const builder = userInteraction
|
||||
.configBuilder()
|
||||
.withUsername(form.username)
|
||||
.withPassword(form.password)
|
||||
.withDestination(destination)
|
||||
.withProxyAddress(
|
||||
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
|
||||
)
|
||||
.withServerDomain(form.domain)
|
||||
.withAuthToken(target.authToken)
|
||||
.withDesktopSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
})
|
||||
.withExtension(exts.displayControl(true));
|
||||
|
||||
if (form.pcb !== "") {
|
||||
builder.withExtension(exts.preConnectionBlob(form.pcb));
|
||||
}
|
||||
if (form.kdcProxyUrl !== "") {
|
||||
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionInfo = await userInteraction.connect(builder.build());
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setConnecting(false);
|
||||
setShowLogin(false);
|
||||
userInteraction.setVisibility(true);
|
||||
|
||||
const termInfo = await sessionInfo.run();
|
||||
fileTransferRef.current?.dispose();
|
||||
fileTransferRef.current = null;
|
||||
setShowLogin(true);
|
||||
} catch (err) {
|
||||
setConnecting(false);
|
||||
setShowLogin(true);
|
||||
if (isIronError(err)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Connection failed",
|
||||
description: err.backtrace()
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Connection failed",
|
||||
description: `${err}`
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ui = () => userInteractionRef.current;
|
||||
|
||||
const toggleCursorKind = () => {
|
||||
const u = ui();
|
||||
if (!u) return;
|
||||
if (cursorOverrideActive) {
|
||||
u.setCursorStyleOverride(null);
|
||||
} else {
|
||||
u.setCursorStyleOverride('url("crosshair.png") 7 7, default');
|
||||
}
|
||||
setCursorOverrideActive((v) => !v);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{showLogin && (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="mb-4 text-2xl font-semibold">RDP</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Domain" id="domain">
|
||||
<Input
|
||||
id="domain"
|
||||
value={form.domain}
|
||||
onChange={(e) =>
|
||||
update("domain", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Username" id="username">
|
||||
<Input
|
||||
id="username"
|
||||
value={form.username}
|
||||
onChange={(e) =>
|
||||
update("username", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Password" id="password">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
update("password", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{/*
|
||||
<Field label="Pre Connection Blob (optional)" id="pcb">
|
||||
<Input
|
||||
id="pcb"
|
||||
value={form.pcb}
|
||||
onChange={(e) => update("pcb", e.target.value)}
|
||||
/>
|
||||
</Field> */}
|
||||
|
||||
{/* <Field
|
||||
label="KDC Proxy URL (optional)"
|
||||
id="kdcProxyUrl"
|
||||
>
|
||||
<Input
|
||||
id="kdcProxyUrl"
|
||||
value={form.kdcProxyUrl}
|
||||
onChange={(e) =>
|
||||
update("kdcProxyUrl", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field> */}
|
||||
{/* <div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enable_clipboard"
|
||||
checked={form.enableClipboard}
|
||||
onCheckedChange={(checked) =>
|
||||
update("enableClipboard", checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="enable_clipboard">
|
||||
Enable Clipboard
|
||||
</Label>
|
||||
</div> */}
|
||||
<Button
|
||||
onClick={startSession}
|
||||
disabled={!moduleReady}
|
||||
loading={connecting}
|
||||
className="w-full"
|
||||
>
|
||||
{moduleReady ? "Connect" : "Loading module..."}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex h-screen flex-col bg-neutral-900"
|
||||
style={{ display: showLogin ? "none" : "flex" }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => ui()?.setScale(1)}
|
||||
>
|
||||
Fit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => ui()?.setScale(2)}
|
||||
>
|
||||
Full
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => ui()?.setScale(3)}
|
||||
>
|
||||
Real
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => ui()?.ctrlAltDel()}
|
||||
>
|
||||
Ctrl+Alt+Del
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => ui()?.metaKey()}
|
||||
>
|
||||
Meta
|
||||
</Button>
|
||||
{/* <Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={toggleCursorKind}
|
||||
>
|
||||
Toggle cursor
|
||||
</Button> */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
const ft = fileTransferRef.current;
|
||||
if (!ft) return;
|
||||
const files = await ft.showFilePicker({
|
||||
multiple: true
|
||||
});
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
ft.uploadFiles(files);
|
||||
toast({
|
||||
title: "Files ready to paste",
|
||||
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Upload failed",
|
||||
description: `${err}`
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upload files
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
ui()?.shutdown();
|
||||
setShowLogin(true);
|
||||
}}
|
||||
>
|
||||
Terminate
|
||||
</Button>
|
||||
<label className="ml-2 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unicodeMode}
|
||||
onChange={(e) => {
|
||||
setUnicodeMode(e.target.checked);
|
||||
ui()?.setKeyboardUnicodeMode(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
Unicode keyboard mode
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{moduleReady && (
|
||||
<iron-remote-desktop
|
||||
ref={remoteElementRef}
|
||||
verbose="true"
|
||||
scale="fit"
|
||||
flexcenter="true"
|
||||
module={backendRef.current}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
id,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { headers } from "next/headers";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
import RdpClient from "./RdpClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "RDP"
|
||||
};
|
||||
|
||||
export default async function RdpPage() {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get("host") || "";
|
||||
const hostname = host.split(":")[0];
|
||||
|
||||
let target: { ip: string; port: number; authToken: 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;
|
||||
console.log("Fetched browser target:", target);
|
||||
} catch (error) {
|
||||
console.error("Error fetching browser target:", error);
|
||||
error = "No resource found for this domain";
|
||||
}
|
||||
|
||||
return <RdpClient target={target} error={error} />;
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
"use client";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { SignSshKeyResponse } from "@server/private/routers/ssh";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
|
||||
type FormState = {
|
||||
username: string;
|
||||
password: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
type ConnectCredentials = {
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
};
|
||||
|
||||
export default function SshClient({
|
||||
target,
|
||||
error,
|
||||
signedKeyData,
|
||||
privateKey: signedPrivateKey
|
||||
}: {
|
||||
target: GetBrowserTargetResponse | null;
|
||||
error: string | null;
|
||||
signedKeyData?: SignSshKeyResponse | null;
|
||||
privateKey?: string | null;
|
||||
}) {
|
||||
const STORAGE_KEY = "pangolin_ssh_credentials";
|
||||
|
||||
const [form, setForm] = useState<FormState>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) return JSON.parse(saved) as FormState;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { username: "", password: "", privateKey: "" };
|
||||
});
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result;
|
||||
if (typeof text === "string") {
|
||||
setForm((prev) => ({ ...prev, privateKey: text }));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset input so the same file can be re-selected if needed.
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
|
||||
const fitAddonRef = useRef<import("@xterm/addon-fit").FitAddon | null>(
|
||||
null
|
||||
);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// Mount the terminal div once connected.
|
||||
useEffect(() => {
|
||||
if (!connected || !terminalRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] =
|
||||
await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
import("@xterm/addon-web-links")
|
||||
]);
|
||||
if (cancelled || !terminalRef.current) return;
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
||||
theme: {
|
||||
background: "#0d0d0d",
|
||||
foreground: "#f0f0f0"
|
||||
},
|
||||
scrollback: 5000
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
terminal.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
xtermRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// Send user keystrokes to the WebSocket.
|
||||
terminal.onData((data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "data", data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Send resize events.
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({ type: "resize", cols, rows })
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Send the initial size once the terminal is rendered.
|
||||
const { cols, rows } = terminal;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({ type: "resize", cols, rows })
|
||||
);
|
||||
}
|
||||
})().catch(console.error);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connected]);
|
||||
|
||||
// Refit terminal when the window resizes.
|
||||
useEffect(() => {
|
||||
const onResize = () => fitAddonRef.current?.fit();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
wsRef.current?.close();
|
||||
xtermRef.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-connect when signed key data is provided (push PAM mode).
|
||||
useEffect(() => {
|
||||
if (signedKeyData && signedPrivateKey && target) {
|
||||
connect({
|
||||
username: signedKeyData.sshUsername,
|
||||
privateKey: signedPrivateKey,
|
||||
certificate: signedKeyData.certificate
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function connect(override?: ConnectCredentials) {
|
||||
setConnectError(null);
|
||||
setConnecting(true);
|
||||
|
||||
if (!target) {
|
||||
setConnectError("No target specified");
|
||||
setConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const username = override?.username ?? form.username;
|
||||
const password = override?.password ?? form.password;
|
||||
const privateKey = override?.privateKey ?? form.privateKey;
|
||||
const certificate = override?.certificate;
|
||||
|
||||
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
|
||||
const url = new URL(proxyAddress);
|
||||
url.searchParams.set("host", target.ip ?? "");
|
||||
url.searchParams.set("port", String(target.port ?? 22));
|
||||
url.searchParams.set("username", username);
|
||||
url.searchParams.set("authToken", target.authToken ?? "");
|
||||
|
||||
const ws = new WebSocket(url.toString(), ["ssh"]);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send credentials as the first frame so the proxy can complete
|
||||
// SSH authentication before piping pty data.
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth",
|
||||
password,
|
||||
privateKey,
|
||||
certificate
|
||||
})
|
||||
);
|
||||
if (!override) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setConnecting(false);
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
if (typeof evt.data === "string") {
|
||||
try {
|
||||
const msg = JSON.parse(evt.data as string) as {
|
||||
type: string;
|
||||
data?: string;
|
||||
error?: string;
|
||||
};
|
||||
if (msg.type === "data" && msg.data) {
|
||||
xtermRef.current?.write(msg.data);
|
||||
} else if (msg.type === "error") {
|
||||
xtermRef.current?.writeln(
|
||||
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
xtermRef.current?.write(evt.data);
|
||||
}
|
||||
} else if (evt.data instanceof Blob) {
|
||||
evt.data.text().then((t) => xtermRef.current?.write(t));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnecting(false);
|
||||
setConnected(false);
|
||||
setConnectError("WebSocket connection failed");
|
||||
};
|
||||
|
||||
ws.onclose = (evt) => {
|
||||
setConnecting(false);
|
||||
setConnected(false);
|
||||
xtermRef.current?.writeln(
|
||||
`\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
wsRef.current?.close();
|
||||
xtermRef.current?.dispose();
|
||||
xtermRef.current = null;
|
||||
setConnected(false);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// In push mode, show a connecting/connected state without the login form.
|
||||
if (signedKeyData && signedPrivateKey) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{!connected && (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">
|
||||
{connectError
|
||||
? connectError
|
||||
: connecting
|
||||
? "Connecting…"
|
||||
: "Initializing…"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{connected && (
|
||||
<div className="flex h-screen flex-col bg-neutral-900">
|
||||
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={disconnect}
|
||||
>
|
||||
Terminate
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ minHeight: 0 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{!connected && (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="mb-4 text-2xl font-semibold">SSH</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Username" id="username">
|
||||
<Input
|
||||
id="username"
|
||||
value={form.username}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
username: e.target.value
|
||||
})
|
||||
}
|
||||
placeholder="root"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Password" id="password">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
password: e.target.value
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
form.privateKey
|
||||
? "Optional with key auth"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Private Key (optional)" id="privateKey">
|
||||
<Textarea
|
||||
id="privateKey"
|
||||
value={form.privateKey}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
privateKey: e.target.value
|
||||
})
|
||||
}
|
||||
placeholder="Paste your private key here (PEM format)…"
|
||||
rows={5}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
Upload key file
|
||||
</Button>
|
||||
{form.privateKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline"
|
||||
onClick={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
privateKey: ""
|
||||
}))
|
||||
}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pem,.key,.pub,*"
|
||||
onChange={handleKeyFile}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{connectError && (
|
||||
<p className="text-destructive text-sm">
|
||||
{connectError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => connect()}
|
||||
loading={connecting}
|
||||
disabled={
|
||||
!form.username ||
|
||||
(!form.password && !form.privateKey)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{connecting ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connected && (
|
||||
<div className="flex h-screen flex-col bg-neutral-900">
|
||||
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={disconnect}
|
||||
>
|
||||
Terminate
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ minHeight: 0 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
id,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { headers } from "next/headers";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
import SshClient from "./SshClient";
|
||||
import { SignSshKeyResponse } from "@server/private/routers/ssh";
|
||||
import crypto from "crypto";
|
||||
|
||||
function generateEphemeralKeyPair(): {
|
||||
privateKeyPem: string;
|
||||
publicKeyOpenSSH: string;
|
||||
} {
|
||||
const { publicKey: pubKeyObj, privateKey: privKeyObj } =
|
||||
crypto.generateKeyPairSync("ed25519");
|
||||
|
||||
const privateKeyPem = privKeyObj.export({
|
||||
type: "pkcs8",
|
||||
format: "pem"
|
||||
}) as string;
|
||||
|
||||
// Build OpenSSH wire format: uint32-length-prefixed strings
|
||||
const pubKeyDer = pubKeyObj.export({
|
||||
type: "spki",
|
||||
format: "der"
|
||||
}) as Buffer;
|
||||
const rawPubKey = pubKeyDer.subarray(pubKeyDer.length - 32); // last 32 bytes are the Ed25519 key
|
||||
|
||||
function encodeField(b: Buffer): Buffer {
|
||||
const len = Buffer.allocUnsafe(4);
|
||||
len.writeUInt32BE(b.length, 0);
|
||||
return Buffer.concat([len, b]);
|
||||
}
|
||||
|
||||
const keyBlob = Buffer.concat([
|
||||
encodeField(Buffer.from("ssh-ed25519")),
|
||||
encodeField(rawPubKey)
|
||||
]);
|
||||
const publicKeyOpenSSH = `ssh-ed25519 ${keyBlob.toString("base64")}`;
|
||||
|
||||
return { privateKeyPem, publicKeyOpenSSH };
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "SSH"
|
||||
};
|
||||
|
||||
export default async function SshPage() {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get("host") || "";
|
||||
const hostname = host.split(":")[0];
|
||||
|
||||
let target: GetBrowserTargetResponse | null = null;
|
||||
let signedKeyData: SignSshKeyResponse | null = null;
|
||||
let privateKey: string | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||
);
|
||||
target = res.data.data;
|
||||
|
||||
if (target.pamMode === "push") {
|
||||
const { privateKeyPem, publicKeyOpenSSH } =
|
||||
generateEphemeralKeyPair();
|
||||
privateKey = privateKeyPem;
|
||||
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
|
||||
`/org/${target.orgId}/ssh/sign-key`,
|
||||
{
|
||||
publicKey: publicKeyOpenSSH,
|
||||
resource: target.niceId
|
||||
}
|
||||
);
|
||||
signedKeyData = res.data.data;
|
||||
console.log("Received signed SSH key:", signedKeyData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching browser target:", error);
|
||||
error = "No resource found for this domain";
|
||||
}
|
||||
|
||||
return (
|
||||
<SshClient
|
||||
target={target}
|
||||
error={error}
|
||||
signedKeyData={signedKeyData}
|
||||
privateKey={privateKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
|
||||
type FormState = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export default function VncClient({
|
||||
target,
|
||||
error
|
||||
}: {
|
||||
target: GetBrowserTargetResponse | null;
|
||||
error: string | null;
|
||||
}) {
|
||||
const STORAGE_KEY = "pangolin_vnc_credentials";
|
||||
|
||||
const [form, setForm] = useState<FormState>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) return JSON.parse(saved) as FormState;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { password: "" };
|
||||
});
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rfbRef = useRef<any>(null);
|
||||
const screenRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Disconnect and clean up the RFB instance.
|
||||
const disconnect = () => {
|
||||
if (rfbRef.current) {
|
||||
rfbRef.current.disconnect();
|
||||
rfbRef.current = null;
|
||||
}
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
// Clean up on unmount.
|
||||
useEffect(() => {
|
||||
return () => disconnect();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const connect = async () => {
|
||||
if (!target) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "No target",
|
||||
description: "No resource target is available"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!screenRef.current) return;
|
||||
|
||||
// Disconnect any existing session first.
|
||||
disconnect();
|
||||
|
||||
// noVNC has no ESM default export — import the module dynamically to
|
||||
// keep it out of the server bundle, then grab the default export.
|
||||
let RFB: new (
|
||||
target: HTMLElement,
|
||||
url: string,
|
||||
options?: Record<string, unknown>
|
||||
) => unknown;
|
||||
try {
|
||||
// @ts-expect-error — @novnc/novnc ships plain JS with no bundled types
|
||||
const mod = await import("@novnc/novnc");
|
||||
RFB = mod.default ?? mod;
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to load noVNC",
|
||||
description: `${err}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the proxy WebSocket URL:
|
||||
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
|
||||
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
|
||||
const base = proxyAddress.replace(/\/$/, "");
|
||||
const params = new URLSearchParams({
|
||||
host: target.ip,
|
||||
port: String(target.port),
|
||||
authToken: target.authToken
|
||||
});
|
||||
const wsUrl = `${base}?${params.toString()}`;
|
||||
|
||||
// Clear the container so noVNC gets a clean mount point.
|
||||
screenRef.current.innerHTML = "";
|
||||
|
||||
const options: Record<string, unknown> = {};
|
||||
if (form.password) {
|
||||
options.credentials = { password: form.password };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rfb: any = new RFB(screenRef.current, wsUrl, options);
|
||||
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = true;
|
||||
|
||||
rfb.addEventListener("connect", () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
rfb.addEventListener(
|
||||
"disconnect",
|
||||
(e: { detail: { clean: boolean } }) => {
|
||||
rfbRef.current = null;
|
||||
setConnected(false);
|
||||
}
|
||||
);
|
||||
|
||||
rfb.addEventListener(
|
||||
"securityfailure",
|
||||
(e: { detail: { status: number; reason?: string } }) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Authentication failed",
|
||||
description: e.detail.reason ?? `Status ${e.detail.status}`
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
rfbRef.current = rfb;
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{!connected && (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="mb-4 text-2xl font-semibold">VNC</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Password (optional)" id="password">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
update("password", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button onClick={connect} className="w-full">
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex h-screen flex-col bg-neutral-900"
|
||||
style={{ display: connected ? "flex" : "none" }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (rfbRef.current) {
|
||||
rfbRef.current.sendCtrlAltDel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ctrl+Alt+Del
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
?.readText()
|
||||
.then((text) => {
|
||||
rfbRef.current?.clipboardPasteFrom(text);
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
>
|
||||
Paste clipboard
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={disconnect}
|
||||
>
|
||||
Terminate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* noVNC mounts a <canvas> inside this div */}
|
||||
<div
|
||||
ref={screenRef}
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ background: "#000" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
id,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { headers } from "next/headers";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||
import VncClient from "./VncClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "VNC"
|
||||
};
|
||||
|
||||
export default async function VncPage() {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get("host") || "";
|
||||
const hostname = host.split(":")[0];
|
||||
|
||||
let target: GetBrowserTargetResponse | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||
);
|
||||
target = res.data.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching browser target:", error);
|
||||
error = "No resource found for this domain";
|
||||
}
|
||||
|
||||
return <VncClient target={target} error={error} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user