From 89cc99f915aee9fbc8346ce39b67ed8345934cb2 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 15:27:06 -0700 Subject: [PATCH 01/23] Initial rdp working --- package-lock.json | 71 ++---- package.json | 2 + src/app/rdp/RdpClient.tsx | 463 ++++++++++++++++++++++++++++++++++++++ src/app/rdp/page.tsx | 11 + 4 files changed, 494 insertions(+), 53 deletions(-) create mode 100644 src/app/rdp/RdpClient.tsx create mode 100644 src/app/rdp/page.tsx diff --git a/package-lock.json b/package-lock.json index 8c241554a..463bae493 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "8.4.1", "@aws-sdk/client-s3": "3.1011.0", + "@devolutions/iron-remote-desktop": "https://s3.us-east-1.amazonaws.com/static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", + "@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz", "@faker-js/faker": "10.3.0", "@headlessui/react": "2.2.9", "@hookform/resolvers": "5.2.2", @@ -1058,7 +1060,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1460,6 +1461,16 @@ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "license": "MIT" }, + "node_modules/@devolutions/iron-remote-desktop": { + "version": "0.0.0", + "resolved": "https://s3.us-east-1.amazonaws.com/static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", + "integrity": "sha512-96z7WShjpJJhr4I2RzhXB52GcdmVFMEVvUgoQ0a20n3gATNJ+n2V3W2i8AUeMqVR38uvcyK3e+loY5T050NgQg==" + }, + "node_modules/@devolutions/iron-remote-desktop-rdp": { + "version": "0.0.0", + "resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz", + "integrity": "sha512-qkpqYOMmSU6jIdDKOWpnLL7pb0sA/Y7Yjm4wI0/VbBIRfZPH8WdKxDU5DNp8jFF60l1zbcSJeRIq5yIAvws3Hw==" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.54.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz", @@ -2354,7 +2365,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2377,7 +2387,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2400,7 +2409,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2417,7 +2425,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2434,7 +2441,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2451,7 +2457,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2468,7 +2473,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2485,7 +2489,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2502,7 +2505,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2519,7 +2521,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2536,7 +2537,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2553,7 +2553,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2576,7 +2575,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2599,7 +2597,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2622,7 +2619,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2645,7 +2641,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2668,7 +2663,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2691,7 +2685,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2714,7 +2707,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2734,7 +2726,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2754,7 +2745,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2774,7 +2764,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3034,7 +3023,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -6981,7 +6969,6 @@ "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -8442,7 +8429,6 @@ "version": "5.90.21", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -8558,7 +8544,6 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -8906,7 +8891,6 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -9002,7 +8986,6 @@ "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -9030,7 +9013,6 @@ "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9056,7 +9038,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -9067,7 +9048,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9154,7 +9134,8 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/ws": { "version": "8.18.1", @@ -9228,7 +9209,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -9702,7 +9682,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10152,7 +10131,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -10224,7 +10202,6 @@ "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -10353,7 +10330,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11260,7 +11236,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -11701,6 +11676,7 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "engines": { "node": ">=20" }, @@ -12335,7 +12311,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12421,7 +12396,6 @@ "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -12558,7 +12532,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12952,7 +12925,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -15370,6 +15342,7 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", + "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -15380,6 +15353,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -15468,7 +15442,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz", "integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.15", "@swc/helpers": "0.5.15", @@ -16428,7 +16401,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -16936,7 +16908,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16968,7 +16939,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -17261,7 +17231,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18723,8 +18692,7 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.2", @@ -19199,7 +19167,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19627,7 +19594,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -19834,7 +19800,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 1da507c0d..7ed8f53fa 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "8.4.1", "@aws-sdk/client-s3": "3.1011.0", + "@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", + "@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz", "@faker-js/faker": "10.3.0", "@headlessui/react": "2.2.9", "@hookform/resolvers": "5.2.2", diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx new file mode 100644 index 000000000..17d12b066 --- /dev/null +++ b/src/app/rdp/RdpClient.tsx @@ -0,0 +1,463 @@ +"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 { Checkbox } from "@/components/ui/checkbox"; +import { toast } from "@app/hooks/useToast"; +import type { + UserInteraction, + IronError +} from "@devolutions/iron-remote-desktop/dist"; + +declare module "react" { + namespace JSX { + interface IntrinsicElements { + "iron-remote-desktop": React.DetailedHTMLProps< + React.HTMLAttributes & { + scale?: string; + verbose?: string; + flexcenter?: string; + module?: unknown; + }, + HTMLElement + >; + } + } +} + +type FormState = { + username: string; + password: string; + gatewayAddress: string; + hostname: string; + domain: string; + authtoken: string; + kdcProxyUrl: string; + pcb: string; + desktopWidth: number; + desktopHeight: number; + enableClipboard: boolean; +}; + +const isIronError = (error: unknown): error is IronError => { + return ( + typeof error === "object" && + error !== null && + typeof (error as IronError).backtrace === "function" && + typeof (error as IronError).kind === "function" + ); +}; + +export default function RdpClient() { + const [form, setForm] = useState({ + username: "Administrator", + password: "Wdvwy1W*ITK-(OK.sW?nVK%?mTl30wL0", + gatewayAddress: "ws://localhost:7171/jet/rdp", + hostname: "172.31.3.58:3389", + domain: "", + authtoken: "", + kdcProxyUrl: "", + pcb: "", + desktopWidth: 1280, + desktopHeight: 720, + enableClipboard: true + }); + + const [showLogin, setShowLogin] = useState(true); + const [moduleReady, setModuleReady] = useState(false); + const [unicodeMode, setUnicodeMode] = useState(false); + const [cursorOverrideActive, setCursorOverrideActive] = useState(false); + + const userInteractionRef = useRef(null); + const backendRef = useRef(null); + const extensionsRef = useRef<{ + displayControl: (enable: boolean) => unknown; + preConnectionBlob: (pcb: string) => unknown; + kdcProxyUrl: (url: string) => unknown; + } | null>(null); + + // Load the iron-remote-desktop modules client-side and register the + // `` custom element. + useEffect(() => { + let cancelled = false; + (async () => { + const [coreMod, rdpMod] = await Promise.all([ + import("@devolutions/iron-remote-desktop/dist"), + import("@devolutions/iron-remote-desktop-rdp/dist") + ]); + if (cancelled) return; + + await rdpMod.init("INFO"); + + backendRef.current = rdpMod.Backend; + extensionsRef.current = { + displayControl: rdpMod.displayControl, + preConnectionBlob: rdpMod.preConnectionBlob, + kdcProxyUrl: rdpMod.kdcProxyUrl + }; + // Importing the package registers the custom element as a side + // effect. Touch the default export to avoid tree-shaking. + void coreMod; + + setModuleReady(true); + })().catch((err) => { + console.error("Failed to load iron-remote-desktop modules", err); + toast({ + variant: "destructive", + title: "Failed to load RDP module", + description: `${err}` + }); + }); + + return () => { + cancelled = true; + }; + }, []); + + // Attach the "ready" listener synchronously the moment the custom + // element mounts. The custom element dispatches `ready` from its own + // `onMount`, so a deferred useEffect can race and miss it. + const remoteElementRef = (el: HTMLElement | null) => { + if (!el) return; + const onReady = (e: Event) => { + const event = e as CustomEvent; + userInteractionRef.current = event.detail.irgUserInteraction; + }; + el.addEventListener("ready", onReady); + }; + + const update = (key: K, value: FormState[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const startSession = async () => { + const userInteraction = userInteractionRef.current; + const exts = extensionsRef.current; + if (!userInteraction || !exts) { + toast({ + variant: "destructive", + title: "Not ready", + description: "RDP module is still initializing" + }); + return; + } + + if (form.authtoken === "") { + toast({ + variant: "destructive", + title: "Missing auth token", + description: + "An auth token is required to connect through the gateway" + }); + return; + } + + toast({ + title: "Connecting...", + description: "Connection in progress" + }); + + userInteraction.setEnableClipboard(form.enableClipboard); + + const builder = userInteraction + .configBuilder() + .withUsername(form.username) + .withPassword(form.password) + .withDestination(form.hostname) + .withProxyAddress(form.gatewayAddress) + .withServerDomain(form.domain) + .withAuthToken(form.authtoken) + .withDesktopSize({ + width: form.desktopWidth, + height: form.desktopHeight + }) + .withExtension(exts.displayControl(true)); + + if (form.pcb !== "") { + builder.withExtension(exts.preConnectionBlob(form.pcb)); + } + if (form.kdcProxyUrl !== "") { + builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl)); + } + + try { + const sessionInfo = await userInteraction.connect(builder.build()); + + toast({ title: "Connected" }); + setShowLogin(false); + userInteraction.setVisibility(true); + + const termInfo = await sessionInfo.run(); + toast({ + title: "Session terminated", + description: termInfo.reason() + }); + setShowLogin(true); + } catch (err) { + setShowLogin(true); + if (isIronError(err)) { + toast({ + variant: "destructive", + title: "Connection failed", + description: err.backtrace() + }); + } else { + toast({ + variant: "destructive", + title: "Connection failed", + description: `${err}` + }); + } + } + }; + + const ui = () => userInteractionRef.current; + + const toggleCursorKind = () => { + const u = ui(); + if (!u) return; + if (cursorOverrideActive) { + u.setCursorStyleOverride(null); + } else { + u.setCursorStyleOverride('url("crosshair.png") 7 7, default'); + } + setCursorOverrideActive((v) => !v); + }; + + return ( +
+ {showLogin && ( +
+

+ RDP Test Connection +

+ +
+ + + update("hostname", e.target.value) + } + /> + + + + update("domain", e.target.value) + } + /> + + + + update("username", e.target.value) + } + /> + + + + update("password", e.target.value) + } + /> + + + + update("gatewayAddress", e.target.value) + } + /> + + + + update("authtoken", e.target.value) + } + /> + + + update("pcb", e.target.value)} + /> + +
+ + + update( + "desktopWidth", + Number(e.target.value) || 0 + ) + } + /> + + + + update( + "desktopHeight", + Number(e.target.value) || 0 + ) + } + /> + +
+ + + update("kdcProxyUrl", e.target.value) + } + /> + +
+ + update("enableClipboard", checked === true) + } + /> + +
+ + +
+
+ )} + +
+
+ + + + + + + + +
+ + {moduleReady && ( + + )} +
+
+ ); +} + +function Field({ + label, + id, + children +}: { + label: string; + id: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/rdp/page.tsx b/src/app/rdp/page.tsx new file mode 100644 index 000000000..52893af79 --- /dev/null +++ b/src/app/rdp/page.tsx @@ -0,0 +1,11 @@ +import RdpClient from "./RdpClient"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: "RDP Test" +}; + +export default function RdpPage() { + return ; +} From d42b6076d2f3d0b1e8ee301edd4d060511c3a6de Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 16:10:24 -0700 Subject: [PATCH 02/23] Comment out some fields --- src/app/rdp/RdpClient.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 17d12b066..c0c18edde 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -57,7 +57,7 @@ export default function RdpClient() { gatewayAddress: "ws://localhost:7171/jet/rdp", hostname: "172.31.3.58:3389", domain: "", - authtoken: "", + authtoken: "abc123", kdcProxyUrl: "", pcb: "", desktopWidth: 1280, @@ -281,7 +281,7 @@ export default function RdpClient() { } /> - + {/* update("pcb", e.target.value)} /> - + */}
- @@ -336,7 +336,7 @@ export default function RdpClient() { update("kdcProxyUrl", e.target.value) } /> - + */}
Date: Mon, 11 May 2026 16:53:03 -0700 Subject: [PATCH 03/23] Add first iteration of ssh proxy --- package-lock.json | 30 +++- package.json | 3 + src/app/ssh/SshClient.tsx | 355 +++++++++++++++++++++++++++++++++++++ src/app/ssh/page.tsx | 11 ++ src/types/css-modules.d.ts | 3 + 5 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 src/app/ssh/SshClient.tsx create mode 100644 src/app/ssh/page.tsx create mode 100644 src/types/css-modules.d.ts diff --git a/package-lock.json b/package-lock.json index 463bae493..f9678e7b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "8.4.1", "@aws-sdk/client-s3": "3.1011.0", - "@devolutions/iron-remote-desktop": "https://s3.us-east-1.amazonaws.com/static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", + "@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", "@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz", "@faker-js/faker": "10.3.0", "@headlessui/react": "2.2.9", @@ -46,6 +46,9 @@ "@tailwindcss/forms": "0.5.11", "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "arctic": "3.7.0", "axios": "1.15.0", "better-sqlite3": "11.9.1", @@ -1463,8 +1466,8 @@ }, "node_modules/@devolutions/iron-remote-desktop": { "version": "0.0.0", - "resolved": "https://s3.us-east-1.amazonaws.com/static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", - "integrity": "sha512-96z7WShjpJJhr4I2RzhXB52GcdmVFMEVvUgoQ0a20n3gATNJ+n2V3W2i8AUeMqVR38uvcyK3e+loY5T050NgQg==" + "resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", + "integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA==" }, "node_modules/@devolutions/iron-remote-desktop-rdp": { "version": "0.0.0", @@ -9663,6 +9666,27 @@ "win32" ] }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", diff --git a/package.json b/package.json index 7ed8f53fa..33e54c3d0 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,9 @@ "@tailwindcss/forms": "0.5.11", "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "arctic": "3.7.0", "axios": "1.15.0", "better-sqlite3": "11.9.1", diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx new file mode 100644 index 000000000..1c0cf73d9 --- /dev/null +++ b/src/app/ssh/SshClient.tsx @@ -0,0 +1,355 @@ +"use client"; + +import "@xterm/xterm/css/xterm.css"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +type FormState = { + gatewayAddress: string; + hostname: string; + port: string; + username: string; + password: string; + authToken: string; +}; + +export default function SshClient() { + const [form, setForm] = useState({ + gatewayAddress: "ws://localhost:7171/jet/ssh", + hostname: "", + port: "22", + username: "", + password: "", + authToken: "abc123" + }); + + const [connected, setConnected] = useState(false); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(null); + + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef( + null + ); + const wsRef = useRef(null); + + // Mount the terminal div once connected. + useEffect(() => { + if (!connected || !terminalRef.current) return; + + let cancelled = false; + + (async () => { + const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = + await Promise.all([ + import("@xterm/xterm"), + import("@xterm/addon-fit"), + import("@xterm/addon-web-links") + ]); + if (cancelled || !terminalRef.current) return; + + const terminal = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: "Menlo, Monaco, 'Courier New', monospace", + theme: { + background: "#0d0d0d", + foreground: "#f0f0f0" + }, + scrollback: 5000 + }); + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); + + terminal.open(terminalRef.current); + fitAddon.fit(); + + xtermRef.current = terminal; + fitAddonRef.current = fitAddon; + + // Send user keystrokes to the WebSocket. + terminal.onData((data) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "data", data })); + } + }); + + // Send resize events. + terminal.onResize(({ cols, rows }) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ type: "resize", cols, rows }) + ); + } + }); + + // Send the initial size once the terminal is rendered. + const { cols, rows } = terminal; + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ type: "resize", cols, rows }) + ); + } + })().catch(console.error); + + return () => { + cancelled = true; + }; + }, [connected]); + + // Refit terminal when the window resizes. + useEffect(() => { + const onResize = () => fitAddonRef.current?.fit(); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + // Cleanup on unmount. + useEffect(() => { + return () => { + wsRef.current?.close(); + xtermRef.current?.dispose(); + }; + }, []); + + function connect() { + setError(null); + setConnecting(true); + + const url = new URL(form.gatewayAddress); + // Pass connection parameters as query params so the proxy can route + // before any application-level framing is needed. + url.searchParams.set("host", form.hostname); + url.searchParams.set("port", form.port); + url.searchParams.set("username", form.username); + // Auth token is sent as a query param; the proxy validates it before + // forwarding any data. + url.searchParams.set("authToken", form.authToken); + + const ws = new WebSocket(url.toString(), ["ssh"]); + wsRef.current = ws; + + ws.onopen = () => { + // Send the password (or empty string) as the first frame so the + // proxy can complete SSH authentication before piping pty data. + ws.send(JSON.stringify({ type: "auth", password: form.password })); + setConnecting(false); + setConnected(true); + }; + + ws.onmessage = (evt) => { + if (typeof evt.data === "string") { + try { + const msg = JSON.parse(evt.data as string) as { + type: string; + data?: string; + error?: string; + }; + if (msg.type === "data" && msg.data) { + xtermRef.current?.write(msg.data); + } else if (msg.type === "error") { + xtermRef.current?.writeln( + `\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n` + ); + } + } catch { + xtermRef.current?.write(evt.data); + } + } else if (evt.data instanceof Blob) { + evt.data.text().then((t) => xtermRef.current?.write(t)); + } + }; + + ws.onerror = () => { + setConnecting(false); + setConnected(false); + setError("WebSocket connection failed"); + }; + + ws.onclose = (evt) => { + setConnecting(false); + setConnected(false); + xtermRef.current?.writeln( + `\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n` + ); + }; + } + + function disconnect() { + wsRef.current?.close(); + xtermRef.current?.dispose(); + xtermRef.current = null; + setConnected(false); + } + + return ( +
+

SSH Terminal

+ + {!connected && ( +
+
+
+ + + setForm({ + ...form, + gatewayAddress: e.target.value + }) + } + placeholder="ws://localhost:7171/jet/ssh" + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+ +
+ + + setForm({ + ...form, + hostname: e.target.value + }) + } + placeholder="192.168.1.1" + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+ +
+ + + setForm({ ...form, port: e.target.value }) + } + placeholder="22" + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+ +
+ + + setForm({ + ...form, + username: e.target.value + }) + } + placeholder="root" + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+ +
+ + + setForm({ + ...form, + password: e.target.value + }) + } + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+ +
+ + + setForm({ + ...form, + authToken: e.target.value + }) + } + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+
+ + {error &&

{error}

} + + +
+ )} + + {connected && ( +
+
+ +
+
+
+ )} +
+ ); +} diff --git a/src/app/ssh/page.tsx b/src/app/ssh/page.tsx new file mode 100644 index 000000000..62159f301 --- /dev/null +++ b/src/app/ssh/page.tsx @@ -0,0 +1,11 @@ +import SshClient from "./SshClient"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: "SSH Terminal" +}; + +export default function SshPage() { + return ; +} diff --git a/src/types/css-modules.d.ts b/src/types/css-modules.d.ts new file mode 100644 index 000000000..0857acac4 --- /dev/null +++ b/src/types/css-modules.d.ts @@ -0,0 +1,3 @@ +// Allow importing plain CSS files as side-effect imports (e.g. xterm.css). +declare module "*.css" {} +declare module "@xterm/xterm/css/xterm.css" {} From 3f17f1a468f27627f9fc325d8ad8c9108cd3ef41 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 20:13:48 -0700 Subject: [PATCH 04/23] Support rdp --- src/app/rdp/RdpClient.tsx | 100 +++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index c0c18edde..b3d166d76 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -8,8 +8,13 @@ import { Checkbox } from "@/components/ui/checkbox"; import { toast } from "@app/hooks/useToast"; import type { UserInteraction, - IronError + IronError, + FileTransferProvider } from "@devolutions/iron-remote-desktop/dist"; +import type { + RdpFileTransferProvider, + FileInfo +} from "@devolutions/iron-remote-desktop-rdp/dist"; declare module "react" { namespace JSX { @@ -72,6 +77,13 @@ export default function RdpClient() { const userInteractionRef = useRef(null); const backendRef = useRef(null); + // Holds the RdpFileTransferProvider constructor so we can create a fresh + // instance per session (avoids stale upload state across reconnects). + const fileTransferClassRef = useRef( + null + ); + // Active session's provider instance; replaced on each connect. + const fileTransferRef = useRef(null); const extensionsRef = useRef<{ displayControl: (enable: boolean) => unknown; preConnectionBlob: (pcb: string) => unknown; @@ -97,6 +109,11 @@ export default function RdpClient() { preConnectionBlob: rdpMod.preConnectionBlob, kdcProxyUrl: rdpMod.kdcProxyUrl }; + + // Store the class; a fresh instance is created per session. + fileTransferClassRef.current = + rdpMod.RdpFileTransferProvider as unknown as typeof RdpFileTransferProvider; + // Importing the package registers the custom element as a side // effect. Touch the default export to avoid tree-shaking. void coreMod; @@ -161,6 +178,56 @@ export default function RdpClient() { userInteraction.setEnableClipboard(form.enableClipboard); + // Dispose any previous session's provider and create a fresh one so + // there is no stale upload state from a prior connection. + fileTransferRef.current?.dispose(); + const ProviderClass = fileTransferClassRef.current; + const fileTransfer = ProviderClass ? new ProviderClass() : null; + fileTransferRef.current = fileTransfer; + + if (fileTransfer) { + // Auto-download files when the remote copies them to clipboard. + fileTransfer.on("files-available", (files: FileInfo[]) => { + const downloadable = files.filter((f) => !f.isDirectory); + if (downloadable.length === 0) return; + toast({ + title: `Downloading ${downloadable.length} file(s) from remote…` + }); + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.isDirectory) continue; + const { completion } = fileTransfer.downloadFile(file, i); + completion + .then((blob) => { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = file.name; + a.click(); + URL.revokeObjectURL(url); + }) + .catch((err) => { + toast({ + variant: "destructive", + title: `Download failed: ${file.name}`, + description: `${err}` + }); + }); + } + }); + + // Notify when individual uploads complete (remote pasted a file). + fileTransfer.on("upload-complete", (file: File) => { + toast({ title: `Uploaded: ${file.name}` }); + }); + + // Register with the web component so CLIPRDR extensions are + // wired up before connect() builds the session. + userInteraction.enableFileTransfer( + fileTransfer as unknown as FileTransferProvider + ); + } + const builder = userInteraction .configBuilder() .withUsername(form.username) @@ -190,6 +257,8 @@ export default function RdpClient() { userInteraction.setVisibility(true); const termInfo = await sessionInfo.run(); + fileTransferRef.current?.dispose(); + fileTransferRef.current = null; toast({ title: "Session terminated", description: termInfo.reason() @@ -401,12 +470,39 @@ export default function RdpClient() { > Meta - */} + +
+
+ )} + +
+
+ + + +
+ + {/* noVNC mounts a inside this div */} +
+
+
+ ); +} + +function Field({ + label, + id, + children +}: { + label: string; + id: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/vnc/page.tsx b/src/app/vnc/page.tsx new file mode 100644 index 000000000..0a9b4b4c3 --- /dev/null +++ b/src/app/vnc/page.tsx @@ -0,0 +1,11 @@ +import VncClient from "./VncClient"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: "VNC Test" +}; + +export default function VncPage() { + return ; +} From 743621eb2539485fa98e1f33ec48b394fda820c7 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 May 2026 21:48:59 -0700 Subject: [PATCH 06/23] Add browserGatewayTarget table --- server/db/pg/schema/privateSchema.ts | 20 ++++++++++++++++++++ server/db/sqlite/schema/privateSchema.ts | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 229fc9ff0..3b4f459f3 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -580,6 +580,23 @@ export const trialNotifications = pgTable("trialNotifications", { sentAt: bigint("sentAt", { mode: "number" }).notNull() }); +export const browserGatewayTarget = pgTable("browserGatewayTarget", { + browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(), + resourceId: integer("resourceId") + .references(() => resources.resourceId, { + onDelete: "cascade" + }) + .notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), + type: varchar("type").notNull(), // "ssh", "rdp", "vnc" + destination: varchar("destination").notNull(), + destinationPort: integer("destinationPort").notNull() +}); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -627,3 +644,6 @@ export type AlertEmailRecipients = InferSelectModel< >; export type AlertWebhookActions = InferSelectModel; export type TrialNotification = InferSelectModel; +export type BrowserGatewayTarget = InferSelectModel< + typeof browserGatewayTarget +>; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index ae7360780..1fdace69b 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -588,6 +588,25 @@ export const trialNotifications = sqliteTable("trialNotifications", { sentAt: integer("sentAt").notNull() }); +export const browserGatewayTarget = sqliteTable("browserGatewayTarget", { + browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({ + autoIncrement: true + }), + resourceId: integer("resourceId") + .references(() => resources.resourceId, { + onDelete: "cascade" + }) + .notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), + type: text("type").notNull(), // "ssh", "rdp", "vnc" + destination: text("destination").notNull(), + destinationPort: integer("destinationPort").notNull() +}); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -627,3 +646,6 @@ export type AlertEmailAction = InferSelectModel; export type AlertEmailRecipient = InferSelectModel; export type AlertWebhookAction = InferSelectModel; export type TrialNotification = InferSelectModel; +export type BrowserGatewayTarget = InferSelectModel< + typeof browserGatewayTarget +>; From 4e07e9c52c8708bde20887f5598cf54b91bc7d35 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 May 2026 11:56:23 -0700 Subject: [PATCH 07/23] Working on new target type --- server/routers/newt/buildConfiguration.ts | 11 ++++++ server/routers/newt/sync.ts | 14 ++++++- server/routers/newt/targets.ts | 47 ++++++++++++++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index f87d38450..ce126f9c5 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -1,4 +1,6 @@ import { + browserGatewayTarget, + BrowserGatewayTarget, clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, @@ -310,3 +312,12 @@ export async function buildTargetConfigurationForNewtClient( udpTargets }; } + +export async function buildBrowserGatewayTargetConfigurationForNewtClient( + siteId: number +): Promise { + return await db + .select() + .from(browserGatewayTarget) + .where(eq(browserGatewayTarget.siteId, siteId)); +} diff --git a/server/routers/newt/sync.ts b/server/routers/newt/sync.ts index 6fce13ff3..d7ceefde4 100644 --- a/server/routers/newt/sync.ts +++ b/server/routers/newt/sync.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { + buildBrowserGatewayTargetConfigurationForNewtClient, buildClientConfigurationForNewtClient, buildTargetConfigurationForNewtClient } from "./buildConfiguration"; @@ -12,6 +13,9 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) { const { tcpTargets, udpTargets, validHealthCheckTargets } = await buildTargetConfigurationForNewtClient(site.siteId); + const browserGatewayTargets = + await buildBrowserGatewayTargetConfigurationForNewtClient(site.siteId); + let exitNode: ExitNode | undefined; if (site.exitNodeId) { [exitNode] = await db @@ -36,7 +40,15 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) { }, healthCheckTargets: validHealthCheckTargets, peers: peers, - clientTargets: targets + clientTargets: targets, + browserGatewayTargets: browserGatewayTargets.map((t) => ({ + id: t.browserGatewayTargetId, + resourceId: t.resourceId, + siteId: t.siteId, + type: t.type, + destination: t.destination, + destinationPort: t.destinationPort + })) } }, { diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index ac25fb27d..ca15e50cc 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,4 +1,4 @@ -import { Target, TargetHealthCheck } from "@server/db"; +import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { canCompress } from "@server/lib/clientVersionChecks"; @@ -239,3 +239,48 @@ export async function removeTargets( { incrementConfigVersion: true, compress: canCompress(version, "newt") } ); } + +export async function sendBrowserGatewayTargets( + newtId: string, + targets: BrowserGatewayTarget[], + version?: string | null +) { + if (targets.length === 0) return; + + const payload = targets.map((t) => ({ + id: t.browserGatewayTargetId, + resourceId: t.resourceId, + siteId: t.siteId, + type: t.type, + destination: t.destination, + destinationPort: t.destinationPort + })); + + await sendToClient( + newtId, + { + type: "newt/browsergateway/add", + data: { + targets: payload + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); +} + +export async function removeBrowserGatewayTarget( + newtId: string, + browserGatewayTargetId: number, + version?: string | null +) { + await sendToClient( + newtId, + { + type: "newt/browsergateway/remove", + data: { + ids: [browserGatewayTargetId] + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); +} From de70d72e0d00914ac76de1915b32ca6c88628470 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 May 2026 17:33:16 -0700 Subject: [PATCH 08/23] Add gateway endpoints into the traefik config --- .../private/lib/traefik/getTraefikConfig.ts | 274 ++++++++++++++++++ server/routers/newt/buildConfiguration.ts | 24 +- .../routers/newt/handleNewtRegisterMessage.ts | 18 +- server/routers/newt/sync.ts | 21 +- src/app/rdp/RdpClient.tsx | 6 +- 5 files changed, 312 insertions(+), 31 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 481192fb5..e551a7a7a 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -12,6 +12,7 @@ */ import { + browserGatewayTarget, certificates, db, domainNamespaces, @@ -277,6 +278,115 @@ export async function getTraefikConfig( }); }); + // Query browser gateway targets for this exit node + const browserGatewayRows = await db + .select({ + // Resource fields + resourceId: resources.resourceId, + resourceName: resources.name, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + subdomain: resources.subdomain, + domainId: resources.domainId, + enabled: resources.enabled, + wildcard: resources.wildcard, + domainCertResolver: domains.certResolver, + preferWildcardCert: domains.preferWildcardCert, + domainNamespaceId: domainNamespaces.domainNamespaceId, + // Browser gateway target fields + browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId, + bgType: browserGatewayTarget.type, + // Site fields + siteId: sites.siteId, + siteType: sites.type, + siteOnline: sites.online, + subnet: sites.subnet, + siteExitNodeId: sites.exitNodeId + }) + .from(browserGatewayTarget) + .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) + .innerJoin( + resources, + eq(resources.resourceId, browserGatewayTarget.resourceId) + ) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) + .leftJoin( + domainNamespaces, + eq(domainNamespaces.domainId, resources.domainId) + ) + .where( + and( + eq(resources.enabled, true), + or( + eq(sites.exitNodeId, exitNodeId), + and( + isNull(sites.exitNodeId), + sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, + eq(sites.type, "local"), + sql`(${build != "saas" ? 1 : 0} = 1)` + ) + ), + inArray(sites.type, siteTypes) + ) + ); + + // Group browser gateway targets by resource + type BrowserGatewayResourceEntry = { + resourceId: number; + name: string; + fullDomain: string | null; + ssl: boolean | null; + subdomain: string | null; + domainId: string | null; + enabled: boolean | null; + wildcard: boolean | null; + domainCertResolver: string | null; + preferWildcardCert: boolean | null; + targets: { + browserGatewayTargetId: number; + bgType: string; + siteId: number; + siteType: string; + siteOnline: boolean | null; + subnet: string | null; + siteExitNodeId: number | null; + }[]; + }; + const browserGatewayResourcesMap = new Map< + number, + BrowserGatewayResourceEntry + >(); + + for (const row of browserGatewayRows) { + if (filterOutNamespaceDomains && row.domainNamespaceId) { + continue; + } + if (!browserGatewayResourcesMap.has(row.resourceId)) { + browserGatewayResourcesMap.set(row.resourceId, { + resourceId: row.resourceId, + name: sanitize(row.resourceName) || "", + fullDomain: row.fullDomain, + ssl: row.ssl, + subdomain: row.subdomain, + domainId: row.domainId, + enabled: row.enabled, + wildcard: row.wildcard, + domainCertResolver: row.domainCertResolver, + preferWildcardCert: row.preferWildcardCert, + targets: [] + }); + } + browserGatewayResourcesMap.get(row.resourceId)!.targets.push({ + browserGatewayTargetId: row.browserGatewayTargetId, + bgType: row.bgType, + siteId: row.siteId, + siteType: row.siteType, + siteOnline: row.siteOnline, + subnet: row.subnet, + siteExitNodeId: row.siteExitNodeId + }); + } + let siteResourcesWithFullDomain: { siteResourceId: number; fullDomain: string | null; @@ -324,6 +434,12 @@ export async function getTraefikConfig( domains.add(sr.fullDomain); } } + // Include browser gateway resource domains + for (const bgResource of browserGatewayResourcesMap.values()) { + if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) { + domains.add(bgResource.fullDomain); + } + } // get the valid certs for these domains validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); @@ -925,6 +1041,164 @@ export async function getTraefikConfig( } } + // Generate Traefik config for browser gateway resources + const browserGatewayPort = 39999; + for (const [, bgResource] of browserGatewayResourcesMap.entries()) { + if (!bgResource.enabled) continue; + if (!bgResource.domainId) continue; + if (!bgResource.fullDomain) continue; + + if (!config_output.http.routers) config_output.http.routers = {}; + if (!config_output.http.services) config_output.http.services = {}; + + const fullDomain = bgResource.fullDomain; + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + const routerMiddlewares = [ + badgerMiddlewareName, + ...additionalMiddlewares + ]; + + const hostRule = `Host(\`${fullDomain}\`)`; + + // Build TLS config + let tls = {}; + if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { + const domainParts = fullDomain.split("."); + let wildCard: string; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + if (!bgResource.subdomain) { + wildCard = fullDomain; + } + + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; + const resolverName = bgResource.domainCertResolver + ? bgResource.domainCertResolver.trim() + : globalDefaultResolver; + const preferWildcard = + bgResource.preferWildcardCert !== undefined && + bgResource.preferWildcardCert !== null + ? bgResource.preferWildcardCert + : globalDefaultPreferWildcard; + + tls = { + certResolver: resolverName, + ...(preferWildcard ? { domains: [{ main: wildCard }] } : {}) + }; + } else { + const matchingCert = validCerts.find( + (cert) => cert.queriedDomain === fullDomain + ); + if (!matchingCert) { + logger.debug( + `No matching certificate found for browser gateway domain: ${fullDomain}` + ); + continue; + } + } + + const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`; + + if (bgResource.ssl) { + const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`; + config_output.http.routers![redirectRouterName] = { + entryPoints: [config.getRawConfig().traefik.http_entrypoint], + middlewares: [redirectHttpsMiddlewareName], + service: bgUiServiceName, + rule: hostRule, + priority: 100 + }; + } + + // Collect online sites for this resource (for any type) + const anySiteOnline = bgResource.targets.some((t) => t.siteOnline); + + // Group targets by type and generate per-type websocket routers and services + const typeMap = new Map(); + for (const t of bgResource.targets) { + if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []); + typeMap.get(t.bgType)!.push(t); + } + + for (const [bgType, typedTargets] of typeMap.entries()) { + const bgKey = `bg-r${bgResource.resourceId}-${bgType}`; + const bgRouterName = `${bgKey}-router`; + const bgServiceName = `${bgKey}-service`; + const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`; + + const servers = typedTargets + .filter((t) => { + if (!t.siteOnline && anySiteOnline) return false; + if (t.siteType === "newt") return !!t.subnet; + return false; // browser gateway only supported on newt sites + }) + .map((t) => ({ + url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}` + })) + .filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i); + + config_output.http.routers![bgRouterName] = { + entryPoints: [ + bgResource.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: routerMiddlewares, + service: bgServiceName, + rule: bgRule, + priority: 110, // higher than 105 (UI router) to match /gateway/* first + ...(bgResource.ssl ? { tls } : {}) + }; + + config_output.http.services![bgServiceName] = { + loadBalancer: { + servers + } + }; + } + + // UI router: serve the browser gateway pages from the internal pangolin instance + // Covers /{type} paths for each configured type plus Next.js assets + const internalHost = config.getRawConfig().server.internal_hostname; + const internalPort = config.getRawConfig().server.next_port; + + const typePaths = Array.from(typeMap.keys()) + .map((t) => `PathPrefix(\`/${t}\`)`) + .join(" || "); + const uiRule = `${hostRule} && (${typePaths} || PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`; + + config_output.http.services![bgUiServiceName] = { + loadBalancer: { + servers: [ + { + url: `http://${internalHost}:${internalPort}` + } + ] + } + }; + + config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] = + { + entryPoints: [ + bgResource.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: routerMiddlewares, + service: bgUiServiceName, + rule: uiRule, + priority: 105, + ...(bgResource.ssl ? { tls } : {}) + }; + } + // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that // Traefik generates TLS certificates for those domains even when no // matching resource exists yet. diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index ce126f9c5..fb398236a 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -235,6 +235,11 @@ export async function buildTargetConfigurationForNewtClient( .from(targetHealthCheck) .where(eq(targetHealthCheck.siteId, siteId)); + const allBrowserGatewayTargets = await db + .select() + .from(browserGatewayTarget) + .where(eq(browserGatewayTarget.siteId, siteId)); + const { tcpTargets, udpTargets } = allTargets.reduce( (acc, target) => { // Filter out invalid targets @@ -306,18 +311,17 @@ export async function buildTargetConfigurationForNewtClient( (target) => target !== null ); + const browserGatewayTargets = allBrowserGatewayTargets.map((t) => ({ + id: t.browserGatewayTargetId, + type: t.type, + destination: t.destination, + destinationPort: t.destinationPort + })); + return { validHealthCheckTargets, tcpTargets, - udpTargets + udpTargets, + browserGatewayTargets }; } - -export async function buildBrowserGatewayTargetConfigurationForNewtClient( - siteId: number -): Promise { - return await db - .select() - .from(browserGatewayTarget) - .where(eq(browserGatewayTarget.siteId, siteId)); -} diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index f3902a35d..bd4aaacb3 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -43,8 +43,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { const siteId = newt.siteId; - const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } = - message.data; + const { + publicKey, + pingResults, + newtVersion, + backwardsCompatible, + chainId + } = message.data; if (!publicKey) { logger.warn("Public key not provided"); return; @@ -191,8 +196,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .where(eq(newts.newtId, newt.newtId)); } - const { tcpTargets, udpTargets, validHealthCheckTargets } = - await buildTargetConfigurationForNewtClient(siteId, newtVersion); + const { + tcpTargets, + udpTargets, + validHealthCheckTargets, + browserGatewayTargets + } = await buildTargetConfigurationForNewtClient(siteId, newtVersion); logger.debug( `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` @@ -212,6 +221,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { tcp: tcpTargets }, healthCheckTargets: validHealthCheckTargets, + browserGatewayTargets: browserGatewayTargets, chainId: chainId } }, diff --git a/server/routers/newt/sync.ts b/server/routers/newt/sync.ts index d7ceefde4..b8f152bec 100644 --- a/server/routers/newt/sync.ts +++ b/server/routers/newt/sync.ts @@ -3,18 +3,18 @@ import { eq } from "drizzle-orm"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { - buildBrowserGatewayTargetConfigurationForNewtClient, buildClientConfigurationForNewtClient, buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; export async function sendNewtSyncMessage(newt: Newt, site: Site) { - const { tcpTargets, udpTargets, validHealthCheckTargets } = - await buildTargetConfigurationForNewtClient(site.siteId); - - const browserGatewayTargets = - await buildBrowserGatewayTargetConfigurationForNewtClient(site.siteId); + const { + tcpTargets, + udpTargets, + validHealthCheckTargets, + browserGatewayTargets + } = await buildTargetConfigurationForNewtClient(site.siteId); let exitNode: ExitNode | undefined; if (site.exitNodeId) { @@ -41,14 +41,7 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) { healthCheckTargets: validHealthCheckTargets, peers: peers, clientTargets: targets, - browserGatewayTargets: browserGatewayTargets.map((t) => ({ - id: t.browserGatewayTargetId, - resourceId: t.resourceId, - siteId: t.siteId, - type: t.type, - destination: t.destination, - destinationPort: t.destinationPort - })) + browserGatewayTargets: browserGatewayTargets } }, { diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index b3d166d76..15ca74f9e 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -58,11 +58,11 @@ const isIronError = (error: unknown): error is IronError => { export default function RdpClient() { const [form, setForm] = useState({ username: "Administrator", - password: "Wdvwy1W*ITK-(OK.sW?nVK%?mTl30wL0", - gatewayAddress: "ws://localhost:7171/jet/rdp", + password: "Password123!", + gatewayAddress: "ws://localhost:8082/rdp", hostname: "172.31.3.58:3389", domain: "", - authtoken: "abc123", + authtoken: "pangolin-browser-gateway-dev", kdcProxyUrl: "", pcb: "", desktopWidth: 1280, From a6ae9290f28e472a0ed49b686892e73da55a90d6 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 May 2026 18:01:36 -0700 Subject: [PATCH 09/23] Serve the resource from the right place --- .../private/lib/traefik/getTraefikConfig.ts | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index e551a7a7a..5def19865 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -1153,7 +1153,7 @@ export async function getTraefikConfig( middlewares: routerMiddlewares, service: bgServiceName, rule: bgRule, - priority: 110, // higher than 105 (UI router) to match /gateway/* first + priority: 110, // highest - websocket path takes precedence ...(bgResource.ssl ? { tls } : {}) }; @@ -1164,37 +1164,59 @@ export async function getTraefikConfig( }; } - // UI router: serve the browser gateway pages from the internal pangolin instance - // Covers /{type} paths for each configured type plus Next.js assets + // UI: serve the browser gateway page from the internal pangolin instance. + // The primary type is used for the path rewrite (e.g. /rdp), mirroring + // how the maintenance page rewrites everything to /maintenance-screen. + const primaryType = typeMap.keys().next().value as string; const internalHost = config.getRawConfig().server.internal_hostname; const internalPort = config.getRawConfig().server.next_port; + const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`; + const entrypoint = bgResource.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint; - const typePaths = Array.from(typeMap.keys()) - .map((t) => `PathPrefix(\`/${t}\`)`) - .join(" || "); - const uiRule = `${hostRule} && (${typePaths} || PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`; + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + config_output.http.middlewares![uiRewriteMiddlewareName] = { + replacePathRegex: { + regex: "^/(.*)", + replacement: `/${primaryType}` + } + }; config_output.http.services![bgUiServiceName] = { loadBalancer: { servers: [ { - url: `http://${internalHost}:${internalPort}` + // url: `http://${internalHost}:${internalPort}` + url: `https://owen-devel.hostlocal.app` } ] } }; + // Assets router at higher priority so /_next files load without rewrite + config_output.http.routers![ + `bg-r${bgResource.resourceId}-assets-router` + ] = { + entryPoints: [entrypoint], + middlewares: routerMiddlewares, + service: bgUiServiceName, + rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, + priority: 101, + ...(bgResource.ssl ? { tls } : {}) + }; + + // Catch-all router rewrites everything on the domain to /{primaryType} config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] = { - entryPoints: [ - bgResource.ssl - ? config.getRawConfig().traefik.https_entrypoint - : config.getRawConfig().traefik.http_entrypoint - ], - middlewares: routerMiddlewares, + entryPoints: [entrypoint], + middlewares: [...routerMiddlewares, uiRewriteMiddlewareName], service: bgUiServiceName, - rule: uiRule, - priority: 105, + rule: hostRule, + priority: 100, ...(bgResource.ssl ? { tls } : {}) }; } From 013af49137a558b570a4fa97a4761d7832feaffe Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 May 2026 21:11:25 -0700 Subject: [PATCH 10/23] Add favicon passthrough --- server/private/lib/traefik/getTraefikConfig.ts | 11 +++++------ src/app/favicon.ico | Bin 0 -> 15406 bytes src/app/rdp/RdpClient.tsx | 15 +++------------ 3 files changed, 8 insertions(+), 18 deletions(-) create mode 100644 src/app/favicon.ico diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 5def19865..132bb95bc 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -705,7 +705,7 @@ export async function getTraefikConfig( resource.ssl ? entrypointHttps : entrypointHttp ], service: maintenanceServiceName, - rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, + rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `, priority: 2001, ...(resource.ssl ? { tls } : {}) }; @@ -1190,8 +1190,7 @@ export async function getTraefikConfig( loadBalancer: { servers: [ { - // url: `http://${internalHost}:${internalPort}` - url: `https://owen-devel.hostlocal.app` + url: `http://${internalHost}:${internalPort}` } ] } @@ -1204,7 +1203,7 @@ export async function getTraefikConfig( entryPoints: [entrypoint], middlewares: routerMiddlewares, service: bgUiServiceName, - rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, + rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, priority: 101, ...(bgResource.ssl ? { tls } : {}) }; @@ -1336,7 +1335,7 @@ export async function getTraefikConfig( config_output.http.routers[`${siteResourceRouterName}-assets`] = { entryPoints: [config.getRawConfig().traefik.https_entrypoint], service: siteResourceServiceName, - rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, + rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, priority: 101, tls }; @@ -1439,7 +1438,7 @@ export async function getTraefikConfig( config.getRawConfig().traefik.https_entrypoint ], service: "landing-service", - rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`, + rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, priority: 203, tls: tls }; diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..bcaab339d8dd0c5be5e558c11e1af040a73e72e0 GIT binary patch literal 15406 zcmeI3e~=tS6~`y?TR({T}d);do(~PZ*U3M|JcNr&cZ5Rg{hSAmKzwf=PVJxL=zx~F)zt=Dx z?J$hJsY4T5_~qOZ{nEB$=L+7wQn|BlY|pgq9+$SgUQ>CWouSG*Pevr8ym{2$lv1TH zLg$Y>mm}9Gw3g0P>&+;g>;mUK8QUEiufHkME=p%zcA-a)Cw`f{_x0E|%DpXZl@1W> zX=|t}ZI>2NZ!Nr5@6os6-DaONL#SkI=a)6@m2=Yshl!`%`brt0M6bys9b7=bn_ouj4AiKXr+vn_&gTigX&!!rCUs}08%qaKnXuHb0a{rK; z?{-Q%$~w%xSKg_gt>OXT{b#!1b|bg>mHM^gC31fQoNK{%xwM_NXn0q~Rxp=ms**Xk zue=R7{$4{vIsZXtzMEFw85z6uVZ$g-GcE6PQWsh`Qh!rt!Aog8z3vAx%Gppu1K!kK zNgwZ%@f!c`Wb$r57*+{Q(N)1=+Rn$Rw)e`QmJF-U7z4hID949cF0luj8bFo#@-ApfxXLIf_31q(VEi0<68kijUpikr5M3!ug+{Awkv7=_+ z$Skq#nS%TNaozoK)i^>Iz0x*24g>Oe7jk+%aUYd?Ya<&1tqyF~(h5!G{!i?39RG}6 zJiJn`feg@*>zbKktjz^zvu=s4b{`b}$nEgl;_?mw7UuBj$@n{~x~%87585TYBm0E` z{jBBf2aaoy_xFSLGB&~YN$+{*n?ajq@5t?Xp^dCBfcLL>-o$tuEp&LlZc@JEVMnvb+z%`$tLmoA{Cez2@$|4Ki)_H2AE`Sne+Ze)N3}{kl1L zpX`nduV{|1Uz>Sw*{nt7U1zBEJM&zG$GMa7sryYiZZ1DIyBQ4Pk3erDa$h3txZjqt z-9zj0XgvScAirb8*9&mW>G{XDrd2vh){mepxFa*)m&OOwexL0gul+>ZTh}TE-40nW zZcp<3H`YZ@`W0Pk(yF*1%)_)tW{Ry7Y~Z>u{H^6@>>6#a78wM6mb=7AloR7j#64!- zIXI}JV;toUhQHT)Mn|cKEmiKL{+PsZmH0e`vse&qzd zdid`{5ySUMId(HGlk7>^XA$U8HT(enwn-?E1st`>T8d z!wpg1!Mpa+m3wC#jW(5S%GkriUH*@8{F<5fzN+I3e1>dcFxO!Xq>;{U_1IU~FRAv;_-tem$G_J`?2~y!+$sT&!b_ zW@Xx-zVJ@x-KzK{;2XVX3nMw~#l^G<;_9jPe;`$G4~_C^_)hF3F+B6JpX*94Svwx! z{s?1*-{36g`JPF~i#oXzN3sohwNN2bD9wd;yo{LV*w+^QlLlari`aB{SFI=L%?M?8 zFm8u8;}bXFR`t14jqTybxH>Le1+ROszn7$J=g5wpft{)IxV?Y$&r}7&cRvi4L0J_DZ6-3 z&MNNZ`zc9t5ABf)KHZVbkC&K}o4~Rpj;dfwDd#Ne+>cN9EO*IMzREQ)ey!#HBJH2T z*4-ufCUgFVSyA6Mj&{psJ>{)*46fV~ytVDm^t#9T^vcsDH!D6rV*=)u{DV=k-D&cx zItLx&hqa6y9}i52Aj=m5>~fcN#Oy22!0+kjot%1td~Aa?6*}U_t{^Yj-(?N8Wo|*Q zL)+JgT(ipg7~_v$>Q%;sx>H@3gZ8Dglon0HsKdPXjR&BZJx2KVr>@7w0mH5niF{6e*QV(ym z+_XKLx#>+2zwDfK2i;JtH@(RDF=9hPrwHjXJZmQ-Q>!x=}xNh7tZOARl`dQVsnK`^s;_)8Vy*TAvyoq^9FUkDNIxEJ(CZsvJ~F6?_a*C#afK0{3`l6WizC3a}1KO=cPf32)wctGSs{!jLF zUY5PYuq@b@`XuEwHh{8*I>a1XiC>}j&cigP`s`z>#NSS*8u}1(JK68#?(aP|hq3;65XXoPHQG;3 z2;Gp}A386-#9CsreWaa#e#Tp^bENh1T~q7NYB_6UBv^lgGfQ#((Yby9{E(Ix@yz*B zPjtvs?)_SqZ09%ht-d^4{3d$HICIX2^GwlxNIZ=%nX$YZC)=~c?>_GjV!PYebN4sQ#*kAt>+Oem-tGb;d~mN zjHO;*g#R1JQ1%(I9}<%~kmhPn~i_t&%F1HTyAY`L;w-49!A12~w|ve#b^c2&85PFP}_ ziM4Ob;jYyuc(ay09GpQ8uu41-JV);m6KC09rQdpg|IHl>Z$!sM5kG{vdIs};89H`3 zbLuoO9>;Zv=$+WRj{f3aoJl!~dU9qXh}&Z2vswFx3_C0@9hEUWiQ)Cy+g=~Kt-9uT zat1SsJ$M(JMnCS_tnzEUKPA&@e5Np$FHdLPyp;L=`dg?UZ?HyOJ3S`97RWw)*JsXH z_rWhdOkMwD3-u3uZY_r=el<4dJo^45Sl^6!QG)+9@NPZ-$QmYbXUcNFB70xxRpea5 z=33X7tG07y-7{=qg-QI(nH8Nw72T4&ygo0#3i-&suI%LwAg}fM^7oo3lmB8QU;7wq z=AHAebfI`eVpuI!l)Qr2;xzxmk$seuJ$$ICh7Z&K*Wo`xsY3CiwIsfFJ2A%5lH4iS+52(o?N`eBEq?8Docp?3 z`WY8L#?ju~Wu1M)d!q07`cBmE7R=J06`WVOf<3hp#4c=ZJc;S!Po?CXq;j7DzhIZS fyOL+wFF9B8H~KsWv6RGGiRr#w@ofog0SWvchs=Li literal 0 HcmV?d00001 diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 15ca74f9e..cedcd5282 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -35,7 +35,6 @@ declare module "react" { type FormState = { username: string; password: string; - gatewayAddress: string; hostname: string; domain: string; authtoken: string; @@ -59,7 +58,6 @@ export default function RdpClient() { const [form, setForm] = useState({ username: "Administrator", password: "Password123!", - gatewayAddress: "ws://localhost:8082/rdp", hostname: "172.31.3.58:3389", domain: "", authtoken: "pangolin-browser-gateway-dev", @@ -233,7 +231,9 @@ export default function RdpClient() { .withUsername(form.username) .withPassword(form.password) .withDestination(form.hostname) - .withProxyAddress(form.gatewayAddress) + .withProxyAddress( + `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp` + ) .withServerDomain(form.domain) .withAuthToken(form.authtoken) .withDesktopSize({ @@ -341,15 +341,6 @@ export default function RdpClient() { } /> - - - update("gatewayAddress", e.target.value) - } - /> - {/* Date: Wed, 13 May 2026 21:16:00 -0700 Subject: [PATCH 11/23] Clean up forms a bit --- src/app/rdp/RdpClient.tsx | 24 ++--------------- src/app/ssh/SshClient.tsx | 57 ++++----------------------------------- src/app/vnc/VncClient.tsx | 33 ++++------------------- 3 files changed, 12 insertions(+), 102 deletions(-) diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index cedcd5282..57be5f919 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -37,7 +37,6 @@ type FormState = { password: string; hostname: string; domain: string; - authtoken: string; kdcProxyUrl: string; pcb: string; desktopWidth: number; @@ -60,7 +59,6 @@ export default function RdpClient() { password: "Password123!", hostname: "172.31.3.58:3389", domain: "", - authtoken: "pangolin-browser-gateway-dev", kdcProxyUrl: "", pcb: "", desktopWidth: 1280, @@ -159,16 +157,6 @@ export default function RdpClient() { return; } - if (form.authtoken === "") { - toast({ - variant: "destructive", - title: "Missing auth token", - description: - "An auth token is required to connect through the gateway" - }); - return; - } - toast({ title: "Connecting...", description: "Connection in progress" @@ -235,7 +223,7 @@ export default function RdpClient() { `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp` ) .withServerDomain(form.domain) - .withAuthToken(form.authtoken) + .withAuthToken("test-token") .withDesktopSize({ width: form.desktopWidth, height: form.desktopHeight @@ -341,15 +329,7 @@ export default function RdpClient() { } /> - {/* - - update("authtoken", e.target.value) - } - /> - + {/* ({ - gatewayAddress: "ws://localhost:7171/jet/ssh", hostname: "", port: "22", username: "", - password: "", - authToken: "abc123" + password: "" }); const [connected, setConnected] = useState(false); @@ -122,7 +118,8 @@ export default function SshClient() { setError(null); setConnecting(true); - const url = new URL(form.gatewayAddress); + const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`; + const url = new URL(proxyAddress); // Pass connection parameters as query params so the proxy can route // before any application-level framing is needed. url.searchParams.set("host", form.hostname); @@ -130,7 +127,7 @@ export default function SshClient() { url.searchParams.set("username", form.username); // Auth token is sent as a query param; the proxy validates it before // forwarding any data. - url.searchParams.set("authToken", form.authToken); + url.searchParams.set("authToken", "test-token"); const ws = new WebSocket(url.toString(), ["ssh"]); wsRef.current = ws; @@ -195,27 +192,6 @@ export default function SshClient() { {!connected && (
-
- - - setForm({ - ...form, - gatewayAddress: e.target.value - }) - } - placeholder="ws://localhost:7171/jet/ssh" - className="bg-neutral-800 border-neutral-700 text-white" - /> -
-
- -
- - - setForm({ - ...form, - authToken: e.target.value - }) - } - className="bg-neutral-800 border-neutral-700 text-white" - /> -
{error &&

{error}

} @@ -320,10 +276,7 @@ export default function SshClient() { From 795a3d351e533b4c1568a825491b02ed56d86369 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 May 2026 21:16:40 -0700 Subject: [PATCH 12/23] Reinstall packages --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 8d6fbdffc..55c77c326 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1473,7 +1473,7 @@ "node_modules/@devolutions/iron-remote-desktop-rdp": { "version": "0.0.0", "resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz", - "integrity": "sha512-qkpqYOMmSU6jIdDKOWpnLL7pb0sA/Y7Yjm4wI0/VbBIRfZPH8WdKxDU5DNp8jFF60l1zbcSJeRIq5yIAvws3Hw==" + "integrity": "sha512-O0YVpOJDwUzekH3N2QKj+48WP+56wI0sj4VmaJkGoW5XgyAj2ONn2k3i+vk17Eavx+Vg6vAg3lwYRAOK4kKIDQ==" }, "node_modules/@dotenvx/dotenvx": { "version": "1.54.1", From 7d922ac95f82e87fb945a9d961476b0d20a158e7 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 May 2026 21:48:07 -0700 Subject: [PATCH 13/23] Add internal api get for proxy information --- server/routers/internal.ts | 2 + server/routers/resource/getBrowserTarget.ts | 76 ++++++++++++++++++ server/routers/resource/index.ts | 1 + src/app/rdp/RdpClient.tsx | 42 ++++++---- src/app/rdp/page.tsx | 24 +++++- src/app/ssh/SshClient.tsx | 87 ++++++++------------- src/app/ssh/page.tsx | 24 +++++- src/app/vnc/VncClient.tsx | 55 ++++++------- src/app/vnc/page.tsx | 24 +++++- 9 files changed, 229 insertions(+), 106 deletions(-) create mode 100644 server/routers/resource/getBrowserTarget.ts diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 2fa5239cc..3caff4864 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -42,6 +42,8 @@ internalRouter.get("/idp", idp.listIdps); internalRouter.get("/idp/:idpId", idp.getIdp); +internalRouter.get("/resource/browser-target", resource.getBrowserTarget); + // Gerbil routes const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); diff --git a/server/routers/resource/getBrowserTarget.ts b/server/routers/resource/getBrowserTarget.ts new file mode 100644 index 000000000..b69de7521 --- /dev/null +++ b/server/routers/resource/getBrowserTarget.ts @@ -0,0 +1,76 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resources, targets } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; + +const getBrowserTargetSchema = z + .object({ + fullDomain: z.string().min(1, "fullDomain is required") + }) + .strict(); + +export type GetBrowserTargetResponse = { + ip: string; + port: number; +}; + +export async function getBrowserTarget( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsed = getBrowserTargetSchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsed.error).toString() + ) + ); + } + + const { fullDomain } = parsed.data; + + const [row] = await db + .select({ + ip: targets.ip, + port: targets.port + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where(eq(resources.fullDomain, fullDomain)) + .limit(1); + + if (!row) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No resource found for this domain" + ) + ); + } + + return response(res, { + data: { ip: row.ip, port: row.port }, + success: true, + error: false, + message: "Browser target retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred while retrieving the browser target" + ) + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 6a259d7fe..d8ff4dba9 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -33,3 +33,4 @@ export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; export * from "./getStatusHistory"; +export * from "./getBrowserTarget"; diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 57be5f919..5b61e527d 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -32,10 +32,14 @@ declare module "react" { } } +type Target = { + ip: string; + port: number; +}; + type FormState = { username: string; password: string; - hostname: string; domain: string; kdcProxyUrl: string; pcb: string; @@ -53,11 +57,16 @@ const isIronError = (error: unknown): error is IronError => { ); }; -export default function RdpClient() { +export default function RdpClient({ + target, + error +}: { + target: Target | null; + error: string | null; +}) { const [form, setForm] = useState({ - username: "Administrator", - password: "Password123!", - hostname: "172.31.3.58:3389", + username: "", + password: "", domain: "", kdcProxyUrl: "", pcb: "", @@ -214,11 +223,15 @@ export default function RdpClient() { ); } + const destination = target + ? `${target.ip}:${target.port}` + : ""; + const builder = userInteraction .configBuilder() .withUsername(form.username) .withPassword(form.password) - .withDestination(form.hostname) + .withDestination(destination) .withProxyAddress( `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp` ) @@ -283,6 +296,14 @@ export default function RdpClient() { setCursorOverrideActive((v) => !v); }; + if (error) { + return ( +
+

{error}

+
+ ); + } + return (
{showLogin && ( @@ -292,15 +313,6 @@ export default function RdpClient() {
- - - update("hostname", e.target.value) - } - /> - ; +export default async function RdpPage() { + const headersList = await headers(); + const host = headersList.get("host") || ""; + const hostname = host.split(":")[0]; + + let target: { ip: string; port: number } | null = null; + let error: string | null = null; + + try { + const res = await internal.get>( + `/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}` + ); + target = res.data.data; + } catch { + error = "No resource found for this domain"; + } + + return ; } diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index e5ac8227b..0c96f1355 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -6,24 +6,31 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +type Target = { + ip: string; + port: number; +}; + type FormState = { - hostname: string; - port: string; username: string; password: string; }; -export default function SshClient() { +export default function SshClient({ + target, + error +}: { + target: Target | null; + error: string | null; +}) { const [form, setForm] = useState({ - hostname: "", - port: "22", username: "", password: "" }); const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(false); - const [error, setError] = useState(null); + const [connectError, setConnectError] = useState(null); const terminalRef = useRef(null); const xtermRef = useRef(null); @@ -115,18 +122,14 @@ export default function SshClient() { }, []); function connect() { - setError(null); + setConnectError(null); setConnecting(true); const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`; const url = new URL(proxyAddress); - // Pass connection parameters as query params so the proxy can route - // before any application-level framing is needed. - url.searchParams.set("host", form.hostname); - url.searchParams.set("port", form.port); + url.searchParams.set("host", target?.ip ?? ""); + url.searchParams.set("port", String(target?.port ?? 22)); url.searchParams.set("username", form.username); - // Auth token is sent as a query param; the proxy validates it before - // forwarding any data. url.searchParams.set("authToken", "test-token"); const ws = new WebSocket(url.toString(), ["ssh"]); @@ -166,7 +169,7 @@ export default function SshClient() { ws.onerror = () => { setConnecting(false); setConnected(false); - setError("WebSocket connection failed"); + setConnectError("WebSocket connection failed"); }; ws.onclose = (evt) => { @@ -185,6 +188,14 @@ export default function SshClient() { setConnected(false); } + if (error) { + return ( +
+

{error}

+
+ ); + } + return (

SSH Terminal

@@ -192,43 +203,7 @@ export default function SshClient() { {!connected && (
-
- - - setForm({ - ...form, - hostname: e.target.value - }) - } - placeholder="192.168.1.1" - className="bg-neutral-800 border-neutral-700 text-white" - /> -
- -
- - - setForm({ ...form, port: e.target.value }) - } - placeholder="22" - className="bg-neutral-800 border-neutral-700 text-white" - /> -
- -
+
-
+
- {error &&

{error}

} + {connectError && ( +

{connectError}

+ )}
- - {connectError && ( -

{connectError}

- )} - -
)} {connected && ( -
-
+
+
@@ -281,3 +270,20 @@ export default function SshClient({
); } + +function Field({ + label, + id, + children +}: { + label: string; + id: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index 373eb7763..174921eed 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -181,7 +181,7 @@ export default function VncClient({ className="flex h-screen flex-col bg-neutral-900" style={{ display: connected ? "flex" : "none" }} > -
+
From 00e1675f7bc58b8aeb07753c0631761f756646b5 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 12:08:40 -0700 Subject: [PATCH 17/23] Remove extra fields --- src/app/rdp/RdpClient.tsx | 42 +++++---------------------------------- src/app/rdp/page.tsx | 2 +- src/app/ssh/page.tsx | 2 +- src/app/vnc/page.tsx | 2 +- 4 files changed, 8 insertions(+), 40 deletions(-) diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 5114646cb..75f351548 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -43,8 +43,6 @@ type FormState = { domain: string; kdcProxyUrl: string; pcb: string; - desktopWidth: number; - desktopHeight: number; enableClipboard: boolean; }; @@ -70,8 +68,6 @@ export default function RdpClient({ domain: "", kdcProxyUrl: "", pcb: "", - desktopWidth: 1280, - desktopHeight: 720, enableClipboard: true }); @@ -238,8 +234,8 @@ export default function RdpClient({ .withServerDomain(form.domain) .withAuthToken("test-token") .withDesktopSize({ - width: form.desktopWidth, - height: form.desktopHeight + width: window.innerWidth, + height: window.innerHeight }) .withExtension(exts.displayControl(true)); @@ -349,34 +345,7 @@ export default function RdpClient({ onChange={(e) => update("pcb", e.target.value)} /> */} -
- - - update( - "desktopWidth", - Number(e.target.value) || 0 - ) - } - /> - - - - update( - "desktopHeight", - Number(e.target.value) || 0 - ) - } - /> - -
+ {/* */} -
+ {/*
Enable Clipboard -
- +
*/}
diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index 7718fd665..9df0d97b9 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -96,8 +96,6 @@ export default function VncClient({ }); const wsUrl = `${base}?${params.toString()}`; - toast({ title: "Connecting…", description: wsUrl }); - // Clear the container so noVNC gets a clean mount point. screenRef.current.innerHTML = ""; @@ -113,7 +111,6 @@ export default function VncClient({ rfb.resizeSession = true; rfb.addEventListener("connect", () => { - toast({ title: "Connected" }); setConnected(true); }); @@ -122,10 +119,6 @@ export default function VncClient({ (e: { detail: { clean: boolean } }) => { rfbRef.current = null; setConnected(false); - toast({ - title: e.detail.clean ? "Disconnected" : "Connection lost", - variant: e.detail.clean ? "default" : "destructive" - }); } ); From 0f9a6fd968aed2c75e0bbd88d786962441f6611c Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 15 May 2026 16:07:14 -0700 Subject: [PATCH 20/23] Support private key --- src/app/ssh/SshClient.tsx | 94 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 817bb1f30..a84b1dacc 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -5,6 +5,7 @@ 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"; type Target = { ip: string; @@ -15,6 +16,7 @@ type Target = { type FormState = { username: string; password: string; + privateKey: string; }; export default function SshClient({ @@ -26,9 +28,27 @@ export default function SshClient({ }) { const [form, setForm] = useState({ username: "", - password: "" + password: "", + privateKey: "" }); + const fileInputRef = useRef(null); + + function handleKeyFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result; + if (typeof text === "string") { + setForm((prev) => ({ ...prev, privateKey: text })); + } + }; + reader.readAsText(file); + // Reset input so the same file can be re-selected if needed. + e.target.value = ""; + } + const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(false); const [connectError, setConnectError] = useState(null); @@ -143,9 +163,15 @@ export default function SshClient({ wsRef.current = ws; ws.onopen = () => { - // Send the password (or empty string) as the first frame so the - // proxy can complete SSH authentication before piping pty data. - ws.send(JSON.stringify({ type: "auth", password: form.password })); + // Send credentials as the first frame so the proxy can complete + // SSH authentication before piping pty data. + ws.send( + JSON.stringify({ + type: "auth", + password: form.password, + privateKey: form.privateKey + }) + ); setConnecting(false); setConnected(true); }; @@ -236,6 +262,60 @@ export default function SshClient({ password: e.target.value }) } + placeholder={ + form.privateKey + ? "Optional with key auth" + : "" + } + /> + + + +