From 51629247a51456325747dad493046031f90a1b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Fri, 29 May 2026 22:44:16 +0000 Subject: [PATCH 01/28] fix(middleware): prevent cross-org site binding in target create/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend verifySiteAccess to check that when req.userOrgId is already set by a prior middleware (e.g. verifyResourceAccess/verifyTargetAccess), the site from req.body.siteId belongs to the same organization. This prevents the cross-organization tunnel boundary bypass where an attacker with resource access in one org binds that resource's target to a site in another org. Add verifySiteAccess to both target route stacks: - PUT /resource/:resourceId/target (after verifyResourceAccess) - POST /target/:targetId (after verifyTargetAccess) The org-match check runs before req.userOrg is overwritten, so the resource's organization context is preserved for comparison. Signed-off-by: Marc Schäfer --- server/middlewares/verifySiteAccess.ts | 14 ++++++++++---- server/routers/external.ts | 5 ++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index e630cf0f1..c4d35a52f 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -71,6 +71,15 @@ export async function verifySiteAccess( ); } + if (req.userOrgId && site.orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this site" + ) + ); + } + if (!req.userOrg) { // Get user's role ID in the organization const userOrgRole = await db @@ -128,10 +137,7 @@ export async function verifySiteAccess( .where( and( eq(roleSites.siteId, site.siteId), - inArray( - roleSites.roleId, - req.userOrgRoleIds! - ) + inArray(roleSites.roleId, req.userOrgRoleIds!) ) ) .limit(1) diff --git a/server/routers/external.ts b/server/routers/external.ts index 440bb5f21..db0db594a 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -561,6 +561,7 @@ authenticated.delete( authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, + verifySiteAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), @@ -612,6 +613,7 @@ authenticated.get( authenticated.post( "/target/:targetId", verifyTargetAccess, + verifySiteAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateTarget), logActionAudit(ActionsEnum.updateTarget), @@ -1234,7 +1236,8 @@ export const authRouter = Router(); unauthenticated.use("/auth", authRouter); authRouter.use( rateLimit({ - windowMs: config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000, + windowMs: + config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000, max: config.getRawConfig().rate_limits.auth.max_requests, keyGenerator: (req) => `authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`, From f617f93a94874bb0bb5d4ffa8f4f47b4082cd21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Fri, 29 May 2026 22:57:39 +0000 Subject: [PATCH 02/28] test(middleware): add regression tests for cross-org site binding prevention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test the org-match logic in verifySiteAccess: - Same org: allowed - Cross-org: rejected with 403 - No prior org context (site-only routes): check skipped, normal flow Test route stack ordering: - verifySiteAccess runs after verifyResourceAccess/verifyTargetAccess - verifySiteAccess runs before the target create/update handler Test security scenarios for both WireGuard and newt site types. Signed-off-by: Marc Schäfer --- server/middlewares/verifySiteAccess.test.ts | 322 ++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 server/middlewares/verifySiteAccess.test.ts diff --git a/server/middlewares/verifySiteAccess.test.ts b/server/middlewares/verifySiteAccess.test.ts new file mode 100644 index 000000000..6a08eda18 --- /dev/null +++ b/server/middlewares/verifySiteAccess.test.ts @@ -0,0 +1,322 @@ +import { assertEquals } from "@test/assert"; + +/** + * Tests for the cross-organization site binding prevention in verifySiteAccess. + * + * verifySiteAccess now includes a check: if req.userOrgId is already set by a + * previous middleware (e.g. verifyResourceAccess or verifyTargetAccess), and the + * loaded site's orgId differs from req.userOrgId, the request is rejected with + * 403 Forbidden. + * + * Route stacks after fix: + * PUT /resource/:resourceId/target + * → verifyResourceAccess → verifySiteAccess → verifyLimits → ... + * POST /target/:targetId + * → verifyTargetAccess → verifySiteAccess → verifyLimits → ... + * + * verifyResourceAccess sets req.userOrgId to the resource's org. + * verifyTargetAccess sets req.userOrgId to the target's resource org. + * verifySiteAccess then checks site.orgId against req.userOrgId before + * overwriting it with the site's org. + */ + +// --- Core org-matching logic (mirrors the check in verifySiteAccess) --- +function siteOrgMatchesExpectedOrg( + siteOrgId: string | null | undefined, + expectedOrgId: string | null | undefined +): boolean { + if (!siteOrgId || !expectedOrgId) { + return false; + } + return siteOrgId === expectedOrgId; +} + +// Simulates the condition check in verifySiteAccess: +// if (req.userOrgId && site.orgId !== req.userOrgId) { reject } +function shouldRejectCrossOrgSite( + siteOrgId: string, + reqUserOrgId: string | undefined +): boolean { + // The actual check in verifySiteAccess is: + // if (req.userOrgId && site.orgId !== req.userOrgId) { reject } + return !!(reqUserOrgId && siteOrgId !== reqUserOrgId); +} + +// --- Tests --- + +function testSiteOrgMatchLogic() { + console.log("Running verifySiteAccess org-match logic tests..."); + + // Test 1: Same org — should match + { + const result = siteOrgMatchesExpectedOrg( + "org-attacker", + "org-attacker" + ); + assertEquals(result, true, "Same org should match"); + } + + // Test 2: Different org — should NOT match (cross-org bypass scenario) + { + const result = siteOrgMatchesExpectedOrg("org-victim", "org-attacker"); + assertEquals( + result, + false, + "Cross-org site should NOT match expected org" + ); + } + + // Test 3: Site orgId is null — should NOT match + { + const result = siteOrgMatchesExpectedOrg(null, "org-attacker"); + assertEquals(result, false, "Null site orgId should NOT match"); + } + + // Test 4: Expected orgId is null — should NOT match + { + const result = siteOrgMatchesExpectedOrg("org-attacker", null); + assertEquals(result, false, "Null expected orgId should NOT match"); + } + + // Test 5: Both null — should NOT match + { + const result = siteOrgMatchesExpectedOrg(null, null); + assertEquals(result, false, "Both null should NOT match"); + } + + // Test 6: Empty string orgIds — should NOT match (empty string is falsy) + { + const result = siteOrgMatchesExpectedOrg("", "org-attacker"); + assertEquals(result, false, "Empty site orgId should NOT match"); + } + + // Test 7: Undefined orgIds — should NOT match + { + const result = siteOrgMatchesExpectedOrg(undefined, "org-attacker"); + assertEquals(result, false, "Undefined site orgId should NOT match"); + } + + console.log("All verifySiteAccess org-match logic tests passed."); +} + +function testShouldRejectCrossOrgSite() { + console.log( + "Running shouldRejectCrossOrgSite tests (mirrors verifySiteAccess check)..." + ); + + // Test: No prior org context (undefined) — should NOT reject + // This is the normal case for site-only routes (e.g. PUT /site/:siteId) + // where verifySiteAccess runs without a prior verifyResourceAccess. + { + const shouldReject = shouldRejectCrossOrgSite("org-victim", undefined); + assertEquals( + shouldReject, + false, + "No prior org context should NOT reject (normal site routes)" + ); + } + + // Test: Same org — should NOT reject + { + const shouldReject = shouldRejectCrossOrgSite( + "org-attacker", + "org-attacker" + ); + assertEquals(shouldReject, false, "Same org should NOT reject"); + } + + // Test: Different org — should reject + { + const shouldReject = shouldRejectCrossOrgSite( + "org-victim", + "org-attacker" + ); + assertEquals(shouldReject, true, "Cross-org site should be rejected"); + } + + // Test: Empty string userOrgId — should NOT reject (falsy, check is skipped) + { + const shouldReject = shouldRejectCrossOrgSite("org-victim", ""); + assertEquals( + shouldReject, + false, + "Empty string userOrgId should NOT reject (check is skipped)" + ); + } + + console.log("All shouldRejectCrossOrgSite tests passed."); +} + +// --- Route stack validation tests --- + +function testRouteStackOrdering() { + console.log("Running route stack ordering tests..."); + + const createTargetStack = [ + "verifyResourceAccess", + "verifySiteAccess", + "verifyLimits", + "verifyUserHasAction", + "logActionAudit", + "createTarget" + ]; + + const updateTargetStack = [ + "verifyTargetAccess", + "verifySiteAccess", + "verifyLimits", + "verifyUserHasAction", + "logActionAudit", + "updateTarget" + ]; + + // Verify verifySiteAccess comes after resource/target access middleware + { + const siteAccessIndex = createTargetStack.indexOf("verifySiteAccess"); + const resourceAccessIndex = createTargetStack.indexOf( + "verifyResourceAccess" + ); + assertEquals( + siteAccessIndex > resourceAccessIndex, + true, + "verifySiteAccess must come after verifyResourceAccess in create target stack" + ); + } + + { + const siteAccessIndex = updateTargetStack.indexOf("verifySiteAccess"); + const targetAccessIndex = + updateTargetStack.indexOf("verifyTargetAccess"); + assertEquals( + siteAccessIndex > targetAccessIndex, + true, + "verifySiteAccess must come after verifyTargetAccess in update target stack" + ); + } + + // Verify verifySiteAccess comes before the handler + { + const siteAccessIndex = createTargetStack.indexOf("verifySiteAccess"); + const handlerIndex = createTargetStack.indexOf("createTarget"); + assertEquals( + siteAccessIndex < handlerIndex, + true, + "verifySiteAccess must come before createTarget handler" + ); + } + + { + const siteAccessIndex = updateTargetStack.indexOf("verifySiteAccess"); + const handlerIndex = updateTargetStack.indexOf("updateTarget"); + assertEquals( + siteAccessIndex < handlerIndex, + true, + "verifySiteAccess must come before updateTarget handler" + ); + } + + console.log("All route stack ordering tests passed."); +} + +// --- Security scenario tests --- + +function testSecurityScenarios() { + console.log("Running security scenario tests..."); + + // Scenario 1: Attacker has resource access in org_attacker, but tries to + // bind target to a site in org_victim. + // verifyResourceAccess passes (sets req.userOrgId = "org_attacker"). + // verifySiteAccess loads site (org_victim), checks site.orgId !== req.userOrgId. + // Expected: 403 Forbidden. + { + const shouldReject = shouldRejectCrossOrgSite( + "org_victim", + "org_attacker" + ); + assertEquals( + shouldReject, + true, + "Scenario 1: Cross-org site binding must be rejected" + ); + } + + // Scenario 2: Attacker has resource access AND site access in another org. + // Even though the user has site access, verifySiteAccess rejects because + // the org-match check runs before the site access check. + // Expected: 403 Forbidden (org mismatch caught before site access check). + { + const shouldReject = shouldRejectCrossOrgSite( + "org_victim", + "org_attacker" + ); + assertEquals( + shouldReject, + true, + "Scenario 2: Cross-org site must be rejected even if user has site access" + ); + } + + // Scenario 3: Legitimate user creates target with site in same org. + // verifyResourceAccess passes, verifySiteAccess org-match passes (same org), + // verifySiteAccess site access passes. + // Expected: 201 Created. + { + const shouldReject = shouldRejectCrossOrgSite( + "org_attacker", + "org_attacker" + ); + assertEquals( + shouldReject, + false, + "Scenario 3: Same-org site must be allowed" + ); + } + + // Scenario 4: WireGuard site in victim org — org mismatch is caught before + // any DB write, pickPort, addPeer, or addTargets side effect. + { + const shouldReject = shouldRejectCrossOrgSite( + "org_victim", + "org_attacker" + ); + assertEquals( + shouldReject, + true, + "Scenario 4: WireGuard cross-org site must be rejected before addPeer" + ); + } + + // Scenario 5: Newt site in victim org — same as scenario 4 but for newt. + { + const shouldReject = shouldRejectCrossOrgSite( + "org_victim", + "org_attacker" + ); + assertEquals( + shouldReject, + true, + "Scenario 5: Newt cross-org site must be rejected before addTargets" + ); + } + + // Scenario 6: Normal site-only route (e.g. PUT /site/:siteId) where + // verifySiteAccess runs without a prior verifyResourceAccess. + // req.userOrgId is undefined, so the org-match check is skipped. + // Normal site access verification proceeds. + { + const shouldReject = shouldRejectCrossOrgSite("org_victim", undefined); + assertEquals( + shouldReject, + false, + "Scenario 6: Site-only routes should skip org-match check" + ); + } + + console.log("All security scenario tests passed."); +} + +// Run all tests +testSiteOrgMatchLogic(); +testShouldRejectCrossOrgSite(); +testRouteStackOrdering(); +testSecurityScenarios(); From 6c4cbcab5de8cbaccbbf469ccec6a6f1d483d725 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 10:22:29 -0700 Subject: [PATCH 03/28] Fix eslint errors --- server/lib/ip.ts | 2 +- .../settings/resources/public/ProxyResourceTargetsForm.tsx | 2 +- src/app/page.tsx | 2 +- src/app/private-maintenance-screen/page.tsx | 6 +++--- src/app/rdp/page.tsx | 2 +- src/app/ssh/SshClient.tsx | 1 - src/app/vnc/VncClient.tsx | 4 +--- src/app/vnc/page.tsx | 2 +- 8 files changed, 9 insertions(+), 12 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 24ee3e834..d7e85e7ac 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -665,7 +665,7 @@ export async function generateSubnetProxyTargetV2( return; } - let targets: SubnetProxyTargetV2[] = []; + const targets: SubnetProxyTargetV2[] = []; const portRange = [ ...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"), diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index 7e0e86066..5863a50a8 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -103,7 +103,7 @@ export function ProxyResourceTargetsForm({ // Notify parent of changes (create mode) useEffect(() => { onChange?.(targets); - }, [targets]); // eslint-disable-line react-hooks/exhaustive-deps + }, [targets]); // Poll health status only in edit mode const { data: polledTargets } = useQuery({ diff --git a/src/app/page.tsx b/src/app/page.tsx index f6f30276a..188089bda 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -86,7 +86,7 @@ export default async function Page(props: { targetOrgId = lastOrgCookie; } else { let ownedOrg = orgs.find((org) => org.isOwner); - let primaryOrg = orgs.find((org) => org.isPrimaryOrg); + const primaryOrg = orgs.find((org) => org.isPrimaryOrg); if (!ownedOrg) { if (primaryOrg) { ownedOrg = primaryOrg; diff --git a/src/app/private-maintenance-screen/page.tsx b/src/app/private-maintenance-screen/page.tsx index 3f7959206..5e890e96a 100644 --- a/src/app/private-maintenance-screen/page.tsx +++ b/src/app/private-maintenance-screen/page.tsx @@ -16,9 +16,9 @@ export const metadata: Metadata = { export default async function MaintenanceScreen() { const t = await getTranslations(); - let title = t("privateMaintenanceScreenTitle"); - let message = t("privateMaintenanceScreenMessage"); - let steps = t("privateMaintenanceScreenSteps"); + const title = t("privateMaintenanceScreenTitle"); + const message = t("privateMaintenanceScreenMessage"); + const steps = t("privateMaintenanceScreenSteps"); return (
diff --git a/src/app/rdp/page.tsx b/src/app/rdp/page.tsx index 368d2f477..980edaf24 100644 --- a/src/app/rdp/page.tsx +++ b/src/app/rdp/page.tsx @@ -17,7 +17,7 @@ export default async function RdpPage() { const hostname = host.split(":")[0]; let target: GetBrowserTargetResponse | null = null; - let error: string | null = null; + const error: string | null = null; try { const res = await priv.get>( diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index bf899887f..a9601738b 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -180,7 +180,6 @@ export default function SshClient({ certificate: signedKeyData.certificate }); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function connect(override?: ConnectCredentials) { diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index f5e95d232..03857169e 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -39,7 +39,6 @@ export default function VncClient({ }); const [connected, setConnected] = useState(false); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const rfbRef = useRef(null); const screenRef = useRef(null); @@ -59,7 +58,7 @@ export default function VncClient({ // Clean up on unmount. useEffect(() => { return () => disconnect(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, []); const connect = async () => { if (!target) { @@ -115,7 +114,6 @@ export default function VncClient({ 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; diff --git a/src/app/vnc/page.tsx b/src/app/vnc/page.tsx index 0b30d0989..7de845578 100644 --- a/src/app/vnc/page.tsx +++ b/src/app/vnc/page.tsx @@ -17,7 +17,7 @@ export default async function VncPage() { const hostname = host.split(":")[0]; let target: GetBrowserTargetResponse | null = null; - let error: string | null = null; + const error: string | null = null; try { const res = await priv.get>( From 01361884eb86a98eae9a61797113620710fcbffc Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 4 Jun 2026 10:33:15 -0700 Subject: [PATCH 04/28] Potential fix for pull request finding 'CodeQL / Insecure randomness' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- server/private/routers/ssh/signSshKey.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index f10cd407d..dac4ae62a 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -12,6 +12,7 @@ */ import { Request, Response, NextFunction } from "express"; +import { randomInt } from "crypto"; import { z } from "zod"; import { actionAuditLog, @@ -392,7 +393,7 @@ export async function signSshKey( if (existingUserWithSameName) { let foundUniqueUsername = false; for (let attempt = 0; attempt < 20; attempt++) { - const randomNum = Math.floor(Math.random() * 101); // 0 to 100 + const randomNum = randomInt(0, 101); // 0 to 100 const candidateUsername = `${usernameToUse}${randomNum}`; const [existingUser] = await db From 2d78a4b628ef87755feff1868947a26b9ef528af Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 11:21:40 -0700 Subject: [PATCH 05/28] Fix installer --- install/config/config.yml | 6 ++-- install/config/docker-compose.yml | 53 ++++++++++++------------------- install/config/privateConfig.yml | 6 ++-- install/main.go | 11 ++++--- 4 files changed, 31 insertions(+), 45 deletions(-) diff --git a/install/config/config.yml b/install/config/config.yml index 85dbc944f..f64190fec 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -38,7 +38,5 @@ flags: disable_user_create_org: false allow_raw_resources: true -{{if .IsPostgreSQL}} -postgres: - connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin -{{end}} +{{if .IsPostgreSQL}}postgres: + connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}} diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index fe6a41644..96b15ce47 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -7,23 +7,17 @@ services: deploy: resources: limits: - memory: 1g + memory: 2g reservations: - memory: 256m -{{if or .IsPostgreSQL .IsRedis}} - depends_on: - {{if .IsPostgreSQL}} - postgres: - condition: service_healthy - {{end}} - {{if .IsRedis}} - redis: - condition: service_healthy - {{end}} + memory: 512m + {{if or .IsPostgreSQL .IsRedis}}depends_on: + {{if .IsPostgreSQL}}postgres: + condition: service_healthy{{end}} + {{if .IsRedis}}redis: + condition: service_healthy{{end}} networks: - default - - backend -{{end}} + - backend{{end}} volumes: - ./config:/app/config healthcheck: @@ -31,8 +25,8 @@ services: interval: "10s" timeout: "10s" retries: 15 -{{if .InstallGerbil}} - gerbil: + + {{if .InstallGerbil}}gerbil: image: docker.io/fosrl/gerbil:{{.GerbilVersion}} container_name: gerbil restart: unless-stopped @@ -53,17 +47,16 @@ services: - 21820:21820/udp - 443:443 - 443:443/udp # For http3 QUIC if desired - - 80:80 -{{end}} + - 80:80{{end}} + traefik: image: docker.io/traefik:v3.6 container_name: traefik restart: unless-stopped -{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} + {{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} ports: - 443:443 - - 80:80 -{{end}} + - 80:80{{end}} depends_on: pangolin: condition: service_healthy @@ -74,8 +67,7 @@ services: - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates - ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs -{{if .IsPostgreSQL}} - postgres: + {{if .IsPostgreSQL}}postgres: image: postgres:18 container_name: postgres restart: unless-stopped @@ -91,11 +83,9 @@ services: timeout: 5s retries: 5 networks: - - backend -{{end}} + - backend{{end}} -{{if .IsRedis}} - redis: + {{if .IsRedis}}redis: image: redis:8-trixie container_name: redis restart: unless-stopped @@ -113,17 +103,14 @@ services: retries: 3 start_period: 10s networks: - - backend -{{end}} + - backend{{end}} networks: default: driver: bridge name: pangolin_frontend {{if .EnableIPv6}} enable_ipv6: true{{end}} -{{if or .IsPostgreSQL .IsRedis}} - backend: +{{if or .IsPostgreSQL .IsRedis}} backend: driver: bridge name: pangolin_backend - internal: true -{{end}} + internal: true{{end}} diff --git a/install/config/privateConfig.yml b/install/config/privateConfig.yml index 58a4c9435..1afe55c1c 100644 --- a/install/config/privateConfig.yml +++ b/install/config/privateConfig.yml @@ -1,6 +1,4 @@ -{{if .IsRedis}} -redis: +{{if .IsRedis}}redis: host: "redis" port: 6379 - password: "{{.IsRedisPass}}" -{{end}} + password: "{{.IsRedisPass}}"{{end}} diff --git a/install/main.go b/install/main.go index 5687f6f5d..001f09a52 100644 --- a/install/main.go +++ b/install/main.go @@ -71,9 +71,12 @@ const ( Undefined SupportedContainer = "undefined" ) +var redisFlag *bool + func main() { crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt") + redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.") flag.Parse() // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking @@ -491,13 +494,13 @@ func collectUserInput() Config { config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.") if config.IsEnterprise { - config.IsRedis = readBool("Do you want to run the Redis containers locally? Required for HA.") - if config.IsRedis { + if *redisFlag { + config.IsRedis = true config.IsRedisPass = readPassword("Enter a unique password for the Redis service.") } } - config.IsPostgreSQL = readBool("Do you want to run the PostgreSQL containers locally? Otherwise, default to the local SQLite database only.", false) + config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false) if config.IsPostgreSQL { config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.") } @@ -544,7 +547,7 @@ func collectUserInput() Config { fmt.Println("\n=== Advanced Configuration ===") config.EnableIPv6 = readBool("Is your server IPv6 capable?", true) - config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ADN databases for blocking functionality?", true) + config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ASN databases for blocking functionality?", true) if config.DashboardDomain == "" { fmt.Println("Error: Dashboard Domain name is required") From a7a41b820eb3cd7ed74503425f42f1bcf6d5cae2 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 15:20:52 -0700 Subject: [PATCH 06/28] Add missing sshAccess key --- messages/en-US.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/en-US.json b/messages/en-US.json index 2264f1332..0745f861a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2046,6 +2046,7 @@ "requireDeviceApproval": "Require Device Approvals", "requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.", "sshSettings": "SSH Settings", + "sshAccess": "SSH Access", "rdpSettings": "RDP Settings", "vncSettings": "VNC Settings", "sshServer": "SSH Server", From 769d36e289d2290e802fa645e914e43ce9107224 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 15:36:25 -0700 Subject: [PATCH 07/28] Fix http resources not being pulled --- server/lib/traefik/getTraefikConfig.ts | 7 ++++--- server/private/lib/traefik/getTraefikConfig.ts | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 518dd964c..48eb03638 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -44,7 +44,8 @@ export async function getTraefikConfig( filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE allowRawResources = true, - allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE + allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE + allowBrowserGatewayResources = true ): Promise { // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources @@ -240,7 +241,7 @@ export async function getTraefikConfig( continue; } - if (resource.http) { + if (resource.mode === "http") { if (!resource.domainId || !resource.fullDomain) { continue; } @@ -572,7 +573,7 @@ export async function getTraefikConfig( serviceName ].loadBalancer.serversTransport = transportName; } - } else { + } else if (resource.mode === "tcp" || resource.mode === "udp") { // Non-HTTP (TCP/UDP) configuration if (!resource.enableProxy || !resource.proxyPort) { continue; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 7ad6b853b..a46033196 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -493,16 +493,29 @@ export async function getTraefikConfig( const transportName = `${key}-transport`; const headersMiddlewareName = `${key}-headers-middleware`; + logger.debug( + `Processing resource ${resource.name} with domain ${fullDomain} and ${targets.length} targets` + ); + if (!resource.enabled) { + logger.debug( + `Resource ${resource.name} is disabled, skipping Traefik config` + ); continue; } - if (resource.http) { + if (resource.mode == "http") { if (!resource.domainId) { + logger.debug( + `Resource ${resource.name} does not have a domainId, skipping Traefik config` + ); continue; } if (!resource.fullDomain) { + logger.debug( + `Resource ${resource.name} does not have a fullDomain, skipping Traefik config` + ); continue; } @@ -958,7 +971,7 @@ export async function getTraefikConfig( serviceName ].loadBalancer.serversTransport = transportName; } - } else { + } else if (resource.mode == "tcp" || resource.mode == "udp") { // Non-HTTP (TCP/UDP) configuration if (!resource.enableProxy) { continue; From 6420a90d08b19e5e685801b2fe9cde287fb24fa7 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 16:21:30 -0700 Subject: [PATCH 08/28] Replace tab component --- src/app/ssh/SshClient.tsx | 135 ++++++++++++----------- src/components/newt-install-commands.tsx | 30 ++++- 2 files changed, 97 insertions(+), 68 deletions(-) diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index a9601738b..4ba1c9211 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -17,7 +17,7 @@ import { import Link from "next/link"; import { ExternalLink, Loader2, AlertCircle } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { cn } from "@app/lib/cn"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import type { SignSshKeyResponse } from "@server/routers/ssh/types"; import { useTranslations } from "next-intl"; @@ -61,8 +61,6 @@ export default function SshClient({ const t = useTranslations(); - const [authTab, setAuthTab] = useState("password"); - function handleKeyFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; @@ -182,7 +180,10 @@ export default function SshClient({ } }, []); - function connect(override?: ConnectCredentials) { + function connect( + override?: ConnectCredentials, + authMethod: AuthTab = "password" + ) { setConnectError(null); setConnecting(true); @@ -194,10 +195,11 @@ export default function SshClient({ const username = override?.username ?? form.username; const password = - override?.password ?? (authTab === "password" ? form.password : ""); + override?.password ?? + (authMethod === "password" ? form.password : ""); const privateKey = override?.privateKey ?? - (authTab === "privateKey" ? form.privateKey : ""); + (authMethod === "privateKey" ? form.privateKey : ""); const certificate = override?.certificate; const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`; @@ -224,7 +226,7 @@ export default function SshClient({ ws.onopen = () => { // Send credentials as the first frame so the proxy can complete // SSH authentication before piping pty data. Stay in "connecting" - // state until the server responds — this prevents the flash to the + // state until the server responds - this prevents the flash to the // terminal page that would occur if we set connected=true here. ws.send( JSON.stringify({ @@ -260,7 +262,7 @@ export default function SshClient({ xtermRef.current?.write(msg.data); } else if (msg.type === "error") { if (!authConfirmed) { - // Auth-phase error — show in the login form. + // Auth-phase error - show in the login form. authErrorShown = true; setConnecting(false); setConnectError( @@ -281,13 +283,13 @@ export default function SshClient({ xtermRef.current?.write(evt.data); } } else if (evt.data instanceof Blob) { - evt.data.text().then((t) => { + evt.data.text().then((text) => { if (!authConfirmed) { authConfirmed = true; setConnecting(false); setConnected(true); } - xtermRef.current?.write(t); + xtermRef.current?.write(text); }); } }; @@ -426,31 +428,15 @@ export default function SshClient({ - {/* Tab row */} -
- {(["password", "privateKey"] as const).map( - (tab) => ( - - ) - )} -
- - {authTab === "password" && ( -
+ +
@@ -480,11 +465,31 @@ export default function SshClient({ } /> -
- )} +
+ {connectError && ( +

+ {connectError} +

+ )} - {authTab === "privateKey" && ( -
+ +
+
+ +

{t("sshPrivateKeyDisclaimer")}{" "} +

+ {connectError && ( +

+ {connectError} +

+ )} + + +
- )} - -
- {connectError && ( -

- {connectError} -

- )} - - -
+
diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index 422bc476d..ac8109eab 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -43,11 +43,14 @@ export function NewtSiteInstallCommands({ const t = useTranslations(); const [acceptClients, setAcceptClients] = useState(true); + const [allowPangolinSsh, setAllowPangolinSsh] = useState(true); const [platform, setPlatform] = useState("linux"); const [architecture, setArchitecture] = useState( () => getArchitectures(platform)[0] ); + const supportsSshOption = platform === "linux" || platform === "nixos"; + const acceptClientsFlag = !acceptClients ? " --disable-clients" : ""; const acceptClientsEnv = !acceptClients ? "\n - DISABLE_CLIENTS=true" @@ -57,6 +60,11 @@ export function NewtSiteInstallCommands({ --set newtInstances[0].acceptClients=true` : ""; + const disableSshFlag = + supportsSshOption && !allowPangolinSsh ? " --disable-ssh" : ""; + const runAsRootPrefix = + supportsSshOption && allowPangolinSsh ? "sudo " : ""; + const commandList: Record> = { linux: { Run: [ @@ -66,7 +74,7 @@ export function NewtSiteInstallCommands({ }, { title: t("run"), - command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}` + command: `${runAsRootPrefix}newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}` } ], "Systemd Service": [ @@ -86,6 +94,11 @@ PANGOLIN_ENDPOINT=${endpoint}${ ? ` DISABLE_CLIENTS=true` : "" + }${ + !allowPangolinSsh + ? ` +DISABLE_SSH=true` + : "" } EOF sudo chmod 600 /etc/newt/newt.env` @@ -205,7 +218,7 @@ WantedBy=default.target` }, nixos: { Flake: [ - `nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}` + `${runAsRootPrefix}nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}` ] } }; @@ -273,6 +286,19 @@ WantedBy=default.target` label={t("siteAcceptClientConnections")} />
+ {supportsSshOption && ( +
+ { + const value = checked as boolean; + setAllowPangolinSsh(value); + }} + label="Allow Pangolin SSH" + /> +
+ )}

Date: Thu, 4 Jun 2026 16:24:29 -0700 Subject: [PATCH 09/28] remove check on oidc login --- server/routers/idp/validateOidcCallback.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index b5415c52d..8188f46da 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -332,17 +332,6 @@ export async function validateOidcCallback( .where(eq(idpOrg.idpId, existingIdp.idp.idpId)) .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)); allOrgs = idpOrgs.map((o) => o.orgs); - - for (const org of allOrgs) { - const subscribed = await isSubscribed( - org.orgId, - tierMatrix.autoProvisioning - ); - if (!subscribed) { - // filter out the org - allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId); - } - } } else { allOrgs = await db.select().from(orgs); } From e5d0673bbf994819ef3d587801902dc7834e653c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 16:30:14 -0700 Subject: [PATCH 10/28] prefill site field on create private resource when filtering sites --- src/components/CreatePrivateResourceDialog.tsx | 6 +++++- src/components/PrivateResourceForm.tsx | 14 +++++++++++--- src/components/PrivateResourcesTable.tsx | 6 ++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/components/CreatePrivateResourceDialog.tsx b/src/components/CreatePrivateResourceDialog.tsx index 4bfb478ba..38907d5d8 100644 --- a/src/components/CreatePrivateResourceDialog.tsx +++ b/src/components/CreatePrivateResourceDialog.tsx @@ -23,19 +23,22 @@ import { isHostname, type InternalResourceFormValues } from "./PrivateResourceForm"; +import type { Selectedsite } from "./site-selector"; type CreateInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; orgId: string; onSuccess?: () => void; + initialSites?: Selectedsite[]; }; export default function CreatePrivateResourceDialog({ open, setOpen, orgId, - onSuccess + onSuccess, + initialSites }: CreateInternalResourceDialogProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); @@ -175,6 +178,7 @@ export default function CreatePrivateResourceDialog({ formId="create-internal-resource-form" onSubmit={handleSubmit} onSubmitDisabledChange={setIsHttpModeDisabled} + initialSites={initialSites} /> diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 856e18885..4a8b0b62b 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -208,6 +208,7 @@ type InternalResourceFormProps = { formId: string; onSubmit: (values: InternalResourceFormValues) => void | Promise; onSubmitDisabledChange?: (disabled: boolean) => void; + initialSites?: Selectedsite[]; }; export function PrivateResourceForm({ @@ -218,7 +219,8 @@ export function PrivateResourceForm({ siteResourceId, formId, onSubmit, - onSubmitDisabledChange + onSubmitDisabledChange, + initialSites = [] }: InternalResourceFormProps) { const t = useTranslations(); const { env } = useEnvContext(); @@ -609,6 +611,8 @@ export function PrivateResourceForm({ authDaemonMode === "remote"; const hasInitialized = useRef(false); const previousResourceId = useRef(null); + const initialSitesRef = useRef(initialSites); + initialSitesRef.current = initialSites; useEffect(() => { const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts); @@ -623,9 +627,13 @@ export function PrivateResourceForm({ // Reset when create dialog opens useEffect(() => { if (variant === "create" && open) { + const prefillSites = + initialSitesRef.current.length > 0 + ? initialSitesRef.current + : []; form.reset({ name: "", - siteIds: [], + siteIds: prefillSites.map((s) => s.siteId), mode: "host", destination: "", alias: null, @@ -645,7 +653,7 @@ export function PrivateResourceForm({ users: [], clients: [] }); - setSelectedSites([]); + setSelectedSites(prefillSites); setSshServerMode("native"); setTcpPortMode("all"); setUdpPortMode("all"); diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 396ba9759..0a356a059 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -187,6 +187,11 @@ export default function PrivateResourcesTable({ }; }, [initialFilterSite, siteIdQ, siteIdNum, t]); + const createInitialSites = useMemo( + () => (selectedSite ? [selectedSite] : undefined), + [selectedSite] + ); + const refreshData = () => { startRefreshTransition(() => { try { @@ -686,6 +691,7 @@ export default function PrivateResourcesTable({ open={isCreateDialogOpen} setOpen={setIsCreateDialogOpen} orgId={orgId} + initialSites={createInitialSites} onSuccess={() => { // Delay refresh to allow modal to close smoothly setTimeout(() => { From 9d3f96cf836d4683c1ccc846b559de16a402fedb Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 16:31:45 -0700 Subject: [PATCH 11/28] Add disable_private_http_placeholder --- server/private/lib/readConfigFile.ts | 6 +++++- server/private/lib/traefik/getTraefikConfig.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 087143007..565a0151a 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -109,7 +109,11 @@ export const privateConfigSchema = z enable_redis: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false), use_org_only_idp: z.boolean().optional(), - enable_acme_cert_sync: z.boolean().optional().default(true) + enable_acme_cert_sync: z.boolean().optional().default(true), + disable_private_http_placeholder: z + .boolean() + .optional() + .default(false) }) .optional() .prefault({}), diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index a46033196..7ff452880 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -410,7 +410,11 @@ export async function getTraefikConfig( fullDomain: string | null; mode: "http" | "host" | "cidr" | "ssh"; }[] = []; - if (build == "enterprise") { + if ( + build == "enterprise" && + !privateConfig.getRawPrivateConfig().flags + .disable_private_http_placeholder + ) { // we dont want to do this on the cloud // Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge siteResourcesWithFullDomain = await db From 889f78ddb849fb70a433e5b056d17587f1f6ad03 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 16:45:22 -0700 Subject: [PATCH 12/28] use resource name in ssh/rdp/vnc page meta --- .../browserGatewayTarget/getBrowserTarget.ts | 4 +- server/routers/browserGatewayTarget/types.ts | 1 + src/app/rdp/page.tsx | 31 ++----- src/app/ssh/page.tsx | 87 +++++++++---------- src/app/vnc/page.tsx | 30 ++----- src/lib/browserGatewayMetadata.ts | 13 +++ src/lib/getBrowserTargetForRequest.ts | 20 +++++ 7 files changed, 92 insertions(+), 94 deletions(-) create mode 100644 src/lib/browserGatewayMetadata.ts create mode 100644 src/lib/getBrowserTargetForRequest.ts diff --git a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts index 7feda01e5..51e16de75 100644 --- a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts +++ b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts @@ -58,6 +58,7 @@ export async function getBrowserTarget( authToken: browserGatewayTarget.authToken, resourceId: resources.resourceId, niceId: resources.niceId, + name: resources.name, orgId: resources.orgId, pamMode: resources.pamMode, authDaemonMode: resources.authDaemonMode @@ -93,7 +94,8 @@ export async function getBrowserTarget( authDaemonMode: browserTarget.authDaemonMode, orgId: browserTarget.orgId, resourceId: browserTarget.resourceId, - niceId: browserTarget.niceId + niceId: browserTarget.niceId, + name: browserTarget.name }, success: true, error: false, diff --git a/server/routers/browserGatewayTarget/types.ts b/server/routers/browserGatewayTarget/types.ts index e644c952a..df6302391 100644 --- a/server/routers/browserGatewayTarget/types.ts +++ b/server/routers/browserGatewayTarget/types.ts @@ -5,6 +5,7 @@ export type GetBrowserTargetResponse = { orgId: string; resourceId: number; niceId: string; + name: string; pamMode: "passthrough" | "push" | null; authDaemonMode: "site" | "remote" | "native" | null; }; diff --git a/src/app/rdp/page.tsx b/src/app/rdp/page.tsx index 980edaf24..c6da3f4bf 100644 --- a/src/app/rdp/page.tsx +++ b/src/app/rdp/page.tsx @@ -1,34 +1,17 @@ -import { headers } from "next/headers"; -import { priv } from "@app/lib/api"; -import { AxiosResponse } from "axios"; -import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; +import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata"; +import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest"; import RdpClient from "./RdpClient"; import AuthFooter from "@app/components/AuthFooter"; export const dynamic = "force-dynamic"; -export const metadata = { - title: "RDP" -}; +export async function generateMetadata() { + return generateBrowserGatewayMetadata("RDP"); +} export default async function RdpPage() { - const headersList = await headers(); - const host = headersList.get("host") || ""; - const hostname = host.split(":")[0]; - - let target: GetBrowserTargetResponse | null = null; - const error: string | null = null; - - try { - const res = await priv.get>( - `/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"; - } + const { target } = await getBrowserTargetForRequest(); + const error = target ? null : "No resource found for this domain"; return (

diff --git a/src/app/ssh/page.tsx b/src/app/ssh/page.tsx index 23cc9d908..44d5f1201 100644 --- a/src/app/ssh/page.tsx +++ b/src/app/ssh/page.tsx @@ -1,5 +1,7 @@ import { headers } from "next/headers"; import { priv } from "@app/lib/api"; +import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata"; +import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest"; import { AxiosResponse } from "axios"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import SshClient from "./SshClient"; @@ -99,14 +101,12 @@ function generateEphemeralKeyPair(): { export const dynamic = "force-dynamic"; -export const metadata = { - title: "SSH" -}; +export async function generateMetadata() { + return generateBrowserGatewayMetadata("SSH"); +} export default async function SshPage() { const headersList = await headers(); - const host = headersList.get("host") || ""; - const hostname = host.split(":")[0]; const cookieHeader = headersList.get("cookie") || ""; let target: GetBrowserTargetResponse | null = null; @@ -114,49 +114,44 @@ export default async function SshPage() { let privateKey: string | null = null; let error: string | null = null; - try { - const res = await priv.get>( - `/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}` - ); - target = res.data.data; + const { target: browserTarget } = await getBrowserTargetForRequest(); + target = browserTarget; - if (target.pamMode === "push") { - try { - const { privateKeyPem, publicKeyOpenSSH } = - generateEphemeralKeyPair(); - privateKey = privateKeyPem; - const res = await priv.post>( - `/org/${target.orgId}/ssh/sign-key`, - { - publicKey: publicKeyOpenSSH, - resourceId: target.resourceId, - type: "public" - }, - { - headers: { - Cookie: cookieHeader - } - } - ); - signedKeyData = res.data.data; - - const messageIds = - signedKeyData.messageIds.length > 0 - ? signedKeyData.messageIds - : signedKeyData.messageId - ? [signedKeyData.messageId] - : []; - - await waitForRoundTripCompletion(messageIds, cookieHeader); - } catch (err) { - console.error("Error signing SSH key:", err); - error = - "Failed to sign SSH key for PAM push authentication. Did you sign in as a user?"; - } - } - } catch (err) { - console.error("Error fetching browser target:", err); + if (!target) { error = "No resource found for this domain"; + } else if (target.pamMode === "push") { + try { + const { privateKeyPem, publicKeyOpenSSH } = + generateEphemeralKeyPair(); + privateKey = privateKeyPem; + const res = await priv.post>( + `/org/${target.orgId}/ssh/sign-key`, + { + publicKey: publicKeyOpenSSH, + resourceId: target.resourceId, + type: "public" + }, + { + headers: { + Cookie: cookieHeader + } + } + ); + signedKeyData = res.data.data; + + const messageIds = + signedKeyData.messageIds.length > 0 + ? signedKeyData.messageIds + : signedKeyData.messageId + ? [signedKeyData.messageId] + : []; + + await waitForRoundTripCompletion(messageIds, cookieHeader); + } catch (err) { + console.error("Error signing SSH key:", err); + error = + "Failed to sign SSH key for PAM push authentication. Did you sign in as a user?"; + } } return ( diff --git a/src/app/vnc/page.tsx b/src/app/vnc/page.tsx index 7de845578..85eec047b 100644 --- a/src/app/vnc/page.tsx +++ b/src/app/vnc/page.tsx @@ -1,33 +1,17 @@ -import { headers } from "next/headers"; -import { priv } from "@app/lib/api"; -import { AxiosResponse } from "axios"; -import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; +import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata"; +import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest"; import VncClient from "./VncClient"; import AuthFooter from "@app/components/AuthFooter"; export const dynamic = "force-dynamic"; -export const metadata = { - title: "VNC" -}; +export async function generateMetadata() { + return generateBrowserGatewayMetadata("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; - const error: string | null = null; - - try { - const res = await priv.get>( - `/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"; - } + const { target } = await getBrowserTargetForRequest(); + const error = target ? null : "No resource found for this domain"; return (
diff --git a/src/lib/browserGatewayMetadata.ts b/src/lib/browserGatewayMetadata.ts new file mode 100644 index 000000000..6d44c6b9e --- /dev/null +++ b/src/lib/browserGatewayMetadata.ts @@ -0,0 +1,13 @@ +import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest"; +import type { Metadata } from "next"; + +export async function generateBrowserGatewayMetadata( + protocol: "SSH" | "RDP" | "VNC" +): Promise { + const { target } = await getBrowserTargetForRequest(); + return { + title: target?.name + ? `${protocol} - ${target.name}` + : `${protocol} - Pangolin` + }; +} diff --git a/src/lib/getBrowserTargetForRequest.ts b/src/lib/getBrowserTargetForRequest.ts new file mode 100644 index 000000000..179e6e6f1 --- /dev/null +++ b/src/lib/getBrowserTargetForRequest.ts @@ -0,0 +1,20 @@ +import { priv } from "@app/lib/api"; +import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; +import { AxiosResponse } from "axios"; +import { headers } from "next/headers"; +import { cache } from "react"; + +export const getBrowserTargetForRequest = cache(async () => { + const headersList = await headers(); + const host = headersList.get("host") || ""; + const hostname = host.split(":")[0]; + + try { + const res = await priv.get>( + `/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}` + ); + return { target: res.data.data }; + } catch { + return { target: null }; + } +}); From 6affebc6660ed91f89b17923dd9f483fca4b1f81 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 16:50:52 -0700 Subject: [PATCH 13/28] Finish adding ssh toggle --- messages/en-US.json | 3 ++- src/components/newt-install-commands.tsx | 34 +++++++++++++++--------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0745f861a..0727a9a2d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3455,5 +3455,6 @@ "sshErrorNoTarget": "No target specified", "sshErrorWebSocket": "WebSocket connection failed", "sshErrorAuthFailed": "Authentication failed", - "sshErrorConnectionClosed": "Connection closed before authentication completed" + "sshErrorConnectionClosed": "Connection closed before authentication completed", + "sitePangolinSshDescription": "Allow SSH access to resources on this site. This can be changed later." } diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index ac8109eab..c2d6f48b6 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -286,25 +286,33 @@ WantedBy=default.target` label={t("siteAcceptClientConnections")} />
- {supportsSshOption && ( -
- { - const value = checked as boolean; - setAllowPangolinSsh(value); - }} - label="Allow Pangolin SSH" - /> -
- )}

{t("siteAcceptClientConnectionsDescription")}

+ {supportsSshOption && ( + <> +
+ { + const value = checked as boolean; + setAllowPangolinSsh(value); + }} + label="Allow Pangolin SSH" + /> +
+

+ {t("sitePangolinSshDescription")} +

+ + )}
From 567ef23ac40fb79ede9da03c029fd25c93050941 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 16:53:57 -0700 Subject: [PATCH 14/28] Add initial advantech install commands --- src/components/newt-install-commands.tsx | 122 +++++++++++++++-------- 1 file changed, 78 insertions(+), 44 deletions(-) diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index c2d6f48b6..0d5ecad4c 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -10,7 +10,14 @@ import { import { CheckboxWithLabel } from "./ui/checkbox"; import { OptionSelect, type OptionSelectOption } from "./OptionSelect"; import { useState } from "react"; -import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa"; +import { + FaApple, + FaCubes, + FaDocker, + FaHdd, + FaLinux, + FaWindows +} from "react-icons/fa"; import { SiKubernetes, SiNixos } from "react-icons/si"; export type CommandItem = string | { title: string; command: string }; @@ -20,6 +27,7 @@ const PLATFORMS = [ "macos", "docker", "kubernetes", + "advantech", "podman", "nixos", "windows" @@ -49,6 +57,7 @@ export function NewtSiteInstallCommands({ () => getArchitectures(platform)[0] ); + const showSiteConfiguration = platform !== "advantech"; const supportsSshOption = platform === "linux" || platform === "nixos"; const acceptClientsFlag = !acceptClients ? " --disable-clients" : ""; @@ -193,6 +202,9 @@ sudo systemctl enable --now newt` --set-string newtInstances[0].auth.existingSecretName="newt-main-tunnel-auth"${acceptClientsHelmValue}` ] }, + advantech: { + Documentation: [] + }, podman: { "Podman Quadlet": [ `[Unit] @@ -270,50 +282,52 @@ WantedBy=default.target` className="mt-4" /> -
-

- {t("siteConfiguration")} -

-
- { - const value = checked as boolean; - setAcceptClients(value); - }} - label={t("siteAcceptClientConnections")} - /> + {showSiteConfiguration && ( +
+

+ {t("siteConfiguration")} +

+
+ { + const value = checked as boolean; + setAcceptClients(value); + }} + label={t("siteAcceptClientConnections")} + /> +
+

+ {t("siteAcceptClientConnectionsDescription")} +

+ {supportsSshOption && ( + <> +
+ { + const value = checked as boolean; + setAllowPangolinSsh(value); + }} + label="Allow Pangolin SSH" + /> +
+

+ {t("sitePangolinSshDescription")} +

+ + )}
-

- {t("siteAcceptClientConnectionsDescription")} -

- {supportsSshOption && ( - <> -
- { - const value = checked as boolean; - setAllowPangolinSsh(value); - }} - label="Allow Pangolin SSH" - /> -
-

- {t("sitePangolinSshDescription")} -

- - )} -
+ )}

{t("commands")}

@@ -332,6 +346,20 @@ WantedBy=default.target` .

)} + {platform === "advantech" && ( +

+ For Advantech modem installation instructions, see{" "} + + docs.pangolin.net/manage/sites/install-advantech + + . +

+ )}
{commands.map((item, index) => { const commandText = @@ -376,6 +404,8 @@ function getPlatformIcon(platformName: Platform) { return ; case "kubernetes": return ; + case "advantech": + return ; case "podman": return ; case "nixos": @@ -397,6 +427,8 @@ function getPlatformName(platformName: Platform) { return "Docker"; case "kubernetes": return "Kubernetes"; + case "advantech": + return "Advantech"; case "podman": return "Podman"; case "nixos": @@ -418,6 +450,8 @@ function getArchitectures(platform: Platform) { return ["Docker Compose", "Docker Run"]; case "kubernetes": return ["Helm Chart"]; + case "advantech": + return ["Documentation"]; case "podman": return ["Podman Quadlet", "Podman Run"]; case "nixos": From b2f1115ef83cded6766e0ef5df55b311b3e997cb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 17:23:49 -0700 Subject: [PATCH 15/28] standardize and fix branding on new resources auth pages --- src/app/rdp/RdpClient.tsx | 43 ++++++---------------- src/app/rdp/page.tsx | 10 ++++- src/app/ssh/SshClient.tsx | 46 +++++++---------------- src/app/ssh/page.tsx | 6 +++ src/app/vnc/VncClient.tsx | 43 ++++++---------------- src/app/vnc/page.tsx | 10 ++++- src/components/BrandedAuthSurface.tsx | 26 +++++++++++++ src/components/OrgLoginPage.tsx | 23 +++--------- src/components/PoweredByPangolin.tsx | 53 +++++++++++++++++++++++++++ src/components/ResourceAuthPortal.tsx | 50 ++++--------------------- src/lib/loadOrgLoginPageBranding.ts | 31 ++++++++++++++++ 11 files changed, 181 insertions(+), 160 deletions(-) create mode 100644 src/components/BrandedAuthSurface.tsx create mode 100644 src/components/PoweredByPangolin.tsx create mode 100644 src/lib/loadOrgLoginPageBranding.ts diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index d4b708fbf..721fd037b 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -22,7 +22,8 @@ import { CardTitle, CardDescription } from "@app/components/ui/card"; -import Link from "next/link"; +import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; +import PoweredByPangolin from "@app/components/PoweredByPangolin"; declare module "react" { namespace JSX { @@ -60,10 +61,12 @@ const isIronError = (error: unknown): error is IronError => { export default function RdpClient({ target, - error + error, + primaryColor }: { target: GetBrowserTargetResponse | null; error: string | null; + primaryColor?: string | null; }) { const STORAGE_KEY = "pangolin_rdp_credentials"; @@ -315,20 +318,8 @@ export default function RdpClient({ if (error) { return ( -
-
- - Powered by{" "} - - Pangolin - - -
+ + RDP @@ -337,27 +328,15 @@ export default function RdpClient({

{error}

-
+ ); } return ( <> {showLogin && ( -
-
- - Powered by{" "} - - Pangolin - - -
+ + Sign in to Remote Desktop @@ -441,7 +420,7 @@ export default function RdpClient({
-
+ )}
- +
diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 4ba1c9211..945963ec0 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -20,6 +20,8 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import type { SignSshKeyResponse } from "@server/routers/ssh/types"; import { useTranslations } from "next-intl"; +import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; +import PoweredByPangolin from "@app/components/PoweredByPangolin"; type AuthTab = "password" | "privateKey"; @@ -40,12 +42,14 @@ export default function SshClient({ target, error, signedKeyData, - privateKey: signedPrivateKey + privateKey: signedPrivateKey, + primaryColor }: { target: GetBrowserTargetResponse | null; error: string | null; signedKeyData?: SignSshKeyResponse | null; privateKey?: string | null; + primaryColor?: string | null; }) { const STORAGE_KEY = "pangolin_ssh_credentials"; @@ -377,20 +381,8 @@ export default function SshClient({ if (error) { return ( -
-
- - {t("sshPoweredBy")}{" "} - - Pangolin - - -
+ + {t("sshTitle")} @@ -399,27 +391,15 @@ export default function SshClient({

{error}

-
+ ); } return ( <> {!connected && ( -
-
- - {t("sshPoweredBy")}{" "} - - Pangolin - - -
+ + {t("sshSignInTitle")} @@ -496,10 +476,10 @@ export default function SshClient({ href="https://docs.pangolin.net/" target="_blank" rel="noopener noreferrer" - className="underline inline-flex items-center gap-1" + className="text-primary hover:underline inline-flex items-center gap-1" > {t("sshLearnMore")} - +

-
+ )} {connected && ( diff --git a/src/app/ssh/page.tsx b/src/app/ssh/page.tsx index 44d5f1201..5e2e057b0 100644 --- a/src/app/ssh/page.tsx +++ b/src/app/ssh/page.tsx @@ -2,6 +2,7 @@ import { headers } from "next/headers"; import { priv } from "@app/lib/api"; import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata"; import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest"; +import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding"; import { AxiosResponse } from "axios"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import SshClient from "./SshClient"; @@ -154,6 +155,10 @@ export default async function SshPage() { } } + const { primaryColor } = target + ? await loadOrgLoginPageBranding(target.orgId) + : { primaryColor: null }; + return (
@@ -163,6 +168,7 @@ export default async function SshPage() { error={error} signedKeyData={signedKeyData} privateKey={privateKey} + primaryColor={primaryColor} />
diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index 03857169e..7a93537fd 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -13,7 +13,8 @@ import { CardTitle, CardDescription } from "@app/components/ui/card"; -import Link from "next/link"; +import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; +import PoweredByPangolin from "@app/components/PoweredByPangolin"; type FormState = { password: string; @@ -21,10 +22,12 @@ type FormState = { export default function VncClient({ target, - error + error, + primaryColor }: { target: GetBrowserTargetResponse | null; error: string | null; + primaryColor?: string | null; }) { const STORAGE_KEY = "pangolin_vnc_credentials"; @@ -152,20 +155,8 @@ export default function VncClient({ if (error) { return ( -
-
- - Powered by{" "} - - Pangolin - - -
+ + VNC @@ -174,27 +165,15 @@ export default function VncClient({

{error}

-
+ ); } return ( <> {!connected && ( -
-
- - Powered by{" "} - - Pangolin - - -
+ + VNC @@ -224,7 +203,7 @@ export default function VncClient({
-
+ )}
- +
diff --git a/src/components/BrandedAuthSurface.tsx b/src/components/BrandedAuthSurface.tsx new file mode 100644 index 000000000..2b12808aa --- /dev/null +++ b/src/components/BrandedAuthSurface.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; + +type BrandedAuthSurfaceProps = { + primaryColor?: string | null; + children: React.ReactNode; +}; + +export default function BrandedAuthSurface({ + primaryColor, + children +}: BrandedAuthSurfaceProps) { + const { isUnlocked } = useLicenseStatusContext(); + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/OrgLoginPage.tsx b/src/components/OrgLoginPage.tsx index 3270b7cb4..d70c278cf 100644 --- a/src/components/OrgLoginPage.tsx +++ b/src/components/OrgLoginPage.tsx @@ -14,9 +14,10 @@ import { import { Button } from "@app/components/ui/button"; import Link from "next/link"; import { replacePlaceholder } from "@app/lib/replacePlaceholder"; +import PoweredByPangolin from "@app/components/PoweredByPangolin"; +import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; -import { build } from "@server/build"; type OrgLoginPageProps = { loginPage: LoadLoginPageResponse | undefined; @@ -52,22 +53,8 @@ export default async function OrgLoginPage({ const env = pullEnv(); const t = await getTranslations(); return ( -
- {build !== "enterprise" || !env.branding.hidePoweredBy ? ( -
- - {t("poweredBy")}{" "} - - {env.branding.appName || "Pangolin"} - - -
- ) : null} + + {branding?.logoUrl && ( @@ -127,6 +114,6 @@ export default async function OrgLoginPage({ {t("loginBack")}

-
+ ); } diff --git a/src/components/PoweredByPangolin.tsx b/src/components/PoweredByPangolin.tsx new file mode 100644 index 000000000..cca479a4f --- /dev/null +++ b/src/components/PoweredByPangolin.tsx @@ -0,0 +1,53 @@ +"use client"; + +import Link from "next/link"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; + +function PoweredByLabel({ brandName }: { brandName: string }) { + const t = useTranslations(); + + return ( +
+ + {t("poweredBy")}{" "} + {brandName === "Pangolin" ? ( + + Pangolin + + ) : ( + brandName + )} + +
+ ); +} + +export default function PoweredByPangolin() { + const { env } = useEnvContext(); + const { isUnlocked } = useLicenseStatusContext(); + + if (isUnlocked() && build === "enterprise") { + if ( + env.branding.resourceAuthPage?.hidePoweredBy || + env.branding.hidePoweredBy + ) { + return null; + } + + return ( + + ); + } + + return ; +} diff --git a/src/components/ResourceAuthPortal.tsx b/src/components/ResourceAuthPortal.tsx index 64e1d2725..018a08179 100644 --- a/src/components/ResourceAuthPortal.tsx +++ b/src/components/ResourceAuthPortal.tsx @@ -41,8 +41,9 @@ import { } from "@app/actions/server"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; -import Link from "next/link"; import BrandingLogo from "@app/components/BrandingLogo"; +import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; +import PoweredByPangolin from "@app/components/PoweredByPangolin"; import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; @@ -366,57 +367,20 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { : 100; return ( -
+ {!accessDenied ? (
- {isUnlocked() && build === "enterprise" ? ( - !env.branding.resourceAuthPage?.hidePoweredBy && - !env.branding.hidePoweredBy && ( -
- - {t("poweredBy")}{" "} - - {env.branding.appName || "Pangolin"} - - -
- ) - ) : ( -
- - {t("poweredBy")}{" "} - - Pangolin - - -
- )} + {isUnlocked() && build !== "oss" && - (env.branding?.resourceAuthPage?.showLogo || - props.branding) && ( + props.branding?.logoUrl && (
)} @@ -790,6 +754,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { ) : ( )} -
+
); } diff --git a/src/lib/loadOrgLoginPageBranding.ts b/src/lib/loadOrgLoginPageBranding.ts new file mode 100644 index 000000000..7e549622a --- /dev/null +++ b/src/lib/loadOrgLoginPageBranding.ts @@ -0,0 +1,31 @@ +import { priv } from "@app/lib/api"; +import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed"; +import { build } from "@server/build"; +import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types"; +import { AxiosResponse } from "axios"; + +export async function loadOrgLoginPageBranding(orgId: string): Promise<{ + primaryColor: string | null; +}> { + if (build === "oss") { + return { primaryColor: null }; + } + + const subscribed = await isOrgSubscribed(orgId); + if (!subscribed) { + return { primaryColor: null }; + } + + try { + const res = await priv.get< + AxiosResponse + >(`/login-page-branding?orgId=${orgId}`); + if (res.status === 200) { + return { primaryColor: res.data.data.primaryColor ?? null }; + } + } catch { + // ignore + } + + return { primaryColor: null }; +} From 6b04bcb383a370398a58d457d9ee813b701f9bfe Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 17:35:43 -0700 Subject: [PATCH 16/28] translate strings in auth pages for ssh, vnc, and rdp --- messages/en-US.json | 38 +++++++++++++++++++++- src/app/rdp/RdpClient.tsx | 67 ++++++++++++++++++++++----------------- src/app/rdp/page.tsx | 4 ++- src/app/ssh/SshClient.tsx | 8 +++-- src/app/ssh/page.tsx | 7 ++-- src/app/vnc/VncClient.tsx | 32 +++++++++++-------- src/app/vnc/page.tsx | 4 ++- 7 files changed, 109 insertions(+), 51 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0727a9a2d..4d4a41e43 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3456,5 +3456,41 @@ "sshErrorWebSocket": "WebSocket connection failed", "sshErrorAuthFailed": "Authentication failed", "sshErrorConnectionClosed": "Connection closed before authentication completed", - "sitePangolinSshDescription": "Allow SSH access to resources on this site. This can be changed later." + "sitePangolinSshDescription": "Allow SSH access to resources on this site. This can be changed later.", + "browserGatewayNoResourceForDomain": "No resource found for this domain", + "browserGatewayNoTarget": "No target", + "browserGatewayConnect": "Connect", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Failed to sign SSH key for PAM push authentication. Did you sign in as a user?", + "sshTerminalError": "Error: {error}", + "sshConnectionClosedCode": "Connection closed (code {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "vncTitle": "VNC", + "vncSignInDescription": "Enter your VNC password to connect", + "vncPasswordOptional": "Password (optional)", + "vncNoResourceTarget": "No resource target is available", + "vncFailedToLoadNovnc": "Failed to load noVNC", + "vncAuthFailedStatus": "Status {status}", + "vncPasteClipboard": "Paste clipboard", + "rdpTitle": "RDP", + "rdpSignInTitle": "Sign in to Remote Desktop", + "rdpSignInDescription": "Enter Windows credentials to connect", + "rdpLoadingModule": "Loading module...", + "rdpFailedToLoadModule": "Failed to load RDP module", + "rdpNotReady": "Not ready", + "rdpModuleInitializing": "RDP module is still initializing", + "rdpDownloadingFiles": "Downloading {count} file(s) from remote…", + "rdpDownloadFailed": "Download failed: {fileName}", + "rdpUploaded": "Uploaded: {fileName}", + "rdpNoConnectionTarget": "No connection target available", + "rdpConnectionFailed": "Connection failed", + "rdpFit": "Fit", + "rdpFull": "Full", + "rdpReal": "Real", + "rdpMeta": "Meta", + "rdpUploadFiles": "Upload files", + "rdpFilesReadyToPaste": "Files ready to paste", + "rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.", + "rdpUploadFailed": "Upload failed", + "rdpUnicodeKeyboardMode": "Unicode keyboard mode" } diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 721fd037b..def63fff0 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -24,6 +24,7 @@ import { } from "@app/components/ui/card"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; +import { useTranslations } from "next-intl"; declare module "react" { namespace JSX { @@ -68,6 +69,7 @@ export default function RdpClient({ error: string | null; primaryColor?: string | null; }) { + const t = useTranslations(); const STORAGE_KEY = "pangolin_rdp_credentials"; const [form, setForm] = useState(() => { @@ -141,7 +143,7 @@ export default function RdpClient({ console.error("Failed to load iron-remote-desktop modules", err); toast({ variant: "destructive", - title: "Failed to load RDP module", + title: t("rdpFailedToLoadModule"), description: `${err}` }); }); @@ -175,8 +177,8 @@ export default function RdpClient({ setConnecting(false); toast({ variant: "destructive", - title: "Not ready", - description: "RDP module is still initializing" + title: t("rdpNotReady"), + description: t("rdpModuleInitializing") }); return; } @@ -196,7 +198,9 @@ export default function RdpClient({ const downloadable = files.filter((f) => !f.isDirectory); if (downloadable.length === 0) return; toast({ - title: `Downloading ${downloadable.length} file(s) from remote…` + title: t("rdpDownloadingFiles", { + count: downloadable.length + }) }); for (let i = 0; i < files.length; i++) { const file = files[i]; @@ -214,7 +218,9 @@ export default function RdpClient({ .catch((err) => { toast({ variant: "destructive", - title: `Download failed: ${file.name}`, + title: t("rdpDownloadFailed", { + fileName: file.name + }), description: `${err}` }); }); @@ -223,7 +229,7 @@ export default function RdpClient({ // Notify when individual uploads complete (remote pasted a file). fileTransfer.on("upload-complete", (file: File) => { - toast({ title: `Uploaded: ${file.name}` }); + toast({ title: t("rdpUploaded", { fileName: file.name }) }); }); // Register with the web component so CLIPRDR extensions are @@ -237,8 +243,8 @@ export default function RdpClient({ setConnecting(false); toast({ variant: "destructive", - title: "No target", - description: "No connection target available" + title: t("browserGatewayNoTarget"), + description: t("rdpNoConnectionTarget") }); return; } @@ -290,13 +296,13 @@ export default function RdpClient({ if (isIronError(err)) { toast({ variant: "destructive", - title: "Connection failed", + title: t("rdpConnectionFailed"), description: err.backtrace() }); } else { toast({ variant: "destructive", - title: "Connection failed", + title: t("rdpConnectionFailed"), description: `${err}` }); } @@ -322,7 +328,7 @@ export default function RdpClient({ - RDP + {t("rdpTitle")}

{error}

@@ -339,14 +345,14 @@ export default function RdpClient({ - Sign in to Remote Desktop + {t("rdpSignInTitle")} - Enter Windows credentials to access xxxx + {t("rdpSignInDescription")}
- + - + - + {moduleReady - ? "Connect" - : "Loading module..."} + ? t("browserGatewayConnect") + : t("rdpLoadingModule")}
@@ -433,35 +439,35 @@ export default function RdpClient({ variant="secondary" onClick={() => ui()?.setScale(1)} > - Fit + {t("rdpFit")} {/*
diff --git a/src/app/rdp/page.tsx b/src/app/rdp/page.tsx index 408a95dea..b7190b428 100644 --- a/src/app/rdp/page.tsx +++ b/src/app/rdp/page.tsx @@ -3,6 +3,7 @@ import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest" import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding"; import RdpClient from "./RdpClient"; import AuthFooter from "@app/components/AuthFooter"; +import { getTranslations } from "next-intl/server"; export const dynamic = "force-dynamic"; @@ -11,8 +12,9 @@ export async function generateMetadata() { } export default async function RdpPage() { + const t = await getTranslations(); const { target } = await getBrowserTargetForRequest(); - const error = target ? null : "No resource found for this domain"; + const error = target ? null : t("browserGatewayNoResourceForDomain"); const { primaryColor } = target ? await loadOrgLoginPageBranding(target.orgId) : { primaryColor: null }; diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 945963ec0..e4ca9c806 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -274,7 +274,7 @@ export default function SshClient({ ); } else { xtermRef.current?.writeln( - `\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n` + `\r\n\x1b[31m${t("sshTerminalError", { error: msg.error ?? "" })}\x1b[0m\r\n` ); } } @@ -309,7 +309,7 @@ export default function SshClient({ if (authConfirmed) { setConnected(false); xtermRef.current?.writeln( - `\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n` + `\r\n\x1b[33m${t("sshConnectionClosedCode", { code: evt.code })}\x1b[0m\r\n` ); } // If auth was never confirmed the login form is already visible; @@ -510,7 +510,9 @@ export default function SshClient({ privateKey: e.target.value }) } - placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" + placeholder={t( + "sshPrivateKeyPlaceholder" + )} rows={5} className="font-mono text-xs" /> diff --git a/src/app/ssh/page.tsx b/src/app/ssh/page.tsx index 5e2e057b0..8ab62d110 100644 --- a/src/app/ssh/page.tsx +++ b/src/app/ssh/page.tsx @@ -9,6 +9,7 @@ import SshClient from "./SshClient"; import crypto from "crypto"; import AuthFooter from "@app/components/AuthFooter"; import type { SignSshKeyResponse } from "@server/routers/ssh/types"; +import { getTranslations } from "next-intl/server"; const pollInitialDelayMs = 250; const pollStartIntervalMs = 250; @@ -107,6 +108,7 @@ export async function generateMetadata() { } export default async function SshPage() { + const t = await getTranslations(); const headersList = await headers(); const cookieHeader = headersList.get("cookie") || ""; @@ -119,7 +121,7 @@ export default async function SshPage() { target = browserTarget; if (!target) { - error = "No resource found for this domain"; + error = t("browserGatewayNoResourceForDomain"); } else if (target.pamMode === "push") { try { const { privateKeyPem, publicKeyOpenSSH } = @@ -150,8 +152,7 @@ export default async function SshPage() { await waitForRoundTripCompletion(messageIds, cookieHeader); } catch (err) { console.error("Error signing SSH key:", err); - error = - "Failed to sign SSH key for PAM push authentication. Did you sign in as a user?"; + error = t("sshErrorSignKeyFailed"); } } diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index 7a93537fd..cec474df3 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/ui/card"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; +import { useTranslations } from "next-intl"; type FormState = { password: string; @@ -29,6 +30,7 @@ export default function VncClient({ error: string | null; primaryColor?: string | null; }) { + const t = useTranslations(); const STORAGE_KEY = "pangolin_vnc_credentials"; const [form, setForm] = useState(() => { @@ -67,8 +69,8 @@ export default function VncClient({ if (!target) { toast({ variant: "destructive", - title: "No target", - description: "No resource target is available" + title: t("browserGatewayNoTarget"), + description: t("vncNoResourceTarget") }); return; } @@ -92,7 +94,7 @@ export default function VncClient({ } catch (err) { toast({ variant: "destructive", - title: "Failed to load noVNC", + title: t("vncFailedToLoadNovnc"), description: `${err}` }); return; @@ -144,8 +146,12 @@ export default function VncClient({ (e: { detail: { status: number; reason?: string } }) => { toast({ variant: "destructive", - title: "Authentication failed", - description: e.detail.reason ?? `Status ${e.detail.status}` + title: t("sshErrorAuthFailed"), + description: + e.detail.reason ?? + t("vncAuthFailedStatus", { + status: e.detail.status + }) }); } ); @@ -159,7 +165,7 @@ export default function VncClient({ - VNC + {t("vncTitle")}

{error}

@@ -176,15 +182,15 @@ export default function VncClient({ - VNC + {t("vncTitle")} - Enter your credentials to access xxxx + {t("vncSignInDescription")}
@@ -220,7 +226,7 @@ export default function VncClient({ } }} > - Ctrl+Alt+Del + {t("browserGatewayCtrlAltDel")}
diff --git a/src/app/vnc/page.tsx b/src/app/vnc/page.tsx index b7ec90c59..01c8c15cf 100644 --- a/src/app/vnc/page.tsx +++ b/src/app/vnc/page.tsx @@ -3,6 +3,7 @@ import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest" import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding"; import VncClient from "./VncClient"; import AuthFooter from "@app/components/AuthFooter"; +import { getTranslations } from "next-intl/server"; export const dynamic = "force-dynamic"; @@ -11,8 +12,9 @@ export async function generateMetadata() { } export default async function VncPage() { + const t = await getTranslations(); const { target } = await getBrowserTargetForRequest(); - const error = target ? null : "No resource found for this domain"; + const error = target ? null : t("browserGatewayNoResourceForDomain"); const { primaryColor } = target ? await loadOrgLoginPageBranding(target.orgId) : { primaryColor: null }; From ff507f12759f3e8a2a519954ed995c695349d4ee Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 17:44:45 -0700 Subject: [PATCH 17/28] use standard error alert --- src/app/rdp/RdpClient.tsx | 39 ++++++++++++++++++--------------------- src/app/ssh/SshClient.tsx | 28 +++++++++++++++------------- src/app/vnc/VncClient.tsx | 32 ++++++++++++++++++++------------ 3 files changed, 53 insertions(+), 46 deletions(-) diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index def63fff0..9b5292b1b 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -22,6 +22,7 @@ import { CardTitle, CardDescription } from "@app/components/ui/card"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; import { useTranslations } from "next-intl"; @@ -92,6 +93,7 @@ export default function RdpClient({ const [showLogin, setShowLogin] = useState(true); const [moduleReady, setModuleReady] = useState(false); const [connecting, setConnecting] = useState(false); + const [submitError, setSubmitError] = useState(null); const [unicodeMode, setUnicodeMode] = useState(false); const [cursorOverrideActive, setCursorOverrideActive] = useState(false); @@ -170,16 +172,13 @@ export default function RdpClient({ }; const startSession = async () => { + setSubmitError(null); setConnecting(true); const userInteraction = userInteractionRef.current; const exts = extensionsRef.current; if (!userInteraction || !exts) { setConnecting(false); - toast({ - variant: "destructive", - title: t("rdpNotReady"), - description: t("rdpModuleInitializing") - }); + setSubmitError(t("rdpModuleInitializing")); return; } @@ -241,11 +240,7 @@ export default function RdpClient({ if (!target) { setConnecting(false); - toast({ - variant: "destructive", - title: t("browserGatewayNoTarget"), - description: t("rdpNoConnectionTarget") - }); + setSubmitError(t("rdpNoConnectionTarget")); return; } @@ -294,17 +289,9 @@ export default function RdpClient({ setConnecting(false); setShowLogin(true); if (isIronError(err)) { - toast({ - variant: "destructive", - title: t("rdpConnectionFailed"), - description: err.backtrace() - }); + setSubmitError(err.backtrace()); } else { - toast({ - variant: "destructive", - title: t("rdpConnectionFailed"), - description: `${err}` - }); + setSubmitError(`${err}`); } } }; @@ -331,7 +318,9 @@ export default function RdpClient({ {t("rdpTitle")} -

{error}

+ + {error} +
@@ -413,6 +402,14 @@ export default function RdpClient({ Enable Clipboard
*/} + {submitError && ( + + + {submitError} + + + )} + From d2793dfad713f585f2f62c0c0d9ed62f2cf875db Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 18:07:50 -0700 Subject: [PATCH 18/28] use react forms --- messages/en-US.json | 1 + src/app/rdp/RdpClient.tsx | 258 +++++++++++----------- src/app/ssh/SshClient.tsx | 437 +++++++++++++++++++++----------------- src/app/vnc/VncClient.tsx | 146 ++++++------- 4 files changed, 438 insertions(+), 404 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 4d4a41e43..409a589b1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3465,6 +3465,7 @@ "sshTerminalError": "Error: {error}", "sshConnectionClosedCode": "Connection closed (code {code})", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "Private key is required", "vncTitle": "VNC", "vncSignInDescription": "Enter your VNC password to connect", "vncPasswordOptional": "Password (optional)", diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 9b5292b1b..7be3d9360 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -1,9 +1,19 @@ "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 { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +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 { toast } from "@app/hooks/useToast"; import type { UserInteraction, @@ -43,7 +53,7 @@ declare module "react" { } } -type FormState = { +type RdpCredentialsForm = { username: string; password: string; domain: string; @@ -52,6 +62,23 @@ type FormState = { enableClipboard: boolean; }; +function loadStoredCredentials(key: string): RdpCredentialsForm { + try { + const saved = localStorage.getItem(key); + if (saved) return JSON.parse(saved) as RdpCredentialsForm; + } catch { + // ignore + } + return { + username: "", + password: "", + domain: "", + kdcProxyUrl: "", + pcb: "", + enableClipboard: true + }; +} + const isIronError = (error: unknown): error is IronError => { return ( typeof error === "object" && @@ -73,21 +100,18 @@ export default function RdpClient({ const t = useTranslations(); const STORAGE_KEY = "pangolin_rdp_credentials"; - const [form, setForm] = useState(() => { - 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 formSchema = z.object({ + username: z.string().min(1, { message: t("usernameRequired") }), + password: z.string().min(1, { message: t("passwordRequired") }), + domain: z.string(), + kdcProxyUrl: z.string(), + pcb: z.string(), + enableClipboard: z.boolean() + }); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: loadStoredCredentials(STORAGE_KEY) }); const [showLogin, setShowLogin] = useState(true); @@ -167,12 +191,7 @@ export default function RdpClient({ el.addEventListener("ready", onReady); }; - const update = (key: K, value: FormState[K]) => { - setForm((prev) => ({ ...prev, [key]: value })); - }; - - const startSession = async () => { - setSubmitError(null); + const startSession = async (values: RdpCredentialsForm) => { setConnecting(true); const userInteraction = userInteractionRef.current; const exts = extensionsRef.current; @@ -182,7 +201,7 @@ export default function RdpClient({ return; } - userInteraction.setEnableClipboard(form.enableClipboard); + userInteraction.setEnableClipboard(values.enableClipboard); // Dispose any previous session's provider and create a fresh one so // there is no stale upload state from a prior connection. @@ -248,13 +267,13 @@ export default function RdpClient({ const builder = userInteraction .configBuilder() - .withUsername(form.username) - .withPassword(form.password) + .withUsername(values.username) + .withPassword(values.password) .withDestination(destination) .withProxyAddress( `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp` ) - .withServerDomain(form.domain) + .withServerDomain(values.domain) .withAuthToken(target.authToken) .withDesktopSize({ width: window.innerWidth, @@ -262,18 +281,18 @@ export default function RdpClient({ }) .withExtension(exts.displayControl(true)); - if (form.pcb !== "") { - builder.withExtension(exts.preConnectionBlob(form.pcb)); + if (values.pcb !== "") { + builder.withExtension(exts.preConnectionBlob(values.pcb)); } - if (form.kdcProxyUrl !== "") { - builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl)); + if (values.kdcProxyUrl !== "") { + builder.withExtension(exts.kdcProxyUrl(values.kdcProxyUrl)); } try { const sessionInfo = await userInteraction.connect(builder.build()); try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); } catch { // ignore } @@ -296,6 +315,11 @@ export default function RdpClient({ } }; + const onSubmit = (values: RdpCredentialsForm) => { + setSubmitError(null); + startSession(values); + }; + const ui = () => userInteractionRef.current; const toggleCursorKind = () => { @@ -340,87 +364,76 @@ export default function RdpClient({ -
- - - update("domain", e.target.value) - } - /> - - - - update("username", e.target.value) - } - /> - - - - update("password", e.target.value) - } - /> - - {/* - - update("pcb", e.target.value)} - /> - */} - - {/* - - update("kdcProxyUrl", e.target.value) - } - /> - */} - {/*
- - update("enableClipboard", checked === true) - } - /> - -
*/} - {submitError && ( - - - {submitError} - - - )} - - -
+ ( + + + {t("domain")} + + + + + + + )} + /> + ( + + + {t("username")} + + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + + {submitError && ( + + + {submitError} + + + )} + +
@@ -539,20 +552,3 @@ export default function RdpClient({ ); } - -function Field({ - label, - id, - children -}: { - label: string; - id: string; - children: React.ReactNode; -}) { - return ( -
- - {children} -
- ); -} diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index c3123561c..4a2c3a652 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -2,10 +2,19 @@ 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 { useForm } from "react-hook-form"; +import * as z from "zod"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { Textarea } from "@app/components/ui/textarea"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import { Card, @@ -16,7 +25,7 @@ import { } from "@app/components/ui/card"; import Link from "next/link"; import { ExternalLink, Loader2 } from "lucide-react"; -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import type { SignSshKeyResponse } from "@server/routers/ssh/types"; import { useTranslations } from "next-intl"; @@ -25,7 +34,7 @@ import PoweredByPangolin from "@app/components/PoweredByPangolin"; type AuthTab = "password" | "privateKey"; -type FormState = { +type SshCredentialsForm = { username: string; password: string; privateKey: string; @@ -38,6 +47,16 @@ type ConnectCredentials = { certificate?: string; }; +function loadStoredCredentials(key: string): SshCredentialsForm { + try { + const saved = localStorage.getItem(key); + if (saved) return JSON.parse(saved) as SshCredentialsForm; + } catch { + // ignore + } + return { username: "", password: "", privateKey: "" }; +} + export default function SshClient({ target, error, @@ -52,18 +71,21 @@ export default function SshClient({ primaryColor?: string | null; }) { const STORAGE_KEY = "pangolin_ssh_credentials"; + const t = useTranslations(); - const [form, setForm] = useState(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) return JSON.parse(saved) as FormState; - } catch { - // ignore - } - return { username: "", password: "", privateKey: "" }; + const passwordTabSchema = z.object({ + username: z.string().min(1, { message: t("usernameRequired") }), + password: z.string().min(1, { message: t("passwordRequired") }) }); - const t = useTranslations(); + const privateKeyTabSchema = z.object({ + username: z.string().min(1, { message: t("usernameRequired") }), + privateKey: z.string().min(1, { message: t("sshPrivateKeyRequired") }) + }); + + const form = useForm({ + defaultValues: loadStoredCredentials(STORAGE_KEY) + }); function handleKeyFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -72,11 +94,10 @@ export default function SshClient({ reader.onload = (ev) => { const text = ev.target?.result; if (typeof text === "string") { - setForm((prev) => ({ ...prev, privateKey: text })); + form.setValue("privateKey", text, { shouldDirty: true }); } }; reader.readAsText(file); - // Reset input so the same file can be re-selected if needed. e.target.value = ""; } @@ -128,14 +149,12 @@ export default function SshClient({ 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( @@ -144,7 +163,6 @@ export default function SshClient({ } }); - // Send the initial size once the terminal is rendered. const { cols, rows } = terminal; if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send( @@ -158,14 +176,12 @@ export default function SshClient({ }; }, [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(); @@ -173,7 +189,6 @@ export default function SshClient({ }; }, []); - // Auto-connect when signed key data is provided (push PAM mode). useEffect(() => { if (signedKeyData && signedPrivateKey && target) { connect({ @@ -188,7 +203,6 @@ export default function SshClient({ override?: ConnectCredentials, authMethod: AuthTab = "password" ) { - setConnectError(null); setConnecting(true); if (!target) { @@ -197,13 +211,14 @@ export default function SshClient({ return; } - const username = override?.username ?? form.username; + const values = form.getValues(); + const username = override?.username ?? values.username; const password = override?.password ?? - (authMethod === "password" ? form.password : ""); + (authMethod === "password" ? values.password : ""); const privateKey = override?.privateKey ?? - (authMethod === "privateKey" ? form.privateKey : ""); + (authMethod === "privateKey" ? values.privateKey : ""); const certificate = override?.certificate; const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`; @@ -222,16 +237,10 @@ export default function SshClient({ const ws = new WebSocket(url.toString(), ["ssh"]); wsRef.current = ws; - // Track whether the server has confirmed auth by sending the first - // data frame. Until then, errors are shown in the login form. let authConfirmed = false; let authErrorShown = false; ws.onopen = () => { - // Send credentials as the first frame so the proxy can complete - // SSH authentication before piping pty data. Stay in "connecting" - // state until the server responds - this prevents the flash to the - // terminal page that would occur if we set connected=true here. ws.send( JSON.stringify({ type: "auth", @@ -242,7 +251,10 @@ export default function SshClient({ ); if (!override) { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify(form.getValues()) + ); } catch { // ignore } @@ -266,7 +278,6 @@ export default function SshClient({ xtermRef.current?.write(msg.data); } else if (msg.type === "error") { if (!authConfirmed) { - // Auth-phase error - show in the login form. authErrorShown = true; setConnecting(false); setConnectError( @@ -312,8 +323,6 @@ export default function SshClient({ `\r\n\x1b[33m${t("sshConnectionClosedCode", { code: evt.code })}\x1b[0m\r\n` ); } - // If auth was never confirmed the login form is already visible; - // a generic error is shown only when no specific error was received. if (!authConfirmed && !authErrorShown) { setConnectError(t("sshErrorConnectionClosed")); } @@ -327,7 +336,40 @@ export default function SshClient({ setConnected(false); } - // In push mode, show a connecting/connected state without the login form. + function applyTabSchemaErrors( + schema: z.ZodObject, + values: SshCredentialsForm + ) { + form.clearErrors(); + const result = schema.safeParse(values); + if (result.success) return true; + for (const issue of result.error.issues) { + const field = issue.path[0]; + if (typeof field === "string") { + form.setError(field as keyof SshCredentialsForm, { + message: issue.message + }); + } + } + return false; + } + + function onPasswordSubmit(e: React.FormEvent) { + e.preventDefault(); + setConnectError(null); + const values = form.getValues(); + if (!applyTabSchemaErrors(passwordTabSchema, values)) return; + connect(undefined, "password"); + } + + function onPrivateKeySubmit(e: React.FormEvent) { + e.preventDefault(); + setConnectError(null); + const values = form.getValues(); + if (!applyTabSchemaErrors(privateKeyTabSchema, values)) return; + connect(undefined, "privateKey"); + } + if (signedKeyData && signedPrivateKey) { return ( <> @@ -352,7 +394,10 @@ export default function SshClient({
)} {connectError && ( - + {connectError} @@ -406,155 +451,164 @@ export default function SshClient({ - -
- + +
- - setForm({ - ...form, - username: e.target.value - }) - } - /> - - - - setForm({ - ...form, - password: e.target.value - }) - } - /> - -
- {connectError && ( - - - {connectError} - - - )} - - -
-
- -
-

- {t("sshPrivateKeyDisclaimer")}{" "} - - {t("sshLearnMore")} - - -

- - - setForm({ - ...form, - username: e.target.value - }) - } - /> - - -