mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-01 08:51:35 +02:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e9a040174 | |||
| a5332bb0cc | |||
| b3963cc34b | |||
| ddb132f9fa | |||
| cdc50ed47a | |||
| e2441ce284 | |||
| 0b6a3234a5 | |||
| ae8599c723 | |||
| 938e9b0d49 | |||
| 05e4ad3200 | |||
| 9eb55ba68c | |||
| 6d14a4df49 | |||
| 94949aa3fd | |||
| df098f55ba | |||
| facbb8f0a4 | |||
| 36fbd8818c | |||
| 91883397e6 | |||
| fd1813f3a7 | |||
| 0d820df797 | |||
| 76aea311a4 | |||
| 1a3cf2094b | |||
| 09cb20a084 | |||
| d1fb2e19d3 | |||
| 2934bbdd20 | |||
| 2b46e8eaba | |||
| 3b89104a59 | |||
| 5bf8b336c5 | |||
| 21a144753d | |||
| c1b8dfc863 | |||
| 5efcd4479a | |||
| e4e8b33e9f | |||
| af13790c93 | |||
| 87bcd8ec1b | |||
| b3cfe82dff | |||
| d65128671c | |||
| 41fdd5de74 | |||
| 2704202ba9 | |||
| 72ef0ae020 | |||
| 1442faa740 | |||
| 6aa589e612 | |||
| 4b1a8e14c4 | |||
| 1a0db10b1a | |||
| b7634086db | |||
| a163cc3678 | |||
| 1dfb3408e8 | |||
| 67fb2beba1 | |||
| 1ba75092f9 | |||
| c500979099 | |||
| 82745c701a | |||
| 18d380ce30 | |||
| 81274960f6 |
@@ -14,12 +14,13 @@ body:
|
|||||||
label: Environment
|
label: Environment
|
||||||
description: Please fill out the relevant details below for your environment.
|
description: Please fill out the relevant details below for your environment.
|
||||||
value: |
|
value: |
|
||||||
- OS Type & Version: (e.g., Ubuntu 22.04)
|
- OS Type & Version:
|
||||||
- Pangolin Version:
|
- Pangolin Version:
|
||||||
|
- Edition (Community or Enterprise):
|
||||||
- Gerbil Version:
|
- Gerbil Version:
|
||||||
- Traefik Version:
|
- Traefik Version:
|
||||||
- Newt Version:
|
- Newt Version:
|
||||||
- Olm Version: (if applicable)
|
- Client Version:
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
git push origin -d 1.11.0-s.0
|
|
||||||
git push origin -d 1.11.0-s.1
|
|
||||||
git push origin -d 1.11.0-s.2
|
|
||||||
git push origin -d 1.11.0-s.3
|
|
||||||
git push origin -d 1.11.0-s.4
|
|
||||||
git push origin -d 1.11.0-s.5
|
|
||||||
git push origin -d 1.11.1-s.0
|
|
||||||
git push origin -d 1.12.0-s.0
|
|
||||||
git push origin -d 1.12.2-s.0
|
|
||||||
git push origin -d 1.12.2-s.1
|
|
||||||
git push origin -d 1.12.2-s.2
|
|
||||||
git push origin -d 1.12.2-s.3
|
|
||||||
git push origin -d 1.12.2-s.4
|
|
||||||
git push origin -d 1.12.2-s.5
|
|
||||||
git push origin -d 1.13.0.s.0
|
|
||||||
git push origin -d 1.13.1-s.0
|
|
||||||
git push origin -d 1.14.0-s.2
|
|
||||||
git push origin -d 1.14.1-s.0
|
|
||||||
git push origin -d 1.14.1-s.1
|
|
||||||
git push origin -d 1.14.1-s.2
|
|
||||||
git push origin -d 1.14.1-s.3
|
|
||||||
git push origin -d 1.15.0-s.0
|
|
||||||
git push origin -d 1.15.0-s.1
|
|
||||||
git push origin -d 1.15.0-s.2
|
|
||||||
git push origin -d 1.15.0-s.3
|
|
||||||
git push origin -d 1.15.0-s.4
|
|
||||||
git push origin -d 1.15.0-s.5
|
|
||||||
git push origin -d 1.15.1-s.0
|
|
||||||
git push origin -d 1.15.1-s.1
|
|
||||||
git push origin -d 1.15.3-s.0
|
|
||||||
git push origin -d 1.15.3-s.1
|
|
||||||
git push origin -d 1.15.4-s.0
|
|
||||||
git push origin -d 1.15.4-s.1
|
|
||||||
git push origin -d 1.15.4-s.10
|
|
||||||
git push origin -d 1.15.4-s.2
|
|
||||||
git push origin -d 1.15.4-s.3
|
|
||||||
git push origin -d 1.15.4-s.4
|
|
||||||
git push origin -d 1.15.4-s.5
|
|
||||||
git push origin -d 1.15.4-s.6
|
|
||||||
git push origin -d 1.15.4-s.7
|
|
||||||
git push origin -d 1.15.4-s.8
|
|
||||||
git push origin -d 1.15.4-s.9
|
|
||||||
git push origin -d 1.16.0-s.0
|
|
||||||
git push origin -d 1.16.0-s.1
|
|
||||||
git push origin -d 1.16.1-s.0
|
|
||||||
git push origin -d 1.16.1-s.1
|
|
||||||
git push origin -d 1.16.2-s.0
|
|
||||||
git push origin -d 1.16.2-s.1
|
|
||||||
git push origin -d 1.16.2-s.10
|
|
||||||
git push origin -d 1.16.2-s.11
|
|
||||||
git push origin -d 1.16.2-s.12
|
|
||||||
git push origin -d 1.16.2-s.13
|
|
||||||
git push origin -d 1.16.2-s.14
|
|
||||||
git push origin -d 1.16.2-s.15
|
|
||||||
git push origin -d 1.16.2-s.16
|
|
||||||
git push origin -d 1.16.2-s.17
|
|
||||||
git push origin -d 1.16.2-s.18
|
|
||||||
git push origin -d 1.16.2-s.19
|
|
||||||
git push origin -d 1.16.2-s.2
|
|
||||||
git push origin -d 1.16.2-s.20
|
|
||||||
git push origin -d 1.16.2-s.21
|
|
||||||
git push origin -d 1.16.2-s.22
|
|
||||||
git push origin -d 1.16.2-s.3
|
|
||||||
git push origin -d 1.16.2-s.4
|
|
||||||
git push origin -d 1.16.2-s.5
|
|
||||||
git push origin -d 1.16.2-s.6
|
|
||||||
git push origin -d 1.16.2-s.7
|
|
||||||
git push origin -d 1.16.2-s.8
|
|
||||||
git push origin -d 1.16.2-s.9
|
|
||||||
git push origin -d 1.17.0-s.0
|
|
||||||
git push origin -d 1.17.0-s.1
|
|
||||||
git push origin -d 1.17.0-s.2
|
|
||||||
git push origin -d 1.17.0-s.3
|
|
||||||
git push origin -d 1.17.0-s.4
|
|
||||||
git push origin -d 1.17.1-s.0
|
|
||||||
git push origin -d 1.17.1-s.1
|
|
||||||
git push origin -d 1.17.1-s.2
|
|
||||||
git push origin -d 1.17.1-s.3
|
|
||||||
git push origin -d 1.17.1-s.4
|
|
||||||
git push origin -d 1.17.1-s.5
|
|
||||||
git push origin -d 1.17.1-s.6
|
|
||||||
git push origin -d 1.17.1-s.7
|
|
||||||
git push origin -d 1.18.0-s.0
|
|
||||||
git push origin -d 1.18.0-s.1
|
|
||||||
git push origin -d 1.18.0-s.2
|
|
||||||
git push origin -d 1.18.1-s.0
|
|
||||||
git push origin -d 1.18.1-s.1
|
|
||||||
git push origin -d 1.18.1-s.2
|
|
||||||
git push origin -d 1.18.1-s.3
|
|
||||||
git push origin -d 1.18.1-s.4
|
|
||||||
git push origin -d 1.18.1-s.5
|
|
||||||
git push origin -d 1.18.1-s.6
|
|
||||||
git push origin -d 1.18.1-s.7
|
|
||||||
git push origin -d 1.18.2-s.0
|
|
||||||
git push origin -d 1.18.2-s.1
|
|
||||||
git push origin -d 1.18.2-s.2
|
|
||||||
git push origin -d 1.18.2-s.3
|
|
||||||
git push origin -d 1.18.2-s.4
|
|
||||||
git push origin -d 1.18.2-s.5
|
|
||||||
git push origin -d 1.18.3-s.0
|
|
||||||
git push origin -d 1.18.3-s.1
|
|
||||||
git push origin -d 1.18.3-s.2
|
|
||||||
git push origin -d 1.18.3-s.3
|
|
||||||
git push origin -d 1.18.4-s.0
|
|
||||||
git push origin -d 1.18.4-s.1
|
|
||||||
git push origin -d 1.18.4-s.2
|
|
||||||
git push origin -d 1.18.4-s.3
|
|
||||||
git push origin -d 1.18.4-s.4
|
|
||||||
git push origin -d 1.18.4-s.5
|
|
||||||
git push origin -d 1.18.4-s.6
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { APP_PATH } from "@server/lib/consts";
|
import { APP_PATH } from "./server/lib/consts";
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
golang.org/x/term v0.42.0
|
golang.org/x/term v0.43.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +33,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-4
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
|||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
+31
-41
@@ -220,9 +220,8 @@
|
|||||||
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Create Resource",
|
"resourceCreate": "Create Resource",
|
||||||
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
||||||
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
|
|
||||||
"resourceSeeAll": "See All Resources",
|
"resourceSeeAll": "See All Resources",
|
||||||
"resourceCreateGeneral": "General",
|
"resourceInfo": "Resource Information",
|
||||||
"resourceNameDescription": "This is the display name for the resource.",
|
"resourceNameDescription": "This is the display name for the resource.",
|
||||||
"siteSelect": "Select site",
|
"siteSelect": "Select site",
|
||||||
"siteSearch": "Search site",
|
"siteSearch": "Search site",
|
||||||
@@ -232,15 +231,12 @@
|
|||||||
"noCountryFound": "No country found.",
|
"noCountryFound": "No country found.",
|
||||||
"siteSelectionDescription": "This site will provide connectivity to the target.",
|
"siteSelectionDescription": "This site will provide connectivity to the target.",
|
||||||
"resourceType": "Resource Type",
|
"resourceType": "Resource Type",
|
||||||
"resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This can’t be changed later.",
|
"resourceTypeDescription": "Determine how to access the resource",
|
||||||
"resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
|
|
||||||
"resourceHTTPSSettings": "HTTPS Settings",
|
"resourceHTTPSSettings": "HTTPS Settings",
|
||||||
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
|
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
|
||||||
"resourcePortDescription": "The external port on the Pangolin instance or node where the resource will be accessible.",
|
|
||||||
"domainType": "Domain Type",
|
"domainType": "Domain Type",
|
||||||
"subdomain": "Subdomain",
|
"subdomain": "Subdomain",
|
||||||
"baseDomain": "Base Domain",
|
"baseDomain": "Base Domain",
|
||||||
"configure": "Configure",
|
|
||||||
"subdomnainDescription": "The subdomain where the resource will be accessible.",
|
"subdomnainDescription": "The subdomain where the resource will be accessible.",
|
||||||
"resourceRawSettings": "TCP/UDP Settings",
|
"resourceRawSettings": "TCP/UDP Settings",
|
||||||
"resourceRawSettingsDescription": "Configure how the resource will be accessed over TCP/UDP",
|
"resourceRawSettingsDescription": "Configure how the resource will be accessed over TCP/UDP",
|
||||||
@@ -1168,6 +1164,9 @@
|
|||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||||
"labelsNotFound": "Labels not found",
|
"labelsNotFound": "Labels not found",
|
||||||
"labelSearch": "Search labels",
|
"labelSearch": "Search labels",
|
||||||
|
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||||
|
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
||||||
|
"accessLabelFilterClear": "Clear label filters",
|
||||||
"selectColor": "Select color",
|
"selectColor": "Select color",
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
"createNewLabel": "Create new org label \"{label}\"",
|
||||||
"inviteInvalidDescription": "The invite link is invalid.",
|
"inviteInvalidDescription": "The invite link is invalid.",
|
||||||
@@ -1355,6 +1354,29 @@
|
|||||||
"otpAuthBack": "Back to Password",
|
"otpAuthBack": "Back to Password",
|
||||||
"navbar": "Navigation Menu",
|
"navbar": "Navigation Menu",
|
||||||
"navbarDescription": "Main navigation menu for the application",
|
"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",
|
"navbarDocsLink": "Documentation",
|
||||||
"otpErrorEnable": "Unable to enable 2FA",
|
"otpErrorEnable": "Unable to enable 2FA",
|
||||||
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
||||||
@@ -1650,6 +1672,7 @@
|
|||||||
"certificateStatus": "Certificate",
|
"certificateStatus": "Certificate",
|
||||||
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
|
"loadingEllipsis": "Loading...",
|
||||||
"loadingAnalytics": "Loading Analytics",
|
"loadingAnalytics": "Loading Analytics",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
"domains": "Domains",
|
"domains": "Domains",
|
||||||
@@ -1876,7 +1899,6 @@
|
|||||||
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
|
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
|
||||||
"billingCurrentKeys": "Current Keys",
|
"billingCurrentKeys": "Current Keys",
|
||||||
"billingModifyCurrentPlan": "Modify Current Plan",
|
"billingModifyCurrentPlan": "Modify Current Plan",
|
||||||
"billingManageLicenseSubscriptionDescription": "Manage your subscription for paid self-hosted license keys and download invoices.",
|
|
||||||
"billingConfirmUpgrade": "Confirm Upgrade",
|
"billingConfirmUpgrade": "Confirm Upgrade",
|
||||||
"billingConfirmDowngrade": "Confirm Downgrade",
|
"billingConfirmDowngrade": "Confirm Downgrade",
|
||||||
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
|
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
|
||||||
@@ -1974,36 +1996,6 @@
|
|||||||
"timeIsInSeconds": "Time is in seconds",
|
"timeIsInSeconds": "Time is in seconds",
|
||||||
"requireDeviceApproval": "Require Device Approvals",
|
"requireDeviceApproval": "Require Device Approvals",
|
||||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||||
"sshSettings": "SSH Settings",
|
|
||||||
"rdpSettings": "RDP Settings",
|
|
||||||
"vncSettings": "VNC Settings",
|
|
||||||
"sshServer": "SSH Server",
|
|
||||||
"rdpServer": "RDP Server",
|
|
||||||
"vncServer": "VNC Server",
|
|
||||||
"sshServerDescription": "Set up the authentication method, daemon location, and server destination",
|
|
||||||
"rdpServerDescription": "Configure the destination and port of the RDP server",
|
|
||||||
"vncServerDescription": "Configure the destination and port of the VNC server",
|
|
||||||
"sshServerMode": "Mode",
|
|
||||||
"sshServerModeStandard": "Standard SSH Server",
|
|
||||||
"sshServerModePangolin": "Pangolin SSH",
|
|
||||||
"sshServerModeStandardDescription": "Routes commands over network to an SSH server such as OpenSSH.",
|
|
||||||
"sshServerModeNative": "Native SSH Server",
|
|
||||||
"sshServerModeNativeDescription": "Executes commands directly on the host via the Site Connector. No network config required.",
|
|
||||||
"sshAuthenticationMethod": "Authentication Method",
|
|
||||||
"sshAuthMethodManual": "Manual Authentication",
|
|
||||||
"sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.",
|
|
||||||
"sshAuthMethodAutomated": "Automated Provisioning",
|
|
||||||
"sshAuthMethodAutomatedDescription": "Automatically creates users, groups, and sudo permissions on host.",
|
|
||||||
"sshAuthDaemonLocation": "Auth Daemon Location",
|
|
||||||
"sshDaemonLocationSiteDescription": "Executes locally on the machine hosting the site connector.",
|
|
||||||
"sshDaemonLocationRemote": "On Remote Host",
|
|
||||||
"sshDaemonLocationRemoteDescription": "Executes on a separate target machine on the same network.",
|
|
||||||
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
|
||||||
"sshDaemonPort": "Daemon Port",
|
|
||||||
"sshServerDestination": "Server Destination",
|
|
||||||
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
|
|
||||||
"destination": "Destination",
|
|
||||||
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
|
||||||
"sshAccess": "SSH Access",
|
"sshAccess": "SSH Access",
|
||||||
"roleAllowSsh": "Allow SSH",
|
"roleAllowSsh": "Allow SSH",
|
||||||
"roleAllowSshAllow": "Allow",
|
"roleAllowSshAllow": "Allow",
|
||||||
@@ -2998,7 +2990,7 @@
|
|||||||
"learnMore": "Learn more",
|
"learnMore": "Learn more",
|
||||||
"backToHome": "Go back to home",
|
"backToHome": "Go back to home",
|
||||||
"needToSignInToOrg": "Need to use your organization's identity provider?",
|
"needToSignInToOrg": "Need to use your organization's identity provider?",
|
||||||
"maintenanceMode": "Maintenance Page",
|
"maintenanceMode": "Maintenance Mode",
|
||||||
"maintenanceModeDescription": "Display a maintenance page to visitors",
|
"maintenanceModeDescription": "Display a maintenance page to visitors",
|
||||||
"maintenanceModeType": "Maintenance Mode Type",
|
"maintenanceModeType": "Maintenance Mode Type",
|
||||||
"showMaintenancePage": "Show a maintenance page to visitors",
|
"showMaintenancePage": "Show a maintenance page to visitors",
|
||||||
@@ -3028,7 +3020,6 @@
|
|||||||
"maintenanceScreenEstimatedCompletion": "Estimated Completion:",
|
"maintenanceScreenEstimatedCompletion": "Estimated Completion:",
|
||||||
"createInternalResourceDialogDestinationRequired": "Destination is required",
|
"createInternalResourceDialogDestinationRequired": "Destination is required",
|
||||||
"available": "Available",
|
"available": "Available",
|
||||||
"disabledResourceDescription": "When disabled, the resource will be inaccessible by everyone.",
|
|
||||||
"archived": "Archived",
|
"archived": "Archived",
|
||||||
"noArchivedDevices": "No archived devices found",
|
"noArchivedDevices": "No archived devices found",
|
||||||
"deviceArchived": "Device archived",
|
"deviceArchived": "Device archived",
|
||||||
@@ -3358,6 +3349,5 @@
|
|||||||
"memberPortalResourceDisabled": "Resource Disabled",
|
"memberPortalResourceDisabled": "Resource Disabled",
|
||||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||||
"memberPortalPrevious": "Previous",
|
"memberPortalPrevious": "Previous",
|
||||||
"memberPortalNext": "Next",
|
"memberPortalNext": "Next"
|
||||||
"httpSettings": "HTTP Settings"
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-7
@@ -5,13 +5,7 @@ const withNextIntl = createNextIntlPlugin();
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
transpilePackages: ["@novnc/novnc"],
|
reactCompiler: true,
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true
|
|
||||||
},
|
|
||||||
experimental: {
|
|
||||||
reactCompiler: true
|
|
||||||
},
|
|
||||||
output: "standalone"
|
output: "standalone"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
+2187
-3393
File diff suppressed because it is too large
Load Diff
+52
-56
@@ -32,16 +32,13 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@aws-sdk/client-s3": "3.1011.0",
|
"@aws-sdk/client-s3": "3.1047.0",
|
||||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
"@faker-js/faker": "10.4.0",
|
||||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
"@headlessui/react": "2.2.10",
|
||||||
"@faker-js/faker": "10.3.0",
|
|
||||||
"@headlessui/react": "2.2.9",
|
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "2.0.2",
|
||||||
"@novnc/novnc": "^1.7.0",
|
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@radix-ui/react-avatar": "1.1.11",
|
"@radix-ui/react-avatar": "1.1.11",
|
||||||
@@ -62,19 +59,17 @@
|
|||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-toast": "1.2.15",
|
"@radix-ui/react-toast": "1.2.15",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@react-email/components": "1.0.8",
|
"@react-email/body": "0.3.0",
|
||||||
"@react-email/render": "2.0.4",
|
"@react-email/components": "1.0.12",
|
||||||
"@react-email/tailwind": "2.0.5",
|
"@react-email/render": "2.0.8",
|
||||||
|
"@react-email/tailwind": "2.0.7",
|
||||||
"@simplewebauthn/browser": "13.3.0",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@simplewebauthn/server": "13.3.0",
|
"@simplewebauthn/server": "13.3.0",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.100.10",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
|
||||||
"@xterm/xterm": "^6.0.0",
|
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.16.1",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
"canvas-confetti": "1.9.4",
|
"canvas-confetti": "1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
@@ -86,76 +81,76 @@
|
|||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.3.0",
|
"express-rate-limit": "8.5.2",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.10.0",
|
"ioredis": "5.10.1",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "0.577.0",
|
"lucide-react": "0.577.0",
|
||||||
"maxmind": "5.0.5",
|
"maxmind": "5.0.6",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.15",
|
"next": "16.2.6",
|
||||||
"next-intl": "4.8.3",
|
"next-intl": "4.12.0",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "8.0.7",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.20.0",
|
"pg": "8.20.0",
|
||||||
"posthog-node": "5.28.0",
|
"posthog-node": "5.34.1",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.6",
|
||||||
"react-day-picker": "9.14.0",
|
"react-day-picker": "9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.6",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.2",
|
"react-hook-form": "7.75.0",
|
||||||
"react-icons": "5.6.0",
|
"react-icons": "5.6.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "3.8.1",
|
||||||
"reodotdev": "1.1.0",
|
"reodotdev": "1.1.0",
|
||||||
"resend": "6.9.2",
|
"resend": "6.12.3",
|
||||||
"semver": "7.7.4",
|
"semver": "7.8.0",
|
||||||
"sshpk": "1.18.0",
|
"sshpk": "1.18.0",
|
||||||
"stripe": "20.4.1",
|
"stripe": "20.4.1",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"use-debounce": "10.1.0",
|
"use-debounce": "10.1.1",
|
||||||
"uuid": "13.0.0",
|
"uuid": "14.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
"winston": "3.19.0",
|
"winston": "3.19.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.19.0",
|
"ws": "8.20.1",
|
||||||
"yaml": "2.8.3",
|
"yaml": "2.9.0",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "4.3.6",
|
"zod": "4.4.3",
|
||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.54.1",
|
"@dotenvx/dotenvx": "1.66.0",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/preview-server": "5.2.10",
|
"@react-email/ui": "^6.1.4",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.3.0",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.100.10",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/crypto-js": "4.2.2",
|
"@types/crypto-js": "4.2.2",
|
||||||
"@types/d3": "7.4.3",
|
"@types/d3": "7.4.3",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.18.2",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "25.3.5",
|
"@types/node": "25.8.0",
|
||||||
"@types/nodemailer": "7.0.11",
|
"@types/nodemailer": "8.0.0",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.18.0",
|
"@types/pg": "8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
@@ -166,21 +161,22 @@
|
|||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.0",
|
||||||
"esbuild-node-externals": "1.20.1",
|
"esbuild-node-externals": "1.22.0",
|
||||||
"eslint": "10.0.3",
|
"eslint": "10.3.0",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.2.6",
|
||||||
"postcss": "8.5.8",
|
"postcss": "8.5.14",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.3",
|
||||||
"react-email": "5.2.10",
|
"react-email": "6.1.4",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.3.0",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.17",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.22.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.56.1"
|
"typescript-eslint": "8.59.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.0",
|
||||||
"dompurify": "3.3.2"
|
"dompurify": "3.4.0",
|
||||||
|
"postcss": "8.5.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,12 +158,7 @@ export enum ActionsEnum {
|
|||||||
createHealthCheck = "createHealthCheck",
|
createHealthCheck = "createHealthCheck",
|
||||||
updateHealthCheck = "updateHealthCheck",
|
updateHealthCheck = "updateHealthCheck",
|
||||||
deleteHealthCheck = "deleteHealthCheck",
|
deleteHealthCheck = "deleteHealthCheck",
|
||||||
listHealthChecks = "listHealthChecks",
|
listHealthChecks = "listHealthChecks"
|
||||||
createBrowserGatewayTarget = "createBrowserGatewayTarget",
|
|
||||||
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
|
|
||||||
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
|
|
||||||
getBrowserGatewayTarget = "getBrowserGatewayTarget",
|
|
||||||
listBrowserGatewayTargets = "listBrowserGatewayTargets"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
@@ -580,24 +580,6 @@ export const trialNotifications = pgTable("trialNotifications", {
|
|||||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
|
|
||||||
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
authToken: varchar("authToken").notNull(),
|
|
||||||
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
|
|
||||||
destination: varchar("destination").notNull(),
|
|
||||||
destinationPort: integer("destinationPort").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -645,6 +627,3 @@ export type AlertEmailRecipients = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
export type BrowserGatewayTarget = InferSelectModel<
|
|
||||||
typeof browserGatewayTarget
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export const resources = pgTable("resources", {
|
|||||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||||
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
||||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||||
|
|
||||||
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
@@ -158,15 +159,7 @@ export const resources = pgTable("resources", {
|
|||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||||
wildcard: boolean("wildcard").notNull().default(false),
|
wildcard: boolean("wildcard").notNull().default(false)
|
||||||
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", {
|
export const labels = pgTable("labels", {
|
||||||
@@ -358,11 +351,8 @@ export const siteResources = pgTable("siteResources", {
|
|||||||
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
|
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
|
||||||
disableIcmp: boolean("disableIcmp").notNull().default(false),
|
disableIcmp: boolean("disableIcmp").notNull().default(false),
|
||||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||||
pamMode: varchar("pamMode", { length: 32 })
|
|
||||||
.$type<"passthrough" | "push">()
|
|
||||||
.default("passthrough"),
|
|
||||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||||
.$type<"site" | "remote" | "native">()
|
.$type<"site" | "remote">()
|
||||||
.default("site"),
|
.default("site"),
|
||||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
|
|||||||
@@ -588,26 +588,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
|||||||
sentAt: integer("sentAt").notNull()
|
sentAt: integer("sentAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
|
|
||||||
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
authToken: text("authToken").notNull(),
|
|
||||||
type: text("type").notNull(), // "ssh", "rdp", "vnc"
|
|
||||||
destination: text("destination").notNull(),
|
|
||||||
destinationPort: integer("destinationPort").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -647,6 +627,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
|||||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
export type BrowserGatewayTarget = InferSelectModel<
|
|
||||||
typeof browserGatewayTarget
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -180,15 +180,7 @@ export const resources = sqliteTable("resources", {
|
|||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
||||||
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", {
|
export const labels = sqliteTable("labels", {
|
||||||
@@ -394,11 +386,8 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||||
pamMode: text("pamMode")
|
|
||||||
.$type<"passthrough" | "push">()
|
|
||||||
.default("passthrough"),
|
|
||||||
authDaemonMode: text("authDaemonMode")
|
authDaemonMode: text("authDaemonMode")
|
||||||
.$type<"site" | "remote" | "native">()
|
.$type<"site" | "remote">()
|
||||||
.default("site"),
|
.default("site"),
|
||||||
domainId: text("domainId").references(() => domains.domainId, {
|
domainId: text("domainId").references(() => domains.domainId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
#! /usr/bin/env node
|
#! /usr/bin/env node
|
||||||
import "./extendZod.ts";
|
import "./extendZod";
|
||||||
|
|
||||||
import { runSetupFunctions } from "./setup";
|
import { runSetupFunctions } from "./setup";
|
||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
|
|||||||
@@ -154,8 +154,19 @@ class AdaptiveCache {
|
|||||||
keys(): string[] {
|
keys(): string[] {
|
||||||
return localCache.keys();
|
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 singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
|
export const regionalCache = cache; // Alias for compatability with the private version
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
||||||
|
|
||||||
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +39,11 @@ import {
|
|||||||
removePeerData,
|
removePeerData,
|
||||||
removeTargets as removeSubnetProxyTargets
|
removeTargets as removeSubnetProxyTargets
|
||||||
} from "@server/routers/client/targets";
|
} 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(
|
export async function getClientSiteResourceAccess(
|
||||||
siteResource: SiteResource,
|
siteResource: SiteResource,
|
||||||
@@ -161,6 +166,23 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
pubKey: string | null;
|
pubKey: string | null;
|
||||||
subnet: 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(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
||||||
@@ -539,6 +561,29 @@ 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()) {
|
for (const client of clientsToProcess.values()) {
|
||||||
// UPDATE THE NEWT
|
// UPDATE THE NEWT
|
||||||
if (!client.subnet || !client.pubKey) {
|
if (!client.subnet || !client.pubKey) {
|
||||||
@@ -582,7 +627,14 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAdd) {
|
if (isAdd) {
|
||||||
// TODO: if we are in jit mode here should we really be sending this?
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -600,9 +652,24 @@ async function handleMessagesForSiteClients(
|
|||||||
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(exitNodeJobs);
|
Promise.all(exitNodeJobs).catch((error) => {
|
||||||
await Promise.all(newtJobs); // do the servers first to make sure they are ready?
|
logger.error(
|
||||||
await Promise.all(olmJobs);
|
`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
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PeerDestination {
|
interface PeerDestination {
|
||||||
@@ -885,6 +952,17 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
export async function rebuildClientAssociationsFromClient(
|
export async function rebuildClientAssociationsFromClient(
|
||||||
client: Client,
|
client: Client,
|
||||||
trx: Transaction | typeof db = db
|
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> {
|
): Promise<void> {
|
||||||
let newSiteResourceIds: number[] = [];
|
let newSiteResourceIds: number[] = [];
|
||||||
|
|
||||||
@@ -1157,6 +1235,12 @@ async function handleMessagesForClientSites(
|
|||||||
const olmJobs: Promise<any>[] = [];
|
const olmJobs: Promise<any>[] = [];
|
||||||
const exitNodeJobs: 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) {
|
for (const siteData of sitesData) {
|
||||||
const site = siteData.sites;
|
const site = siteData.sites;
|
||||||
const exitNode = siteData.exitNodes;
|
const exitNode = siteData.exitNodes;
|
||||||
@@ -1217,7 +1301,14 @@ async function handleMessagesForClientSites(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: if we are in jit mode here should we really be sending this?
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -1245,9 +1336,24 @@ async function handleMessagesForClientSites(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(exitNodeJobs);
|
Promise.all(exitNodeJobs).catch((error) => {
|
||||||
await Promise.all(newtJobs);
|
logger.error(
|
||||||
await Promise.all(olmJobs);
|
`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
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMessagesForClientResources(
|
async function handleMessagesForClientResources(
|
||||||
@@ -1528,3 +1634,195 @@ async function handleMessagesForClientResources(
|
|||||||
|
|
||||||
await Promise.all([...proxyJobs, ...olmJobs]);
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function getFirstString(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value) && typeof value[0] === "string") {
|
||||||
|
return value[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, logsDb, statusHistory } from "@server/db";
|
import { db, logsDb, statusHistory } from "@server/db";
|
||||||
import { and, eq, gte, asc } from "drizzle-orm";
|
import { and, eq, gte, asc } from "drizzle-orm";
|
||||||
import cache from "@server/lib/cache";
|
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||||
|
|
||||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export async function invalidateStatusHistoryCache(
|
|||||||
entityId: number
|
entityId: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
||||||
const keys = cache.keys().filter((k) => k.startsWith(prefix));
|
const keys = await cache.keysWithPrefix(prefix);
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await cache.del(keys);
|
await cache.del(keys);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resourceAccessToken, resources, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyAccessTokenAccess(
|
export async function verifyApiKeyAccessTokenAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,7 @@ export async function verifyApiKeyAccessTokenAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const accessTokenId = req.params.accessTokenId;
|
const accessTokenId = getFirstString(req.params.accessTokenId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,6 +21,12 @@ export async function verifyApiKeyAccessTokenAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!accessTokenId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [accessToken] = await db
|
const [accessToken] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourceAccessToken)
|
.from(resourceAccessToken)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiKeys, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq, or } from "drizzle-orm";
|
import { and, eq, or } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyApiKeyAccess(
|
export async function verifyApiKeyApiKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,8 +15,10 @@ export async function verifyApiKeyApiKeyAccess(
|
|||||||
const { apiKey: callerApiKey } = req;
|
const { apiKey: callerApiKey } = req;
|
||||||
|
|
||||||
const apiKeyId =
|
const apiKeyId =
|
||||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
getFirstString(req.params.apiKeyId) ||
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.body.apiKeyId) ||
|
||||||
|
getFirstString(req.query.apiKeyId);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!callerApiKey) {
|
if (!callerApiKey) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db, domains, orgDomains, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyDomainAccess(
|
export async function verifyApiKeyDomainAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,8 +13,10 @@ export async function verifyApiKeyDomainAccess(
|
|||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const domainId =
|
const domainId =
|
||||||
req.params.domainId || req.body.domainId || req.query.domainId;
|
getFirstString(req.params.domainId) ||
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.body.domainId) ||
|
||||||
|
getFirstString(req.query.domainId);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -27,6 +30,12 @@ export async function verifyApiKeyDomainAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (apiKey.isRoot) {
|
if (apiKey.isRoot) {
|
||||||
// Root keys can access any domain in any org
|
// Root keys can access any domain in any org
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { idp, idpOrg, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyIdpAccess(
|
export async function verifyApiKeyIdpAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,8 +13,12 @@ export async function verifyApiKeyIdpAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
|
const idpIdRaw =
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.params.idpId) ||
|
||||||
|
getFirstString(req.body.idpId) ||
|
||||||
|
getFirstString(req.query.idpId);
|
||||||
|
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -27,7 +32,7 @@ export async function verifyApiKeyIdpAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idpId) {
|
if (Number.isNaN(idpId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyOrgAccess(
|
export async function verifyApiKeyOrgAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,7 @@ export async function verifyApiKeyOrgAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKeyId = req.apiKey?.apiKeyId;
|
const apiKeyId = req.apiKey?.apiKeyId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKeyId) {
|
if (!apiKeyId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -45,7 +46,7 @@ export async function verifyApiKeyOrgAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!req.apiKeyOrg) {
|
if (!req.apiKeyOrg) {
|
||||||
next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"Key does not have access to this organization"
|
"Key does not have access to this organization"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { siteResources, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeySiteResourceAccess(
|
export async function verifyApiKeySiteResourceAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,8 @@ export async function verifyApiKeySiteResourceAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const siteResourceId = parseInt(req.params.siteResourceId);
|
const siteResourceIdRaw = getFirstString(req.params.siteResourceId);
|
||||||
|
const siteResourceId = Number.parseInt(siteResourceIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,7 +22,7 @@ export async function verifyApiKeySiteResourceAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!siteResourceId) {
|
if (Number.isNaN(siteResourceId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resources, targets, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyTargetAccess(
|
export async function verifyApiKeyTargetAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,8 @@ export async function verifyApiKeyTargetAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const targetId = parseInt(req.params.targetId);
|
const targetIdRaw = getFirstString(req.params.targetId);
|
||||||
|
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,7 +22,7 @@ export async function verifyApiKeyTargetAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNaN(targetId)) {
|
if (Number.isNaN(targetId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyAccessTokenAccess(
|
export async function verifyAccessTokenAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,7 +15,7 @@ export async function verifyAccessTokenAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const accessTokenId = req.params.accessTokenId;
|
const accessTokenId = getFirstString(req.params.accessTokenId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -22,6 +23,12 @@ export async function verifyAccessTokenAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!accessTokenId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [accessToken] = await db
|
const [accessToken] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourceAccessToken)
|
.from(resourceAccessToken)
|
||||||
@@ -87,7 +94,7 @@ export async function verifyAccessTokenAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"User does not have access to this organization"
|
"User does not have access to this organization"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyAccess(
|
export async function verifyApiKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,9 +15,24 @@ export async function verifyApiKeyAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const apiKeyId =
|
const apiKeyIdFromParams = getFirstString(req.params?.apiKeyId);
|
||||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
const apiKeyIdFromBody = getFirstString(req.body?.apiKeyId);
|
||||||
const orgId = req.params.orgId;
|
|
||||||
|
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);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -104,10 +120,7 @@ export async function verifyApiKeyAccess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||||
req.userOrg.userId,
|
|
||||||
orgId
|
|
||||||
);
|
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyDomainAccess(
|
export async function verifyDomainAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,9 +15,8 @@ export async function verifyDomainAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const domainId =
|
const domainId = getFirstString(req.params.domainId);
|
||||||
req.params.domainId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const orgId = req.params.orgId;
|
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -62,10 +62,7 @@ export async function verifyDomainAccess(
|
|||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||||
eq(userOrgs.userId, userId),
|
|
||||||
eq(userOrgs.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
req.userOrg = userOrgRole[0];
|
req.userOrg = userOrgRole[0];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyLimits(
|
export async function verifyLimits(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,7 +14,10 @@ export async function verifyLimits(
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.orgId;
|
const orgId =
|
||||||
|
req.userOrgId ||
|
||||||
|
req.apiKeyOrg?.orgId ||
|
||||||
|
getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!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
|
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyOrgAccess(
|
export async function verifyOrgAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,7 +14,7 @@ export async function verifyOrgAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
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 { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifySiteProvisioningKeyAccess(
|
export async function verifySiteProvisioningKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,8 +19,10 @@ export async function verifySiteProvisioningKeyAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const siteProvisioningKeyId = req.params.siteProvisioningKeyId;
|
const siteProvisioningKeyId = getFirstString(
|
||||||
const orgId = req.params.orgId;
|
req.params.siteProvisioningKeyId
|
||||||
|
);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -80,10 +88,7 @@ export async function verifySiteProvisioningKeyAccess(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userOrgs.userId, userId),
|
eq(userOrgs.userId, userId),
|
||||||
eq(
|
eq(userOrgs.orgId, row.siteProvisioningKeyOrg.orgId)
|
||||||
userOrgs.orgId,
|
|
||||||
row.siteProvisioningKeyOrg.orgId
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyTargetAccess(
|
export async function verifyTargetAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,7 +15,8 @@ export async function verifyTargetAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const targetId = parseInt(req.params.targetId);
|
const targetIdRaw = getFirstString(req.params.targetId);
|
||||||
|
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { userOrgs } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyUserIsOrgOwner(
|
export async function verifyUserIsOrgOwner(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -11,7 +12,7 @@ export async function verifyUserIsOrgOwner(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -780,9 +780,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// logger.debug(
|
logger.debug(
|
||||||
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||||
// );
|
);
|
||||||
|
|
||||||
for (const domain of allDomains) {
|
for (const domain of allDomains) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+145
-1
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { redisManager } from "@server/private/lib/redis";
|
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
||||||
|
|
||||||
// Create local cache with maxKeys limit to prevent memory leaks
|
// Create local cache with maxKeys limit to prevent memory leaks
|
||||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||||
@@ -298,3 +298,147 @@ class AdaptiveCache {
|
|||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regional adaptive cache backed by the in-cluster Redis instance.
|
||||||
|
* Falls back to a local NodeCache when the regional Redis is unavailable.
|
||||||
|
* Use this for data that is regional in nature (e.g. status history) so
|
||||||
|
* reads are served from the same cluster the user is hitting.
|
||||||
|
*/
|
||||||
|
const regionalLocalCache = new NodeCache({
|
||||||
|
stdTTL: 3600,
|
||||||
|
checkperiod: 120,
|
||||||
|
maxKeys: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
class RegionalAdaptiveCache {
|
||||||
|
private useRedis(): boolean {
|
||||||
|
return (
|
||||||
|
regionalRedisManager.isRedisEnabled() &&
|
||||||
|
regionalRedisManager.getHealthStatus().isHealthy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: any, ttl?: number): Promise<boolean> {
|
||||||
|
const effectiveTtl = ttl === 0 ? undefined : ttl;
|
||||||
|
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
|
||||||
|
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
const success = await regionalRedisManager.set(
|
||||||
|
key,
|
||||||
|
serialized,
|
||||||
|
redisTtl
|
||||||
|
);
|
||||||
|
if (success) {
|
||||||
|
logger.debug(`[regional] Set key in Redis: ${key}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis set error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
|
||||||
|
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T = any>(key: string): Promise<T | undefined> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const value = await regionalRedisManager.get(key);
|
||||||
|
if (value !== null) {
|
||||||
|
logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
}
|
||||||
|
logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis get error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = regionalLocalCache.get<T>(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
logger.debug(`[regional] Cache hit in local cache: ${key}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`[regional] Cache miss in local cache: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string | string[]): Promise<number> {
|
||||||
|
const keys = Array.isArray(key) ? key : [key];
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
for (const k of keys) {
|
||||||
|
const success = await regionalRedisManager.del(k);
|
||||||
|
if (success) {
|
||||||
|
deletedCount++;
|
||||||
|
logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedCount === keys.length) return deletedCount;
|
||||||
|
deletedCount = 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[regional] Redis del error:`, error);
|
||||||
|
deletedCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
const count = regionalLocalCache.del(k);
|
||||||
|
if (count > 0) {
|
||||||
|
deletedCount++;
|
||||||
|
logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(key: string): Promise<boolean> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
const value = await regionalRedisManager.get(key);
|
||||||
|
return value !== null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[regional] Redis has error for key ${key}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return regionalLocalCache.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns keys matching the given prefix from whichever backend is active.
|
||||||
|
* Redis uses a KEYS scan; local cache filters in-memory keys.
|
||||||
|
*/
|
||||||
|
async keysWithPrefix(prefix: string): Promise<string[]> {
|
||||||
|
if (this.useRedis()) {
|
||||||
|
try {
|
||||||
|
return await regionalRedisManager.keys(`${prefix}*`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[regional] Redis keys error:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentBackend(): "redis" | "local" {
|
||||||
|
return this.useRedis() ? "redis" : "local";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regionalCache = new RegionalAdaptiveCache();
|
||||||
|
|||||||
@@ -73,6 +73,25 @@ export const privateConfigSchema = z
|
|||||||
.object({
|
.object({
|
||||||
rejectUnauthorized: z.boolean().optional().default(true)
|
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()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -855,3 +855,163 @@ class RedisManager {
|
|||||||
export const redisManager = new RedisManager();
|
export const redisManager = new RedisManager();
|
||||||
export const redis = redisManager.getClient();
|
export const redis = redisManager.getClient();
|
||||||
export default redisManager;
|
export default redisManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
|
||||||
|
* Connects only when `redis.regional_redis` is present in the private config
|
||||||
|
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
|
||||||
|
* caching of regionally-scoped data.
|
||||||
|
*/
|
||||||
|
class RegionalRedisManager {
|
||||||
|
private writeClient: Redis | null = null;
|
||||||
|
private readClient: Redis | null = null;
|
||||||
|
private isEnabled: boolean = false;
|
||||||
|
private isHealthy: boolean = false;
|
||||||
|
private connectionTimeout: number = 5000;
|
||||||
|
private commandTimeout: number = 5000;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (build === "oss") return;
|
||||||
|
|
||||||
|
const cfg = privateConfig.getRawPrivateConfig();
|
||||||
|
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
|
||||||
|
|
||||||
|
this.isEnabled = true;
|
||||||
|
this.initializeClients();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfig(): RedisOptions {
|
||||||
|
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
|
||||||
|
const opts: RedisOptions = {
|
||||||
|
host: r.host,
|
||||||
|
port: r.port,
|
||||||
|
password: r.password,
|
||||||
|
db: r.db
|
||||||
|
};
|
||||||
|
if (r.tls) {
|
||||||
|
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeClients(): void {
|
||||||
|
const cfg = this.getConfig();
|
||||||
|
const baseOpts = {
|
||||||
|
...cfg,
|
||||||
|
enableReadyCheck: false,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
keepAlive: 10000,
|
||||||
|
connectTimeout: this.connectionTimeout,
|
||||||
|
commandTimeout: this.commandTimeout
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.writeClient = new Redis(baseOpts);
|
||||||
|
// redis-1 (replica) handles reads; fall back to primary if not resolvable
|
||||||
|
this.readClient = new Redis({
|
||||||
|
...baseOpts,
|
||||||
|
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
|
||||||
|
// Derive replica hostname from the headless service pattern:
|
||||||
|
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
|
||||||
|
// If it doesn't look like a k8s service, just use the same host
|
||||||
|
return h + rest;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// For simplicity use same host for both; callers can always read from primary
|
||||||
|
// The real replica routing is handled by the StatefulSet headless service
|
||||||
|
this.readClient = this.writeClient;
|
||||||
|
|
||||||
|
this.writeClient.on("ready", () => {
|
||||||
|
logger.info("Regional Redis client ready");
|
||||||
|
this.isHealthy = true;
|
||||||
|
});
|
||||||
|
this.writeClient.on("error", (err) => {
|
||||||
|
logger.error("Regional Redis client error:", err);
|
||||||
|
this.isHealthy = false;
|
||||||
|
});
|
||||||
|
this.writeClient.on("reconnecting", () => {
|
||||||
|
logger.info("Regional Redis client reconnecting...");
|
||||||
|
this.isHealthy = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Regional Redis client initialized");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to initialize regional Redis client:", error);
|
||||||
|
this.isEnabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isRedisEnabled(): boolean {
|
||||||
|
return this.isEnabled && this.writeClient !== null && this.isHealthy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHealthStatus() {
|
||||||
|
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
ttl?: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
try {
|
||||||
|
if (ttl) {
|
||||||
|
await this.writeClient.setex(key, ttl, value);
|
||||||
|
} else {
|
||||||
|
await this.writeClient.set(key, value);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis SET error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(key: string): Promise<string | null> {
|
||||||
|
if (!this.isRedisEnabled() || !this.readClient) return null;
|
||||||
|
try {
|
||||||
|
return await this.readClient.get(key);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis GET error:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async del(key: string): Promise<boolean> {
|
||||||
|
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||||
|
try {
|
||||||
|
await this.writeClient.del(key);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis DEL error:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async keys(pattern: string): Promise<string[]> {
|
||||||
|
if (!this.isRedisEnabled() || !this.readClient) return [];
|
||||||
|
try {
|
||||||
|
return await this.readClient.keys(pattern);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Regional Redis KEYS error:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this.writeClient) {
|
||||||
|
await this.writeClient.quit();
|
||||||
|
this.writeClient = null;
|
||||||
|
}
|
||||||
|
this.readClient = null;
|
||||||
|
logger.info("Regional Redis client disconnected");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error disconnecting regional Redis client:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regionalRedisManager = new RegionalRedisManager();
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
browserGatewayTarget,
|
|
||||||
certificates,
|
certificates,
|
||||||
db,
|
db,
|
||||||
domainNamespaces,
|
domainNamespaces,
|
||||||
@@ -278,115 +277,6 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query browser gateway targets for this exit node
|
|
||||||
const browserGatewayRows = await db
|
|
||||||
.select({
|
|
||||||
// Resource fields
|
|
||||||
resourceId: resources.resourceId,
|
|
||||||
resourceName: resources.name,
|
|
||||||
fullDomain: resources.fullDomain,
|
|
||||||
ssl: resources.ssl,
|
|
||||||
subdomain: resources.subdomain,
|
|
||||||
domainId: resources.domainId,
|
|
||||||
enabled: resources.enabled,
|
|
||||||
wildcard: resources.wildcard,
|
|
||||||
domainCertResolver: domains.certResolver,
|
|
||||||
preferWildcardCert: domains.preferWildcardCert,
|
|
||||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
|
||||||
// Browser gateway target fields
|
|
||||||
browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId,
|
|
||||||
bgType: browserGatewayTarget.type,
|
|
||||||
// Site fields
|
|
||||||
siteId: sites.siteId,
|
|
||||||
siteType: sites.type,
|
|
||||||
siteOnline: sites.online,
|
|
||||||
subnet: sites.subnet,
|
|
||||||
siteExitNodeId: sites.exitNodeId
|
|
||||||
})
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
|
||||||
.innerJoin(
|
|
||||||
resources,
|
|
||||||
eq(resources.resourceId, browserGatewayTarget.resourceId)
|
|
||||||
)
|
|
||||||
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
|
||||||
.leftJoin(
|
|
||||||
domainNamespaces,
|
|
||||||
eq(domainNamespaces.domainId, resources.domainId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.enabled, true),
|
|
||||||
or(
|
|
||||||
eq(sites.exitNodeId, exitNodeId),
|
|
||||||
and(
|
|
||||||
isNull(sites.exitNodeId),
|
|
||||||
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
|
||||||
eq(sites.type, "local"),
|
|
||||||
sql`(${build != "saas" ? 1 : 0} = 1)`
|
|
||||||
)
|
|
||||||
),
|
|
||||||
inArray(sites.type, siteTypes)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Group browser gateway targets by resource
|
|
||||||
type BrowserGatewayResourceEntry = {
|
|
||||||
resourceId: number;
|
|
||||||
name: string;
|
|
||||||
fullDomain: string | null;
|
|
||||||
ssl: boolean | null;
|
|
||||||
subdomain: string | null;
|
|
||||||
domainId: string | null;
|
|
||||||
enabled: boolean | null;
|
|
||||||
wildcard: boolean | null;
|
|
||||||
domainCertResolver: string | null;
|
|
||||||
preferWildcardCert: boolean | null;
|
|
||||||
targets: {
|
|
||||||
browserGatewayTargetId: number;
|
|
||||||
bgType: string;
|
|
||||||
siteId: number;
|
|
||||||
siteType: string;
|
|
||||||
siteOnline: boolean | null;
|
|
||||||
subnet: string | null;
|
|
||||||
siteExitNodeId: number | null;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
const browserGatewayResourcesMap = new Map<
|
|
||||||
number,
|
|
||||||
BrowserGatewayResourceEntry
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const row of browserGatewayRows) {
|
|
||||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!browserGatewayResourcesMap.has(row.resourceId)) {
|
|
||||||
browserGatewayResourcesMap.set(row.resourceId, {
|
|
||||||
resourceId: row.resourceId,
|
|
||||||
name: sanitize(row.resourceName) || "",
|
|
||||||
fullDomain: row.fullDomain,
|
|
||||||
ssl: row.ssl,
|
|
||||||
subdomain: row.subdomain,
|
|
||||||
domainId: row.domainId,
|
|
||||||
enabled: row.enabled,
|
|
||||||
wildcard: row.wildcard,
|
|
||||||
domainCertResolver: row.domainCertResolver,
|
|
||||||
preferWildcardCert: row.preferWildcardCert,
|
|
||||||
targets: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
|
|
||||||
browserGatewayTargetId: row.browserGatewayTargetId,
|
|
||||||
bgType: row.bgType,
|
|
||||||
siteId: row.siteId,
|
|
||||||
siteType: row.siteType,
|
|
||||||
siteOnline: row.siteOnline,
|
|
||||||
subnet: row.subnet,
|
|
||||||
siteExitNodeId: row.siteExitNodeId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let siteResourcesWithFullDomain: {
|
let siteResourcesWithFullDomain: {
|
||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
fullDomain: string | null;
|
fullDomain: string | null;
|
||||||
@@ -434,12 +324,6 @@ export async function getTraefikConfig(
|
|||||||
domains.add(sr.fullDomain);
|
domains.add(sr.fullDomain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Include browser gateway resource domains
|
|
||||||
for (const bgResource of browserGatewayResourcesMap.values()) {
|
|
||||||
if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) {
|
|
||||||
domains.add(bgResource.fullDomain);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// get the valid certs for these domains
|
// get the valid certs for these domains
|
||||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||||
@@ -705,7 +589,7 @@ export async function getTraefikConfig(
|
|||||||
resource.ssl ? entrypointHttps : entrypointHttp
|
resource.ssl ? entrypointHttps : entrypointHttp
|
||||||
],
|
],
|
||||||
service: maintenanceServiceName,
|
service: maintenanceServiceName,
|
||||||
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `,
|
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||||
priority: 2001,
|
priority: 2001,
|
||||||
...(resource.ssl ? { tls } : {})
|
...(resource.ssl ? { tls } : {})
|
||||||
};
|
};
|
||||||
@@ -1041,185 +925,6 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Traefik config for browser gateway resources
|
|
||||||
const browserGatewayPort = 39999;
|
|
||||||
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
|
|
||||||
if (!bgResource.enabled) continue;
|
|
||||||
if (!bgResource.domainId) continue;
|
|
||||||
if (!bgResource.fullDomain) continue;
|
|
||||||
|
|
||||||
if (!config_output.http.routers) config_output.http.routers = {};
|
|
||||||
if (!config_output.http.services) config_output.http.services = {};
|
|
||||||
|
|
||||||
const fullDomain = bgResource.fullDomain;
|
|
||||||
const additionalMiddlewares =
|
|
||||||
config.getRawConfig().traefik.additional_middlewares || [];
|
|
||||||
const routerMiddlewares = [
|
|
||||||
badgerMiddlewareName,
|
|
||||||
...additionalMiddlewares
|
|
||||||
];
|
|
||||||
|
|
||||||
const hostRule = `Host(\`${fullDomain}\`)`;
|
|
||||||
|
|
||||||
// Build TLS config
|
|
||||||
let tls = {};
|
|
||||||
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
|
||||||
const domainParts = fullDomain.split(".");
|
|
||||||
let wildCard: string;
|
|
||||||
if (domainParts.length <= 2) {
|
|
||||||
wildCard = `*.${domainParts.join(".")}`;
|
|
||||||
} else {
|
|
||||||
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
|
||||||
}
|
|
||||||
if (!bgResource.subdomain) {
|
|
||||||
wildCard = fullDomain;
|
|
||||||
}
|
|
||||||
|
|
||||||
const globalDefaultResolver =
|
|
||||||
config.getRawConfig().traefik.cert_resolver;
|
|
||||||
const globalDefaultPreferWildcard =
|
|
||||||
config.getRawConfig().traefik.prefer_wildcard_cert;
|
|
||||||
const resolverName = bgResource.domainCertResolver
|
|
||||||
? bgResource.domainCertResolver.trim()
|
|
||||||
: globalDefaultResolver;
|
|
||||||
const preferWildcard =
|
|
||||||
bgResource.preferWildcardCert !== undefined &&
|
|
||||||
bgResource.preferWildcardCert !== null
|
|
||||||
? bgResource.preferWildcardCert
|
|
||||||
: globalDefaultPreferWildcard;
|
|
||||||
|
|
||||||
tls = {
|
|
||||||
certResolver: resolverName,
|
|
||||||
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const matchingCert = validCerts.find(
|
|
||||||
(cert) => cert.queriedDomain === fullDomain
|
|
||||||
);
|
|
||||||
if (!matchingCert) {
|
|
||||||
logger.debug(
|
|
||||||
`No matching certificate found for browser gateway domain: ${fullDomain}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
|
|
||||||
|
|
||||||
if (bgResource.ssl) {
|
|
||||||
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
|
|
||||||
config_output.http.routers![redirectRouterName] = {
|
|
||||||
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
|
|
||||||
middlewares: [redirectHttpsMiddlewareName],
|
|
||||||
service: bgUiServiceName,
|
|
||||||
rule: hostRule,
|
|
||||||
priority: 100
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect online sites for this resource (for any type)
|
|
||||||
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
|
|
||||||
|
|
||||||
// Group targets by type and generate per-type websocket routers and services
|
|
||||||
const typeMap = new Map<string, typeof bgResource.targets>();
|
|
||||||
for (const t of bgResource.targets) {
|
|
||||||
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
|
|
||||||
typeMap.get(t.bgType)!.push(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [bgType, typedTargets] of typeMap.entries()) {
|
|
||||||
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
|
|
||||||
const bgRouterName = `${bgKey}-router`;
|
|
||||||
const bgServiceName = `${bgKey}-service`;
|
|
||||||
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
|
|
||||||
|
|
||||||
const servers = typedTargets
|
|
||||||
.filter((t) => {
|
|
||||||
if (!t.siteOnline && anySiteOnline) return false;
|
|
||||||
if (t.siteType === "newt") return !!t.subnet;
|
|
||||||
return false; // browser gateway only supported on newt sites
|
|
||||||
})
|
|
||||||
.map((t) => ({
|
|
||||||
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
|
|
||||||
}))
|
|
||||||
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
|
|
||||||
|
|
||||||
config_output.http.routers![bgRouterName] = {
|
|
||||||
entryPoints: [
|
|
||||||
bgResource.ssl
|
|
||||||
? config.getRawConfig().traefik.https_entrypoint
|
|
||||||
: config.getRawConfig().traefik.http_entrypoint
|
|
||||||
],
|
|
||||||
middlewares: routerMiddlewares,
|
|
||||||
service: bgServiceName,
|
|
||||||
rule: bgRule,
|
|
||||||
priority: 110, // highest - websocket path takes precedence
|
|
||||||
...(bgResource.ssl ? { tls } : {})
|
|
||||||
};
|
|
||||||
|
|
||||||
config_output.http.services![bgServiceName] = {
|
|
||||||
loadBalancer: {
|
|
||||||
servers
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI: serve the browser gateway page from the internal pangolin instance.
|
|
||||||
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
|
|
||||||
// how the maintenance page rewrites everything to /maintenance-screen.
|
|
||||||
const primaryType = typeMap.keys().next().value as string;
|
|
||||||
const internalHost = config.getRawConfig().server.internal_hostname;
|
|
||||||
const internalPort = config.getRawConfig().server.next_port;
|
|
||||||
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
|
|
||||||
const entrypoint = bgResource.ssl
|
|
||||||
? config.getRawConfig().traefik.https_entrypoint
|
|
||||||
: config.getRawConfig().traefik.http_entrypoint;
|
|
||||||
|
|
||||||
if (!config_output.http.middlewares) {
|
|
||||||
config_output.http.middlewares = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
config_output.http.middlewares![uiRewriteMiddlewareName] = {
|
|
||||||
replacePathRegex: {
|
|
||||||
regex: "^/(.*)",
|
|
||||||
replacement: `/${primaryType}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
config_output.http.services![bgUiServiceName] = {
|
|
||||||
loadBalancer: {
|
|
||||||
servers: [
|
|
||||||
{
|
|
||||||
url: `http://${internalHost}:${internalPort}`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Assets router at higher priority so /_next files load without rewrite
|
|
||||||
config_output.http.routers![
|
|
||||||
`bg-r${bgResource.resourceId}-assets-router`
|
|
||||||
] = {
|
|
||||||
entryPoints: [entrypoint],
|
|
||||||
middlewares: routerMiddlewares,
|
|
||||||
service: bgUiServiceName,
|
|
||||||
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
|
||||||
priority: 101,
|
|
||||||
...(bgResource.ssl ? { tls } : {})
|
|
||||||
};
|
|
||||||
|
|
||||||
// Catch-all router rewrites everything on the domain to /{primaryType}
|
|
||||||
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
|
|
||||||
{
|
|
||||||
entryPoints: [entrypoint],
|
|
||||||
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
|
|
||||||
service: bgUiServiceName,
|
|
||||||
rule: hostRule,
|
|
||||||
priority: 100,
|
|
||||||
...(bgResource.ssl ? { tls } : {})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||||
// Traefik generates TLS certificates for those domains even when no
|
// Traefik generates TLS certificates for those domains even when no
|
||||||
// matching resource exists yet.
|
// matching resource exists yet.
|
||||||
@@ -1335,7 +1040,7 @@ export async function getTraefikConfig(
|
|||||||
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
||||||
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
||||||
service: siteResourceServiceName,
|
service: siteResourceServiceName,
|
||||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||||
priority: 101,
|
priority: 101,
|
||||||
tls
|
tls
|
||||||
};
|
};
|
||||||
@@ -1438,7 +1143,7 @@ export async function getTraefikConfig(
|
|||||||
config.getRawConfig().traefik.https_entrypoint
|
config.getRawConfig().traefik.https_entrypoint
|
||||||
],
|
],
|
||||||
service: "landing-service",
|
service: "landing-service",
|
||||||
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||||
priority: 203,
|
priority: 203,
|
||||||
tls: tls
|
tls: tls
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyCertificateAccess(
|
export async function verifyCertificateAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -27,11 +28,43 @@ export async function verifyCertificateAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Assume user/org access is already verified
|
// Assume user/org access is already verified
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const certId =
|
|
||||||
req.params.certId || req.body?.certId || req.query?.certId;
|
const certIdFromParams = getFirstString(req.params?.certId);
|
||||||
let domainId =
|
const certIdFromBody = getFirstString(req.body?.certId);
|
||||||
req.params.domainId || req.body?.domainId || req.query?.domainId;
|
|
||||||
|
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;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -65,7 +98,7 @@ export async function verifyCertificateAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
domainId = cert.domainId;
|
domainId = cert.domainId ?? undefined;
|
||||||
if (!domainId) {
|
if (!domainId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyIdpAccess(
|
export async function verifyIdpAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -25,8 +26,12 @@ export async function verifyIdpAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
|
const idpIdRaw =
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.params.idpId) ||
|
||||||
|
getFirstString(req.body?.idpId) ||
|
||||||
|
getFirstString(req.query?.idpId);
|
||||||
|
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -40,7 +45,7 @@ export async function verifyIdpAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idpId) {
|
if (Number.isNaN(idpId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyRemoteExitNodeAccess(
|
export async function verifyRemoteExitNodeAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -25,11 +26,11 @@ export async function verifyRemoteExitNodeAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId; // Assuming you have user information in the request
|
const userId = req.user!.userId; // Assuming you have user information in the request
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const remoteExitNodeId =
|
const remoteExitNodeId =
|
||||||
req.params.remoteExitNodeId ||
|
getFirstString(req.params.remoteExitNodeId) ||
|
||||||
req.body.remoteExitNodeId ||
|
getFirstString(req.body?.remoteExitNodeId) ||
|
||||||
req.query.remoteExitNodeId;
|
getFirstString(req.query?.remoteExitNodeId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -37,6 +38,15 @@ export async function verifyRemoteExitNodeAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId || !remoteExitNodeId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid organization or remote exit node ID"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [remoteExitNode] = await db
|
const [remoteExitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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";
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
browserGatewayTarget,
|
|
||||||
BrowserGatewayTarget,
|
|
||||||
db,
|
|
||||||
newts,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
browserGatewayTargetId: z
|
|
||||||
.string()
|
|
||||||
.transform(Number)
|
|
||||||
.pipe(z.number().int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodySchema = z.strictObject({
|
|
||||||
siteId: z.number().int().positive().optional(),
|
|
||||||
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
|
|
||||||
destination: z.string().nonempty().optional(),
|
|
||||||
destinationPort: z.number().int().min(1).max(65535).optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "post",
|
|
||||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
|
||||||
description: "Update a browser gateway target.",
|
|
||||||
tags: [OpenAPITags.Org],
|
|
||||||
request: {
|
|
||||||
params: paramsSchema,
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: bodySchema
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
responses: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function updateBrowserGatewayTarget(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ bgt: browserGatewayTarget, site: sites })
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
browserGatewayTarget.browserGatewayTargetId,
|
|
||||||
browserGatewayTargetId
|
|
||||||
),
|
|
||||||
eq(sites.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateValues: Partial<BrowserGatewayTarget> = {};
|
|
||||||
if (siteId !== undefined) updateValues.siteId = siteId;
|
|
||||||
if (type !== undefined) updateValues.type = type;
|
|
||||||
if (destination !== undefined) updateValues.destination = destination;
|
|
||||||
if (destinationPort !== undefined)
|
|
||||||
updateValues.destinationPort = destinationPort;
|
|
||||||
|
|
||||||
const [updated] = await db
|
|
||||||
.update(browserGatewayTarget)
|
|
||||||
.set(updateValues)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
browserGatewayTarget.browserGatewayTargetId,
|
|
||||||
browserGatewayTargetId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const targetSiteId = siteId ?? existing.bgt.siteId;
|
|
||||||
const [site] = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.siteId, targetSiteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (site && site.type === "newt") {
|
|
||||||
const [newt] = await db
|
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, targetSiteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (newt) {
|
|
||||||
await sendBrowserGatewayTargets(
|
|
||||||
newt.newtId,
|
|
||||||
[updated],
|
|
||||||
newt.version
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
|
|
||||||
|
|
||||||
return response<UpdateBrowserGatewayTargetResponse>(res, {
|
|
||||||
data: updated,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Browser gateway target updated successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to update browser gateway target"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,8 +31,8 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
|||||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
|
||||||
import * as labels from "#private/routers/labels";
|
import * as labels from "#private/routers/labels";
|
||||||
|
import * as client from "@server/routers/client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -831,47 +831,14 @@ authenticated.get(
|
|||||||
healthChecks.getHealthCheckStatusHistory
|
healthChecks.getHealthCheckStatusHistory
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.createBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
"/client/:clientId/verify-associations-cache",
|
||||||
verifyValidLicense,
|
verifyClientAccess,
|
||||||
verifyOrgAccess,
|
client.verifyClientAssociationsCache
|
||||||
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
|
|
||||||
browserGatewayTarget.listBrowserGatewayTargets
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.getBrowserGatewayTarget
|
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
"/client/:clientId/rebuild-associations-cache",
|
||||||
verifyValidLicense,
|
verifyClientAccess,
|
||||||
verifyOrgAccess,
|
client.rebuildClientAssociationsCacheRoute
|
||||||
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,40 +16,44 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { response as sendResponse } from "@server/lib/response";
|
import { response as sendResponse } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
||||||
|
|
||||||
export interface CreateNewLicenseResponse {
|
export interface CreateNewLicenseResponse {
|
||||||
data: Data
|
data: Data;
|
||||||
success: boolean
|
success: boolean;
|
||||||
error: boolean
|
error: boolean;
|
||||||
message: string
|
message: string;
|
||||||
status: number
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Data {
|
export interface Data {
|
||||||
licenseKey: LicenseKey
|
licenseKey: LicenseKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LicenseKey {
|
export interface LicenseKey {
|
||||||
id: number
|
id: number;
|
||||||
instanceName: any
|
instanceName: any;
|
||||||
instanceId: string
|
instanceId: string;
|
||||||
licenseKey: string
|
licenseKey: string;
|
||||||
tier: string
|
tier: string;
|
||||||
type: string
|
type: string;
|
||||||
quantity: number
|
quantity: number;
|
||||||
quantity_2: number
|
quantity_2: number;
|
||||||
isValid: boolean
|
isValid: boolean;
|
||||||
updatedAt: string
|
updatedAt: string;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
expiresAt: string
|
expiresAt: string;
|
||||||
paidFor: boolean
|
paidFor: boolean;
|
||||||
orgId: string
|
orgId: string;
|
||||||
metadata: string
|
metadata: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> {
|
export async function createNewLicense(
|
||||||
|
orgId: string,
|
||||||
|
licenseData: any
|
||||||
|
): Promise<CreateNewLicenseResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
||||||
@@ -80,7 +84,7 @@ export async function generateNewLicense(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { response as sendResponse } from "@server/lib/response";
|
import { response as sendResponse } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import {
|
import {
|
||||||
GeneratedLicenseKey,
|
GeneratedLicenseKey,
|
||||||
@@ -55,7 +56,7 @@ export async function listSaasLicenseKeys(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import * as org from "#private/routers/org";
|
|||||||
import * as logs from "#private/routers/auditLogs";
|
import * as logs from "#private/routers/auditLogs";
|
||||||
import * as alertEvents from "#private/routers/alertEvents";
|
import * as alertEvents from "#private/routers/alertEvents";
|
||||||
import * as certificates from "#private/routers/certificates";
|
import * as certificates from "#private/routers/certificates";
|
||||||
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyApiKeyHasAction,
|
verifyApiKeyHasAction,
|
||||||
@@ -216,43 +215,3 @@ authenticated.delete(
|
|||||||
logActionAudit(ActionsEnum.removeUserRole),
|
logActionAudit(ActionsEnum.removeUserRole),
|
||||||
user.removeUserRole
|
user.removeUserRole
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.createBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
|
|
||||||
browserGatewayTarget.listBrowserGatewayTargets
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.getBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.updateBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.deleteBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, InferInsertModel } from "drizzle-orm";
|
import { eq, InferInsertModel } from "drizzle-orm";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
|
||||||
import config from "#private/lib/config";
|
import config from "#private/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -35,78 +34,9 @@ const paramsSchema = z.strictObject({
|
|||||||
|
|
||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
logoUrl: z
|
logoUrl: z
|
||||||
.union([
|
|
||||||
z.literal(""),
|
|
||||||
z
|
|
||||||
.string()
|
.string()
|
||||||
.superRefine(async (urlOrPath, ctx) => {
|
.optional()
|
||||||
const parseResult = z.url().safeParse(urlOrPath);
|
.transform((val) => (val === "" ? null : val)),
|
||||||
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),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
resourceTitle: z.string(),
|
resourceTitle: z.string(),
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ import {
|
|||||||
roundTripMessageTracker,
|
roundTripMessageTracker,
|
||||||
siteResources,
|
siteResources,
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
userOrgs,
|
userOrgs
|
||||||
sites
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||||
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
||||||
@@ -49,8 +48,7 @@ const bodySchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
publicKey: z.string().nonempty(),
|
publicKey: z.string().nonempty(),
|
||||||
resourceId: z.number().int().positive().optional(),
|
resourceId: z.number().int().positive().optional(),
|
||||||
resource: z.string().nonempty().optional(), // this is either the nice id or the alias
|
resource: z.string().nonempty().optional() // this is either the nice id or the alias
|
||||||
username: z.string().nonempty().optional()
|
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -65,19 +63,19 @@ const bodySchema = z
|
|||||||
);
|
);
|
||||||
|
|
||||||
export type SignSshKeyResponse = {
|
export type SignSshKeyResponse = {
|
||||||
certificate?: string;
|
certificate: string;
|
||||||
messageIds: number[];
|
messageIds: number[];
|
||||||
messageId?: number;
|
messageId: number;
|
||||||
sshUsername: string;
|
sshUsername: string;
|
||||||
sshHost: string;
|
sshHost: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
siteIds: number[];
|
siteIds: number[];
|
||||||
siteId: number;
|
siteId: number;
|
||||||
keyId?: string;
|
keyId: string;
|
||||||
validPrincipals?: string[];
|
validPrincipals: string[];
|
||||||
validAfter?: string;
|
validAfter: string;
|
||||||
validBefore?: string;
|
validBefore: string;
|
||||||
expiresIn?: number;
|
expiresIn: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// registry.registerPath({
|
// registry.registerPath({
|
||||||
@@ -128,8 +126,7 @@ export async function signSshKey(
|
|||||||
const {
|
const {
|
||||||
publicKey,
|
publicKey,
|
||||||
resourceId,
|
resourceId,
|
||||||
resource: resourceQueryString,
|
resource: resourceQueryString
|
||||||
username
|
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
const roleIds = req.userOrgRoleIds ?? [];
|
const roleIds = req.userOrgRoleIds ?? [];
|
||||||
@@ -177,6 +174,101 @@ export async function signSshKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let usernameToUse;
|
||||||
|
if (!userOrg.pamUsername) {
|
||||||
|
if (req.user?.email) {
|
||||||
|
// Extract username from email (first part before @)
|
||||||
|
usernameToUse = req.user?.email
|
||||||
|
.split("@")[0]
|
||||||
|
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||||
|
if (!usernameToUse) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Unable to extract username from email"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (req.user?.username) {
|
||||||
|
usernameToUse = req.user.username;
|
||||||
|
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||||
|
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||||
|
if (!usernameToUse) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Username is not valid for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"User does not have a valid email or username for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefix with p-
|
||||||
|
usernameToUse = `p-${usernameToUse}`;
|
||||||
|
|
||||||
|
// check if we have a existing user in this org with the same
|
||||||
|
const [existingUserWithSameName] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
eq(userOrgs.pamUsername, usernameToUse)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUserWithSameName) {
|
||||||
|
let foundUniqueUsername = false;
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
||||||
|
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||||
|
|
||||||
|
const [existingUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
eq(userOrgs.pamUsername, candidateUsername)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
usernameToUse = candidateUsername;
|
||||||
|
foundUniqueUsername = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundUniqueUsername) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Unable to generate a unique username for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(userOrgs)
|
||||||
|
.set({ pamUsername: usernameToUse })
|
||||||
|
.where(
|
||||||
|
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
usernameToUse = userOrg.pamUsername;
|
||||||
|
}
|
||||||
|
|
||||||
// Get and decrypt the org's CA keys
|
// Get and decrypt the org's CA keys
|
||||||
const caKeys = await getOrgCAKeys(
|
const caKeys = await getOrgCAKeys(
|
||||||
orgId,
|
orgId,
|
||||||
@@ -269,128 +361,6 @@ export async function signSshKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sitesFromNetworks = await db
|
|
||||||
.select({ siteId: siteNetworks.siteId })
|
|
||||||
.from(siteNetworks)
|
|
||||||
.where(eq(siteNetworks.networkId, resource.networkId!));
|
|
||||||
|
|
||||||
const siteIds = sitesFromNetworks.map((site) => site.siteId);
|
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
const roleRows = await db
|
||||||
.select({
|
.select({
|
||||||
sshSudoCommands: roles.sshSudoCommands,
|
sshSudoCommands: roles.sshSudoCommands,
|
||||||
@@ -446,11 +416,17 @@ export async function signSshKey(
|
|||||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sites = await db
|
||||||
|
.select({ siteId: siteNetworks.siteId })
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(eq(siteNetworks.networkId, resource.networkId!));
|
||||||
|
|
||||||
|
const siteIds = sites.map((site) => site.siteId);
|
||||||
|
|
||||||
// Sign the public key
|
// Sign the public key
|
||||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||||
// only valid for 5 minutes
|
// only valid for 5 minutes
|
||||||
const validFor = 300n;
|
const validFor = 300n;
|
||||||
expiresIn = Number(validFor); // seconds
|
|
||||||
|
|
||||||
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||||
keyId: `${usernameToUse}@${resource.niceId}`,
|
keyId: `${usernameToUse}@${resource.niceId}`,
|
||||||
@@ -503,9 +479,7 @@ export async function signSshKey(
|
|||||||
messageId: message.messageId,
|
messageId: message.messageId,
|
||||||
orgId: orgId,
|
orgId: orgId,
|
||||||
agentPort: resource.authDaemonPort ?? 22123,
|
agentPort: resource.authDaemonPort ?? 22123,
|
||||||
authDaemonMode: resource.authDaemonMode, // site, remote, native where native is the pty mode
|
externalAuthDaemon: resource.authDaemonMode === "remote",
|
||||||
externalAuthDaemon:
|
|
||||||
resource.authDaemonMode === "remote", // keep this for backward compatibility but new newts are using the authDaemonMode field
|
|
||||||
agentHost: resource.destination,
|
agentHost: resource.destination,
|
||||||
caCert: caKeys.publicKeyOpenSSH,
|
caCert: caKeys.publicKeyOpenSSH,
|
||||||
username: usernameToUse,
|
username: usernameToUse,
|
||||||
@@ -519,82 +493,15 @@ export async function signSshKey(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} 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;
|
const expiresIn = Number(validFor); // seconds
|
||||||
if (
|
|
||||||
resource.authDaemonMode === "site" ||
|
let sshHost;
|
||||||
resource.authDaemonMode === "remote"
|
|
||||||
) {
|
|
||||||
if (resource.alias && resource.alias != "") {
|
if (resource.alias && resource.alias != "") {
|
||||||
sshHost = resource.alias;
|
sshHost = resource.alias;
|
||||||
} else {
|
} else {
|
||||||
sshHost = resource.destination;
|
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"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// get the site
|
|
||||||
const [site] = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.siteId, siteIds[0]))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!site) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Site associated with resource not found"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!site.address) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Site address not configured, unable to determine SSH host when in native mode"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// its the address but split off the cidr if there is one
|
|
||||||
sshHost = site.address.split("/")[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sshHost) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Unable to determine SSH host for the resource"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await logsDb.insert(actionAuditLog).values({
|
await logsDb.insert(actionAuditLog).values({
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
@@ -620,7 +527,7 @@ export async function signSshKey(
|
|||||||
: undefined,
|
: undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
resourceName: resource.name,
|
resourceName: resource.name,
|
||||||
siteIds: siteIds,
|
siteId: siteIds[0],
|
||||||
sshUsername: usernameToUse,
|
sshUsername: usernameToUse,
|
||||||
sshHost: sshHost
|
sshHost: sshHost
|
||||||
},
|
},
|
||||||
@@ -630,18 +537,18 @@ export async function signSshKey(
|
|||||||
|
|
||||||
return response<SignSshKeyResponse>(res, {
|
return response<SignSshKeyResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
certificate: cert?.certificate,
|
certificate: cert.certificate,
|
||||||
messageIds: messageIds,
|
messageIds: messageIds,
|
||||||
messageId: messageIds[0], // just pick the first one for backward compatibility with older olms
|
messageId: messageIds[0], // just pick the first one for backward compatibility
|
||||||
sshUsername: usernameToUse,
|
sshUsername: usernameToUse,
|
||||||
sshHost: sshHost, // just pick the first one for backward compatibility with older olms
|
sshHost: sshHost,
|
||||||
resourceId: resource.siteResourceId,
|
resourceId: resource.siteResourceId,
|
||||||
siteIds: siteIds,
|
siteIds: siteIds,
|
||||||
siteId: siteIds[0], // just pick the first one for backward compatibility with older olms
|
siteId: siteIds[0], // just pick the first one for backward compatibility
|
||||||
keyId: cert?.keyId,
|
keyId: cert.keyId,
|
||||||
validPrincipals: cert?.validPrincipals,
|
validPrincipals: cert.validPrincipals,
|
||||||
validAfter: cert?.validAfter.toISOString(),
|
validAfter: cert.validAfter.toISOString(),
|
||||||
validBefore: cert?.validBefore.toISOString(),
|
validBefore: cert.validBefore.toISOString(),
|
||||||
expiresIn
|
expiresIn
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { UserType } from "@server/types/UserTypes";
|
|||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
import { verifyTotpCode } from "@server/auth/totp";
|
import { verifyTotpCode } from "@server/auth/totp";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
// The RP ID is the domain name of your application
|
// The RP ID is the domain name of your application
|
||||||
const rpID = (() => {
|
const rpID = (() => {
|
||||||
@@ -406,7 +407,12 @@ export async function deleteSecurityKey(
|
|||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const { credentialId: encodedCredentialId } = req.params;
|
const encodedCredentialId = getFirstString(req.params.credentialId);
|
||||||
|
if (!encodedCredentialId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid credential ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
const credentialId = decodeURIComponent(encodedCredentialId);
|
const credentialId = decodeURIComponent(encodedCredentialId);
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ export * from "./listUserDevices";
|
|||||||
export * from "./updateClient";
|
export * from "./updateClient";
|
||||||
export * from "./getClient";
|
export * from "./getClient";
|
||||||
export * from "./createUserClient";
|
export * from "./createUserClient";
|
||||||
|
export * from "./verifyClientAssociationsCache";
|
||||||
|
export * from "./rebuildClientAssociationsCacheRoute";
|
||||||
|
|||||||
@@ -118,7 +118,27 @@ const listClientsSchema = z.object({
|
|||||||
description:
|
description:
|
||||||
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
|
"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() {
|
function queryClientsBase() {
|
||||||
@@ -210,8 +230,16 @@ export async function listClients(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { page, pageSize, online, query, status, sort_by, order } =
|
const {
|
||||||
parsedQuery.data;
|
page,
|
||||||
|
pageSize,
|
||||||
|
online,
|
||||||
|
query,
|
||||||
|
status,
|
||||||
|
sort_by,
|
||||||
|
order,
|
||||||
|
labels: labelFilter
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -298,6 +326,22 @@ export async function listClients(
|
|||||||
conditions.push(or(...filterAggregates));
|
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) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { clients } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { verifyClientAssociationsCache as verifyClientAssociationsCacheLib } from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/client/{clientId}/verify-associations-cache",
|
||||||
|
description:
|
||||||
|
"Read-only check of whether the client's site/site-resource association cache matches what the current permissions imply.",
|
||||||
|
tags: [OpenAPITags.Client],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function verifyClientAssociationsCache(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Client with ID ${clientId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await verifyClientAssociationsCacheLib(client);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: report,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: report.consistent
|
||||||
|
? "Client association cache is consistent"
|
||||||
|
: "Client association cache is INCONSISTENT",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to verify client association cache"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { domain } from "zod/v4/core/regexes";
|
|
||||||
|
|
||||||
const getDomainSchema = z.strictObject({
|
const getDomainSchema = z.strictObject({
|
||||||
domainId: z.string().optional(),
|
domainId: z.string().optional(),
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ internalRouter.get("/idp", idp.listIdps);
|
|||||||
|
|
||||||
internalRouter.get("/idp/:idpId", idp.getIdp);
|
internalRouter.get("/idp/:idpId", idp.getIdp);
|
||||||
|
|
||||||
internalRouter.get("/resource/browser-target", resource.getBrowserTarget);
|
|
||||||
|
|
||||||
// Gerbil routes
|
// Gerbil routes
|
||||||
const gerbilRouter = Router();
|
const gerbilRouter = Router();
|
||||||
internalRouter.use("/gerbil", gerbilRouter);
|
internalRouter.use("/gerbil", gerbilRouter);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
browserGatewayTarget,
|
|
||||||
BrowserGatewayTarget,
|
|
||||||
clients,
|
clients,
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache,
|
||||||
@@ -18,7 +16,6 @@ import logger from "@server/logger";
|
|||||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { decrypt } from "@server/lib/crypto";
|
|
||||||
import {
|
import {
|
||||||
formatEndpoint,
|
formatEndpoint,
|
||||||
generateSubnetProxyTargetV2,
|
generateSubnetProxyTargetV2,
|
||||||
@@ -236,11 +233,6 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
.from(targetHealthCheck)
|
.from(targetHealthCheck)
|
||||||
.where(eq(targetHealthCheck.siteId, siteId));
|
.where(eq(targetHealthCheck.siteId, siteId));
|
||||||
|
|
||||||
const allBrowserGatewayTargets = await db
|
|
||||||
.select()
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.where(eq(browserGatewayTarget.siteId, siteId));
|
|
||||||
|
|
||||||
const { tcpTargets, udpTargets } = allTargets.reduce(
|
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||||
(acc, target) => {
|
(acc, target) => {
|
||||||
// Filter out invalid targets
|
// Filter out invalid targets
|
||||||
@@ -312,22 +304,9 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
(target) => target !== null
|
(target) => target !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
const serverSecret = config.getRawConfig().server.secret!;
|
|
||||||
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
|
|
||||||
const decryptAuthToken = decrypt(t.authToken, serverSecret);
|
|
||||||
return {
|
|
||||||
id: t.browserGatewayTargetId,
|
|
||||||
type: t.type,
|
|
||||||
destination: t.destination,
|
|
||||||
destinationPort: t.destinationPort,
|
|
||||||
authToken: decryptAuthToken
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validHealthCheckTargets,
|
validHealthCheckTargets,
|
||||||
tcpTargets,
|
tcpTargets,
|
||||||
udpTargets,
|
udpTargets
|
||||||
browserGatewayTargets
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,13 +43,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
const siteId = newt.siteId;
|
const siteId = newt.siteId;
|
||||||
|
|
||||||
const {
|
const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } =
|
||||||
publicKey,
|
message.data;
|
||||||
pingResults,
|
|
||||||
newtVersion,
|
|
||||||
backwardsCompatible,
|
|
||||||
chainId
|
|
||||||
} = message.data;
|
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
logger.warn("Public key not provided");
|
logger.warn("Public key not provided");
|
||||||
return;
|
return;
|
||||||
@@ -196,12 +191,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.where(eq(newts.newtId, newt.newtId));
|
.where(eq(newts.newtId, newt.newtId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||||
tcpTargets,
|
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
||||||
udpTargets,
|
|
||||||
validHealthCheckTargets,
|
|
||||||
browserGatewayTargets
|
|
||||||
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||||
@@ -221,7 +212,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
tcp: tcpTargets
|
tcp: tcpTargets
|
||||||
},
|
},
|
||||||
healthCheckTargets: validHealthCheckTargets,
|
healthCheckTargets: validHealthCheckTargets,
|
||||||
browserGatewayTargets: browserGatewayTargets,
|
|
||||||
chainId: chainId
|
chainId: chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,12 +9,8 @@ import {
|
|||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
||||||
const {
|
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||||
tcpTargets,
|
await buildTargetConfigurationForNewtClient(site.siteId);
|
||||||
udpTargets,
|
|
||||||
validHealthCheckTargets,
|
|
||||||
browserGatewayTargets
|
|
||||||
} = await buildTargetConfigurationForNewtClient(site.siteId);
|
|
||||||
|
|
||||||
let exitNode: ExitNode | undefined;
|
let exitNode: ExitNode | undefined;
|
||||||
if (site.exitNodeId) {
|
if (site.exitNodeId) {
|
||||||
@@ -40,8 +36,7 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
|||||||
},
|
},
|
||||||
healthCheckTargets: validHealthCheckTargets,
|
healthCheckTargets: validHealthCheckTargets,
|
||||||
peers: peers,
|
peers: peers,
|
||||||
clientTargets: targets,
|
clientTargets: targets
|
||||||
browserGatewayTargets: browserGatewayTargets
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
|
import { Target, TargetHealthCheck } from "@server/db";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
import { decrypt } from "@server/lib/crypto";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
|
|
||||||
export async function addTargets(
|
export async function addTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
@@ -241,55 +239,3 @@ export async function removeTargets(
|
|||||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendBrowserGatewayTargets(
|
|
||||||
newtId: string,
|
|
||||||
targets: BrowserGatewayTarget[],
|
|
||||||
version?: string | null
|
|
||||||
) {
|
|
||||||
if (targets.length === 0) return;
|
|
||||||
|
|
||||||
const payload = targets.map((t) => {
|
|
||||||
const decryptAuthToken = decrypt(
|
|
||||||
t.authToken,
|
|
||||||
config.getRawConfig().server.secret!
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
id: t.browserGatewayTargetId,
|
|
||||||
resourceId: t.resourceId,
|
|
||||||
siteId: t.siteId,
|
|
||||||
type: t.type,
|
|
||||||
destination: t.destination,
|
|
||||||
destinationPort: t.destinationPort,
|
|
||||||
authToken: decryptAuthToken
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendToClient(
|
|
||||||
newtId,
|
|
||||||
{
|
|
||||||
type: "newt/browsergateway/add",
|
|
||||||
data: {
|
|
||||||
targets: payload
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeBrowserGatewayTarget(
|
|
||||||
newtId: string,
|
|
||||||
browserGatewayTargetId: number,
|
|
||||||
version?: string | null
|
|
||||||
) {
|
|
||||||
await sendToClient(
|
|
||||||
newtId,
|
|
||||||
{
|
|
||||||
type: "newt/browsergateway/remove",
|
|
||||||
data: {
|
|
||||||
ids: [browserGatewayTargetId]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import { getUniqueResourceName } from "@server/db/names";
|
import { getUniqueResourceName } from "@server/db/names";
|
||||||
import {
|
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
||||||
validateAndConstructDomain,
|
|
||||||
checkWildcardDomainConflict
|
|
||||||
} from "@server/lib/domainUtils";
|
|
||||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
@@ -43,12 +40,7 @@ const createHttpResourceSchema = z
|
|||||||
protocol: z.enum(["tcp", "udp"]),
|
protocol: z.enum(["tcp", "udp"]),
|
||||||
domainId: z.string(),
|
domainId: z.string(),
|
||||||
stickySession: z.boolean().optional(),
|
stickySession: z.boolean().optional(),
|
||||||
postAuthPath: z.string().nullable().optional(),
|
postAuthPath: z.string().nullable().optional()
|
||||||
browserAccessType: z.enum(["http", "ssh", "rdp", "vnc"]).optional(),
|
|
||||||
// SSH Settings
|
|
||||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
|
||||||
authDaemonPort: z.int().positive().optional(),
|
|
||||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional()
|
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -206,15 +198,7 @@ async function createHttpResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { name, domainId, postAuthPath } = parsedBody.data;
|
||||||
name,
|
|
||||||
domainId,
|
|
||||||
postAuthPath,
|
|
||||||
browserAccessType,
|
|
||||||
authDaemonPort,
|
|
||||||
authDaemonMode,
|
|
||||||
pamMode
|
|
||||||
} = parsedBody.data;
|
|
||||||
const subdomain = parsedBody.data.subdomain;
|
const subdomain = parsedBody.data.subdomain;
|
||||||
const stickySession = parsedBody.data.stickySession;
|
const stickySession = parsedBody.data.stickySession;
|
||||||
|
|
||||||
@@ -339,10 +323,6 @@ async function createHttpResource(
|
|||||||
name,
|
name,
|
||||||
subdomain: finalSubdomain,
|
subdomain: finalSubdomain,
|
||||||
http: true,
|
http: true,
|
||||||
browserAccessType: browserAccessType,
|
|
||||||
pamMode: pamMode,
|
|
||||||
authDaemonMode: authDaemonMode,
|
|
||||||
authDaemonPort: authDaemonPort,
|
|
||||||
protocol: "tcp",
|
protocol: "tcp",
|
||||||
ssl: true,
|
ssl: true,
|
||||||
stickySession: stickySession,
|
stickySession: stickySession,
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { browserGatewayTarget, db } from "@server/db";
|
|
||||||
import { resources, targets } from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { decrypt } from "@server/lib/crypto";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
|
|
||||||
const getBrowserTargetSchema = z
|
|
||||||
.object({
|
|
||||||
fullDomain: z.string().min(1, "fullDomain is required")
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export type GetBrowserTargetResponse = {
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
authToken: string;
|
|
||||||
orgId: string;
|
|
||||||
resourceId: number;
|
|
||||||
niceId: string;
|
|
||||||
pamMode: "passthrough" | "push" | null;
|
|
||||||
authDaemonMode: "site" | "remote" | "native" | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getBrowserTarget(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsed = getBrowserTargetSchema.safeParse(req.query);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsed.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { fullDomain } = parsed.data;
|
|
||||||
|
|
||||||
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
|
|
||||||
|
|
||||||
const [browserTarget] = await db
|
|
||||||
.select({
|
|
||||||
destination: browserGatewayTarget.destination,
|
|
||||||
destinationPort: browserGatewayTarget.destinationPort,
|
|
||||||
authToken: browserGatewayTarget.authToken,
|
|
||||||
resourceId: resources.resourceId,
|
|
||||||
niceId: resources.niceId,
|
|
||||||
orgId: resources.orgId,
|
|
||||||
pamMode: resources.pamMode,
|
|
||||||
authDaemonMode: resources.authDaemonMode
|
|
||||||
})
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.innerJoin(
|
|
||||||
resources,
|
|
||||||
eq(browserGatewayTarget.resourceId, resources.resourceId)
|
|
||||||
)
|
|
||||||
.where(eq(resources.fullDomain, fullDomain))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const decryptedAuthToken = decrypt(
|
|
||||||
browserTarget.authToken,
|
|
||||||
config.getRawConfig().server.secret!
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!browserTarget) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
"No resource found for this domain"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response<GetBrowserTargetResponse>(res, {
|
|
||||||
data: {
|
|
||||||
ip: browserTarget.destination,
|
|
||||||
port: browserTarget.destinationPort,
|
|
||||||
authToken: decryptedAuthToken,
|
|
||||||
pamMode: browserTarget.pamMode,
|
|
||||||
authDaemonMode: browserTarget.authDaemonMode,
|
|
||||||
orgId: browserTarget.orgId,
|
|
||||||
resourceId: browserTarget.resourceId,
|
|
||||||
niceId: browserTarget.niceId
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Browser target retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"An error occurred while retrieving the browser target"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function getUserResources(
|
export async function getUserResources(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -26,7 +27,7 @@ export async function getUserResources(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -35,6 +36,12 @@ 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
|
// Check user is in organization and get their role IDs
|
||||||
const [userOrg] = await db
|
const [userOrg] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -33,4 +33,3 @@ export * from "./removeUserFromResource";
|
|||||||
export * from "./listAllResourceNames";
|
export * from "./listAllResourceNames";
|
||||||
export * from "./removeEmailFromResourceWhitelist";
|
export * from "./removeEmailFromResourceWhitelist";
|
||||||
export * from "./getStatusHistory";
|
export * from "./getStatusHistory";
|
||||||
export * from "./getBrowserTarget";
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
browserGatewayTarget,
|
|
||||||
db,
|
db,
|
||||||
labels,
|
labels,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
@@ -72,7 +71,7 @@ const listResourcesSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
query: z.string().optional(),
|
query: z.string().optional(),
|
||||||
sort_by: z
|
sort_by: z
|
||||||
.enum(["name"])
|
.literal("name")
|
||||||
.optional()
|
.optional()
|
||||||
.catch(undefined)
|
.catch(undefined)
|
||||||
.openapi({
|
.openapi({
|
||||||
@@ -124,6 +123,26 @@ const listResourcesSchema = z.object({
|
|||||||
type: "integer",
|
type: "integer",
|
||||||
description:
|
description:
|
||||||
"When set, only resources that have at least one target on this site are returned"
|
"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"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,7 +165,6 @@ export type ResourceWithTargets = {
|
|||||||
headerAuthId: number | null;
|
headerAuthId: number | null;
|
||||||
wildcard: boolean;
|
wildcard: boolean;
|
||||||
health: string | null;
|
health: string | null;
|
||||||
browserAccessType: string | null;
|
|
||||||
targets: Array<{
|
targets: Array<{
|
||||||
targetId: number;
|
targetId: number;
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -185,8 +203,7 @@ function queryResourcesBase() {
|
|||||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||||
headerAuthExtendedCompatibilityId:
|
headerAuthExtendedCompatibilityId:
|
||||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
||||||
health: resources.health,
|
health: resources.health
|
||||||
browserAccessType: resources.browserAccessType
|
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
@@ -264,7 +281,8 @@ export async function listResources(
|
|||||||
healthStatus,
|
healthStatus,
|
||||||
sort_by,
|
sort_by,
|
||||||
order,
|
order,
|
||||||
siteId
|
siteId,
|
||||||
|
labels: labelFilter
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||||
@@ -382,6 +400,23 @@ export async function listResources(
|
|||||||
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||||
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
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) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
@@ -485,30 +520,6 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
||||||
|
|
||||||
const allBgTargetSites =
|
|
||||||
resourceIdList.length === 0
|
|
||||||
? []
|
|
||||||
: await db
|
|
||||||
.select({
|
|
||||||
resourceId: browserGatewayTarget.resourceId,
|
|
||||||
siteId: browserGatewayTarget.siteId,
|
|
||||||
siteName: sites.name,
|
|
||||||
siteNiceId: sites.niceId,
|
|
||||||
siteOnline: sites.online,
|
|
||||||
siteType: sites.type
|
|
||||||
})
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
browserGatewayTarget.resourceId,
|
|
||||||
resourceIdList
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
sites,
|
|
||||||
eq(sites.siteId, browserGatewayTarget.siteId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// avoids TS issues with reduce/never[]
|
// avoids TS issues with reduce/never[]
|
||||||
const map = new Map<number, ResourceWithTargets>();
|
const map = new Map<number, ResourceWithTargets>();
|
||||||
|
|
||||||
@@ -529,7 +540,6 @@ export async function listResources(
|
|||||||
protocol: row.protocol,
|
protocol: row.protocol,
|
||||||
proxyPort: row.proxyPort,
|
proxyPort: row.proxyPort,
|
||||||
wildcard: row.wildcard,
|
wildcard: row.wildcard,
|
||||||
browserAccessType: row.browserAccessType,
|
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
domainId: row.domainId,
|
domainId: row.domainId,
|
||||||
headerAuthId: row.headerAuthId,
|
headerAuthId: row.headerAuthId,
|
||||||
@@ -573,21 +583,6 @@ export async function listResources(
|
|||||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const bgRaw = allBgTargetSites.filter(
|
|
||||||
(t) => t.resourceId === entry.resourceId
|
|
||||||
);
|
|
||||||
for (const t of bgRaw) {
|
|
||||||
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const isLocal = t.siteType === "local";
|
|
||||||
siteById.set(t.siteId, {
|
|
||||||
siteId: t.siteId,
|
|
||||||
siteName: t.siteName ?? "",
|
|
||||||
siteNiceId: t.siteNiceId ?? "",
|
|
||||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
entry.sites = Array.from(siteById.values());
|
entry.sites = Array.from(siteById.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ import {
|
|||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { OpenAPITags } from "@server/openApi";
|
import { OpenAPITags } from "@server/openApi";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import {
|
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
||||||
validateAndConstructDomain,
|
|
||||||
checkWildcardDomainConflict
|
|
||||||
} from "@server/lib/domainUtils";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
@@ -71,12 +68,7 @@ const updateHttpResourceBodySchema = z
|
|||||||
maintenanceTitle: z.string().max(255).nullable().optional(),
|
maintenanceTitle: z.string().max(255).nullable().optional(),
|
||||||
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
||||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
||||||
postAuthPath: z.string().nullable().optional(),
|
postAuthPath: z.string().nullable().optional()
|
||||||
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, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
error: "At least one field must be provided for update"
|
error: "At least one field must be provided for update"
|
||||||
|
|||||||
@@ -187,6 +187,26 @@ const listSitesSchema = z.object({
|
|||||||
type: "string",
|
type: "string",
|
||||||
enum: ["pending", "approved"],
|
enum: ["pending", "approved"],
|
||||||
description: "Filter by site status"
|
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"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,8 +339,16 @@ export async function listSites(
|
|||||||
tierMatrix.labels
|
tierMatrix.labels
|
||||||
);
|
);
|
||||||
|
|
||||||
const { pageSize, page, query, sort_by, order, online, status } =
|
const {
|
||||||
parsedQuery.data;
|
pageSize,
|
||||||
|
page,
|
||||||
|
query,
|
||||||
|
sort_by,
|
||||||
|
order,
|
||||||
|
online,
|
||||||
|
status,
|
||||||
|
labels: labelFilter
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||||
|
|
||||||
@@ -337,6 +365,23 @@ export async function listSites(
|
|||||||
if (typeof status !== "undefined") {
|
if (typeof status !== "undefined") {
|
||||||
conditions.push(eq(sites.status, status));
|
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) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
@@ -366,7 +411,9 @@ export async function listSites(
|
|||||||
|
|
||||||
// we need to add `as` so that drizzle filters the result as a subquery
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
const countQuery = db.$count(
|
const countQuery = db.$count(
|
||||||
querySitesBase().where(and(...conditions)).as("filtered_sites")
|
querySitesBase()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.as("filtered_sites")
|
||||||
);
|
);
|
||||||
|
|
||||||
const siteListQuery = baseQuery
|
const siteListQuery = baseQuery
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ const createSiteResourceSchema = z
|
|||||||
disableIcmp: z.boolean().optional(),
|
disableIcmp: z.boolean().optional(),
|
||||||
authDaemonPort: z.int().positive().optional(),
|
authDaemonPort: z.int().positive().optional(),
|
||||||
authDaemonMode: z.enum(["site", "remote"]).optional(),
|
authDaemonMode: z.enum(["site", "remote"]).optional(),
|
||||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
|
||||||
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
|
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
|
||||||
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
|
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -85,6 +85,26 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
|||||||
type: "integer",
|
type: "integer",
|
||||||
description:
|
description:
|
||||||
"When set, only site resources associated with this site (via network) are returned"
|
"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"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,8 +259,16 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
const {
|
||||||
parsedQuery.data;
|
page,
|
||||||
|
pageSize,
|
||||||
|
query,
|
||||||
|
mode,
|
||||||
|
sort_by,
|
||||||
|
order,
|
||||||
|
siteId,
|
||||||
|
labels: labelFilter
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
orgId,
|
orgId,
|
||||||
@@ -276,6 +304,22 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
conditions.push(eq(siteResources.mode, mode));
|
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) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
|
|||||||
@@ -1352,12 +1352,6 @@ export default function BillingPage() {
|
|||||||
{t("billingModifyCurrentPlan") ||
|
{t("billingModifyCurrentPlan") ||
|
||||||
"Modify Current Plan"}
|
"Modify Current Plan"}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
|
||||||
{t(
|
|
||||||
"billingManageLicenseSubscriptionDescription"
|
|
||||||
) ||
|
|
||||||
"Manage your subscription for paid self-hosted license keys and download invoices."}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
|
|||||||
@@ -153,6 +153,65 @@ export default function GeneralPage() {
|
|||||||
const [approvalId, setApprovalId] = useState<number | null>(null);
|
const [approvalId, setApprovalId] = useState<number | null>(null);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [, startTransition] = useTransition();
|
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 { env } = useEnvContext();
|
||||||
|
|
||||||
const showApprovalFeatures =
|
const showApprovalFeatures =
|
||||||
@@ -844,6 +903,75 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</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>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useState, useRef, useEffect, useTransition } from "react";
|
import { useState, useTransition, useMemo } from "react";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
@@ -20,6 +20,9 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
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() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,23 +33,8 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
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<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -61,40 +49,21 @@ export default function GeneralPage() {
|
|||||||
actor: searchParams.get("actor") || undefined
|
actor: searchParams.get("actor") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = 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);
|
const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
|
||||||
|
|
||||||
// Set default date range to last 24 hours
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,75 +72,95 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
const defaultRange = getDefaultDateRange();
|
let timeEnd: string | undefined;
|
||||||
queryDateTime(
|
|
||||||
defaultRange.startDate,
|
if (dateRange.startDate?.date) {
|
||||||
defaultRange.endDate,
|
const dt = new Date(dateRange.startDate.date);
|
||||||
0,
|
if (dateRange.startDate.time) {
|
||||||
pageSize
|
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()
|
||||||
);
|
);
|
||||||
}, [orgId]); // Re-run if orgId changes
|
}
|
||||||
|
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: []
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
// Create new filters object with updated value
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
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 = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -193,114 +182,8 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
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 () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -316,7 +199,6 @@ export default function GeneralPage() {
|
|||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a URL for the blob and trigger a download
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -334,7 +216,6 @@ export default function GeneralPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
// Parse the Blob as JSON
|
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -351,7 +232,7 @@ export default function GeneralPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -366,7 +247,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("action")}</span>
|
<span>{t("action")}</span>
|
||||||
@@ -379,7 +260,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -396,13 +276,11 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
header: ({ column }) => {
|
header: () => t("ip")
|
||||||
return t("ip");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "location",
|
accessorKey: "location",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("location")}</span>
|
<span>{t("location")}</span>
|
||||||
@@ -417,7 +295,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("location", value)
|
handleFilterChange("location", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -442,7 +319,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("resource")}</span>
|
<span>{t("resource")}</span>
|
||||||
@@ -455,7 +332,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("resourceId", value)
|
handleFilterChange("resourceId", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -481,7 +357,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("type")}</span>
|
<span>{t("type")}</span>
|
||||||
@@ -500,7 +376,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("type", value)
|
handleFilterChange("type", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -518,7 +393,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("actor")}</span>
|
<span>{t("actor")}</span>
|
||||||
@@ -531,7 +406,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -559,16 +433,12 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: ({ column }) => {
|
header: () => t("actorId"),
|
||||||
return t("actorId");
|
cell: ({ row }) => (
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return (
|
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
{row.original.actorId || "-"}
|
{row.original.actorId || "-"}
|
||||||
</span>
|
</span>
|
||||||
);
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -614,13 +484,10 @@ export default function GeneralPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
title={t("accessLogs")}
|
title={t("accessLogs")}
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
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}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
dateRange={{
|
dateRange={{
|
||||||
start: dateRange.startDate,
|
start: dateRange.startDate,
|
||||||
@@ -630,14 +497,12 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
// Row expansion props
|
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"}
|
||||||
@@ -645,3 +510,41 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSampleAccessLogs(): QueryAccessAuditLogResponse["log"] {
|
||||||
|
const locations = ["US", "DE", "GB", "FR", "JP", "CA", "AU"];
|
||||||
|
const types = ["password", "pincode", "login", "whitelistedEmail", "ssh"];
|
||||||
|
const actors = [
|
||||||
|
"alice@example.com",
|
||||||
|
"bob@example.com",
|
||||||
|
"carol@example.com",
|
||||||
|
null
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const action = Math.random() > 0.3;
|
||||||
|
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: Math.floor(
|
||||||
|
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||||
|
),
|
||||||
|
action,
|
||||||
|
orgId: "sample-org",
|
||||||
|
actorType: actor ? "user" : null,
|
||||||
|
actor,
|
||||||
|
actorId: actor ? `user-${i}` : null,
|
||||||
|
resourceId: Math.floor(Math.random() * 5) + 1,
|
||||||
|
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||||
|
resourceName: `Resource ${(i % 3) + 1}`,
|
||||||
|
ip: `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||||
|
location: locations[Math.floor(Math.random() * locations.length)],
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
metadata: null,
|
||||||
|
type: types[Math.floor(Math.random() * types.length)]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
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 { ColumnDef } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Key, User } from "lucide-react";
|
import { Key, User } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,18 +31,8 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
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<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
@@ -48,40 +41,21 @@ export default function GeneralPage() {
|
|||||||
actor: searchParams.get("actor") || undefined
|
actor: searchParams.get("actor") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = 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);
|
const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
|
||||||
|
|
||||||
// Set default date range to last 24 hours
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,78 +64,90 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
if (build === "oss") {
|
let timeEnd: string | undefined;
|
||||||
return;
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
const defaultRange = getDefaultDateRange();
|
timeStart = dt.toISOString();
|
||||||
queryDateTime(
|
}
|
||||||
defaultRange.startDate,
|
|
||||||
defaultRange.endDate,
|
if (dateRange.endDate?.date) {
|
||||||
0,
|
const dt = new Date(dateRange.endDate.date);
|
||||||
pageSize
|
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()
|
||||||
);
|
);
|
||||||
}, [orgId]); // Re-run if orgId changes
|
}
|
||||||
|
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 handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
// Create new filters object with updated value
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
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 = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -183,110 +169,8 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
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 () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -302,7 +186,6 @@ export default function GeneralPage() {
|
|||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a URL for the blob and trigger a download
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -320,7 +203,6 @@ export default function GeneralPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
// Parse the Blob as JSON
|
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -337,7 +219,7 @@ export default function GeneralPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -352,22 +234,16 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("action")}</span>
|
<span>{t("action")}</span>
|
||||||
<ColumnFilter
|
<ColumnFilter
|
||||||
options={filterAttributes.actions.map((action) => ({
|
options={[]}
|
||||||
label:
|
|
||||||
action.charAt(0).toUpperCase() +
|
|
||||||
action.slice(1),
|
|
||||||
value: action
|
|
||||||
}))}
|
|
||||||
selectedValue={filters.action}
|
selectedValue={filters.action}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -385,7 +261,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("actor")}</span>
|
<span>{t("actor")}</span>
|
||||||
@@ -398,7 +274,6 @@ export default function GeneralPage() {
|
|||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
// placeholder=""
|
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder="Search..."
|
||||||
emptyMessage="None found"
|
emptyMessage="None found"
|
||||||
/>
|
/>
|
||||||
@@ -420,7 +295,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("actorId");
|
return t("actorId");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -469,12 +344,9 @@ export default function GeneralPage() {
|
|||||||
title={t("actionLogs")}
|
title={t("actionLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="action"
|
searchColumn="action"
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
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}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
dateRange={{
|
dateRange={{
|
||||||
@@ -485,14 +357,12 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
// Row expansion props
|
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"}
|
||||||
@@ -500,3 +370,39 @@ export default function GeneralPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSampleActionLogs(): QueryActionAuditLogResponse["log"] {
|
||||||
|
const actions = [
|
||||||
|
"createResource",
|
||||||
|
"deleteResource",
|
||||||
|
"updateResource",
|
||||||
|
"createSite",
|
||||||
|
"deleteSite",
|
||||||
|
"inviteUser",
|
||||||
|
"removeUser"
|
||||||
|
];
|
||||||
|
const actors = [
|
||||||
|
"alice@example.com",
|
||||||
|
"bob@example.com",
|
||||||
|
"carol@example.com"
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: Math.floor(
|
||||||
|
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||||
|
),
|
||||||
|
action: actions[Math.floor(Math.random() * actions.length)],
|
||||||
|
orgId: "sample-org",
|
||||||
|
actorType: "user",
|
||||||
|
actor,
|
||||||
|
actorId: `user-${i}`,
|
||||||
|
metadata: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,26 +9,20 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
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 { ColumnDef } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
import { ArrowUpRight, Laptop, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useMemo, 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 {
|
function formatDuration(startedAt: number, endedAt: number | null): string {
|
||||||
if (endedAt === null || endedAt === undefined) return "Active";
|
if (endedAt === null || endedAt === undefined) return "Active";
|
||||||
@@ -54,24 +48,8 @@ export default function ConnectionLogsPage() {
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
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<{
|
const [filters, setFilters] = useState<{
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
destAddr?: string;
|
destAddr?: string;
|
||||||
@@ -86,43 +64,24 @@ export default function ConnectionLogsPage() {
|
|||||||
userId: searchParams.get("userId") || undefined
|
userId: searchParams.get("userId") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = 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(
|
const [pageSize, setPageSize] = useStoredPageSize(
|
||||||
"connection-audit-logs",
|
"connection-audit-logs",
|
||||||
20
|
20
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set default date range to last 7 days
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,78 +90,100 @@ export default function ConnectionLogsPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
if (build === "oss") {
|
let timeEnd: string | undefined;
|
||||||
return;
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
const defaultRange = getDefaultDateRange();
|
timeStart = dt.toISOString();
|
||||||
queryDateTime(
|
}
|
||||||
defaultRange.startDate,
|
|
||||||
defaultRange.endDate,
|
if (dateRange.endDate?.date) {
|
||||||
0,
|
const dt = new Date(dateRange.endDate.date);
|
||||||
pageSize
|
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()
|
||||||
);
|
);
|
||||||
}, [orgId]); // Re-run if orgId changes
|
}
|
||||||
|
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 handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
// Create new filters object with updated value
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
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 = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -224,109 +205,8 @@ export default function ConnectionLogsPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
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 () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
|
||||||
const params: any = {
|
const params: any = {
|
||||||
timeStart: dateRange.startDate?.date
|
timeStart: dateRange.startDate?.date
|
||||||
? new Date(dateRange.startDate.date).toISOString()
|
? new Date(dateRange.startDate.date).toISOString()
|
||||||
@@ -345,7 +225,6 @@ export default function ConnectionLogsPage() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a URL for the blob and trigger a download
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -363,7 +242,6 @@ export default function ConnectionLogsPage() {
|
|||||||
const data = error.response.data;
|
const data = error.response.data;
|
||||||
|
|
||||||
if (data instanceof Blob && data.type === "application/json") {
|
if (data instanceof Blob && data.type === "application/json") {
|
||||||
// Parse the Blob as JSON
|
|
||||||
const text = await data.text();
|
const text = await data.text();
|
||||||
const errorData = JSON.parse(text);
|
const errorData = JSON.parse(text);
|
||||||
apiErrorMessage = errorData.message;
|
apiErrorMessage = errorData.message;
|
||||||
@@ -380,7 +258,7 @@ export default function ConnectionLogsPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "startedAt",
|
accessorKey: "startedAt",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("timestamp");
|
return t("timestamp");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -395,7 +273,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "protocol",
|
accessorKey: "protocol",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("protocol")}</span>
|
<span>{t("protocol")}</span>
|
||||||
@@ -426,7 +304,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("resource")}</span>
|
<span>{t("resource")}</span>
|
||||||
@@ -467,7 +345,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "clientName",
|
accessorKey: "clientName",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("client")}</span>
|
<span>{t("client")}</span>
|
||||||
@@ -510,7 +388,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "userEmail",
|
accessorKey: "userEmail",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("user")}</span>
|
<span>{t("user")}</span>
|
||||||
@@ -543,7 +421,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "sourceAddr",
|
accessorKey: "sourceAddr",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("sourceAddress");
|
return t("sourceAddress");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -556,7 +434,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "destAddr",
|
accessorKey: "destAddr",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t("destinationAddress")}</span>
|
<span>{t("destinationAddress")}</span>
|
||||||
@@ -585,7 +463,7 @@ export default function ConnectionLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "duration",
|
accessorKey: "duration",
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return t("duration");
|
return t("duration");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -606,9 +484,6 @@ export default function ConnectionLogsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/*<div className="flex items-center gap-1 font-semibold text-sm mb-1">
|
|
||||||
Connection Details
|
|
||||||
</div>*/}
|
|
||||||
<div>
|
<div>
|
||||||
<strong>Session ID:</strong>{" "}
|
<strong>Session ID:</strong>{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
@@ -633,18 +508,6 @@ export default function ConnectionLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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>
|
<div>
|
||||||
<strong>Client Endpoint:</strong>{" "}
|
<strong>Client Endpoint:</strong>{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
@@ -680,30 +543,8 @@ export default function ConnectionLogsPage() {
|
|||||||
<strong>Duration:</strong>{" "}
|
<strong>Duration:</strong>{" "}
|
||||||
{formatDuration(row.startedAt, row.endedAt)}
|
{formatDuration(row.startedAt, row.endedAt)}
|
||||||
</div>
|
</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>
|
||||||
|
<div className="space-y-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -724,8 +565,8 @@ export default function ConnectionLogsPage() {
|
|||||||
title={t("connectionLogs")}
|
title={t("connectionLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="protocol"
|
searchColumn="protocol"
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
@@ -737,14 +578,12 @@ export default function ConnectionLogsPage() {
|
|||||||
id: "startedAt",
|
id: "startedAt",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
// Row expansion props
|
|
||||||
expandable={true}
|
expandable={true}
|
||||||
renderExpandedRow={renderExpandedRow}
|
renderExpandedRow={renderExpandedRow}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -754,3 +593,49 @@ export default function ConnectionLogsPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSampleConnectionLogs(): QueryConnectionAuditLogResponse["log"] {
|
||||||
|
const protocols = ["tcp", "udp", "icmp"];
|
||||||
|
const destAddrs = [
|
||||||
|
"10.0.0.1:22",
|
||||||
|
"10.0.0.2:80",
|
||||||
|
"10.0.0.3:443",
|
||||||
|
"192.168.1.10:3306"
|
||||||
|
];
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
return Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const startedAt = Math.floor(
|
||||||
|
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
|
||||||
|
);
|
||||||
|
const active = Math.random() > 0.3;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: `session-${i}`,
|
||||||
|
siteResourceId: (i % 3) + 1,
|
||||||
|
orgId: "sample-org",
|
||||||
|
siteId: 1,
|
||||||
|
clientId: (i % 4) + 1,
|
||||||
|
clientEndpoint: `10.0.0.${i + 1}:51820`,
|
||||||
|
userId: i % 2 === 0 ? `user-${i}` : null,
|
||||||
|
sourceAddr: `192.168.1.${i + 1}:${40000 + i}`,
|
||||||
|
destAddr: destAddrs[Math.floor(Math.random() * destAddrs.length)],
|
||||||
|
protocol:
|
||||||
|
protocols[Math.floor(Math.random() * protocols.length)],
|
||||||
|
startedAt,
|
||||||
|
endedAt: active ? null : startedAt + Math.floor(Math.random() * 3600),
|
||||||
|
bytesTx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
||||||
|
bytesRx: active ? null : Math.floor(Math.random() * 1024 * 1024),
|
||||||
|
resourceName: `Resource ${(i % 3) + 1}`,
|
||||||
|
resourceNiceId: `resource-${(i % 3) + 1}`,
|
||||||
|
siteName: "Sample Site",
|
||||||
|
siteNiceId: "sample-site",
|
||||||
|
clientName: `Client ${(i % 4) + 1}`,
|
||||||
|
clientNiceId: `client-${(i % 4) + 1}`,
|
||||||
|
clientType: i % 2 === 0 ? "user" : "machine",
|
||||||
|
userEmail: i % 2 === 0 ? `user${i}@example.com` : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -25,36 +28,11 @@ export default function GeneralPage() {
|
|||||||
const { orgId } = useParams();
|
const { orgId } = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [rows, setRows] = useState<any[]>([]);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isExporting, startTransition] = useTransition();
|
const [isExporting, startTransition] = useTransition();
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
const [totalCount, setTotalCount] = useState<number>(0);
|
|
||||||
const [currentPage, setCurrentPage] = 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 [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<{
|
const [filters, setFilters] = useState<{
|
||||||
action?: string;
|
action?: string;
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
@@ -75,32 +53,18 @@ export default function GeneralPage() {
|
|||||||
path: searchParams.get("path") || undefined
|
path: searchParams.get("path") || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set default date range to last 24 hours
|
|
||||||
const getDefaultDateRange = () => {
|
const getDefaultDateRange = () => {
|
||||||
// if the time is in the url params, use that instead
|
|
||||||
const startParam = searchParams.get("start");
|
const startParam = searchParams.get("start");
|
||||||
const endParam = searchParams.get("end");
|
const endParam = searchParams.get("end");
|
||||||
if (startParam && endParam) {
|
if (startParam && endParam) {
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: new Date(startParam) },
|
||||||
date: new Date(startParam)
|
endDate: { date: new Date(endParam) }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: new Date(endParam)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const lastWeek = getSevenDaysAgo();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: {
|
startDate: { date: getSevenDaysAgo() },
|
||||||
date: lastWeek
|
endDate: { date: new Date() }
|
||||||
},
|
|
||||||
endDate: {
|
|
||||||
date: now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,80 +73,97 @@ export default function GeneralPage() {
|
|||||||
endDate: DateTimeValue;
|
endDate: DateTimeValue;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
// Trigger search with default values on component mount
|
const queryFilters = useMemo(() => {
|
||||||
useEffect(() => {
|
let timeStart: string | undefined;
|
||||||
if (build === "oss") {
|
let timeEnd: string | undefined;
|
||||||
return;
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
const defaultRange = getDefaultDateRange();
|
timeStart = dt.toISOString();
|
||||||
queryDateTime(
|
}
|
||||||
defaultRange.startDate,
|
|
||||||
defaultRange.endDate,
|
if (dateRange.endDate?.date) {
|
||||||
0,
|
const dt = new Date(dateRange.endDate.date);
|
||||||
pageSize
|
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()
|
||||||
);
|
);
|
||||||
}, [orgId]); // Re-run if orgId changes
|
}
|
||||||
|
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 handleDateRangeChange = (
|
const handleDateRangeChange = (
|
||||||
startDate: DateTimeValue,
|
startDate: DateTimeValue,
|
||||||
endDate: DateTimeValue
|
endDate: DateTimeValue
|
||||||
) => {
|
) => {
|
||||||
setDateRange({ startDate, endDate });
|
setDateRange({ startDate, endDate });
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
// put the search params in the url for the time
|
|
||||||
updateUrlParamsForAllFilters({
|
updateUrlParamsForAllFilters({
|
||||||
start: startDate.date?.toISOString() || "",
|
start: startDate.date?.toISOString() || "",
|
||||||
end: endDate.date?.toISOString() || ""
|
end: endDate.date?.toISOString() || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
queryDateTime(startDate, endDate, 0, pageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page changes
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
queryDateTime(
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
newPage,
|
|
||||||
pageSize
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle page size changes
|
|
||||||
const handlePageSizeChange = (newPageSize: number) => {
|
const handlePageSizeChange = (newPageSize: number) => {
|
||||||
setPageSize(newPageSize);
|
setPageSize(newPageSize);
|
||||||
setCurrentPage(0); // Reset to first page when changing page size
|
setCurrentPage(0);
|
||||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle filter changes generically
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
filterType: keyof typeof filters,
|
filterType: keyof typeof filters,
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
) => {
|
) => {
|
||||||
console.log(`${filterType} filter changed:`, value);
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
|
|
||||||
// Create new filters object with updated value
|
|
||||||
const newFilters = {
|
|
||||||
...filters,
|
|
||||||
[filterType]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setCurrentPage(0); // Reset to first page when filtering
|
setCurrentPage(0);
|
||||||
|
|
||||||
// Update URL params
|
|
||||||
updateUrlParamsForAllFilters(newFilters);
|
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 = (
|
const updateUrlParamsForAllFilters = (
|
||||||
@@ -204,101 +185,6 @@ export default function GeneralPage() {
|
|||||||
router.replace(`?${params.toString()}`, { scroll: false });
|
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 () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
// Prepare query params for export
|
// Prepare query params for export
|
||||||
@@ -781,8 +667,8 @@ export default function GeneralPage() {
|
|||||||
title={t("requestLogs")}
|
title={t("requestLogs")}
|
||||||
searchPlaceholder={t("searchLogs")}
|
searchPlaceholder={t("searchLogs")}
|
||||||
searchColumn="host"
|
searchColumn="host"
|
||||||
onRefresh={refreshData}
|
onRefresh={() => refetch()}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isFetching}
|
||||||
onExport={() => startTransition(exportData)}
|
onExport={() => startTransition(exportData)}
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
@@ -794,7 +680,6 @@ export default function GeneralPage() {
|
|||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
desc: true
|
desc: true
|
||||||
}}
|
}}
|
||||||
// Server-side pagination props
|
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
@@ -808,3 +693,63 @@ 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 { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import SetResourcePasswordForm from "components/SetResourcePasswordForm";
|
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
|
||||||
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|||||||
@@ -507,9 +507,7 @@ export default function GeneralForm() {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
subdomain: data.subdomain
|
subdomain: data.subdomain
|
||||||
? toASCII(
|
? toASCII(finalizeSubdomainSanitize(data.subdomain, true))
|
||||||
finalizeSubdomainSanitize(data.subdomain, true)
|
|
||||||
)
|
|
||||||
: undefined,
|
: undefined,
|
||||||
domainId: data.domainId,
|
domainId: data.domainId,
|
||||||
proxyPort: data.proxyPort
|
proxyPort: data.proxyPort
|
||||||
@@ -557,9 +555,7 @@ export default function GeneralForm() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
{resource?.resourceId &&
|
{resource?.resourceId && resource?.orgId && (
|
||||||
resource?.orgId &&
|
|
||||||
resource.browserAccessType == "http" && (
|
|
||||||
<UptimeAlertSection
|
<UptimeAlertSection
|
||||||
orgId={resource.orgId}
|
orgId={resource.orgId}
|
||||||
resourceId={resource.resourceId}
|
resourceId={resource.resourceId}
|
||||||
@@ -584,7 +580,6 @@ export default function GeneralForm() {
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="general-settings-form"
|
id="general-settings-form"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -615,13 +610,13 @@ export default function GeneralForm() {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"enterIdentifier"
|
"enterIdentifier"
|
||||||
)}
|
)}
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{!resource.http && (
|
{!resource.http && (
|
||||||
<>
|
<>
|
||||||
@@ -731,7 +726,8 @@ export default function GeneralForm() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="enabled"
|
name="enabled"
|
||||||
render={() => (
|
render={() => (
|
||||||
<FormItem>
|
<FormItem className="col-span-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="enable-resource"
|
id="enable-resource"
|
||||||
@@ -751,11 +747,7 @@ export default function GeneralForm() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
</div>
|
||||||
{t(
|
|
||||||
"disabledResourceDescription"
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,651 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import HealthCheckCredenza from "@/components/HealthCheckCredenza";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { HeadersInput } from "@app/components/HeadersInput";
|
|
||||||
import {
|
|
||||||
PathMatchDisplay,
|
|
||||||
PathMatchModal,
|
|
||||||
PathRewriteDisplay,
|
|
||||||
PathRewriteModal
|
|
||||||
} from "@app/components/PathMatchRenameModal";
|
|
||||||
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
|
|
||||||
import {
|
|
||||||
SettingsContainer,
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "@app/components/ui/form";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow
|
|
||||||
} from "@app/components/ui/table";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger
|
|
||||||
} from "@app/components/ui/tooltip";
|
|
||||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
|
||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
|
||||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import { tlsNameSchema } from "@server/lib/schemas";
|
|
||||||
import { type GetResourceResponse } from "@server/routers/resource";
|
|
||||||
import type { ListSitesResponse } from "@server/routers/site";
|
|
||||||
import { CreateTargetResponse } from "@server/routers/target";
|
|
||||||
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
|
||||||
import { ArrayElement } from "@server/types/ArrayElement";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
LocalTarget,
|
|
||||||
ProxyResourceTargetsForm
|
|
||||||
} from "@app/app/[orgId]/settings/resources/proxy/ProxyResourceTargetsForm";
|
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
CircleCheck,
|
|
||||||
CircleX,
|
|
||||||
ExternalLink,
|
|
||||||
Info,
|
|
||||||
Plus,
|
|
||||||
Settings
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
use,
|
|
||||||
useActionState,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState
|
|
||||||
} from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const targetsSettingsSchema = z.object({
|
|
||||||
stickySession: z.boolean()
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function ReverseProxyTargetsPage(props: {
|
|
||||||
params: Promise<{ resourceId: number; orgId: string }>;
|
|
||||||
}) {
|
|
||||||
const params = use(props.params);
|
|
||||||
const { resource, updateResource } = useResourceContext();
|
|
||||||
|
|
||||||
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
|
|
||||||
resourceQueries.resourceTargets({
|
|
||||||
resourceId: resource.resourceId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoadingTargets) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsContainer>
|
|
||||||
<ProxyResourceTargetsForm
|
|
||||||
orgId={params.orgId}
|
|
||||||
isHttp={resource.http}
|
|
||||||
initialTargets={remoteTargets}
|
|
||||||
resource={resource}
|
|
||||||
updateResource={updateResource}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{resource.http && (
|
|
||||||
<ProxyResourceHttpForm
|
|
||||||
resource={resource}
|
|
||||||
updateResource={updateResource}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!resource.http && resource.protocol == "tcp" && (
|
|
||||||
<ProxyResourceProtocolForm
|
|
||||||
resource={resource}
|
|
||||||
updateResource={updateResource}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProxyResourceHttpForm({
|
|
||||||
resource,
|
|
||||||
updateResource
|
|
||||||
}: Pick<ResourceContextType, "resource" | "updateResource">) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const tlsSettingsSchema = z.object({
|
|
||||||
ssl: z.boolean(),
|
|
||||||
tlsServerName: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data) {
|
|
||||||
return tlsNameSchema.safeParse(data).success;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: t("proxyErrorTls")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
const tlsSettingsForm = useForm({
|
|
||||||
resolver: zodResolver(tlsSettingsSchema),
|
|
||||||
defaultValues: {
|
|
||||||
ssl: resource.ssl,
|
|
||||||
tlsServerName: resource.tlsServerName || ""
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const proxySettingsSchema = z.object({
|
|
||||||
setHostHeader: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data) {
|
|
||||||
return tlsNameSchema.safeParse(data).success;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: t("proxyErrorInvalidHeader")
|
|
||||||
}
|
|
||||||
),
|
|
||||||
headers: z
|
|
||||||
.array(z.object({ name: z.string(), value: z.string() }))
|
|
||||||
.nullable(),
|
|
||||||
proxyProtocol: z.boolean().optional(),
|
|
||||||
proxyProtocolVersion: z.int().min(1).max(2).optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
const proxySettingsForm = useForm({
|
|
||||||
resolver: zodResolver(proxySettingsSchema),
|
|
||||||
defaultValues: {
|
|
||||||
setHostHeader: resource.setHostHeader || "",
|
|
||||||
headers: resource.headers,
|
|
||||||
proxyProtocol: resource.proxyProtocol || false,
|
|
||||||
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
|
||||||
const api = createApiClient({ env });
|
|
||||||
|
|
||||||
const targetsSettingsForm = useForm({
|
|
||||||
resolver: zodResolver(targetsSettingsSchema),
|
|
||||||
defaultValues: {
|
|
||||||
stickySession: resource.stickySession
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const [, formAction, isSubmitting] = useActionState(
|
|
||||||
saveResourceHttpSettings,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
async function saveResourceHttpSettings() {
|
|
||||||
const isValidTLS = await tlsSettingsForm.trigger();
|
|
||||||
const isValidProxy = await proxySettingsForm.trigger();
|
|
||||||
const targetSettingsForm = await targetsSettingsForm.trigger();
|
|
||||||
if (!isValidTLS || !isValidProxy || !targetSettingsForm) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Gather all settings
|
|
||||||
const stickySessionData = targetsSettingsForm.getValues();
|
|
||||||
const tlsData = tlsSettingsForm.getValues();
|
|
||||||
const proxyData = proxySettingsForm.getValues();
|
|
||||||
|
|
||||||
// Combine into one payload
|
|
||||||
const payload = {
|
|
||||||
stickySession: stickySessionData.stickySession,
|
|
||||||
ssl: tlsData.ssl,
|
|
||||||
tlsServerName: tlsData.tlsServerName || null,
|
|
||||||
setHostHeader: proxyData.setHostHeader || null,
|
|
||||||
headers: proxyData.headers || null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Single API call to update all settings
|
|
||||||
await api.post(`/resource/${resource.resourceId}`, payload);
|
|
||||||
|
|
||||||
// Update local resource context
|
|
||||||
updateResource({
|
|
||||||
...resource,
|
|
||||||
stickySession: stickySessionData.stickySession,
|
|
||||||
ssl: tlsData.ssl,
|
|
||||||
tlsServerName: tlsData.tlsServerName || null,
|
|
||||||
setHostHeader: proxyData.setHostHeader || null,
|
|
||||||
headers: proxyData.headers || null
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("settingsUpdated"),
|
|
||||||
description: t("settingsUpdatedDescription")
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("settingsErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
err,
|
|
||||||
t("settingsErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("proxyAdditional")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("proxyAdditionalDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm>
|
|
||||||
<Form {...tlsSettingsForm}>
|
|
||||||
<form
|
|
||||||
action={formAction}
|
|
||||||
className="space-y-4"
|
|
||||||
id="tls-settings-form"
|
|
||||||
>
|
|
||||||
{!env.flags.usePangolinDns && (
|
|
||||||
<FormField
|
|
||||||
control={tlsSettingsForm.control}
|
|
||||||
name="ssl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<SwitchInput
|
|
||||||
id="ssl-toggle"
|
|
||||||
label={t("proxyEnableSSL")}
|
|
||||||
description={t(
|
|
||||||
"proxyEnableSSLDescription"
|
|
||||||
)}
|
|
||||||
defaultChecked={field.value}
|
|
||||||
onCheckedChange={(val) => {
|
|
||||||
field.onChange(val);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<FormField
|
|
||||||
control={tlsSettingsForm.control}
|
|
||||||
name="tlsServerName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("targetTlsSni")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("targetTlsSniDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
|
|
||||||
<SettingsSectionForm>
|
|
||||||
<Form {...targetsSettingsForm}>
|
|
||||||
<form
|
|
||||||
action={formAction}
|
|
||||||
className="space-y-4"
|
|
||||||
id="targets-settings-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={targetsSettingsForm.control}
|
|
||||||
name="stickySession"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<SwitchInput
|
|
||||||
id="sticky-toggle"
|
|
||||||
label={t(
|
|
||||||
"targetStickySessions"
|
|
||||||
)}
|
|
||||||
description={t(
|
|
||||||
"targetStickySessionsDescription"
|
|
||||||
)}
|
|
||||||
defaultChecked={field.value}
|
|
||||||
onCheckedChange={(val) => {
|
|
||||||
field.onChange(val);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
|
|
||||||
<SettingsSectionForm>
|
|
||||||
<Form {...proxySettingsForm}>
|
|
||||||
<form
|
|
||||||
action={formAction}
|
|
||||||
className="space-y-4"
|
|
||||||
id="proxy-settings-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={proxySettingsForm.control}
|
|
||||||
name="setHostHeader"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("proxyCustomHeader")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("proxyCustomHeaderDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={proxySettingsForm.control}
|
|
||||||
name="headers"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("customHeaders")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<HeadersInput
|
|
||||||
value={field.value}
|
|
||||||
onChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
}}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("customHeadersDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
<form className="flex justify-end" action={formAction}>
|
|
||||||
<Button
|
|
||||||
disabled={isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{t("saveResourceHttp")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProxyResourceProtocolForm({
|
|
||||||
resource,
|
|
||||||
updateResource
|
|
||||||
}: Pick<ResourceContextType, "resource" | "updateResource">) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
const proxySettingsSchema = z.object({
|
|
||||||
setHostHeader: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data) {
|
|
||||||
return tlsNameSchema.safeParse(data).success;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: t("proxyErrorInvalidHeader")
|
|
||||||
}
|
|
||||||
),
|
|
||||||
headers: z
|
|
||||||
.array(z.object({ name: z.string(), value: z.string() }))
|
|
||||||
.nullable(),
|
|
||||||
proxyProtocol: z.boolean().optional(),
|
|
||||||
proxyProtocolVersion: z.int().min(1).max(2).optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
const proxySettingsForm = useForm({
|
|
||||||
resolver: zodResolver(proxySettingsSchema),
|
|
||||||
defaultValues: {
|
|
||||||
setHostHeader: resource.setHostHeader || "",
|
|
||||||
headers: resource.headers,
|
|
||||||
proxyProtocol: resource.proxyProtocol || false,
|
|
||||||
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [, formAction, isSubmitting] = useActionState(
|
|
||||||
saveProtocolSettings,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
async function saveProtocolSettings() {
|
|
||||||
const isValid = proxySettingsForm.trigger();
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// For TCP/UDP resources, save proxy protocol settings
|
|
||||||
const proxyData = proxySettingsForm.getValues();
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
proxyProtocol: proxyData.proxyProtocol || false,
|
|
||||||
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
|
||||||
};
|
|
||||||
|
|
||||||
await api.post(`/resource/${resource.resourceId}`, payload);
|
|
||||||
|
|
||||||
updateResource({
|
|
||||||
...resource,
|
|
||||||
proxyProtocol: proxyData.proxyProtocol || false,
|
|
||||||
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("settingsUpdated"),
|
|
||||||
description: t("settingsUpdatedDescription")
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("settingsErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
err,
|
|
||||||
t("settingsErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>
|
|
||||||
{t("proxyProtocol")}
|
|
||||||
</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("proxyProtocolDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm>
|
|
||||||
<Form {...proxySettingsForm}>
|
|
||||||
<form
|
|
||||||
action={formAction}
|
|
||||||
className="space-y-4"
|
|
||||||
id="proxy-protocol-settings-form"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={proxySettingsForm.control}
|
|
||||||
name="proxyProtocol"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<SwitchInput
|
|
||||||
id="proxy-protocol-toggle"
|
|
||||||
label={t("enableProxyProtocol")}
|
|
||||||
description={t(
|
|
||||||
"proxyProtocolInfo"
|
|
||||||
)}
|
|
||||||
defaultChecked={
|
|
||||||
field.value || false
|
|
||||||
}
|
|
||||||
onCheckedChange={(val) => {
|
|
||||||
field.onChange(val);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{proxySettingsForm.watch("proxyProtocol") && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={proxySettingsForm.control}
|
|
||||||
name="proxyProtocolVersion"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("proxyProtocolVersion")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
value={String(
|
|
||||||
field.value || 1
|
|
||||||
)}
|
|
||||||
onValueChange={(
|
|
||||||
value
|
|
||||||
) =>
|
|
||||||
field.onChange(
|
|
||||||
parseInt(
|
|
||||||
value,
|
|
||||||
10
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select version" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="1">
|
|
||||||
{t("version1")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="2">
|
|
||||||
{t("version2")}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("versionDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Alert>
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertDescription>
|
|
||||||
<strong>{t("warning")}:</strong>{" "}
|
|
||||||
{t("proxyProtocolWarning")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
<form action={formAction} className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
disabled={isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{t("saveProxyProtocol")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -86,8 +86,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||||||
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
|
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t(`${resource.browserAccessType}Settings`),
|
title: t("proxy"),
|
||||||
href: `/{orgId}/settings/resources/proxy/{niceId}/${resource.browserAccessType}`
|
href: `/{orgId}/settings/resources/proxy/{niceId}/proxy`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ export default async function ResourcePage(props: {
|
|||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
redirect(
|
redirect(
|
||||||
`/${params.orgId}/settings/resources/proxy/${params.niceId}/general`
|
`/${params.orgId}/settings/resources/proxy/${params.niceId}/proxy`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+627
-109
@@ -3,7 +3,15 @@
|
|||||||
import HealthCheckCredenza from "@/components/HealthCheckCredenza";
|
import HealthCheckCredenza from "@/components/HealthCheckCredenza";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { HeadersInput } from "@app/components/HeadersInput";
|
||||||
import {
|
import {
|
||||||
PathMatchDisplay,
|
PathMatchDisplay,
|
||||||
PathMatchModal,
|
PathMatchModal,
|
||||||
@@ -12,12 +20,25 @@ import {
|
|||||||
} from "@app/components/PathMatchRenameModal";
|
} from "@app/components/PathMatchRenameModal";
|
||||||
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
|
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
|
||||||
import {
|
import {
|
||||||
|
SettingsContainer,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -34,13 +55,17 @@ import {
|
|||||||
} from "@app/components/ui/tooltip";
|
} from "@app/components/ui/tooltip";
|
||||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { tlsNameSchema } from "@server/lib/schemas";
|
||||||
import { type GetResourceResponse } from "@server/routers/resource";
|
import { type GetResourceResponse } from "@server/routers/resource";
|
||||||
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
import { CreateTargetResponse } from "@server/routers/target";
|
import { CreateTargetResponse } from "@server/routers/target";
|
||||||
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
import { ListTargetsResponse } from "@server/routers/target/listTargets";
|
||||||
import { ArrayElement } from "@server/types/ArrayElement";
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
@@ -55,18 +80,33 @@ import {
|
|||||||
useReactTable
|
useReactTable
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { ExternalLink, Info, Plus } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CircleCheck,
|
||||||
|
CircleX,
|
||||||
|
ExternalLink,
|
||||||
|
Info,
|
||||||
|
Plus,
|
||||||
|
Settings
|
||||||
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
|
use,
|
||||||
useActionState,
|
useActionState,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState
|
useState
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export type LocalTarget = Omit<
|
const targetsSettingsSchema = z.object({
|
||||||
|
stickySession: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
type LocalTarget = Omit<
|
||||||
ArrayElement<ListTargetsResponse["targets"]> & {
|
ArrayElement<ListTargetsResponse["targets"]> & {
|
||||||
new?: boolean;
|
new?: boolean;
|
||||||
updated?: boolean;
|
updated?: boolean;
|
||||||
@@ -75,43 +115,67 @@ export type LocalTarget = Omit<
|
|||||||
"protocol"
|
"protocol"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
interface ProxyResourceTargetsFormProps {
|
export default function ReverseProxyTargetsPage(props: {
|
||||||
orgId: string;
|
params: Promise<{ resourceId: number; orgId: string }>;
|
||||||
isHttp: boolean;
|
}) {
|
||||||
initialTargets?: LocalTarget[];
|
const params = use(props.params);
|
||||||
/** Edit mode: when provided, shows a save button and polls for health status */
|
const { resource, updateResource } = useResourceContext();
|
||||||
resource?: GetResourceResponse;
|
|
||||||
updateResource?: ResourceContextType["updateResource"];
|
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
|
||||||
/** Create mode: called whenever the targets list changes */
|
resourceQueries.resourceTargets({
|
||||||
onChange?: (targets: LocalTarget[]) => void;
|
resourceId: resource.resourceId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoadingTargets) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<ProxyResourceTargetsForm
|
||||||
|
orgId={params.orgId}
|
||||||
|
initialTargets={remoteTargets}
|
||||||
|
resource={resource}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{resource.http && (
|
||||||
|
<ProxyResourceHttpForm
|
||||||
|
resource={resource}
|
||||||
|
updateResource={updateResource}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!resource.http && resource.protocol == "tcp" && (
|
||||||
|
<ProxyResourceProtocolForm
|
||||||
|
resource={resource}
|
||||||
|
updateResource={updateResource}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProxyResourceTargetsForm({
|
function ProxyResourceTargetsForm({
|
||||||
orgId,
|
orgId,
|
||||||
isHttp,
|
initialTargets,
|
||||||
initialTargets = [],
|
resource
|
||||||
resource,
|
}: {
|
||||||
updateResource,
|
initialTargets: LocalTarget[];
|
||||||
onChange
|
orgId: string;
|
||||||
}: ProxyResourceTargetsFormProps) {
|
resource: GetResourceResponse;
|
||||||
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
|
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
|
||||||
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
|
||||||
|
|
||||||
// Notify parent of changes (create mode)
|
|
||||||
useEffect(() => {
|
|
||||||
onChange?.(targets);
|
|
||||||
}, [targets]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// Poll health status only in edit mode
|
|
||||||
const { data: polledTargets } = useQuery({
|
const { data: polledTargets } = useQuery({
|
||||||
...resourceQueries.resourceTargets({
|
...resourceQueries.resourceTargets({
|
||||||
resourceId: resource?.resourceId ?? 0
|
resourceId: resource.resourceId
|
||||||
}),
|
}),
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000
|
||||||
enabled: !!resource
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -130,7 +194,6 @@ export function ProxyResourceTargetsForm({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}, [polledTargets]);
|
}, [polledTargets]);
|
||||||
|
|
||||||
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
|
||||||
new Map()
|
new Map()
|
||||||
);
|
);
|
||||||
@@ -138,17 +201,14 @@ export function ProxyResourceTargetsForm({
|
|||||||
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
||||||
useState<LocalTarget | null>(null);
|
useState<LocalTarget | null>(null);
|
||||||
|
|
||||||
const [bgDestination, setBgDestination] = useState("");
|
|
||||||
const [bgDestinationPort, setBgDestinationPort] = useState("");
|
|
||||||
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
|
|
||||||
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const initializeDockerForSite = async (siteId: number) => {
|
const initializeDockerForSite = async (siteId: number) => {
|
||||||
if (dockerStates.has(siteId)) {
|
if (dockerStates.has(siteId)) {
|
||||||
return;
|
return; // Already initialized
|
||||||
}
|
}
|
||||||
|
|
||||||
const dockerManager = new DockerManager(api, siteId);
|
const dockerManager = new DockerManager(api, siteId);
|
||||||
const dockerState = await dockerManager.initializeDocker();
|
const dockerState = await dockerManager.initializeDocker();
|
||||||
|
|
||||||
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,6 +216,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
async (siteId: number) => {
|
async (siteId: number) => {
|
||||||
const dockerManager = new DockerManager(api, siteId);
|
const dockerManager = new DockerManager(api, siteId);
|
||||||
const containers = await dockerManager.fetchContainers();
|
const containers = await dockerManager.fetchContainers();
|
||||||
|
|
||||||
setDockerStates((prev) => {
|
setDockerStates((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
const existingState = newMap.get(siteId);
|
const existingState = newMap.get(siteId);
|
||||||
@@ -189,6 +250,8 @@ export function ProxyResourceTargetsForm({
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isHttp = resource.http;
|
||||||
|
|
||||||
const removeTarget = useCallback((targetId: number) => {
|
const removeTarget = useCallback((targetId: number) => {
|
||||||
setTargets((prevTargets) => {
|
setTargets((prevTargets) => {
|
||||||
const targetToRemove = prevTargets.find(
|
const targetToRemove = prevTargets.find(
|
||||||
@@ -207,42 +270,6 @@ export function ProxyResourceTargetsForm({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Browser-gateway targets (edit mode only)
|
|
||||||
const { data: bgTargetsResponse } = useQuery({
|
|
||||||
queryKey: ["browserGatewayTargets", resource?.resourceId, orgId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await api.get(
|
|
||||||
`/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets`
|
|
||||||
);
|
|
||||||
return res.data.data as {
|
|
||||||
targets: Array<{
|
|
||||||
browserGatewayTargetId: number;
|
|
||||||
resourceId: number;
|
|
||||||
siteId: number;
|
|
||||||
type: string;
|
|
||||||
destination: string;
|
|
||||||
destinationPort: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!resource
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!bgTargetsResponse?.targets?.length) return;
|
|
||||||
const bgt = bgTargetsResponse.targets[0];
|
|
||||||
setBgDestination(bgt.destination);
|
|
||||||
setBgDestinationPort(String(bgt.destinationPort));
|
|
||||||
setBgSiteId(bgt.siteId);
|
|
||||||
setBgTargetId(bgt.browserGatewayTargetId);
|
|
||||||
}, [bgTargetsResponse]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sites.length > 0 && bgSiteId === null) {
|
|
||||||
setBgSiteId(sites[0].siteId);
|
|
||||||
}
|
|
||||||
}, [sites, bgSiteId]);
|
|
||||||
|
|
||||||
const updateTarget = useCallback(
|
const updateTarget = useCallback(
|
||||||
(targetId: number, data: Partial<LocalTarget>) => {
|
(targetId: number, data: Partial<LocalTarget>) => {
|
||||||
setTargets((prevTargets) => {
|
setTargets((prevTargets) => {
|
||||||
@@ -348,6 +375,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
{getStatusText(status)}
|
{getStatusText(status)}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
) : (
|
) : (
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
)}
|
)}
|
||||||
@@ -376,15 +404,9 @@ export function ProxyResourceTargetsForm({
|
|||||||
pathMatchType: row.original.pathMatchType
|
pathMatchType: row.original.pathMatchType
|
||||||
}}
|
}}
|
||||||
onChange={(config) =>
|
onChange={(config) =>
|
||||||
updateTarget(
|
updateTarget(row.original.targetId,
|
||||||
row.original.targetId,
|
config.path === null && config.pathMatchType === null
|
||||||
config.path === null &&
|
? { ...config, rewritePath: null, rewritePathType: null }
|
||||||
config.pathMatchType === null
|
|
||||||
? {
|
|
||||||
...config,
|
|
||||||
rewritePath: null,
|
|
||||||
rewritePathType: null
|
|
||||||
}
|
|
||||||
: config
|
: config
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -410,15 +432,9 @@ export function ProxyResourceTargetsForm({
|
|||||||
pathMatchType: row.original.pathMatchType
|
pathMatchType: row.original.pathMatchType
|
||||||
}}
|
}}
|
||||||
onChange={(config) =>
|
onChange={(config) =>
|
||||||
updateTarget(
|
updateTarget(row.original.targetId,
|
||||||
row.original.targetId,
|
config.path === null && config.pathMatchType === null
|
||||||
config.path === null &&
|
? { ...config, rewritePath: null, rewritePathType: null }
|
||||||
config.pathMatchType === null
|
|
||||||
? {
|
|
||||||
...config,
|
|
||||||
rewritePath: null,
|
|
||||||
rewritePathType: null
|
|
||||||
}
|
|
||||||
: config
|
: config
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -571,19 +587,20 @@ export function ProxyResourceTargetsForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isAdvancedMode) {
|
if (isAdvancedMode) {
|
||||||
const cols = [
|
const columns = [
|
||||||
addressColumn,
|
addressColumn,
|
||||||
healthCheckColumn,
|
healthCheckColumn,
|
||||||
enabledColumn,
|
enabledColumn,
|
||||||
actionsColumn
|
actionsColumn
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Only include path-related columns for HTTP resources
|
||||||
if (isHttp) {
|
if (isHttp) {
|
||||||
cols.unshift(matchPathColumn);
|
columns.unshift(matchPathColumn);
|
||||||
cols.splice(3, 0, rewritePathColumn, priorityColumn);
|
columns.splice(3, 0, rewritePathColumn, priorityColumn);
|
||||||
}
|
}
|
||||||
|
|
||||||
return cols;
|
return columns;
|
||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
addressColumn,
|
addressColumn,
|
||||||
@@ -605,20 +622,22 @@ export function ProxyResourceTargetsForm({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function addNewTarget() {
|
function addNewTarget() {
|
||||||
|
const isHttp = resource.http;
|
||||||
|
|
||||||
const newTarget: LocalTarget = {
|
const newTarget: LocalTarget = {
|
||||||
targetId: -Date.now(),
|
targetId: -Date.now(), // Use negative timestamp as temporary ID
|
||||||
ip: "",
|
ip: "",
|
||||||
method: isHttp ? "http" : null,
|
method: isHttp ? "http" : null,
|
||||||
port: 0,
|
port: 0,
|
||||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||||
siteName: sites.length > 0 ? sites[0].name : "",
|
siteName: sites.length > 0 ? sites[0].name : "",
|
||||||
path: null,
|
path: isHttp ? null : null,
|
||||||
pathMatchType: null,
|
pathMatchType: isHttp ? null : null,
|
||||||
rewritePath: null,
|
rewritePath: isHttp ? null : null,
|
||||||
rewritePathType: null,
|
rewritePathType: isHttp ? null : null,
|
||||||
priority: 100,
|
priority: isHttp ? 100 : 100,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
resourceId: resource?.resourceId ?? 0,
|
resourceId: resource.resourceId,
|
||||||
hcEnabled: false,
|
hcEnabled: false,
|
||||||
hcPath: null,
|
hcPath: null,
|
||||||
hcMethod: null,
|
hcMethod: null,
|
||||||
@@ -675,6 +694,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -684,6 +704,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
}, [sites]);
|
}, [sites]);
|
||||||
|
|
||||||
|
// Save advanced mode preference to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@@ -696,8 +717,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
||||||
|
|
||||||
async function saveTargets() {
|
async function saveTargets() {
|
||||||
if (!resource) return;
|
// Validate that no targets have blank IPs or invalid ports
|
||||||
|
|
||||||
const targetsWithInvalidFields = targets.filter(
|
const targetsWithInvalidFields = targets.filter(
|
||||||
(target) =>
|
(target) =>
|
||||||
!target.ip ||
|
!target.ip ||
|
||||||
@@ -706,6 +726,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
target.port <= 0 ||
|
target.port <= 0 ||
|
||||||
isNaN(target.port)
|
isNaN(target.port)
|
||||||
);
|
);
|
||||||
|
console.log(targetsWithInvalidFields);
|
||||||
if (targetsWithInvalidFields.length > 0) {
|
if (targetsWithInvalidFields.length > 0) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -722,6 +743,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Save targets
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const data: any = {
|
const data: any = {
|
||||||
ip: target.ip,
|
ip: target.ip,
|
||||||
@@ -747,7 +769,8 @@ export function ProxyResourceTargetsForm({
|
|||||||
hcUnhealthyThreshold: target.hcUnhealthyThreshold || null
|
hcUnhealthyThreshold: target.hcUnhealthyThreshold || null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isHttp) {
|
// Only include path-related fields for HTTP resources
|
||||||
|
if (resource.http) {
|
||||||
data.path = target.path;
|
data.path = target.path;
|
||||||
data.pathMatchType = target.pathMatchType;
|
data.pathMatchType = target.pathMatchType;
|
||||||
data.rewritePath = target.rewritePath;
|
data.rewritePath = target.rewritePath;
|
||||||
@@ -768,12 +791,10 @@ export function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title:
|
title: targets.length === 0
|
||||||
targets.length === 0
|
|
||||||
? t("targetTargetsCleared")
|
? t("targetTargetsCleared")
|
||||||
: t("settingsUpdated"),
|
: t("settingsUpdated"),
|
||||||
description:
|
description: targets.length === 0
|
||||||
targets.length === 0
|
|
||||||
? t("targetTargetsClearedDescription")
|
? t("targetTargetsClearedDescription")
|
||||||
: t("settingsUpdatedDescription")
|
: t("settingsUpdatedDescription")
|
||||||
});
|
});
|
||||||
@@ -897,6 +918,9 @@ export function ProxyResourceTargetsForm({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
{/* <TableCaption> */}
|
||||||
|
{/* {t('targetNoOneDescription')} */}
|
||||||
|
{/* </TableCaption> */}
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -954,8 +978,6 @@ export function ProxyResourceTargetsForm({
|
|||||||
)}
|
)}
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
|
|
||||||
{/* Save button — only shown in edit mode */}
|
|
||||||
{resource && (
|
|
||||||
<form className="self-end mt-4" action={formAction}>
|
<form className="self-end mt-4" action={formAction}>
|
||||||
<Button
|
<Button
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
@@ -965,7 +987,6 @@ export function ProxyResourceTargetsForm({
|
|||||||
{t("saveResourceTargets")}
|
{t("saveResourceTargets")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{selectedTargetForHealthCheck && (
|
{selectedTargetForHealthCheck && (
|
||||||
@@ -1028,3 +1049,500 @@ export function ProxyResourceTargetsForm({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProxyResourceHttpForm({
|
||||||
|
resource,
|
||||||
|
updateResource
|
||||||
|
}: Pick<ResourceContextType, "resource" | "updateResource">) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const tlsSettingsSchema = z.object({
|
||||||
|
ssl: z.boolean(),
|
||||||
|
tlsServerName: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data) {
|
||||||
|
return tlsNameSchema.safeParse(data).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("proxyErrorTls")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const tlsSettingsForm = useForm({
|
||||||
|
resolver: zodResolver(tlsSettingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
ssl: resource.ssl,
|
||||||
|
tlsServerName: resource.tlsServerName || ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxySettingsSchema = z.object({
|
||||||
|
setHostHeader: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data) {
|
||||||
|
return tlsNameSchema.safeParse(data).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("proxyErrorInvalidHeader")
|
||||||
|
}
|
||||||
|
),
|
||||||
|
headers: z
|
||||||
|
.array(z.object({ name: z.string(), value: z.string() }))
|
||||||
|
.nullable(),
|
||||||
|
proxyProtocol: z.boolean().optional(),
|
||||||
|
proxyProtocolVersion: z.int().min(1).max(2).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxySettingsForm = useForm({
|
||||||
|
resolver: zodResolver(proxySettingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
setHostHeader: resource.setHostHeader || "",
|
||||||
|
headers: resource.headers,
|
||||||
|
proxyProtocol: resource.proxyProtocol || false,
|
||||||
|
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
|
const targetsSettingsForm = useForm({
|
||||||
|
resolver: zodResolver(targetsSettingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
stickySession: resource.stickySession
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const [, formAction, isSubmitting] = useActionState(
|
||||||
|
saveResourceHttpSettings,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
async function saveResourceHttpSettings() {
|
||||||
|
const isValidTLS = await tlsSettingsForm.trigger();
|
||||||
|
const isValidProxy = await proxySettingsForm.trigger();
|
||||||
|
const targetSettingsForm = await targetsSettingsForm.trigger();
|
||||||
|
if (!isValidTLS || !isValidProxy || !targetSettingsForm) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Gather all settings
|
||||||
|
const stickySessionData = targetsSettingsForm.getValues();
|
||||||
|
const tlsData = tlsSettingsForm.getValues();
|
||||||
|
const proxyData = proxySettingsForm.getValues();
|
||||||
|
|
||||||
|
// Combine into one payload
|
||||||
|
const payload = {
|
||||||
|
stickySession: stickySessionData.stickySession,
|
||||||
|
ssl: tlsData.ssl,
|
||||||
|
tlsServerName: tlsData.tlsServerName || null,
|
||||||
|
setHostHeader: proxyData.setHostHeader || null,
|
||||||
|
headers: proxyData.headers || null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single API call to update all settings
|
||||||
|
await api.post(`/resource/${resource.resourceId}`, payload);
|
||||||
|
|
||||||
|
// Update local resource context
|
||||||
|
updateResource({
|
||||||
|
...resource,
|
||||||
|
stickySession: stickySessionData.stickySession,
|
||||||
|
ssl: tlsData.ssl,
|
||||||
|
tlsServerName: tlsData.tlsServerName || null,
|
||||||
|
setHostHeader: proxyData.setHostHeader || null,
|
||||||
|
headers: proxyData.headers || null
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("settingsUpdated"),
|
||||||
|
description: t("settingsUpdatedDescription")
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settingsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("settingsErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("proxyAdditional")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("proxyAdditionalDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...tlsSettingsForm}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="space-y-4"
|
||||||
|
id="tls-settings-form"
|
||||||
|
>
|
||||||
|
{!env.flags.usePangolinDns && (
|
||||||
|
<FormField
|
||||||
|
control={tlsSettingsForm.control}
|
||||||
|
name="ssl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="ssl-toggle"
|
||||||
|
label={t("proxyEnableSSL")}
|
||||||
|
description={t(
|
||||||
|
"proxyEnableSSLDescription"
|
||||||
|
)}
|
||||||
|
defaultChecked={field.value}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
field.onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={tlsSettingsForm.control}
|
||||||
|
name="tlsServerName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("targetTlsSni")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("targetTlsSniDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...targetsSettingsForm}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="space-y-4"
|
||||||
|
id="targets-settings-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={targetsSettingsForm.control}
|
||||||
|
name="stickySession"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="sticky-toggle"
|
||||||
|
label={t(
|
||||||
|
"targetStickySessions"
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"targetStickySessionsDescription"
|
||||||
|
)}
|
||||||
|
defaultChecked={field.value}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
field.onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...proxySettingsForm}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="space-y-4"
|
||||||
|
id="proxy-settings-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={proxySettingsForm.control}
|
||||||
|
name="setHostHeader"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("proxyCustomHeader")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("proxyCustomHeaderDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={proxySettingsForm.control}
|
||||||
|
name="headers"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("customHeaders")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<HeadersInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("customHeadersDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
<form className="flex justify-end" action={formAction}>
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("saveResourceHttp")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProxyResourceProtocolForm({
|
||||||
|
resource,
|
||||||
|
updateResource
|
||||||
|
}: Pick<ResourceContextType, "resource" | "updateResource">) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const proxySettingsSchema = z.object({
|
||||||
|
setHostHeader: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data) {
|
||||||
|
return tlsNameSchema.safeParse(data).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("proxyErrorInvalidHeader")
|
||||||
|
}
|
||||||
|
),
|
||||||
|
headers: z
|
||||||
|
.array(z.object({ name: z.string(), value: z.string() }))
|
||||||
|
.nullable(),
|
||||||
|
proxyProtocol: z.boolean().optional(),
|
||||||
|
proxyProtocolVersion: z.int().min(1).max(2).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxySettingsForm = useForm({
|
||||||
|
resolver: zodResolver(proxySettingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
setHostHeader: resource.setHostHeader || "",
|
||||||
|
headers: resource.headers,
|
||||||
|
proxyProtocol: resource.proxyProtocol || false,
|
||||||
|
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [, formAction, isSubmitting] = useActionState(
|
||||||
|
saveProtocolSettings,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
async function saveProtocolSettings() {
|
||||||
|
const isValid = proxySettingsForm.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For TCP/UDP resources, save proxy protocol settings
|
||||||
|
const proxyData = proxySettingsForm.getValues();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
proxyProtocol: proxyData.proxyProtocol || false,
|
||||||
|
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.post(`/resource/${resource.resourceId}`, payload);
|
||||||
|
|
||||||
|
updateResource({
|
||||||
|
...resource,
|
||||||
|
proxyProtocol: proxyData.proxyProtocol || false,
|
||||||
|
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("settingsUpdated"),
|
||||||
|
description: t("settingsUpdatedDescription")
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settingsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("settingsErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("proxyProtocol")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("proxyProtocolDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...proxySettingsForm}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="space-y-4"
|
||||||
|
id="proxy-protocol-settings-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={proxySettingsForm.control}
|
||||||
|
name="proxyProtocol"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="proxy-protocol-toggle"
|
||||||
|
label={t("enableProxyProtocol")}
|
||||||
|
description={t(
|
||||||
|
"proxyProtocolInfo"
|
||||||
|
)}
|
||||||
|
defaultChecked={
|
||||||
|
field.value || false
|
||||||
|
}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
field.onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{proxySettingsForm.watch("proxyProtocol") && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={proxySettingsForm.control}
|
||||||
|
name="proxyProtocolVersion"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("proxyProtocolVersion")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={String(
|
||||||
|
field.value || 1
|
||||||
|
)}
|
||||||
|
onValueChange={(
|
||||||
|
value
|
||||||
|
) =>
|
||||||
|
field.onChange(
|
||||||
|
parseInt(
|
||||||
|
value,
|
||||||
|
10
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select version" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">
|
||||||
|
{t("version1")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="2">
|
||||||
|
{t("version2")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("versionDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>{t("warning")}:</strong>{" "}
|
||||||
|
{t("proxyProtocolWarning")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
<form action={formAction} className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("saveProxyProtocol")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
SettingsContainer,
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
|
||||||
import { type Selectedsite } from "@app/components/site-selector";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { use, useActionState, useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { GetResourceResponse } from "@server/routers/resource";
|
|
||||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
|
||||||
|
|
||||||
type ExistingTarget = {
|
|
||||||
browserGatewayTargetId: number;
|
|
||||||
siteId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sshFormSchema = z.object({
|
|
||||||
authDaemonPort: z.string().refine(
|
|
||||||
(val) => {
|
|
||||||
if (!val) return true;
|
|
||||||
const n = Number(val);
|
|
||||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
|
||||||
},
|
|
||||||
{ message: "Port must be between 1 and 65535" }
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function SshSettingsPage(props: {
|
|
||||||
params: Promise<{ orgId: string }>;
|
|
||||||
}) {
|
|
||||||
const params = use(props.params);
|
|
||||||
const { resource, updateResource } = useResourceContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsContainer>
|
|
||||||
<SshServerForm
|
|
||||||
orgId={params.orgId}
|
|
||||||
resource={resource}
|
|
||||||
updateResource={updateResource}
|
|
||||||
/>
|
|
||||||
</SettingsContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SshServerForm({
|
|
||||||
orgId,
|
|
||||||
resource,
|
|
||||||
updateResource
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
resource: GetResourceResponse;
|
|
||||||
updateResource: ResourceContextType["updateResource"];
|
|
||||||
}) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Standard mode: multi-site
|
|
||||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
|
||||||
const [bgDestination, setBgDestination] = useState("");
|
|
||||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
|
||||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Native mode: single site
|
|
||||||
const [selectedNativeSite, setSelectedNativeSite] =
|
|
||||||
useState<Selectedsite | null>(null);
|
|
||||||
const [nativeExistingTarget, setNativeExistingTarget] =
|
|
||||||
useState<ExistingTarget | null>(null);
|
|
||||||
|
|
||||||
const { data: bgTargetsResponse } = useQuery({
|
|
||||||
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await api.get(
|
|
||||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
|
||||||
);
|
|
||||||
return res.data.data as {
|
|
||||||
targets: Array<{
|
|
||||||
browserGatewayTargetId: number;
|
|
||||||
resourceId: number;
|
|
||||||
siteId: number;
|
|
||||||
siteName?: string;
|
|
||||||
type: string;
|
|
||||||
destination: string;
|
|
||||||
destinationPort: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!bgTargetsResponse?.targets?.length) return;
|
|
||||||
const targets = bgTargetsResponse.targets;
|
|
||||||
const first = targets[0];
|
|
||||||
|
|
||||||
setBgDestination(first.destination);
|
|
||||||
setBgDestinationPort(String(first.destinationPort));
|
|
||||||
setExistingTargets(
|
|
||||||
targets.map((t) => ({
|
|
||||||
browserGatewayTargetId: t.browserGatewayTargetId,
|
|
||||||
siteId: t.siteId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
setSelectedSites(
|
|
||||||
targets.map((t) => ({
|
|
||||||
siteId: t.siteId,
|
|
||||||
name: t.siteName ?? String(t.siteId),
|
|
||||||
type: "newt" as const
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}, [bgTargetsResponse]);
|
|
||||||
|
|
||||||
const [, formAction, isSubmitting] = useActionState(save, null);
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
try {
|
|
||||||
if (bgDestination && bgDestinationPort) {
|
|
||||||
const selectedSiteIds = new Set(
|
|
||||||
selectedSites.map((s) => s.siteId)
|
|
||||||
);
|
|
||||||
const existingSiteIds = new Set(
|
|
||||||
existingTargets.map((t) => t.siteId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const toDelete = existingTargets.filter(
|
|
||||||
(t) => !selectedSiteIds.has(t.siteId)
|
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
toDelete.map((t) =>
|
|
||||||
api.delete(
|
|
||||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const toUpdate = existingTargets.filter((t) =>
|
|
||||||
selectedSiteIds.has(t.siteId)
|
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
toUpdate.map((t) =>
|
|
||||||
api.post(
|
|
||||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
|
||||||
{
|
|
||||||
type: "rdp",
|
|
||||||
destination: bgDestination,
|
|
||||||
destinationPort: Number(bgDestinationPort),
|
|
||||||
siteId: t.siteId
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const toCreate = selectedSites.filter(
|
|
||||||
(s) => !existingSiteIds.has(s.siteId)
|
|
||||||
);
|
|
||||||
const created = await Promise.all(
|
|
||||||
toCreate.map((s) =>
|
|
||||||
api.put(
|
|
||||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
|
||||||
{
|
|
||||||
siteId: s.siteId,
|
|
||||||
type: "rdp",
|
|
||||||
destination: bgDestination,
|
|
||||||
destinationPort: Number(bgDestinationPort)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
|
||||||
browserGatewayTargetId:
|
|
||||||
res.data.data.browserGatewayTargetId,
|
|
||||||
siteId: toCreate[i].siteId
|
|
||||||
}));
|
|
||||||
setExistingTargets([...toUpdate, ...newTargets]);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("settingsUpdated"),
|
|
||||||
description: t("settingsUpdatedDescription")
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("settingsErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
err,
|
|
||||||
t("settingsErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>{t("rdpServer")}</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("rdpServerDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm variant="half">
|
|
||||||
<BrowserGatewayTargetForm
|
|
||||||
orgId={orgId}
|
|
||||||
multiSite={true}
|
|
||||||
selectedSites={selectedSites}
|
|
||||||
onSitesChange={setSelectedSites}
|
|
||||||
destination={bgDestination}
|
|
||||||
destinationPort={bgDestinationPort}
|
|
||||||
onDestinationChange={setBgDestination}
|
|
||||||
onDestinationPortChange={setBgDestinationPort}
|
|
||||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
|
|
||||||
defaultPort={3389}
|
|
||||||
/>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
<form action={formAction} className="flex justify-end mt-4">
|
|
||||||
<Button
|
|
||||||
disabled={isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{t("saveSettings")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,9 @@ import { Input } from "@/components/ui/input";
|
|||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
@@ -34,6 +36,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
@@ -52,11 +55,18 @@ import {
|
|||||||
SettingsSectionTitle,
|
SettingsSectionTitle,
|
||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
SettingsSectionFooter
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { ArrowUpDown, Check, InfoIcon, X, ChevronsUpDown } from "lucide-react";
|
import { ArrowUpDown, Check, InfoIcon, X, ChevronsUpDown } from "lucide-react";
|
||||||
|
import {
|
||||||
|
InfoSection,
|
||||||
|
InfoSections,
|
||||||
|
InfoSectionTitle
|
||||||
|
} from "@app/components/InfoSection";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
import {
|
import {
|
||||||
isValidCIDR,
|
isValidCIDR,
|
||||||
@@ -68,11 +78,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { COUNTRIES } from "@server/db/countries";
|
import { COUNTRIES } from "@server/db/countries";
|
||||||
import { MAJOR_ASNS } from "@server/db/asns";
|
import { MAJOR_ASNS } from "@server/db/asns";
|
||||||
import {
|
import { REGIONS, getRegionNameById, isValidRegionId } from "@server/db/regions";
|
||||||
REGIONS,
|
|
||||||
getRegionNameById,
|
|
||||||
isValidRegionId
|
|
||||||
} from "@server/db/regions";
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -103,23 +109,25 @@ type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
|||||||
export default function ResourceRules(props: {
|
export default function ResourceRules(props: {
|
||||||
params: Promise<{ resourceId: number }>;
|
params: Promise<{ resourceId: number }>;
|
||||||
}) {
|
}) {
|
||||||
|
const params = use(props.params);
|
||||||
const { resource, updateResource } = useResourceContext();
|
const { resource, updateResource } = useResourceContext();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [rules, setRules] = useState<LocalRule[]>([]);
|
const [rules, setRules] = useState<LocalRule[]>([]);
|
||||||
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
const [rulesEnabled, setRulesEnabled] = useState(
|
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false);
|
||||||
resource.applyRules ?? false
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRulesEnabled(resource.applyRules);
|
setRulesEnabled(resource.applyRules);
|
||||||
}, [resource.applyRules]);
|
}, [resource.applyRules]);
|
||||||
|
|
||||||
|
const [openCountrySelect, setOpenCountrySelect] = useState(false);
|
||||||
|
const [countrySelectValue, setCountrySelectValue] = useState("");
|
||||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false);
|
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] =
|
||||||
|
useState(false);
|
||||||
const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] =
|
const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -149,10 +157,7 @@ export default function ResourceRules(props: {
|
|||||||
resolver: zodResolver(addRuleSchema),
|
resolver: zodResolver(addRuleSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
action: "ACCEPT",
|
action: "ACCEPT",
|
||||||
match:
|
match: "PATH",
|
||||||
resource.http && resource.browserAccessType == "http"
|
|
||||||
? "PATH"
|
|
||||||
: "IP",
|
|
||||||
value: ""
|
value: ""
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -265,12 +270,16 @@ export default function ResourceRules(props: {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.match === "REGION" && !isValidRegionId(data.value)) {
|
if (
|
||||||
|
data.match === "REGION" &&
|
||||||
|
!isValidRegionId(data.value)
|
||||||
|
) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("rulesErrorInvalidRegion"),
|
title: t("rulesErrorInvalidRegion"),
|
||||||
description:
|
description:
|
||||||
t("rulesErrorInvalidRegionDescription") || "Invalid region."
|
t("rulesErrorInvalidRegionDescription") ||
|
||||||
|
"Invalid region."
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -555,24 +564,12 @@ export default function ResourceRules(props: {
|
|||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.match}
|
defaultValue={row.original.match}
|
||||||
onValueChange={(
|
onValueChange={(
|
||||||
value:
|
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION"
|
||||||
| "CIDR"
|
|
||||||
| "IP"
|
|
||||||
| "PATH"
|
|
||||||
| "COUNTRY"
|
|
||||||
| "ASN"
|
|
||||||
| "REGION"
|
|
||||||
) =>
|
) =>
|
||||||
updateRule(row.original.ruleId, {
|
updateRule(row.original.ruleId, {
|
||||||
match: value,
|
match: value,
|
||||||
value:
|
value:
|
||||||
value === "COUNTRY"
|
value === "COUNTRY" ? "US" : value === "ASN" ? "AS15169" : value === "REGION" ? "021" : row.original.value
|
||||||
? "US"
|
|
||||||
: value === "ASN"
|
|
||||||
? "AS15169"
|
|
||||||
: value === "REGION"
|
|
||||||
? "021"
|
|
||||||
: row.original.value
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -580,12 +577,7 @@ export default function ResourceRules(props: {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resource.http &&
|
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
||||||
resource.browserAccessType == "http" && (
|
|
||||||
<SelectItem value="PATH">
|
|
||||||
{RuleMatch.PATH}
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||||
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
||||||
{isMaxmindAvailable && (
|
{isMaxmindAvailable && (
|
||||||
@@ -763,9 +755,7 @@ export default function ResourceRules(props: {
|
|||||||
className="min-w-[200px] justify-between"
|
className="min-w-[200px] justify-between"
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const regionName = getRegionNameById(
|
const regionName = getRegionNameById(row.original.value);
|
||||||
row.original.value
|
|
||||||
);
|
|
||||||
if (!regionName) {
|
if (!regionName) {
|
||||||
return t("selectRegion");
|
return t("selectRegion");
|
||||||
}
|
}
|
||||||
@@ -784,10 +774,7 @@ export default function ResourceRules(props: {
|
|||||||
{t("noRegionFound")}
|
{t("noRegionFound")}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
{REGIONS.map((continent) => (
|
{REGIONS.map((continent) => (
|
||||||
<CommandGroup
|
<CommandGroup key={continent.id} heading={t(continent.name)}>
|
||||||
key={continent.id}
|
|
||||||
heading={t(continent.name)}
|
|
||||||
>
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={continent.id}
|
value={continent.id}
|
||||||
keywords={[
|
keywords={[
|
||||||
@@ -803,17 +790,14 @@ export default function ResourceRules(props: {
|
|||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={`mr-2 h-4 w-4 ${
|
className={`mr-2 h-4 w-4 ${
|
||||||
row.original.value ===
|
row.original.value === continent.id
|
||||||
continent.id
|
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0"
|
: "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{t(continent.name)} (
|
{t(continent.name)} ({continent.id})
|
||||||
{continent.id})
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
{continent.includes.map(
|
{continent.includes.map((subregion) => (
|
||||||
(subregion) => (
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={subregion.id}
|
key={subregion.id}
|
||||||
value={subregion.id}
|
value={subregion.id}
|
||||||
@@ -823,28 +807,21 @@ export default function ResourceRules(props: {
|
|||||||
]}
|
]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
updateRule(
|
updateRule(
|
||||||
row.original
|
row.original.ruleId,
|
||||||
.ruleId,
|
{ value: subregion.id }
|
||||||
{
|
|
||||||
value: subregion.id
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={`mr-2 h-4 w-4 ${
|
className={`mr-2 h-4 w-4 ${
|
||||||
row.original
|
row.original.value === subregion.id
|
||||||
.value ===
|
|
||||||
subregion.id
|
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0"
|
: "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{t(subregion.name)} (
|
{t(subregion.name)} ({subregion.id})
|
||||||
{subregion.id})
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
))}
|
))}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
@@ -1041,9 +1018,7 @@ export default function ResourceRules(props: {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resource.http &&
|
{resource.http && (
|
||||||
resource.browserAccessType ==
|
|
||||||
"http" && (
|
|
||||||
<SelectItem value="PATH">
|
<SelectItem value="PATH">
|
||||||
{
|
{
|
||||||
RuleMatch.PATH
|
RuleMatch.PATH
|
||||||
@@ -1359,16 +1334,8 @@ export default function ResourceRules(props: {
|
|||||||
>
|
>
|
||||||
{field.value
|
{field.value
|
||||||
? (() => {
|
? (() => {
|
||||||
const regionName =
|
const regionName = getRegionNameById(field.value);
|
||||||
getRegionNameById(
|
const translatedName = regionName ? t(regionName) : field.value;
|
||||||
field.value
|
|
||||||
);
|
|
||||||
const translatedName =
|
|
||||||
regionName
|
|
||||||
? t(
|
|
||||||
regionName
|
|
||||||
)
|
|
||||||
: field.value;
|
|
||||||
return `${translatedName} (${field.value})`;
|
return `${translatedName} (${field.value})`;
|
||||||
})()
|
})()
|
||||||
: t(
|
: t(
|
||||||
@@ -1390,26 +1357,12 @@ export default function ResourceRules(props: {
|
|||||||
"noRegionFound"
|
"noRegionFound"
|
||||||
)}
|
)}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
{REGIONS.map(
|
{REGIONS.map((continent) => (
|
||||||
(
|
<CommandGroup key={continent.id} heading={t(continent.name)}>
|
||||||
continent
|
|
||||||
) => (
|
|
||||||
<CommandGroup
|
|
||||||
key={
|
|
||||||
continent.id
|
|
||||||
}
|
|
||||||
heading={t(
|
|
||||||
continent.name
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={
|
value={continent.id}
|
||||||
continent.id
|
|
||||||
}
|
|
||||||
keywords={[
|
keywords={[
|
||||||
t(
|
t(continent.name),
|
||||||
continent.name
|
|
||||||
),
|
|
||||||
continent.id
|
continent.id
|
||||||
]}
|
]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -1423,37 +1376,19 @@ export default function ResourceRules(props: {
|
|||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={`mr-2 h-4 w-4 ${
|
className={`mr-2 h-4 w-4 ${
|
||||||
field.value ===
|
field.value === continent.id
|
||||||
continent.id
|
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0"
|
: "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{t(
|
{t(continent.name)} ({continent.id})
|
||||||
continent.name
|
|
||||||
)}{" "}
|
|
||||||
(
|
|
||||||
{
|
|
||||||
continent.id
|
|
||||||
}
|
|
||||||
|
|
||||||
)
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
{continent.includes.map(
|
{continent.includes.map((subregion) => (
|
||||||
(
|
|
||||||
subregion
|
|
||||||
) => (
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={
|
key={subregion.id}
|
||||||
subregion.id
|
value={subregion.id}
|
||||||
}
|
|
||||||
value={
|
|
||||||
subregion.id
|
|
||||||
}
|
|
||||||
keywords={[
|
keywords={[
|
||||||
t(
|
t(subregion.name),
|
||||||
subregion.name
|
|
||||||
),
|
|
||||||
subregion.id
|
subregion.id
|
||||||
]}
|
]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -1467,27 +1402,16 @@ export default function ResourceRules(props: {
|
|||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={`mr-2 h-4 w-4 ${
|
className={`mr-2 h-4 w-4 ${
|
||||||
field.value ===
|
field.value === subregion.id
|
||||||
subregion.id
|
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0"
|
: "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{t(
|
{t(subregion.name)} ({subregion.id})
|
||||||
subregion.name
|
|
||||||
)}{" "}
|
|
||||||
(
|
|
||||||
{
|
|
||||||
subregion.id
|
|
||||||
}
|
|
||||||
|
|
||||||
)
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -1,524 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
SettingsContainer,
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
|
||||||
import { type Selectedsite } from "@app/components/site-selector";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { use, useActionState, useEffect, useState } from "react";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { GetResourceResponse } from "@server/routers/resource";
|
|
||||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
|
||||||
|
|
||||||
type ExistingTarget = {
|
|
||||||
browserGatewayTargetId: number;
|
|
||||||
siteId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sshFormSchema = z.object({
|
|
||||||
authDaemonPort: z.string().refine(
|
|
||||||
(val) => {
|
|
||||||
if (!val) return true;
|
|
||||||
const n = Number(val);
|
|
||||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
|
||||||
},
|
|
||||||
{ message: "Port must be between 1 and 65535" }
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function SshSettingsPage(props: {
|
|
||||||
params: Promise<{ orgId: string }>;
|
|
||||||
}) {
|
|
||||||
const params = use(props.params);
|
|
||||||
const { resource, updateResource } = useResourceContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsContainer>
|
|
||||||
<SshServerForm
|
|
||||||
orgId={params.orgId}
|
|
||||||
resource={resource}
|
|
||||||
updateResource={updateResource}
|
|
||||||
/>
|
|
||||||
</SettingsContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SshServerForm({
|
|
||||||
orgId,
|
|
||||||
resource,
|
|
||||||
updateResource
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
resource: GetResourceResponse;
|
|
||||||
updateResource: ResourceContextType["updateResource"];
|
|
||||||
}) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Standard mode: multi-site
|
|
||||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
|
||||||
const [bgDestination, setBgDestination] = useState("");
|
|
||||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
|
||||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Native mode: single site
|
|
||||||
const [selectedNativeSite, setSelectedNativeSite] =
|
|
||||||
useState<Selectedsite | null>(null);
|
|
||||||
const [nativeExistingTarget, setNativeExistingTarget] =
|
|
||||||
useState<ExistingTarget | null>(null);
|
|
||||||
|
|
||||||
const { data: bgTargetsResponse } = useQuery({
|
|
||||||
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await api.get(
|
|
||||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
|
||||||
);
|
|
||||||
return res.data.data as {
|
|
||||||
targets: Array<{
|
|
||||||
browserGatewayTargetId: number;
|
|
||||||
resourceId: number;
|
|
||||||
siteId: number;
|
|
||||||
siteName?: string;
|
|
||||||
type: string;
|
|
||||||
destination: string;
|
|
||||||
destinationPort: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!bgTargetsResponse?.targets?.length) return;
|
|
||||||
const targets = bgTargetsResponse.targets;
|
|
||||||
const first = targets[0];
|
|
||||||
|
|
||||||
setBgDestination(first.destination);
|
|
||||||
setBgDestinationPort(String(first.destinationPort));
|
|
||||||
setExistingTargets(
|
|
||||||
targets.map((t) => ({
|
|
||||||
browserGatewayTargetId: t.browserGatewayTargetId,
|
|
||||||
siteId: t.siteId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
setSelectedSites(
|
|
||||||
targets.map((t) => ({
|
|
||||||
siteId: t.siteId,
|
|
||||||
name: t.siteName ?? String(t.siteId),
|
|
||||||
type: "newt" as const
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}, [bgTargetsResponse]);
|
|
||||||
|
|
||||||
const [, formAction, isSubmitting] = useActionState(save, null);
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
try {
|
|
||||||
if (bgDestination && bgDestinationPort) {
|
|
||||||
const selectedSiteIds = new Set(
|
|
||||||
selectedSites.map((s) => s.siteId)
|
|
||||||
);
|
|
||||||
const existingSiteIds = new Set(
|
|
||||||
existingTargets.map((t) => t.siteId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const toDelete = existingTargets.filter(
|
|
||||||
(t) => !selectedSiteIds.has(t.siteId)
|
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
toDelete.map((t) =>
|
|
||||||
api.delete(
|
|
||||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const toUpdate = existingTargets.filter((t) =>
|
|
||||||
selectedSiteIds.has(t.siteId)
|
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
toUpdate.map((t) =>
|
|
||||||
api.post(
|
|
||||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
|
||||||
{
|
|
||||||
type: "vnc",
|
|
||||||
destination: bgDestination,
|
|
||||||
destinationPort: Number(bgDestinationPort),
|
|
||||||
siteId: t.siteId
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const toCreate = selectedSites.filter(
|
|
||||||
(s) => !existingSiteIds.has(s.siteId)
|
|
||||||
);
|
|
||||||
const created = await Promise.all(
|
|
||||||
toCreate.map((s) =>
|
|
||||||
api.put(
|
|
||||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
|
||||||
{
|
|
||||||
siteId: s.siteId,
|
|
||||||
type: "vnc",
|
|
||||||
destination: bgDestination,
|
|
||||||
destinationPort: Number(bgDestinationPort)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
|
||||||
browserGatewayTargetId:
|
|
||||||
res.data.data.browserGatewayTargetId,
|
|
||||||
siteId: toCreate[i].siteId
|
|
||||||
}));
|
|
||||||
setExistingTargets([...toUpdate, ...newTargets]);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("settingsUpdated"),
|
|
||||||
description: t("settingsUpdatedDescription")
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("settingsErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
err,
|
|
||||||
t("settingsErrorUpdateDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsSection>
|
|
||||||
<SettingsSectionHeader>
|
|
||||||
<SettingsSectionTitle>{t("vncServer")}</SettingsSectionTitle>
|
|
||||||
<SettingsSectionDescription>
|
|
||||||
{t("vncServerDescription")}
|
|
||||||
</SettingsSectionDescription>
|
|
||||||
</SettingsSectionHeader>
|
|
||||||
<SettingsSectionBody>
|
|
||||||
<SettingsSectionForm variant="half">
|
|
||||||
<BrowserGatewayTargetForm
|
|
||||||
orgId={orgId}
|
|
||||||
multiSite={true}
|
|
||||||
selectedSites={selectedSites}
|
|
||||||
onSitesChange={setSelectedSites}
|
|
||||||
destination={bgDestination}
|
|
||||||
destinationPort={bgDestinationPort}
|
|
||||||
onDestinationChange={setBgDestination}
|
|
||||||
onDestinationPortChange={setBgDestinationPort}
|
|
||||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
|
|
||||||
defaultPort={5900}
|
|
||||||
/>
|
|
||||||
</SettingsSectionForm>
|
|
||||||
</SettingsSectionBody>
|
|
||||||
<form action={formAction} className="flex justify-end mt-4">
|
|
||||||
<Button
|
|
||||||
disabled={isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{t("saveSettings")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -126,7 +126,6 @@ export default async function ProxyResourcesPage(
|
|||||||
fullDomain: resource.fullDomain ?? null,
|
fullDomain: resource.fullDomain ?? null,
|
||||||
ssl: resource.ssl,
|
ssl: resource.ssl,
|
||||||
wildcard: resource.wildcard,
|
wildcard: resource.wildcard,
|
||||||
browserAccessType: resource.browserAccessType,
|
|
||||||
targets: resource.targets?.map((target) => ({
|
targets: resource.targets?.map((target) => ({
|
||||||
targetId: target.targetId,
|
targetId: target.targetId,
|
||||||
ip: target.ip,
|
ip: target.ip,
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
+3
-1
@@ -21,9 +21,11 @@ export default async function Page(props: {
|
|||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
redirect: string | undefined;
|
redirect: string | undefined;
|
||||||
t: string | undefined;
|
t: string | undefined;
|
||||||
|
orgs?: string | undefined;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const params = await props.searchParams; // this is needed to prevent static optimization
|
const params = await props.searchParams; // this is needed to prevent static optimization
|
||||||
|
const showOrgPicker = params.orgs === "1";
|
||||||
|
|
||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
|
|
||||||
@@ -106,7 +108,7 @@ export default async function Page(props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetOrgId) {
|
if (targetOrgId && !showOrgPicker) {
|
||||||
return <RedirectToOrg targetOrgId={targetOrgId} />;
|
return <RedirectToOrg targetOrgId={targetOrgId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,522 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import { priv } from "@app/lib/api";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
|
||||||
import VncClient from "./VncClient";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "VNC"
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function VncPage() {
|
|
||||||
const headersList = await headers();
|
|
||||||
const host = headersList.get("host") || "";
|
|
||||||
const hostname = host.split(":")[0];
|
|
||||||
|
|
||||||
let target: GetBrowserTargetResponse | null = null;
|
|
||||||
let error: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
|
||||||
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
|
||||||
);
|
|
||||||
target = res.data.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching browser target:", error);
|
|
||||||
error = "No resource found for this domain";
|
|
||||||
}
|
|
||||||
|
|
||||||
return <VncClient target={target} error={error} />;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user