Compare commits

..

60 Commits

Author SHA1 Message Date
Owen 4c1e1daf07 All page types are there and look mostly correct 2026-05-22 17:37:37 -07:00
Owen 7c54df7ed1 Rework page to be functional 2026-05-22 16:09:02 -07:00
Owen 9d77fcc457 Make the first ssh page and conditional http page 2026-05-22 15:12:37 -07:00
Owen 454449ec8a Add support for push pam users 2026-05-22 12:12:55 -07:00
Owen fe67e8e384 Clean up types 2026-05-22 12:12:46 -07:00
Owen 715b957660 Support not push ssh method 2026-05-22 11:19:35 -07:00
Owen f1e4bf8d36 Add hint about invoices for the license key 2026-05-22 10:30:41 -07:00
Owen 23ca3efbf4 Merge branch 'dev' into rdp-ssh 2026-05-19 20:12:05 -07:00
Owen 0f9100fd3a Merge branch 'rdp-ssh' of github.com:fosrl/pangolin into rdp-ssh 2026-05-19 20:06:48 -07:00
Owen 832d45e32b Move pages back 2026-05-19 20:02:27 -07:00
Owen 69e3ac3cd4 Move login page locations 2026-05-19 20:02:27 -07:00
Owen 50865f4265 Remove terminate button 2026-05-19 20:02:27 -07:00
Owen 0d1a8d9695 Only switch if we are actually connected 2026-05-19 20:02:27 -07:00
Owen 5d8486dd7f Sure up some things with browserAccessType 2026-05-19 20:02:27 -07:00
Owen 3c25932787 Adjust page to be editable 2026-05-19 20:02:27 -07:00
Owen 1d0e1eb126 Temp credential storage 2026-05-19 20:02:27 -07:00
Owen 57c0dc8618 Support private key 2026-05-19 20:02:27 -07:00
Owen 526a147570 Clean up toasts 2026-05-19 20:02:27 -07:00
Owen 0938997548 Add crud for browser targets 2026-05-19 20:02:27 -07:00
Owen 0876b482f8 Remove extra fields 2026-05-19 20:02:27 -07:00
Owen d558c31f88 Standardize the ui 2026-05-19 20:02:26 -07:00
Owen 6010515da0 Pull in the destination from the api 2026-05-19 20:02:26 -07:00
Owen 868bcd8e34 USe right table 2026-05-19 20:02:26 -07:00
Owen 20c4904965 Add internal api get for proxy information 2026-05-19 20:02:26 -07:00
Owen 5a5536b38c Reinstall packages 2026-05-19 20:02:26 -07:00
Owen 53e2296de8 Clean up forms a bit 2026-05-19 20:02:26 -07:00
Owen d2423919e9 Add favicon passthrough 2026-05-19 20:02:26 -07:00
Owen 2250fcd177 Serve the resource from the right place 2026-05-19 20:02:26 -07:00
Owen 2a33256d17 Add gateway endpoints into the traefik config 2026-05-19 20:02:26 -07:00
Owen 117aa750f8 Working on new target type 2026-05-19 20:02:26 -07:00
Owen 15f161274f Add browserGatewayTarget table 2026-05-19 20:02:26 -07:00
Owen 09779aca3e Add basic vnc test 2026-05-19 20:02:25 -07:00
Owen 1d1f7cecf4 Support rdp 2026-05-19 20:02:25 -07:00
Owen dc00668cbe Add first iteration of ssh proxy 2026-05-19 20:02:25 -07:00
Owen 57701e13eb Comment out some fields 2026-05-19 20:02:25 -07:00
Owen 46545cb003 Initial rdp working 2026-05-19 20:02:21 -07:00
Owen 987b5d580e Sure up some things with browserAccessType 2026-05-15 17:26:58 -07:00
Owen cb75ffc3b7 Adjust page to be editable 2026-05-15 16:46:43 -07:00
Owen 540f0a754d Temp credential storage 2026-05-15 16:11:23 -07:00
Owen 0f9a6fd968 Support private key 2026-05-15 16:07:14 -07:00
Owen 82112abc34 Clean up toasts 2026-05-15 15:01:37 -07:00
Owen 75b5afd544 Add crud for browser targets 2026-05-15 14:09:33 -07:00
Owen 00e1675f7b Remove extra fields 2026-05-15 12:08:40 -07:00
Owen 2ddbdf977b Standardize the ui 2026-05-15 12:06:05 -07:00
Owen 4c8f0cc9ec Pull in the destination from the api 2026-05-15 11:48:13 -07:00
Owen e822b681cd Merge branch 'dev' into rdp-ssh 2026-05-15 11:18:31 -07:00
Owen e1583a58aa USe right table 2026-05-14 11:33:42 -07:00
Owen 7d922ac95f Add internal api get for proxy information 2026-05-13 21:54:58 -07:00
Owen 795a3d351e Reinstall packages 2026-05-13 21:16:40 -07:00
Owen 4b4c86b4b7 Clean up forms a bit 2026-05-13 21:16:00 -07:00
Owen 013af49137 Add favicon passthrough 2026-05-13 21:11:25 -07:00
Owen a6ae9290f2 Serve the resource from the right place 2026-05-13 18:01:36 -07:00
Owen de70d72e0d Add gateway endpoints into the traefik config 2026-05-13 17:33:16 -07:00
Owen 4e07e9c52c Working on new target type 2026-05-13 11:56:23 -07:00
Owen 743621eb25 Add browserGatewayTarget table 2026-05-12 21:48:59 -07:00
Owen 943923ff4b Add basic vnc test 2026-05-12 21:12:01 -07:00
Owen 3f17f1a468 Support rdp 2026-05-12 21:12:01 -07:00
Owen 436996a43d Add first iteration of ssh proxy 2026-05-12 21:12:01 -07:00
Owen d42b6076d2 Comment out some fields 2026-05-12 21:12:01 -07:00
Owen 89cc99f915 Initial rdp working 2026-05-12 21:12:00 -07:00
142 changed files with 10588 additions and 7224 deletions
+2 -3
View File
@@ -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
+110
View File
@@ -0,0 +1,110 @@
git push origin -d 1.11.0-s.0
git push origin -d 1.11.0-s.1
git push origin -d 1.11.0-s.2
git push origin -d 1.11.0-s.3
git push origin -d 1.11.0-s.4
git push origin -d 1.11.0-s.5
git push origin -d 1.11.1-s.0
git push origin -d 1.12.0-s.0
git push origin -d 1.12.2-s.0
git push origin -d 1.12.2-s.1
git push origin -d 1.12.2-s.2
git push origin -d 1.12.2-s.3
git push origin -d 1.12.2-s.4
git push origin -d 1.12.2-s.5
git push origin -d 1.13.0.s.0
git push origin -d 1.13.1-s.0
git push origin -d 1.14.0-s.2
git push origin -d 1.14.1-s.0
git push origin -d 1.14.1-s.1
git push origin -d 1.14.1-s.2
git push origin -d 1.14.1-s.3
git push origin -d 1.15.0-s.0
git push origin -d 1.15.0-s.1
git push origin -d 1.15.0-s.2
git push origin -d 1.15.0-s.3
git push origin -d 1.15.0-s.4
git push origin -d 1.15.0-s.5
git push origin -d 1.15.1-s.0
git push origin -d 1.15.1-s.1
git push origin -d 1.15.3-s.0
git push origin -d 1.15.3-s.1
git push origin -d 1.15.4-s.0
git push origin -d 1.15.4-s.1
git push origin -d 1.15.4-s.10
git push origin -d 1.15.4-s.2
git push origin -d 1.15.4-s.3
git push origin -d 1.15.4-s.4
git push origin -d 1.15.4-s.5
git push origin -d 1.15.4-s.6
git push origin -d 1.15.4-s.7
git push origin -d 1.15.4-s.8
git push origin -d 1.15.4-s.9
git push origin -d 1.16.0-s.0
git push origin -d 1.16.0-s.1
git push origin -d 1.16.1-s.0
git push origin -d 1.16.1-s.1
git push origin -d 1.16.2-s.0
git push origin -d 1.16.2-s.1
git push origin -d 1.16.2-s.10
git push origin -d 1.16.2-s.11
git push origin -d 1.16.2-s.12
git push origin -d 1.16.2-s.13
git push origin -d 1.16.2-s.14
git push origin -d 1.16.2-s.15
git push origin -d 1.16.2-s.16
git push origin -d 1.16.2-s.17
git push origin -d 1.16.2-s.18
git push origin -d 1.16.2-s.19
git push origin -d 1.16.2-s.2
git push origin -d 1.16.2-s.20
git push origin -d 1.16.2-s.21
git push origin -d 1.16.2-s.22
git push origin -d 1.16.2-s.3
git push origin -d 1.16.2-s.4
git push origin -d 1.16.2-s.5
git push origin -d 1.16.2-s.6
git push origin -d 1.16.2-s.7
git push origin -d 1.16.2-s.8
git push origin -d 1.16.2-s.9
git push origin -d 1.17.0-s.0
git push origin -d 1.17.0-s.1
git push origin -d 1.17.0-s.2
git push origin -d 1.17.0-s.3
git push origin -d 1.17.0-s.4
git push origin -d 1.17.1-s.0
git push origin -d 1.17.1-s.1
git push origin -d 1.17.1-s.2
git push origin -d 1.17.1-s.3
git push origin -d 1.17.1-s.4
git push origin -d 1.17.1-s.5
git push origin -d 1.17.1-s.6
git push origin -d 1.17.1-s.7
git push origin -d 1.18.0-s.0
git push origin -d 1.18.0-s.1
git push origin -d 1.18.0-s.2
git push origin -d 1.18.1-s.0
git push origin -d 1.18.1-s.1
git push origin -d 1.18.1-s.2
git push origin -d 1.18.1-s.3
git push origin -d 1.18.1-s.4
git push origin -d 1.18.1-s.5
git push origin -d 1.18.1-s.6
git push origin -d 1.18.1-s.7
git push origin -d 1.18.2-s.0
git push origin -d 1.18.2-s.1
git push origin -d 1.18.2-s.2
git push origin -d 1.18.2-s.3
git push origin -d 1.18.2-s.4
git push origin -d 1.18.2-s.5
git push origin -d 1.18.3-s.0
git push origin -d 1.18.3-s.1
git push origin -d 1.18.3-s.2
git push origin -d 1.18.3-s.3
git push origin -d 1.18.4-s.0
git push origin -d 1.18.4-s.1
git push origin -d 1.18.4-s.2
git push origin -d 1.18.4-s.3
git push origin -d 1.18.4-s.4
git push origin -d 1.18.4-s.5
git push origin -d 1.18.4-s.6
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+33 -28
View File
@@ -1164,9 +1164,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 +1351,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 +1646,6 @@
"certificateStatus": "Certificate",
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
"loading": "Loading",
"loadingEllipsis": "Loading...",
"loadingAnalytics": "Loading Analytics",
"restart": "Restart",
"domains": "Domains",
@@ -1899,6 +1872,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 +1970,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": "Uses a Pangolin auth daemon to manage SSH authentication on the site or remote host.",
"sshServerModeNative": "Native SSH Server",
"sshServerModeNativeDescription": "SSH authentication is handled natively by an existing SSH server without a separate auth daemon.",
"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 +2994,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 +3024,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",
+7 -1
View File
@@ -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"
};
+3397 -2191
View File
File diff suppressed because it is too large Load Diff
+56 -52
View File
@@ -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"
}
}
+6 -1
View File
@@ -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(
+21
View File
@@ -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
>;
+13 -3
View File
@@ -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"
+23
View File
@@ -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
>;
+13 -2
View File
@@ -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
View File
@@ -1,5 +1,5 @@
#! /usr/bin/env node
import "./extendZod";
import "./extendZod.ts";
import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";
-11
View File
@@ -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;
+9 -307
View File
@@ -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)
};
}
-11
View File
@@ -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;
}
+2 -2
View File
@@ -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"
+7 -20
View File
@@ -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) {
+7 -4
View File
@@ -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];
+1 -5
View File
@@ -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
+1 -2
View File
@@ -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);
+1 -3
View File
@@ -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(
+1 -2
View File
@@ -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(
+3 -3
View File
@@ -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
View File
@@ -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();
-19
View File
@@ -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
View File
@@ -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();
+298 -3
View File
@@ -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"
)
);
}
}
+40 -7
View File
@@ -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(
+41
View File
@@ -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(),
+319 -226
View File
@@ -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,
+1 -7
View File
@@ -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;
-2
View File
@@ -10,5 +10,3 @@ export * from "./listUserDevices";
export * from "./updateClient";
export * from "./getClient";
export * from "./createUserClient";
export * from "./verifyClientAssociationsCache";
export * from "./rebuildClientAssociationsCacheRoute";
+3 -47
View File
@@ -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"
)
);
}
}
+1
View File
@@ -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(),
+2
View File
@@ -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);
+22 -1
View File
@@ -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
}
},
+8 -3
View File
@@ -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
}
},
{
+55 -1
View File
@@ -1,7 +1,9 @@
import { Target, TargetHealthCheck } from "@server/db";
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
import { sendToClient } from "#dynamic/routers/ws";
import 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") }
);
}
+109
View File
@@ -0,0 +1,109 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db } from "@server/db";
import { resources, targets } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
const getBrowserTargetSchema = z
.object({
fullDomain: z.string().min(1, "fullDomain is required")
})
.strict();
export type GetBrowserTargetResponse = {
ip: string;
port: number;
authToken: string;
orgId: string;
resourceId: number;
niceId: string;
pamMode: "passthrough" | "push" | null;
authDaemonMode: "site" | "remote" | "native" | null;
};
export async function getBrowserTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = getBrowserTargetSchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { fullDomain } = parsed.data;
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
const [browserTarget] = await db
.select({
destination: browserGatewayTarget.destination,
destinationPort: browserGatewayTarget.destinationPort,
authToken: browserGatewayTarget.authToken,
resourceId: resources.resourceId,
niceId: resources.niceId,
orgId: resources.orgId,
pamMode: resources.pamMode,
authDaemonMode: resources.authDaemonMode
})
.from(browserGatewayTarget)
.innerJoin(
resources,
eq(browserGatewayTarget.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, fullDomain))
.limit(1);
const decryptedAuthToken = decrypt(
browserTarget.authToken,
config.getRawConfig().server.secret!
);
if (!browserTarget) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No resource found for this domain"
)
);
}
return response<GetBrowserTargetResponse>(res, {
data: {
ip: browserTarget.destination,
port: browserTarget.destinationPort,
authToken: decryptedAuthToken,
pamMode: browserTarget.pamMode,
authDaemonMode: browserTarget.authDaemonMode,
orgId: browserTarget.orgId,
resourceId: browserTarget.resourceId,
niceId: browserTarget.niceId
},
success: true,
error: false,
message: "Browser target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while retrieving the browser target"
)
);
}
}
+1 -8
View File
@@ -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()
+1
View File
@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory";
export * from "./getBrowserTarget";
+47 -42
View File
@@ -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());
}
+10 -2
View File
@@ -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"
+3 -50
View File
@@ -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
@@ -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>
);
}
+221 -124
View File
@@ -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)]
};
});
}
+202 -108
View File
@@ -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
};
});
}
+248 -133
View File
@@ -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
};
});
}
+189 -134
View File
@@ -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
};
});
}
@@ -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>
)}
@@ -121,6 +121,10 @@ export default function ReverseProxyTargetsPage(props: {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const [targetMode, setTargetMode] = useState<
"http" | "ssh" | "rdp" | "vnc"
>((resource.browserAccessType as "http" | "ssh" | "rdp" | "vnc") || "http");
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
resourceQueries.resourceTargets({
resourceId: resource.resourceId
@@ -137,9 +141,12 @@ export default function ReverseProxyTargetsPage(props: {
orgId={params.orgId}
initialTargets={remoteTargets}
resource={resource}
targetMode={targetMode}
setTargetMode={setTargetMode}
updateResource={updateResource}
/>
{resource.http && (
{resource.http && targetMode === "http" && (
<ProxyResourceHttpForm
resource={resource}
updateResource={updateResource}
@@ -159,11 +166,17 @@ export default function ReverseProxyTargetsPage(props: {
function ProxyResourceTargetsForm({
orgId,
initialTargets,
resource
resource,
targetMode,
setTargetMode,
updateResource
}: {
initialTargets: LocalTarget[];
orgId: string;
resource: GetResourceResponse;
targetMode: "http" | "ssh" | "rdp" | "vnc";
setTargetMode: (mode: "http" | "ssh" | "rdp" | "vnc") => void;
updateResource: ResourceContextType["updateResource"];
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
@@ -201,6 +214,11 @@ 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
@@ -270,6 +288,41 @@ function ProxyResourceTargetsForm({
})
);
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;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const bgt = bgTargetsResponse.targets[0];
setTargetMode(bgt.type as "ssh" | "rdp" | "vnc");
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 +409,7 @@ function ProxyResourceTargetsForm({
}
};
return (
return (
<div className="flex items-center justify-center w-full">
{row.original.siteType === "newt" ? (
<Button
@@ -375,7 +428,6 @@ function ProxyResourceTargetsForm({
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
)}
@@ -404,9 +456,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 +490,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
)
}
@@ -717,6 +781,55 @@ function ProxyResourceTargetsForm({
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
async function saveTargets() {
if (targetMode !== "http") {
try {
if (!bgDestination || !bgDestinationPort) {
if (bgTargetId) {
await api.delete(
`/org/${orgId}/browser-gateway-target/${bgTargetId}`
);
setBgTargetId(null);
}
} else if (bgTargetId) {
await api.post(
`/org/${orgId}/browser-gateway-target/${bgTargetId}`,
{
type: targetMode,
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: bgSiteId
}
);
} else {
const res = await api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: bgSiteId ?? sites[0]?.siteId,
type: targetMode,
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
);
setBgTargetId(res.data.data.browserGatewayTargetId);
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
return;
}
// Validate that no targets have blank IPs or invalid ports
const targetsWithInvalidFields = targets.filter(
(target) =>
@@ -791,12 +904,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([]);
@@ -829,102 +944,187 @@ function ProxyResourceTargetsForm({
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{targets.length > 0 ? (
<div className="flex items-center gap-3 mb-4">
<span className="text-sm font-medium">Target Type</span>
<Select
value={targetMode}
onValueChange={async (v) => {
const mode = v as
| "http"
| "ssh"
| "rdp"
| "vnc";
setTargetMode(mode);
try {
await api.post(
`/resource/${resource.resourceId}`,
{ browserAccessType: mode }
);
updateResource({ browserAccessType: mode });
} catch (err) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="ssh">SSH</SelectItem>
<SelectItem value="rdp">RDP</SelectItem>
<SelectItem value="vnc">VNC</SelectItem>
</SelectContent>
</Select>
</div>
{targetMode === "http" ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
const isActionsColumn =
header.column
.id ===
"actions";
return (
<TableHead
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column
.id ===
"actions";
return (
<TableCell
key={
cell.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
{targets.length > 0 ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow
key={headerGroup.id}
>
{headerGroup.headers.map(
(header) => {
const isActionsColumn =
header
.column
.id ===
"actions";
return (
<TableHead
key={
header.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows
?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
>
{row
.getVisibleCells()
.map(
(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
) => {
const isActionsColumn =
cell
.column
.id ===
"actions";
return (
<TableCell
key={
cell.id
}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
}
)}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
columns.length
}
className="h-24 text-center"
>
{t("targetNoOne")}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
<Button
onClick={addNewTarget}
variant="outline"
>
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={
setIsAdvancedMode
}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("targetNoOne")}
</TableCell>
</TableRow>
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table>
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
{t("advancedMode")}
</label>
</div>
</div>
</div>
</>
) : (
<div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button
onClick={addNewTarget}
variant="outline"
@@ -932,50 +1132,91 @@ function ProxyResourceTargetsForm({
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("advancedMode")}
</label>
</div>
</div>
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size >
1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
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 className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Destination
</label>
<Input
placeholder="192.168.1.1"
value={bgDestination}
onChange={(e) =>
setBgDestination(e.target.value)
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Port
</label>
<Input
type="number"
placeholder={
targetMode === "rdp"
? "3389"
: targetMode === "ssh"
? "22"
: "5900"
}
value={bgDestinationPort}
onChange={(e) =>
setBgDestinationPort(e.target.value)
}
/>
</div>
</div>
{sites.length > 1 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Site
</label>
<Select
value={bgSiteId ? String(bgSiteId) : ""}
onValueChange={(v) =>
setBgSiteId(Number(v))
}
>
<SelectTrigger>
<SelectValue placeholder="Select a site" />
</SelectTrigger>
<SelectContent>
{sites.map((site) => (
<SelectItem
key={site.siteId}
value={String(site.siteId)}
>
{site.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
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>
)}
</SettingsSectionBody>
<form className="self-end mt-4" action={formAction}>
@@ -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>
);
}
@@ -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>
);
}
@@ -713,7 +713,7 @@ export default function Page() {
}
};
return (
return (
<div className="flex items-center justify-center w-full">
{row.original.siteType === "newt" ? (
<Button
@@ -732,7 +732,6 @@ export default function Page() {
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
)}
@@ -1427,10 +1426,12 @@ export default function Page() {
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size >
1 && (
new Set(targets.map((t) => t.siteId))
.size > 1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
{t(
"proxyMultiSiteRoundRobinNodeHelp"
)}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
@@ -1627,7 +1628,7 @@ export default function Page() {
type="button"
onClick={() =>
router.push(
`/${orgId}/settings/resources/proxy/${niceId}/proxy`
`/${orgId}/settings/resources/proxy/${niceId}`
)
}
>
@@ -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
View File
@@ -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} />;
}
+522
View File
@@ -0,0 +1,522 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast";
import type {
UserInteraction,
IronError,
FileTransferProvider
} from "@devolutions/iron-remote-desktop/dist";
import type {
RdpFileTransferProvider,
FileInfo
} from "@devolutions/iron-remote-desktop-rdp/dist";
import { GetBrowserTargetResponse } from "@server/routers/resource";
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"iron-remote-desktop": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
scale?: string;
verbose?: string;
flexcenter?: string;
module?: unknown;
},
HTMLElement
>;
}
}
}
type FormState = {
username: string;
password: string;
domain: string;
kdcProxyUrl: string;
pcb: string;
enableClipboard: boolean;
};
const isIronError = (error: unknown): error is IronError => {
return (
typeof error === "object" &&
error !== null &&
typeof (error as IronError).backtrace === "function" &&
typeof (error as IronError).kind === "function"
);
};
export default function RdpClient({
target,
error
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
}) {
const STORAGE_KEY = "pangolin_rdp_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
});
const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false);
const [unicodeMode, setUnicodeMode] = useState(false);
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
const userInteractionRef = useRef<UserInteraction | null>(null);
const backendRef = useRef<unknown>(null);
// Holds the RdpFileTransferProvider constructor so we can create a fresh
// instance per session (avoids stale upload state across reconnects).
const fileTransferClassRef = useRef<typeof RdpFileTransferProvider | null>(
null
);
// Active session's provider instance; replaced on each connect.
const fileTransferRef = useRef<RdpFileTransferProvider | null>(null);
const extensionsRef = useRef<{
displayControl: (enable: boolean) => unknown;
preConnectionBlob: (pcb: string) => unknown;
kdcProxyUrl: (url: string) => unknown;
} | null>(null);
// Load the iron-remote-desktop modules client-side and register the
// `<iron-remote-desktop>` custom element.
useEffect(() => {
let cancelled = false;
(async () => {
const [coreMod, rdpMod] = await Promise.all([
import("@devolutions/iron-remote-desktop/dist"),
import("@devolutions/iron-remote-desktop-rdp/dist")
]);
if (cancelled) return;
await rdpMod.init("INFO");
backendRef.current = rdpMod.Backend;
extensionsRef.current = {
displayControl: rdpMod.displayControl,
preConnectionBlob: rdpMod.preConnectionBlob,
kdcProxyUrl: rdpMod.kdcProxyUrl
};
// Store the class; a fresh instance is created per session.
fileTransferClassRef.current =
rdpMod.RdpFileTransferProvider as unknown as typeof RdpFileTransferProvider;
// Importing the package registers the custom element as a side
// effect. Touch the default export to avoid tree-shaking.
void coreMod;
setModuleReady(true);
})().catch((err) => {
console.error("Failed to load iron-remote-desktop modules", err);
toast({
variant: "destructive",
title: "Failed to load RDP module",
description: `${err}`
});
});
return () => {
cancelled = true;
};
}, []);
// Attach the "ready" listener synchronously the moment the custom
// element mounts. The custom element dispatches `ready` from its own
// `onMount`, so a deferred useEffect can race and miss it.
const remoteElementRef = (el: HTMLElement | null) => {
if (!el) return;
const onReady = (e: Event) => {
const event = e as CustomEvent;
userInteractionRef.current = event.detail.irgUserInteraction;
};
el.addEventListener("ready", onReady);
};
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const startSession = async () => {
setConnecting(true);
const userInteraction = userInteractionRef.current;
const exts = extensionsRef.current;
if (!userInteraction || !exts) {
setConnecting(false);
toast({
variant: "destructive",
title: "Not ready",
description: "RDP module is still initializing"
});
return;
}
userInteraction.setEnableClipboard(form.enableClipboard);
// Dispose any previous session's provider and create a fresh one so
// there is no stale upload state from a prior connection.
fileTransferRef.current?.dispose();
const ProviderClass = fileTransferClassRef.current;
const fileTransfer = ProviderClass ? new ProviderClass() : null;
fileTransferRef.current = fileTransfer;
if (fileTransfer) {
// Auto-download files when the remote copies them to clipboard.
fileTransfer.on("files-available", (files: FileInfo[]) => {
const downloadable = files.filter((f) => !f.isDirectory);
if (downloadable.length === 0) return;
toast({
title: `Downloading ${downloadable.length} file(s) from remote…`
});
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.isDirectory) continue;
const { completion } = fileTransfer.downloadFile(file, i);
completion
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
a.click();
URL.revokeObjectURL(url);
})
.catch((err) => {
toast({
variant: "destructive",
title: `Download failed: ${file.name}`,
description: `${err}`
});
});
}
});
// Notify when individual uploads complete (remote pasted a file).
fileTransfer.on("upload-complete", (file: File) => {
toast({ title: `Uploaded: ${file.name}` });
});
// Register with the web component so CLIPRDR extensions are
// wired up before connect() builds the session.
userInteraction.enableFileTransfer(
fileTransfer as unknown as FileTransferProvider
);
}
if (!target) {
setConnecting(false);
toast({
variant: "destructive",
title: "No target",
description: "No connection target available"
});
return;
}
const destination = `${target.ip}:${target.port}`;
const builder = userInteraction
.configBuilder()
.withUsername(form.username)
.withPassword(form.password)
.withDestination(destination)
.withProxyAddress(
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
)
.withServerDomain(form.domain)
.withAuthToken(target.authToken)
.withDesktopSize({
width: window.innerWidth,
height: window.innerHeight
})
.withExtension(exts.displayControl(true));
if (form.pcb !== "") {
builder.withExtension(exts.preConnectionBlob(form.pcb));
}
if (form.kdcProxyUrl !== "") {
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
}
try {
const sessionInfo = await userInteraction.connect(builder.build());
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnecting(false);
setShowLogin(false);
userInteraction.setVisibility(true);
const termInfo = await sessionInfo.run();
fileTransferRef.current?.dispose();
fileTransferRef.current = null;
setShowLogin(true);
} catch (err) {
setConnecting(false);
setShowLogin(true);
if (isIronError(err)) {
toast({
variant: "destructive",
title: "Connection failed",
description: err.backtrace()
});
} else {
toast({
variant: "destructive",
title: "Connection failed",
description: `${err}`
});
}
}
};
const ui = () => userInteractionRef.current;
const toggleCursorKind = () => {
const u = ui();
if (!u) return;
if (cursorOverrideActive) {
u.setCursorStyleOverride(null);
} else {
u.setCursorStyleOverride('url("crosshair.png") 7 7, default');
}
setCursorOverrideActive((v) => !v);
};
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{showLogin && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">RDP</h1>
<div className="space-y-4">
<Field label="Domain" id="domain">
<Input
id="domain"
value={form.domain}
onChange={(e) =>
update("domain", e.target.value)
}
/>
</Field>
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
update("username", e.target.value)
}
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
{/*
<Field label="Pre Connection Blob (optional)" id="pcb">
<Input
id="pcb"
value={form.pcb}
onChange={(e) => update("pcb", e.target.value)}
/>
</Field> */}
{/* <Field
label="KDC Proxy URL (optional)"
id="kdcProxyUrl"
>
<Input
id="kdcProxyUrl"
value={form.kdcProxyUrl}
onChange={(e) =>
update("kdcProxyUrl", e.target.value)
}
/>
</Field> */}
{/* <div className="flex items-center gap-2">
<Checkbox
id="enable_clipboard"
checked={form.enableClipboard}
onCheckedChange={(checked) =>
update("enableClipboard", checked === true)
}
/>
<Label htmlFor="enable_clipboard">
Enable Clipboard
</Label>
</div> */}
<Button
onClick={startSession}
disabled={!moduleReady}
loading={connecting}
className="w-full"
>
{moduleReady ? "Connect" : "Loading module..."}
</Button>
</div>
</div>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
style={{ display: showLogin ? "none" : "flex" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
Fit
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(2)}
>
Full
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(3)}
>
Real
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.ctrlAltDel()}
>
Ctrl+Alt+Del
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.metaKey()}
>
Meta
</Button>
{/* <Button
size="sm"
variant="secondary"
onClick={toggleCursorKind}
>
Toggle cursor
</Button> */}
<Button
size="sm"
variant="secondary"
onClick={async () => {
const ft = fileTransferRef.current;
if (!ft) return;
const files = await ft.showFilePicker({
multiple: true
});
if (files.length === 0) return;
try {
ft.uploadFiles(files);
toast({
title: "Files ready to paste",
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
});
} catch (err) {
toast({
variant: "destructive",
title: "Upload failed",
description: `${err}`
});
}
}}
>
Upload files
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
ui()?.shutdown();
setShowLogin(true);
}}
>
Terminate
</Button>
<label className="ml-2 flex items-center gap-2">
<input
type="checkbox"
checked={unicodeMode}
onChange={(e) => {
setUnicodeMode(e.target.checked);
ui()?.setKeyboardUnicodeMode(e.target.checked);
}}
/>
Unicode keyboard mode
</label>
</div>
{moduleReady && (
<iron-remote-desktop
ref={remoteElementRef}
verbose="true"
scale="fit"
flexcenter="true"
module={backendRef.current}
/>
)}
</div>
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import RdpClient from "./RdpClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "RDP"
};
export default async function RdpPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: { 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} />;
}
+453
View File
@@ -0,0 +1,453 @@
"use client";
import "@xterm/xterm/css/xterm.css";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { SignSshKeyResponse } from "@server/private/routers/ssh";
import { GetBrowserTargetResponse } from "@server/routers/resource";
type FormState = {
username: string;
password: string;
privateKey: string;
};
type ConnectCredentials = {
username: string;
password?: string;
privateKey?: string;
certificate?: string;
};
export default function SshClient({
target,
error,
signedKeyData,
privateKey: signedPrivateKey
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
signedKeyData?: SignSshKeyResponse | null;
privateKey?: string | null;
}) {
const STORAGE_KEY = "pangolin_ssh_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { username: "", password: "", privateKey: "" };
});
const fileInputRef = useRef<HTMLInputElement>(null);
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result;
if (typeof text === "string") {
setForm((prev) => ({ ...prev, privateKey: text }));
}
};
reader.readAsText(file);
// Reset input so the same file can be re-selected if needed.
e.target.value = "";
}
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
const fitAddonRef = useRef<import("@xterm/addon-fit").FitAddon | null>(
null
);
const wsRef = useRef<WebSocket | null>(null);
// Mount the terminal div once connected.
useEffect(() => {
if (!connected || !terminalRef.current) return;
let cancelled = false;
(async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] =
await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-web-links")
]);
if (cancelled || !terminalRef.current) return;
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
theme: {
background: "#0d0d0d",
foreground: "#f0f0f0"
},
scrollback: 5000
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(terminalRef.current);
fitAddon.fit();
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Send user keystrokes to the WebSocket.
terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "data", data }));
}
});
// Send resize events.
terminal.onResize(({ cols, rows }) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows })
);
}
});
// Send the initial size once the terminal is rendered.
const { cols, rows } = terminal;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows })
);
}
})().catch(console.error);
return () => {
cancelled = true;
};
}, [connected]);
// Refit terminal when the window resizes.
useEffect(() => {
const onResize = () => fitAddonRef.current?.fit();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Cleanup on unmount.
useEffect(() => {
return () => {
wsRef.current?.close();
xtermRef.current?.dispose();
};
}, []);
// Auto-connect when signed key data is provided (push PAM mode).
useEffect(() => {
if (signedKeyData && signedPrivateKey && target) {
connect({
username: signedKeyData.sshUsername,
privateKey: signedPrivateKey,
certificate: signedKeyData.certificate
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function connect(override?: ConnectCredentials) {
setConnectError(null);
setConnecting(true);
if (!target) {
setConnectError("No target specified");
setConnecting(false);
return;
}
const username = override?.username ?? form.username;
const password = override?.password ?? form.password;
const privateKey = override?.privateKey ?? form.privateKey;
const certificate = override?.certificate;
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
const url = new URL(proxyAddress);
url.searchParams.set("host", target.ip ?? "");
url.searchParams.set("port", String(target.port ?? 22));
url.searchParams.set("username", username);
url.searchParams.set("authToken", target.authToken ?? "");
const ws = new WebSocket(url.toString(), ["ssh"]);
wsRef.current = ws;
ws.onopen = () => {
// Send credentials as the first frame so the proxy can complete
// SSH authentication before piping pty data.
ws.send(
JSON.stringify({
type: "auth",
password,
privateKey,
certificate
})
);
if (!override) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
}
setConnecting(false);
setConnected(true);
};
ws.onmessage = (evt) => {
if (typeof evt.data === "string") {
try {
const msg = JSON.parse(evt.data as string) as {
type: string;
data?: string;
error?: string;
};
if (msg.type === "data" && msg.data) {
xtermRef.current?.write(msg.data);
} else if (msg.type === "error") {
xtermRef.current?.writeln(
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
);
}
} catch {
xtermRef.current?.write(evt.data);
}
} else if (evt.data instanceof Blob) {
evt.data.text().then((t) => xtermRef.current?.write(t));
}
};
ws.onerror = () => {
setConnecting(false);
setConnected(false);
setConnectError("WebSocket connection failed");
};
ws.onclose = (evt) => {
setConnecting(false);
setConnected(false);
xtermRef.current?.writeln(
`\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n`
);
};
}
function disconnect() {
wsRef.current?.close();
xtermRef.current?.dispose();
xtermRef.current = null;
setConnected(false);
}
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
// In push mode, show a connecting/connected state without the login form.
if (signedKeyData && signedPrivateKey) {
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">
{connectError
? connectError
: connecting
? "Connecting…"
: "Initializing…"}
</p>
</div>
)}
{connected && (
<div className="flex h-screen flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
<div
ref={terminalRef}
className="flex-1 overflow-hidden"
style={{ minHeight: 0 }}
/>
</div>
)}
</div>
);
}
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">SSH</h1>
<div className="space-y-4">
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
placeholder="root"
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
setForm({
...form,
password: e.target.value
})
}
placeholder={
form.privateKey
? "Optional with key auth"
: ""
}
/>
</Field>
<Field label="Private Key (optional)" id="privateKey">
<Textarea
id="privateKey"
value={form.privateKey}
onChange={(e) =>
setForm({
...form,
privateKey: e.target.value
})
}
placeholder="Paste your private key here (PEM format)…"
rows={5}
className="font-mono text-xs"
/>
<div className="mt-1.5 flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
fileInputRef.current?.click()
}
>
Upload key file
</Button>
{form.privateKey && (
<button
type="button"
className="text-xs text-muted-foreground underline"
onClick={() =>
setForm((prev) => ({
...prev,
privateKey: ""
}))
}
>
Clear
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</Field>
{connectError && (
<p className="text-destructive text-sm">
{connectError}
</p>
)}
<Button
onClick={() => connect()}
loading={connecting}
disabled={
!form.username ||
(!form.password && !form.privateKey)
}
className="w-full"
>
{connecting ? "Connecting..." : "Connect"}
</Button>
</div>
</div>
)}
{connected && (
<div className="flex h-screen flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
<div
ref={terminalRef}
className="flex-1 overflow-hidden"
style={{ minHeight: 0 }}
/>
</div>
)}
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import SshClient from "./SshClient";
import { SignSshKeyResponse } from "@server/private/routers/ssh";
import crypto from "crypto";
function generateEphemeralKeyPair(): {
privateKeyPem: string;
publicKeyOpenSSH: string;
} {
const { publicKey: pubKeyObj, privateKey: privKeyObj } =
crypto.generateKeyPairSync("ed25519");
const privateKeyPem = privKeyObj.export({
type: "pkcs8",
format: "pem"
}) as string;
// Build OpenSSH wire format: uint32-length-prefixed strings
const pubKeyDer = pubKeyObj.export({
type: "spki",
format: "der"
}) as Buffer;
const rawPubKey = pubKeyDer.subarray(pubKeyDer.length - 32); // last 32 bytes are the Ed25519 key
function encodeField(b: Buffer): Buffer {
const len = Buffer.allocUnsafe(4);
len.writeUInt32BE(b.length, 0);
return Buffer.concat([len, b]);
}
const keyBlob = Buffer.concat([
encodeField(Buffer.from("ssh-ed25519")),
encodeField(rawPubKey)
]);
const publicKeyOpenSSH = `ssh-ed25519 ${keyBlob.toString("base64")}`;
return { privateKeyPem, publicKeyOpenSSH };
}
export const dynamic = "force-dynamic";
export const metadata = {
title: "SSH"
};
export default async function SshPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let signedKeyData: SignSshKeyResponse | null = null;
let privateKey: string | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
if (target.pamMode === "push") {
const { privateKeyPem, publicKeyOpenSSH } =
generateEphemeralKeyPair();
privateKey = privateKeyPem;
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
`/org/${target.orgId}/ssh/sign-key`,
{
publicKey: publicKeyOpenSSH,
resource: target.niceId
}
);
signedKeyData = res.data.data;
console.log("Received signed SSH key:", signedKeyData);
}
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
return (
<SshClient
target={target}
error={error}
signedKeyData={signedKeyData}
privateKey={privateKey}
/>
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast";
import { GetBrowserTargetResponse } from "@server/routers/resource";
type FormState = {
password: string;
};
export default function VncClient({
target,
error
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
}) {
const STORAGE_KEY = "pangolin_vnc_credentials";
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { password: "" };
});
const [connected, setConnected] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfbRef = useRef<any>(null);
const screenRef = useRef<HTMLDivElement>(null);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
// Disconnect and clean up the RFB instance.
const disconnect = () => {
if (rfbRef.current) {
rfbRef.current.disconnect();
rfbRef.current = null;
}
setConnected(false);
};
// Clean up on unmount.
useEffect(() => {
return () => disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const connect = async () => {
if (!target) {
toast({
variant: "destructive",
title: "No target",
description: "No resource target is available"
});
return;
}
if (!screenRef.current) return;
// Disconnect any existing session first.
disconnect();
// noVNC has no ESM default export — import the module dynamically to
// keep it out of the server bundle, then grab the default export.
let RFB: new (
target: HTMLElement,
url: string,
options?: Record<string, unknown>
) => unknown;
try {
// @ts-expect-error — @novnc/novnc ships plain JS with no bundled types
const mod = await import("@novnc/novnc");
RFB = mod.default ?? mod;
} catch (err) {
toast({
variant: "destructive",
title: "Failed to load noVNC",
description: `${err}`
});
return;
}
// Build the proxy WebSocket URL:
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
const base = proxyAddress.replace(/\/$/, "");
const params = new URLSearchParams({
host: target.ip,
port: String(target.port),
authToken: target.authToken
});
const wsUrl = `${base}?${params.toString()}`;
// Clear the container so noVNC gets a clean mount point.
screenRef.current.innerHTML = "";
const options: Record<string, unknown> = {};
if (form.password) {
options.credentials = { password: form.password };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfb: any = new RFB(screenRef.current, wsUrl, options);
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener("connect", () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
// ignore
}
setConnected(true);
});
rfb.addEventListener(
"disconnect",
(e: { detail: { clean: boolean } }) => {
rfbRef.current = null;
setConnected(false);
}
);
rfb.addEventListener(
"securityfailure",
(e: { detail: { status: number; reason?: string } }) => {
toast({
variant: "destructive",
title: "Authentication failed",
description: e.detail.reason ?? `Status ${e.detail.status}`
});
}
);
rfbRef.current = rfb;
};
if (error) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-destructive">{error}</p>
</div>
);
}
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">VNC</h1>
<div className="space-y-4">
<Field label="Password (optional)" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
<Button onClick={connect} className="w-full">
Connect
</Button>
</div>
</div>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
style={{ display: connected ? "flex" : "none" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="secondary"
onClick={() => {
if (rfbRef.current) {
rfbRef.current.sendCtrlAltDel();
}
}}
>
Ctrl+Alt+Del
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
navigator.clipboard
?.readText()
.then((text) => {
rfbRef.current?.clipboardPasteFrom(text);
})
.catch(() => {});
}}
>
Paste clipboard
</Button>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
{/* noVNC mounts a <canvas> inside this div */}
<div
ref={screenRef}
className="flex-1 overflow-hidden"
style={{ background: "#000" }}
/>
</div>
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource";
import VncClient from "./VncClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "VNC"
};
export default async function VncPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
return <VncClient target={target} error={error} />;
}
+70 -4
View File
@@ -44,11 +44,77 @@ export type AuthPageCustomizationProps = {
};
const AuthPageFormSchema = z.object({
logoUrl: z
.string()
.optional()
.transform((val) => (val === "" ? undefined : val)),
logoUrl: z.union([
z.literal(""),
z.string().superRefine(async (urlOrPath, ctx) => {
const parseResult = z.url().safeParse(urlOrPath);
if (!parseResult.success) {
if (build !== "enterprise") {
ctx.addIssue({
code: "custom",
message: "Must be a valid URL"
});
return;
} else {
try {
validateLocalPath(urlOrPath);
} catch (error) {
ctx.addIssue({
code: "custom",
message:
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
});
} finally {
return;
}
}
}
try {
const response = await fetch(urlOrPath, {
method: "HEAD"
}).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET
return fetch(urlOrPath, { method: "GET" });
});
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (
error instanceof TypeError &&
error.message.includes("fetch")
) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
}
})
]),
logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1),
orgTitle: z.string().optional(),
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "./multi-site-selector";
import { SitesSelector, type Selectedsite } from "./site-selector";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
type SingleSiteProps = {
multiSite?: false;
selectedSite: Selectedsite | null;
onSiteChange: (site: Selectedsite | null) => void;
};
type MultiSiteProps = {
multiSite: true;
selectedSites: Selectedsite[];
onSitesChange: (sites: Selectedsite[]) => void;
};
export type BrowserGatewayTargetFormProps = {
orgId: string;
destination: string;
defaultPort: number;
destinationPort: string;
onDestinationChange: (v: string) => void;
onDestinationPortChange: (v: string) => void;
learnMoreHref?: string;
} & (SingleSiteProps | MultiSiteProps);
export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
const t = useTranslations();
const [siteOpen, setSiteOpen] = useState(false);
const siteSelector =
props.multiSite === true ? (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{formatMultiSitesSelectorLabel(
props.selectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<MultiSitesSelector
orgId={props.orgId}
selectedSites={props.selectedSites}
onSelectionChange={props.onSitesChange}
/>
</PopoverContent>
</Popover>
) : (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{props.selectedSite?.name ?? t("siteSelect")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={props.orgId}
selectedSite={props.selectedSite}
onSelectSite={(site) => {
props.onSiteChange(site);
setSiteOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("sites")}
</label>
{siteSelector}
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("destination")}
</label>
<Input
placeholder="192.168.1.1"
value={props.destination}
onChange={(e) =>
props.onDestinationChange(e.target.value)
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">{t("port")}</label>
<Input
type="number"
placeholder={props.defaultPort.toString()}
value={props.destinationPort}
onChange={(e) =>
props.onDestinationPortChange(e.target.value)
}
/>
</div>
</div>
{props.multiSite === true && props.selectedSites.length > 1 && (
<p className="text-sm text-muted-foreground">
{t("bgTargetMultiSiteDisclaimer")}{" "}
<a
href={
props.learnMoreHref ??
"https://docs.pangolin.net/manage/resources/public/ssh"
}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
)}
</div>
);
}
+13 -12
View File
@@ -27,18 +27,20 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={userDisplayName ? 3 : 2}>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
<InfoSectionContent>{client.name}</InfoSectionContent>
</InfoSection>
{userDisplayName ? (
<InfoSection>
<InfoSectionTitle>{t("user")}</InfoSectionTitle>
<InfoSectionContent>
<div className="flex flex-wrap items-center gap-2">
<span>{userDisplayName}</span>
{(client.userType ?? "internal") !==
<InfoSection>
<InfoSectionTitle>
{userDisplayName ? t("user") : t("identifier")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="flex flex-wrap items-center gap-2">
<span>{userDisplayName || client.niceId}</span>
{userDisplayName &&
(client.userType ?? "internal") !==
"internal" && (
<IdpTypeBadge
type={client.userType ?? "oidc"}
@@ -52,10 +54,9 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
}
/>
)}
</div>
</InfoSectionContent>
</InfoSection>
) : null}
</div>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
+124 -42
View File
@@ -29,7 +29,8 @@ import {
ChevronDown,
ChevronsUpDownIcon,
Funnel,
MoreHorizontal
MoreHorizontal,
PlusIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
@@ -39,6 +40,7 @@ import {
startTransition,
useEffect,
useMemo,
useOptimistic,
useState,
useTransition
} from "react";
@@ -60,10 +62,8 @@ import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertI
import { build } from "@server/build";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { type SelectedLabel } from "./labels-selector";
import { TableLabelsCell } from "./TableLabelsCell";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { useLocalLabels } from "@app/hooks/useLocalLabels";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
export type InternalResourceSiteRow = ResourceSiteRow;
@@ -162,12 +162,12 @@ export default function ClientResourcesTable({
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
// useEffect(() => {
// const interval = setInterval(() => {
// router.refresh();
// }, 30_000);
// return () => clearInterval(interval);
// }, [router]);
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 30_000);
return () => clearInterval(interval);
}, [router]);
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
@@ -220,6 +220,59 @@ export default function ClientResourcesTable({
}
};
function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
const { siteNames, siteNiceIds, orgId } = resourceRow;
if (!siteNames || siteNames.length === 0) {
return (
<span className="text-muted-foreground">
{t("noSites", { defaultValue: "No sites" })}
</span>
);
}
if (siteNames.length === 1) {
return (
<Link href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}>
<Button variant="outline">
{siteNames[0]}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<span>
{siteNames.length} {t("sites")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{siteNames.map((siteName, idx) => (
<DropdownMenuItem key={siteNiceIds[idx]} asChild>
<Link
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
className="flex items-center gap-2 cursor-pointer"
>
{siteName}
<ArrowUpRight className="h-3 w-3" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
const internalColumns = useMemo<
ExtendedColumnDef<InternalResourceRow>[]
>(() => {
@@ -532,15 +585,9 @@ export default function ClientResourcesTable({
id: "labels",
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
<span className="p-3 text-end w-full inline-block">
{t("labels")}
</span>
),
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
<ClientResourceLabelCell
@@ -556,15 +603,13 @@ export default function ClientResourcesTable({
function handleFilterChange(
column: string,
value: string | undefined | null | string[]
value: string | undefined | null
) {
searchParams.delete(column);
searchParams.delete("page");
if (typeof value === "string") {
if (value) {
searchParams.set(column, value);
} else if (value) {
value.forEach((val) => searchParams.append(column, val));
}
filter({
searchParams
@@ -637,7 +682,7 @@ export default function ClientResourcesTable({
rows={internalResources}
tableId="internal-resources"
searchPlaceholder={t("resourcesSearch")}
searchQuery={searchParams.get("query")?.toString()}
searchQuery={searchParams.get("query") ?? ""}
onAdd={() => setIsCreateDialogOpen(true)}
addButtonText={t("resourceAdd")}
onSearch={handleSearchChange}
@@ -698,28 +743,27 @@ function ClientResourceLabelCell({
}: ClientResourceLabelCellProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [localLabels, setLocalLabels] = useLocalLabels(
resource.labels,
resource.id
);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const router = useRouter();
const labels = resource.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleResourceLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
const previousLabels = localLabels;
void (async () => {
startTransition(async () => {
try {
if (action === "attach") {
setLocalLabels([...previousLabels, label]);
setOptimisticLabels([...optimisticLabels, label]);
await api.put(
`/org/${orgId}/label/${label.labelId}/attach`,
{ siteResourceId: resource.id }
);
} else {
setLocalLabels(
previousLabels.filter(
setOptimisticLabels(
optimisticLabels.filter(
(lb) => lb.labelId !== label.labelId
)
);
@@ -729,21 +773,59 @@ function ClientResourceLabelCell({
);
}
} catch (e) {
setLocalLabels(previousLabels);
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
} finally {
router.refresh();
}
})();
});
}
return (
<TableLabelsCell
orgId={orgId}
localLabels={localLabels}
toggleLabel={toggleResourceLabel}
/>
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
{optimisticLabels.slice(0, 3).map((label) => (
<LabelBadge
key={label.labelId}
onClick={() => setIsPopoverOpen(true)}
{...label}
/>
))}
{optimisticLabels.length > 3 && (
<Button
variant="outline"
className={cn(
"inline-flex gap-1 items-center",
"rounded-full text-sm cursor-pointer",
"px-1.5 py-0 h-auto"
)}
onClick={() => setIsPopoverOpen(true)}
>
+{optimisticLabels.length - 3}
</Button>
)}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
size="icon"
variant="outline"
className="p-1 size-auto rounded-full"
title={t("addLabels")}
>
<span className="sr-only">{t("addLabels")}</span>
<PlusIcon className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="p-0 w-full">
<LabelsSelector
orgId={orgId}
selectedLabels={optimisticLabels}
toggleLabel={toggleResourceLabel}
/>
</PopoverContent>
</Popover>
</div>
);
}

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