mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 05:26:32 +02:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23ca3efbf4 | |||
| 0f9100fd3a | |||
| 832d45e32b | |||
| 69e3ac3cd4 | |||
| 50865f4265 | |||
| 0d1a8d9695 | |||
| 5d8486dd7f | |||
| 3c25932787 | |||
| 1d0e1eb126 | |||
| 57c0dc8618 | |||
| 526a147570 | |||
| 0938997548 | |||
| 0876b482f8 | |||
| d558c31f88 | |||
| 6010515da0 | |||
| 868bcd8e34 | |||
| 20c4904965 | |||
| 5a5536b38c | |||
| 53e2296de8 | |||
| d2423919e9 | |||
| 2250fcd177 | |||
| 2a33256d17 | |||
| 117aa750f8 | |||
| 15f161274f | |||
| 09779aca3e | |||
| 1d1f7cecf4 | |||
| dc00668cbe | |||
| 57701e13eb | |||
| 46545cb003 | |||
| 987b5d580e | |||
| cb75ffc3b7 | |||
| 540f0a754d | |||
| 0f9a6fd968 | |||
| 82112abc34 | |||
| 75b5afd544 | |||
| 00e1675f7b | |||
| 2ddbdf977b | |||
| 4c8f0cc9ec | |||
| e822b681cd | |||
| e1583a58aa | |||
| 7d922ac95f | |||
| 795a3d351e | |||
| 4b4c86b4b7 | |||
| 013af49137 | |||
| a6ae9290f2 | |||
| de70d72e0d | |||
| 4e07e9c52c | |||
| 743621eb25 | |||
| 943923ff4b | |||
| 3f17f1a468 | |||
| 436996a43d | |||
| d42b6076d2 | |||
| 89cc99f915 |
@@ -0,0 +1,110 @@
|
|||||||
|
git push origin -d 1.11.0-s.0
|
||||||
|
git push origin -d 1.11.0-s.1
|
||||||
|
git push origin -d 1.11.0-s.2
|
||||||
|
git push origin -d 1.11.0-s.3
|
||||||
|
git push origin -d 1.11.0-s.4
|
||||||
|
git push origin -d 1.11.0-s.5
|
||||||
|
git push origin -d 1.11.1-s.0
|
||||||
|
git push origin -d 1.12.0-s.0
|
||||||
|
git push origin -d 1.12.2-s.0
|
||||||
|
git push origin -d 1.12.2-s.1
|
||||||
|
git push origin -d 1.12.2-s.2
|
||||||
|
git push origin -d 1.12.2-s.3
|
||||||
|
git push origin -d 1.12.2-s.4
|
||||||
|
git push origin -d 1.12.2-s.5
|
||||||
|
git push origin -d 1.13.0.s.0
|
||||||
|
git push origin -d 1.13.1-s.0
|
||||||
|
git push origin -d 1.14.0-s.2
|
||||||
|
git push origin -d 1.14.1-s.0
|
||||||
|
git push origin -d 1.14.1-s.1
|
||||||
|
git push origin -d 1.14.1-s.2
|
||||||
|
git push origin -d 1.14.1-s.3
|
||||||
|
git push origin -d 1.15.0-s.0
|
||||||
|
git push origin -d 1.15.0-s.1
|
||||||
|
git push origin -d 1.15.0-s.2
|
||||||
|
git push origin -d 1.15.0-s.3
|
||||||
|
git push origin -d 1.15.0-s.4
|
||||||
|
git push origin -d 1.15.0-s.5
|
||||||
|
git push origin -d 1.15.1-s.0
|
||||||
|
git push origin -d 1.15.1-s.1
|
||||||
|
git push origin -d 1.15.3-s.0
|
||||||
|
git push origin -d 1.15.3-s.1
|
||||||
|
git push origin -d 1.15.4-s.0
|
||||||
|
git push origin -d 1.15.4-s.1
|
||||||
|
git push origin -d 1.15.4-s.10
|
||||||
|
git push origin -d 1.15.4-s.2
|
||||||
|
git push origin -d 1.15.4-s.3
|
||||||
|
git push origin -d 1.15.4-s.4
|
||||||
|
git push origin -d 1.15.4-s.5
|
||||||
|
git push origin -d 1.15.4-s.6
|
||||||
|
git push origin -d 1.15.4-s.7
|
||||||
|
git push origin -d 1.15.4-s.8
|
||||||
|
git push origin -d 1.15.4-s.9
|
||||||
|
git push origin -d 1.16.0-s.0
|
||||||
|
git push origin -d 1.16.0-s.1
|
||||||
|
git push origin -d 1.16.1-s.0
|
||||||
|
git push origin -d 1.16.1-s.1
|
||||||
|
git push origin -d 1.16.2-s.0
|
||||||
|
git push origin -d 1.16.2-s.1
|
||||||
|
git push origin -d 1.16.2-s.10
|
||||||
|
git push origin -d 1.16.2-s.11
|
||||||
|
git push origin -d 1.16.2-s.12
|
||||||
|
git push origin -d 1.16.2-s.13
|
||||||
|
git push origin -d 1.16.2-s.14
|
||||||
|
git push origin -d 1.16.2-s.15
|
||||||
|
git push origin -d 1.16.2-s.16
|
||||||
|
git push origin -d 1.16.2-s.17
|
||||||
|
git push origin -d 1.16.2-s.18
|
||||||
|
git push origin -d 1.16.2-s.19
|
||||||
|
git push origin -d 1.16.2-s.2
|
||||||
|
git push origin -d 1.16.2-s.20
|
||||||
|
git push origin -d 1.16.2-s.21
|
||||||
|
git push origin -d 1.16.2-s.22
|
||||||
|
git push origin -d 1.16.2-s.3
|
||||||
|
git push origin -d 1.16.2-s.4
|
||||||
|
git push origin -d 1.16.2-s.5
|
||||||
|
git push origin -d 1.16.2-s.6
|
||||||
|
git push origin -d 1.16.2-s.7
|
||||||
|
git push origin -d 1.16.2-s.8
|
||||||
|
git push origin -d 1.16.2-s.9
|
||||||
|
git push origin -d 1.17.0-s.0
|
||||||
|
git push origin -d 1.17.0-s.1
|
||||||
|
git push origin -d 1.17.0-s.2
|
||||||
|
git push origin -d 1.17.0-s.3
|
||||||
|
git push origin -d 1.17.0-s.4
|
||||||
|
git push origin -d 1.17.1-s.0
|
||||||
|
git push origin -d 1.17.1-s.1
|
||||||
|
git push origin -d 1.17.1-s.2
|
||||||
|
git push origin -d 1.17.1-s.3
|
||||||
|
git push origin -d 1.17.1-s.4
|
||||||
|
git push origin -d 1.17.1-s.5
|
||||||
|
git push origin -d 1.17.1-s.6
|
||||||
|
git push origin -d 1.17.1-s.7
|
||||||
|
git push origin -d 1.18.0-s.0
|
||||||
|
git push origin -d 1.18.0-s.1
|
||||||
|
git push origin -d 1.18.0-s.2
|
||||||
|
git push origin -d 1.18.1-s.0
|
||||||
|
git push origin -d 1.18.1-s.1
|
||||||
|
git push origin -d 1.18.1-s.2
|
||||||
|
git push origin -d 1.18.1-s.3
|
||||||
|
git push origin -d 1.18.1-s.4
|
||||||
|
git push origin -d 1.18.1-s.5
|
||||||
|
git push origin -d 1.18.1-s.6
|
||||||
|
git push origin -d 1.18.1-s.7
|
||||||
|
git push origin -d 1.18.2-s.0
|
||||||
|
git push origin -d 1.18.2-s.1
|
||||||
|
git push origin -d 1.18.2-s.2
|
||||||
|
git push origin -d 1.18.2-s.3
|
||||||
|
git push origin -d 1.18.2-s.4
|
||||||
|
git push origin -d 1.18.2-s.5
|
||||||
|
git push origin -d 1.18.3-s.0
|
||||||
|
git push origin -d 1.18.3-s.1
|
||||||
|
git push origin -d 1.18.3-s.2
|
||||||
|
git push origin -d 1.18.3-s.3
|
||||||
|
git push origin -d 1.18.4-s.0
|
||||||
|
git push origin -d 1.18.4-s.1
|
||||||
|
git push origin -d 1.18.4-s.2
|
||||||
|
git push origin -d 1.18.4-s.3
|
||||||
|
git push origin -d 1.18.4-s.4
|
||||||
|
git push origin -d 1.18.4-s.5
|
||||||
|
git push origin -d 1.18.4-s.6
|
||||||
@@ -5,6 +5,7 @@ const withNextIntl = createNextIntlPlugin();
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
|
transpilePackages: ["@novnc/novnc"],
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true
|
ignoreDuringBuilds: true
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+49
-53
@@ -11,11 +11,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
"@asteasolutions/zod-to-openapi": "8.4.1",
|
||||||
"@aws-sdk/client-s3": "3.1011.0",
|
"@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",
|
"@faker-js/faker": "10.3.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.9",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "2.0.2",
|
||||||
|
"@novnc/novnc": "^1.7.0",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@radix-ui/react-avatar": "1.1.11",
|
"@radix-ui/react-avatar": "1.1.11",
|
||||||
@@ -44,6 +47,9 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.15.0",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
@@ -1058,7 +1064,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1460,6 +1465,16 @@
|
|||||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@devolutions/iron-remote-desktop": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"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",
|
||||||
|
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-O0YVpOJDwUzekH3N2QKj+48WP+56wI0sj4VmaJkGoW5XgyAj2ONn2k3i+vk17Eavx+Vg6vAg3lwYRAOK4kKIDQ=="
|
||||||
|
},
|
||||||
"node_modules/@dotenvx/dotenvx": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.54.1",
|
"version": "1.54.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz",
|
||||||
@@ -2354,7 +2369,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2377,7 +2391,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2400,7 +2413,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2417,7 +2429,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2434,7 +2445,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2451,7 +2461,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2468,7 +2477,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2485,7 +2493,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2502,7 +2509,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2519,7 +2525,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2536,7 +2541,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2553,7 +2557,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2576,7 +2579,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2599,7 +2601,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2622,7 +2623,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2645,7 +2645,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2668,7 +2667,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2691,7 +2689,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2714,7 +2711,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2734,7 +2730,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2754,7 +2749,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2774,7 +2768,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3034,7 +3027,6 @@
|
|||||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.21.3 || >=16"
|
"node": "^14.21.3 || >=16"
|
||||||
},
|
},
|
||||||
@@ -3654,6 +3646,12 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@novnc/novnc": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
|
||||||
|
"license": "MPL-2.0"
|
||||||
|
},
|
||||||
"node_modules/@oslojs/asn1": {
|
"node_modules/@oslojs/asn1": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
|
||||||
@@ -6981,7 +6979,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
||||||
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
@@ -8442,7 +8439,6 @@
|
|||||||
"version": "5.90.21",
|
"version": "5.90.21",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.20"
|
"@tanstack/query-core": "5.90.20"
|
||||||
},
|
},
|
||||||
@@ -8558,7 +8554,6 @@
|
|||||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
@@ -8906,7 +8901,6 @@
|
|||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@@ -9002,7 +8996,6 @@
|
|||||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
@@ -9030,7 +9023,6 @@
|
|||||||
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"pg-protocol": "*",
|
"pg-protocol": "*",
|
||||||
@@ -9056,7 +9048,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -9067,7 +9058,6 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -9154,7 +9144,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
@@ -9228,7 +9219,6 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -9683,6 +9673,27 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -9702,7 +9713,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -10152,7 +10162,6 @@
|
|||||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.26.0"
|
"@babel/types": "^7.26.0"
|
||||||
}
|
}
|
||||||
@@ -10224,7 +10233,6 @@
|
|||||||
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
|
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"prebuild-install": "^7.1.1"
|
"prebuild-install": "^7.1.1"
|
||||||
@@ -10353,7 +10361,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -11260,7 +11267,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -11701,6 +11707,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
@@ -12335,7 +12342,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
@@ -12421,7 +12427,6 @@
|
|||||||
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
@@ -12558,7 +12563,6 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -12952,7 +12956,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -15370,6 +15373,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "3.2.7",
|
"dompurify": "3.2.7",
|
||||||
"marked": "14.0.0"
|
"marked": "14.0.0"
|
||||||
@@ -15380,6 +15384,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
},
|
},
|
||||||
@@ -15468,7 +15473,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
|
||||||
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
|
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.5.15",
|
"@next/env": "15.5.15",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
@@ -16428,7 +16432,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.12.0",
|
"pg-connection-string": "^2.12.0",
|
||||||
"pg-pool": "^3.13.0",
|
"pg-pool": "^3.13.0",
|
||||||
@@ -16936,7 +16939,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -16968,7 +16970,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -17261,7 +17262,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -18723,8 +18723,7 @@
|
|||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
@@ -19199,7 +19198,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -19627,7 +19625,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colors/colors": "^1.6.0",
|
"@colors/colors": "^1.6.0",
|
||||||
"@dabh/diagnostics": "^2.0.8",
|
"@dabh/diagnostics": "^2.0.8",
|
||||||
@@ -19834,7 +19831,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
"@asteasolutions/zod-to-openapi": "8.4.1",
|
||||||
"@aws-sdk/client-s3": "3.1011.0",
|
"@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",
|
"@faker-js/faker": "10.3.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.9",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "2.0.2",
|
||||||
|
"@novnc/novnc": "^1.7.0",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@radix-ui/react-avatar": "1.1.11",
|
"@radix-ui/react-avatar": "1.1.11",
|
||||||
@@ -67,6 +70,9 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.15.0",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
|
|||||||
@@ -158,7 +158,12 @@ export enum ActionsEnum {
|
|||||||
createHealthCheck = "createHealthCheck",
|
createHealthCheck = "createHealthCheck",
|
||||||
updateHealthCheck = "updateHealthCheck",
|
updateHealthCheck = "updateHealthCheck",
|
||||||
deleteHealthCheck = "deleteHealthCheck",
|
deleteHealthCheck = "deleteHealthCheck",
|
||||||
listHealthChecks = "listHealthChecks"
|
listHealthChecks = "listHealthChecks",
|
||||||
|
createBrowserGatewayTarget = "createBrowserGatewayTarget",
|
||||||
|
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
|
||||||
|
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
|
||||||
|
getBrowserGatewayTarget = "getBrowserGatewayTarget",
|
||||||
|
listBrowserGatewayTargets = "listBrowserGatewayTargets"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
@@ -580,6 +580,24 @@ export const trialNotifications = pgTable("trialNotifications", {
|
|||||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
|
||||||
|
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.references(() => resources.resourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
siteId: integer("siteId")
|
||||||
|
.references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
authToken: varchar("authToken").notNull(),
|
||||||
|
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
|
||||||
|
destination: varchar("destination").notNull(),
|
||||||
|
destinationPort: integer("destinationPort").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -627,3 +645,6 @@ export type AlertEmailRecipients = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
|
export type BrowserGatewayTarget = InferSelectModel<
|
||||||
|
typeof browserGatewayTarget
|
||||||
|
>;
|
||||||
|
|||||||
@@ -159,7 +159,8 @@ export const resources = pgTable("resources", {
|
|||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||||
wildcard: boolean("wildcard").notNull().default(false)
|
wildcard: boolean("wildcard").notNull().default(false),
|
||||||
|
browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc
|
||||||
});
|
});
|
||||||
|
|
||||||
export const labels = pgTable("labels", {
|
export const labels = pgTable("labels", {
|
||||||
|
|||||||
@@ -588,6 +588,26 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
|||||||
sentAt: integer("sentAt").notNull()
|
sentAt: integer("sentAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
|
||||||
|
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.references(() => resources.resourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
siteId: integer("siteId")
|
||||||
|
.references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
authToken: text("authToken").notNull(),
|
||||||
|
type: text("type").notNull(), // "ssh", "rdp", "vnc"
|
||||||
|
destination: text("destination").notNull(),
|
||||||
|
destinationPort: integer("destinationPort").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -627,3 +647,6 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
|||||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
|
export type BrowserGatewayTarget = InferSelectModel<
|
||||||
|
typeof browserGatewayTarget
|
||||||
|
>;
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ export const resources = sqliteTable("resources", {
|
|||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
||||||
|
browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc
|
||||||
});
|
});
|
||||||
|
|
||||||
export const labels = sqliteTable("labels", {
|
export const labels = sqliteTable("labels", {
|
||||||
|
|||||||
@@ -780,9 +780,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
// logger.debug(
|
||||||
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||||
);
|
// );
|
||||||
|
|
||||||
for (const domain of allDomains) {
|
for (const domain of allDomains) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
certificates,
|
certificates,
|
||||||
db,
|
db,
|
||||||
domainNamespaces,
|
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: {
|
let siteResourcesWithFullDomain: {
|
||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
fullDomain: string | null;
|
fullDomain: string | null;
|
||||||
@@ -324,6 +434,12 @@ export async function getTraefikConfig(
|
|||||||
domains.add(sr.fullDomain);
|
domains.add(sr.fullDomain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Include browser gateway resource domains
|
||||||
|
for (const bgResource of browserGatewayResourcesMap.values()) {
|
||||||
|
if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) {
|
||||||
|
domains.add(bgResource.fullDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
// get the valid certs for these domains
|
// get the valid certs for these domains
|
||||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||||
@@ -589,7 +705,7 @@ export async function getTraefikConfig(
|
|||||||
resource.ssl ? entrypointHttps : entrypointHttp
|
resource.ssl ? entrypointHttps : entrypointHttp
|
||||||
],
|
],
|
||||||
service: maintenanceServiceName,
|
service: maintenanceServiceName,
|
||||||
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `,
|
||||||
priority: 2001,
|
priority: 2001,
|
||||||
...(resource.ssl ? { tls } : {})
|
...(resource.ssl ? { tls } : {})
|
||||||
};
|
};
|
||||||
@@ -925,6 +1041,185 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate Traefik config for browser gateway resources
|
||||||
|
const browserGatewayPort = 39999;
|
||||||
|
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
|
||||||
|
if (!bgResource.enabled) continue;
|
||||||
|
if (!bgResource.domainId) continue;
|
||||||
|
if (!bgResource.fullDomain) continue;
|
||||||
|
|
||||||
|
if (!config_output.http.routers) config_output.http.routers = {};
|
||||||
|
if (!config_output.http.services) config_output.http.services = {};
|
||||||
|
|
||||||
|
const fullDomain = bgResource.fullDomain;
|
||||||
|
const additionalMiddlewares =
|
||||||
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
|
const routerMiddlewares = [
|
||||||
|
badgerMiddlewareName,
|
||||||
|
...additionalMiddlewares
|
||||||
|
];
|
||||||
|
|
||||||
|
const hostRule = `Host(\`${fullDomain}\`)`;
|
||||||
|
|
||||||
|
// Build TLS config
|
||||||
|
let tls = {};
|
||||||
|
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
|
const domainParts = fullDomain.split(".");
|
||||||
|
let wildCard: string;
|
||||||
|
if (domainParts.length <= 2) {
|
||||||
|
wildCard = `*.${domainParts.join(".")}`;
|
||||||
|
} else {
|
||||||
|
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||||
|
}
|
||||||
|
if (!bgResource.subdomain) {
|
||||||
|
wildCard = fullDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalDefaultResolver =
|
||||||
|
config.getRawConfig().traefik.cert_resolver;
|
||||||
|
const globalDefaultPreferWildcard =
|
||||||
|
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||||
|
const resolverName = bgResource.domainCertResolver
|
||||||
|
? bgResource.domainCertResolver.trim()
|
||||||
|
: globalDefaultResolver;
|
||||||
|
const preferWildcard =
|
||||||
|
bgResource.preferWildcardCert !== undefined &&
|
||||||
|
bgResource.preferWildcardCert !== null
|
||||||
|
? bgResource.preferWildcardCert
|
||||||
|
: globalDefaultPreferWildcard;
|
||||||
|
|
||||||
|
tls = {
|
||||||
|
certResolver: resolverName,
|
||||||
|
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const matchingCert = validCerts.find(
|
||||||
|
(cert) => cert.queriedDomain === fullDomain
|
||||||
|
);
|
||||||
|
if (!matchingCert) {
|
||||||
|
logger.debug(
|
||||||
|
`No matching certificate found for browser gateway domain: ${fullDomain}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
|
||||||
|
|
||||||
|
if (bgResource.ssl) {
|
||||||
|
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
|
||||||
|
config_output.http.routers![redirectRouterName] = {
|
||||||
|
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
|
||||||
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
|
service: bgUiServiceName,
|
||||||
|
rule: hostRule,
|
||||||
|
priority: 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect online sites for this resource (for any type)
|
||||||
|
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
|
||||||
|
|
||||||
|
// Group targets by type and generate per-type websocket routers and services
|
||||||
|
const typeMap = new Map<string, typeof bgResource.targets>();
|
||||||
|
for (const t of bgResource.targets) {
|
||||||
|
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
|
||||||
|
typeMap.get(t.bgType)!.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [bgType, typedTargets] of typeMap.entries()) {
|
||||||
|
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
|
||||||
|
const bgRouterName = `${bgKey}-router`;
|
||||||
|
const bgServiceName = `${bgKey}-service`;
|
||||||
|
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
|
||||||
|
|
||||||
|
const servers = typedTargets
|
||||||
|
.filter((t) => {
|
||||||
|
if (!t.siteOnline && anySiteOnline) return false;
|
||||||
|
if (t.siteType === "newt") return !!t.subnet;
|
||||||
|
return false; // browser gateway only supported on newt sites
|
||||||
|
})
|
||||||
|
.map((t) => ({
|
||||||
|
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
|
||||||
|
}))
|
||||||
|
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
|
||||||
|
|
||||||
|
config_output.http.routers![bgRouterName] = {
|
||||||
|
entryPoints: [
|
||||||
|
bgResource.ssl
|
||||||
|
? config.getRawConfig().traefik.https_entrypoint
|
||||||
|
: config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: routerMiddlewares,
|
||||||
|
service: bgServiceName,
|
||||||
|
rule: bgRule,
|
||||||
|
priority: 110, // highest - websocket path takes precedence
|
||||||
|
...(bgResource.ssl ? { tls } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
config_output.http.services![bgServiceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI: serve the browser gateway page from the internal pangolin instance.
|
||||||
|
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
|
||||||
|
// how the maintenance page rewrites everything to /maintenance-screen.
|
||||||
|
const primaryType = typeMap.keys().next().value as string;
|
||||||
|
const internalHost = config.getRawConfig().server.internal_hostname;
|
||||||
|
const internalPort = config.getRawConfig().server.next_port;
|
||||||
|
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
|
||||||
|
const entrypoint = bgResource.ssl
|
||||||
|
? config.getRawConfig().traefik.https_entrypoint
|
||||||
|
: config.getRawConfig().traefik.http_entrypoint;
|
||||||
|
|
||||||
|
if (!config_output.http.middlewares) {
|
||||||
|
config_output.http.middlewares = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
config_output.http.middlewares![uiRewriteMiddlewareName] = {
|
||||||
|
replacePathRegex: {
|
||||||
|
regex: "^/(.*)",
|
||||||
|
replacement: `/${primaryType}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
config_output.http.services![bgUiServiceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: `http://${internalHost}:${internalPort}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assets router at higher priority so /_next files load without rewrite
|
||||||
|
config_output.http.routers![
|
||||||
|
`bg-r${bgResource.resourceId}-assets-router`
|
||||||
|
] = {
|
||||||
|
entryPoints: [entrypoint],
|
||||||
|
middlewares: routerMiddlewares,
|
||||||
|
service: bgUiServiceName,
|
||||||
|
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||||
|
priority: 101,
|
||||||
|
...(bgResource.ssl ? { tls } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Catch-all router rewrites everything on the domain to /{primaryType}
|
||||||
|
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
|
||||||
|
{
|
||||||
|
entryPoints: [entrypoint],
|
||||||
|
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
|
||||||
|
service: bgUiServiceName,
|
||||||
|
rule: hostRule,
|
||||||
|
priority: 100,
|
||||||
|
...(bgResource.ssl ? { tls } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||||
// Traefik generates TLS certificates for those domains even when no
|
// Traefik generates TLS certificates for those domains even when no
|
||||||
// matching resource exists yet.
|
// matching resource exists yet.
|
||||||
@@ -1040,7 +1335,7 @@ export async function getTraefikConfig(
|
|||||||
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
||||||
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
||||||
service: siteResourceServiceName,
|
service: siteResourceServiceName,
|
||||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||||
priority: 101,
|
priority: 101,
|
||||||
tls
|
tls
|
||||||
};
|
};
|
||||||
@@ -1143,7 +1438,7 @@ export async function getTraefikConfig(
|
|||||||
config.getRawConfig().traefik.https_entrypoint
|
config.getRawConfig().traefik.https_entrypoint
|
||||||
],
|
],
|
||||||
service: "landing-service",
|
service: "landing-service",
|
||||||
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
|
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,
|
priority: 203,
|
||||||
tls: tls
|
tls: tls
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
|
BrowserGatewayTarget,
|
||||||
|
db,
|
||||||
|
newts,
|
||||||
|
resources,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||||
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
siteId: z.number().int().positive(),
|
||||||
|
type: z.enum(["ssh", "rdp", "vnc"]),
|
||||||
|
destination: z.string().nonempty(),
|
||||||
|
destinationPort: z.number().int().min(1).max(65535)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "put",
|
||||||
|
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target",
|
||||||
|
description: "Create a browser gateway target for a resource.",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createBrowserGatewayTarget(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [site] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Site with ID ${siteId} not found in organization ${orgId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainToken = generateId(48);
|
||||||
|
const encryptedToken = encrypt(
|
||||||
|
plainToken,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
|
||||||
|
const [record] = await db
|
||||||
|
.insert(browserGatewayTarget)
|
||||||
|
.values({
|
||||||
|
resourceId,
|
||||||
|
siteId,
|
||||||
|
type,
|
||||||
|
destination,
|
||||||
|
destinationPort,
|
||||||
|
authToken: encryptedToken
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (site.type === "newt") {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (newt) {
|
||||||
|
await sendBrowserGatewayTargets(
|
||||||
|
newt.newtId,
|
||||||
|
[record],
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return response<CreateBrowserGatewayTargetResponse>(res, {
|
||||||
|
data: record,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Browser gateway target created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create browser gateway target"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { browserGatewayTarget, db, newts, sites } from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
browserGatewayTargetId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "delete",
|
||||||
|
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||||
|
description: "Delete a browser gateway target.",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteBrowserGatewayTarget(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ bgt: browserGatewayTarget, site: sites })
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
browserGatewayTarget.browserGatewayTargetId,
|
||||||
|
browserGatewayTargetId
|
||||||
|
),
|
||||||
|
eq(sites.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(browserGatewayTarget)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
browserGatewayTarget.browserGatewayTargetId,
|
||||||
|
browserGatewayTargetId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.site.type === "newt") {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, existing.bgt.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (newt) {
|
||||||
|
await removeBrowserGatewayTarget(
|
||||||
|
newt.newtId,
|
||||||
|
browserGatewayTargetId,
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Browser gateway target deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to delete browser gateway target"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
|
BrowserGatewayTarget,
|
||||||
|
db,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
browserGatewayTargetId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||||
|
description: "Get a browser gateway target.",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getBrowserGatewayTarget(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [result] = await db
|
||||||
|
.select({ bgt: browserGatewayTarget })
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
browserGatewayTarget.browserGatewayTargetId,
|
||||||
|
browserGatewayTargetId
|
||||||
|
),
|
||||||
|
eq(sites.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<GetBrowserGatewayTargetResponse>(res, {
|
||||||
|
data: result.bgt,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Browser gateway target retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to retrieve browser gateway target"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./createBrowserGatewayTarget";
|
||||||
|
export * from "./updateBrowserGatewayTarget";
|
||||||
|
export * from "./deleteBrowserGatewayTarget";
|
||||||
|
export * from "./getBrowserGatewayTarget";
|
||||||
|
export * from "./listBrowserGatewayTargets";
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
|
BrowserGatewayTarget,
|
||||||
|
db,
|
||||||
|
resources,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive()),
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative())
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListBrowserGatewayTargetsResponse = {
|
||||||
|
targets: BrowserGatewayTarget[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets",
|
||||||
|
description: "List browser gateway targets for a resource.",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
query: querySchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listBrowserGatewayTargets(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedQuery = querySchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await db
|
||||||
|
.select()
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.where(eq(browserGatewayTarget.resourceId, resourceId))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return response<ListBrowserGatewayTargetsResponse>(res, {
|
||||||
|
data: {
|
||||||
|
targets: targets,
|
||||||
|
total: targets.length,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Browser gateway targets retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to list browser gateway targets"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
|
BrowserGatewayTarget,
|
||||||
|
db,
|
||||||
|
newts,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
browserGatewayTargetId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
siteId: z.number().int().positive().optional(),
|
||||||
|
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
|
||||||
|
destination: z.string().nonempty().optional(),
|
||||||
|
destinationPort: z.number().int().min(1).max(65535).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||||
|
description: "Update a browser gateway target.",
|
||||||
|
tags: [OpenAPITags.Org],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: bodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateBrowserGatewayTarget(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ bgt: browserGatewayTarget, site: sites })
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
browserGatewayTarget.browserGatewayTargetId,
|
||||||
|
browserGatewayTargetId
|
||||||
|
),
|
||||||
|
eq(sites.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateValues: Partial<BrowserGatewayTarget> = {};
|
||||||
|
if (siteId !== undefined) updateValues.siteId = siteId;
|
||||||
|
if (type !== undefined) updateValues.type = type;
|
||||||
|
if (destination !== undefined) updateValues.destination = destination;
|
||||||
|
if (destinationPort !== undefined)
|
||||||
|
updateValues.destinationPort = destinationPort;
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(browserGatewayTarget)
|
||||||
|
.set(updateValues)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
browserGatewayTarget.browserGatewayTargetId,
|
||||||
|
browserGatewayTargetId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const targetSiteId = siteId ?? existing.bgt.siteId;
|
||||||
|
const [site] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, targetSiteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (site && site.type === "newt") {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, targetSiteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (newt) {
|
||||||
|
await sendBrowserGatewayTargets(
|
||||||
|
newt.newtId,
|
||||||
|
[updated],
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
|
||||||
|
|
||||||
|
return response<UpdateBrowserGatewayTargetResponse>(res, {
|
||||||
|
data: updated,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Browser gateway target updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to update browser gateway target"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
|||||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
|
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
||||||
import * as labels from "#private/routers/labels";
|
import * as labels from "#private/routers/labels";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -829,3 +830,48 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.getTarget),
|
verifyUserHasAction(ActionsEnum.getTarget),
|
||||||
healthChecks.getHealthCheckStatusHistory
|
healthChecks.getHealthCheckStatusHistory
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.createBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
|
||||||
|
browserGatewayTarget.listBrowserGatewayTargets
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.getBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.updateBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.deleteBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import * as org from "#private/routers/org";
|
|||||||
import * as logs from "#private/routers/auditLogs";
|
import * as logs from "#private/routers/auditLogs";
|
||||||
import * as alertEvents from "#private/routers/alertEvents";
|
import * as alertEvents from "#private/routers/alertEvents";
|
||||||
import * as certificates from "#private/routers/certificates";
|
import * as certificates from "#private/routers/certificates";
|
||||||
|
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyApiKeyHasAction,
|
verifyApiKeyHasAction,
|
||||||
@@ -215,3 +216,43 @@ authenticated.delete(
|
|||||||
logActionAudit(ActionsEnum.removeUserRole),
|
logActionAudit(ActionsEnum.removeUserRole),
|
||||||
user.removeUserRole
|
user.removeUserRole
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.createBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
|
||||||
|
browserGatewayTarget.listBrowserGatewayTargets
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.getBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.updateBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
||||||
|
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
||||||
|
browserGatewayTarget.deleteBrowserGatewayTarget
|
||||||
|
);
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ internalRouter.get("/idp", idp.listIdps);
|
|||||||
|
|
||||||
internalRouter.get("/idp/:idpId", idp.getIdp);
|
internalRouter.get("/idp/:idpId", idp.getIdp);
|
||||||
|
|
||||||
|
internalRouter.get("/resource/browser-target", resource.getBrowserTarget);
|
||||||
|
|
||||||
// Gerbil routes
|
// Gerbil routes
|
||||||
const gerbilRouter = Router();
|
const gerbilRouter = Router();
|
||||||
internalRouter.use("/gerbil", gerbilRouter);
|
internalRouter.use("/gerbil", gerbilRouter);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
|
BrowserGatewayTarget,
|
||||||
clients,
|
clients,
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache,
|
||||||
@@ -16,6 +18,7 @@ import logger from "@server/logger";
|
|||||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import {
|
import {
|
||||||
formatEndpoint,
|
formatEndpoint,
|
||||||
generateSubnetProxyTargetV2,
|
generateSubnetProxyTargetV2,
|
||||||
@@ -233,6 +236,11 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
.from(targetHealthCheck)
|
.from(targetHealthCheck)
|
||||||
.where(eq(targetHealthCheck.siteId, siteId));
|
.where(eq(targetHealthCheck.siteId, siteId));
|
||||||
|
|
||||||
|
const allBrowserGatewayTargets = await db
|
||||||
|
.select()
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.where(eq(browserGatewayTarget.siteId, siteId));
|
||||||
|
|
||||||
const { tcpTargets, udpTargets } = allTargets.reduce(
|
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||||
(acc, target) => {
|
(acc, target) => {
|
||||||
// Filter out invalid targets
|
// Filter out invalid targets
|
||||||
@@ -304,9 +312,22 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
(target) => target !== null
|
(target) => target !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const serverSecret = config.getRawConfig().server.secret!;
|
||||||
|
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
|
||||||
|
const decryptAuthToken = decrypt(t.authToken, serverSecret);
|
||||||
|
return {
|
||||||
|
id: t.browserGatewayTargetId,
|
||||||
|
type: t.type,
|
||||||
|
destination: t.destination,
|
||||||
|
destinationPort: t.destinationPort,
|
||||||
|
authToken: decryptAuthToken
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validHealthCheckTargets,
|
validHealthCheckTargets,
|
||||||
tcpTargets,
|
tcpTargets,
|
||||||
udpTargets
|
udpTargets,
|
||||||
|
browserGatewayTargets
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
const siteId = newt.siteId;
|
const siteId = newt.siteId;
|
||||||
|
|
||||||
const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } =
|
const {
|
||||||
message.data;
|
publicKey,
|
||||||
|
pingResults,
|
||||||
|
newtVersion,
|
||||||
|
backwardsCompatible,
|
||||||
|
chainId
|
||||||
|
} = message.data;
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
logger.warn("Public key not provided");
|
logger.warn("Public key not provided");
|
||||||
return;
|
return;
|
||||||
@@ -191,8 +196,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.where(eq(newts.newtId, newt.newtId));
|
.where(eq(newts.newtId, newt.newtId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
const {
|
||||||
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
tcpTargets,
|
||||||
|
udpTargets,
|
||||||
|
validHealthCheckTargets,
|
||||||
|
browserGatewayTargets
|
||||||
|
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||||
@@ -212,6 +221,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
tcp: tcpTargets
|
tcp: tcpTargets
|
||||||
},
|
},
|
||||||
healthCheckTargets: validHealthCheckTargets,
|
healthCheckTargets: validHealthCheckTargets,
|
||||||
|
browserGatewayTargets: browserGatewayTargets,
|
||||||
chainId: chainId
|
chainId: chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ import {
|
|||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
||||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
const {
|
||||||
await buildTargetConfigurationForNewtClient(site.siteId);
|
tcpTargets,
|
||||||
|
udpTargets,
|
||||||
|
validHealthCheckTargets,
|
||||||
|
browserGatewayTargets
|
||||||
|
} = await buildTargetConfigurationForNewtClient(site.siteId);
|
||||||
|
|
||||||
let exitNode: ExitNode | undefined;
|
let exitNode: ExitNode | undefined;
|
||||||
if (site.exitNodeId) {
|
if (site.exitNodeId) {
|
||||||
@@ -36,7 +40,8 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
|||||||
},
|
},
|
||||||
healthCheckTargets: validHealthCheckTargets,
|
healthCheckTargets: validHealthCheckTargets,
|
||||||
peers: peers,
|
peers: peers,
|
||||||
clientTargets: targets
|
clientTargets: targets,
|
||||||
|
browserGatewayTargets: browserGatewayTargets
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Target, TargetHealthCheck } from "@server/db";
|
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export async function addTargets(
|
export async function addTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
@@ -239,3 +241,55 @@ export async function removeTargets(
|
|||||||
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendBrowserGatewayTargets(
|
||||||
|
newtId: string,
|
||||||
|
targets: BrowserGatewayTarget[],
|
||||||
|
version?: string | null
|
||||||
|
) {
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
|
const payload = targets.map((t) => {
|
||||||
|
const decryptAuthToken = decrypt(
|
||||||
|
t.authToken,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: t.browserGatewayTargetId,
|
||||||
|
resourceId: t.resourceId,
|
||||||
|
siteId: t.siteId,
|
||||||
|
type: t.type,
|
||||||
|
destination: t.destination,
|
||||||
|
destinationPort: t.destinationPort,
|
||||||
|
authToken: decryptAuthToken
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendToClient(
|
||||||
|
newtId,
|
||||||
|
{
|
||||||
|
type: "newt/browsergateway/add",
|
||||||
|
data: {
|
||||||
|
targets: payload
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeBrowserGatewayTarget(
|
||||||
|
newtId: string,
|
||||||
|
browserGatewayTargetId: number,
|
||||||
|
version?: string | null
|
||||||
|
) {
|
||||||
|
await sendToClient(
|
||||||
|
newtId,
|
||||||
|
{
|
||||||
|
type: "newt/browsergateway/remove",
|
||||||
|
data: {
|
||||||
|
ids: [browserGatewayTargetId]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { browserGatewayTarget, db } from "@server/db";
|
||||||
|
import { resources, targets } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
|
const getBrowserTargetSchema = z
|
||||||
|
.object({
|
||||||
|
fullDomain: z.string().min(1, "fullDomain is required")
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type GetBrowserTargetResponse = {
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
authToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getBrowserTarget(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsed = getBrowserTargetSchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsed.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullDomain } = parsed.data;
|
||||||
|
|
||||||
|
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
|
||||||
|
|
||||||
|
const [browserTarget] = await db
|
||||||
|
.select({
|
||||||
|
destination: browserGatewayTarget.destination,
|
||||||
|
destinationPort: browserGatewayTarget.destinationPort,
|
||||||
|
authToken: browserGatewayTarget.authToken
|
||||||
|
})
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
eq(browserGatewayTarget.resourceId, resources.resourceId)
|
||||||
|
)
|
||||||
|
.where(eq(resources.fullDomain, fullDomain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const decryptAuthToken = decrypt(
|
||||||
|
browserTarget.authToken,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!browserTarget) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"No resource found for this domain"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<GetBrowserTargetResponse>(res, {
|
||||||
|
data: {
|
||||||
|
ip: browserTarget.destination,
|
||||||
|
port: browserTarget.destinationPort,
|
||||||
|
authToken: decryptAuthToken
|
||||||
|
},
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
|
|||||||
export * from "./listAllResourceNames";
|
export * from "./listAllResourceNames";
|
||||||
export * from "./removeEmailFromResourceWhitelist";
|
export * from "./removeEmailFromResourceWhitelist";
|
||||||
export * from "./getStatusHistory";
|
export * from "./getStatusHistory";
|
||||||
|
export * from "./getBrowserTarget";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
browserGatewayTarget,
|
||||||
db,
|
db,
|
||||||
labels,
|
labels,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
@@ -145,6 +146,7 @@ export type ResourceWithTargets = {
|
|||||||
headerAuthId: number | null;
|
headerAuthId: number | null;
|
||||||
wildcard: boolean;
|
wildcard: boolean;
|
||||||
health: string | null;
|
health: string | null;
|
||||||
|
browserAccessType: string | null;
|
||||||
targets: Array<{
|
targets: Array<{
|
||||||
targetId: number;
|
targetId: number;
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -183,7 +185,8 @@ function queryResourcesBase() {
|
|||||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||||
headerAuthExtendedCompatibilityId:
|
headerAuthExtendedCompatibilityId:
|
||||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
||||||
health: resources.health
|
health: resources.health,
|
||||||
|
browserAccessType: resources.browserAccessType
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
@@ -482,6 +485,30 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
||||||
|
|
||||||
|
const allBgTargetSites =
|
||||||
|
resourceIdList.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select({
|
||||||
|
resourceId: browserGatewayTarget.resourceId,
|
||||||
|
siteId: browserGatewayTarget.siteId,
|
||||||
|
siteName: sites.name,
|
||||||
|
siteNiceId: sites.niceId,
|
||||||
|
siteOnline: sites.online,
|
||||||
|
siteType: sites.type
|
||||||
|
})
|
||||||
|
.from(browserGatewayTarget)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
browserGatewayTarget.resourceId,
|
||||||
|
resourceIdList
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sites,
|
||||||
|
eq(sites.siteId, browserGatewayTarget.siteId)
|
||||||
|
);
|
||||||
|
|
||||||
// avoids TS issues with reduce/never[]
|
// avoids TS issues with reduce/never[]
|
||||||
const map = new Map<number, ResourceWithTargets>();
|
const map = new Map<number, ResourceWithTargets>();
|
||||||
|
|
||||||
@@ -502,6 +529,7 @@ export async function listResources(
|
|||||||
protocol: row.protocol,
|
protocol: row.protocol,
|
||||||
proxyPort: row.proxyPort,
|
proxyPort: row.proxyPort,
|
||||||
wildcard: row.wildcard,
|
wildcard: row.wildcard,
|
||||||
|
browserAccessType: row.browserAccessType,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
domainId: row.domainId,
|
domainId: row.domainId,
|
||||||
headerAuthId: row.headerAuthId,
|
headerAuthId: row.headerAuthId,
|
||||||
@@ -545,6 +573,21 @@ export async function listResources(
|
|||||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const bgRaw = allBgTargetSites.filter(
|
||||||
|
(t) => t.resourceId === entry.resourceId
|
||||||
|
);
|
||||||
|
for (const t of bgRaw) {
|
||||||
|
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isLocal = t.siteType === "local";
|
||||||
|
siteById.set(t.siteId, {
|
||||||
|
siteId: t.siteId,
|
||||||
|
siteName: t.siteName ?? "",
|
||||||
|
siteNiceId: t.siteNiceId ?? "",
|
||||||
|
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||||
|
});
|
||||||
|
}
|
||||||
entry.sites = Array.from(siteById.values());
|
entry.sites = Array.from(siteById.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import {
|
|||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { OpenAPITags } from "@server/openApi";
|
import { OpenAPITags } from "@server/openApi";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
import {
|
||||||
|
validateAndConstructDomain,
|
||||||
|
checkWildcardDomainConflict
|
||||||
|
} from "@server/lib/domainUtils";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
@@ -68,7 +71,8 @@ const updateHttpResourceBodySchema = z
|
|||||||
maintenanceTitle: z.string().max(255).nullable().optional(),
|
maintenanceTitle: z.string().max(255).nullable().optional(),
|
||||||
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
||||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
||||||
postAuthPath: z.string().nullable().optional()
|
postAuthPath: z.string().nullable().optional(),
|
||||||
|
browserAccessType: z.enum(["http", "ssh", "rdp", "vnc"]).optional()
|
||||||
})
|
})
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
error: "At least one field must be provided for update"
|
error: "At least one field must be provided for update"
|
||||||
|
|||||||
@@ -507,7 +507,9 @@ export default function GeneralForm() {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
subdomain: data.subdomain
|
subdomain: data.subdomain
|
||||||
? toASCII(finalizeSubdomainSanitize(data.subdomain, true))
|
? toASCII(
|
||||||
|
finalizeSubdomainSanitize(data.subdomain, true)
|
||||||
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
domainId: data.domainId,
|
domainId: data.domainId,
|
||||||
proxyPort: data.proxyPort
|
proxyPort: data.proxyPort
|
||||||
@@ -555,7 +557,9 @@ export default function GeneralForm() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
{resource?.resourceId && resource?.orgId && (
|
{resource?.resourceId &&
|
||||||
|
resource?.orgId &&
|
||||||
|
resource.browserAccessType == "http" && (
|
||||||
<UptimeAlertSection
|
<UptimeAlertSection
|
||||||
orgId={resource.orgId}
|
orgId={resource.orgId}
|
||||||
resourceId={resource.resourceId}
|
resourceId={resource.resourceId}
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ export default function ReverseProxyTargetsPage(props: {
|
|||||||
const params = use(props.params);
|
const params = use(props.params);
|
||||||
const { resource, updateResource } = useResourceContext();
|
const { resource, updateResource } = useResourceContext();
|
||||||
|
|
||||||
|
const [targetMode, setTargetMode] = useState<
|
||||||
|
"http" | "ssh" | "rdp" | "vnc"
|
||||||
|
>((resource.browserAccessType as "http" | "ssh" | "rdp" | "vnc") || "http");
|
||||||
|
|
||||||
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
|
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery(
|
||||||
resourceQueries.resourceTargets({
|
resourceQueries.resourceTargets({
|
||||||
resourceId: resource.resourceId
|
resourceId: resource.resourceId
|
||||||
@@ -137,9 +141,12 @@ export default function ReverseProxyTargetsPage(props: {
|
|||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
initialTargets={remoteTargets}
|
initialTargets={remoteTargets}
|
||||||
resource={resource}
|
resource={resource}
|
||||||
|
targetMode={targetMode}
|
||||||
|
setTargetMode={setTargetMode}
|
||||||
|
updateResource={updateResource}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{resource.http && (
|
{resource.http && targetMode === "http" && (
|
||||||
<ProxyResourceHttpForm
|
<ProxyResourceHttpForm
|
||||||
resource={resource}
|
resource={resource}
|
||||||
updateResource={updateResource}
|
updateResource={updateResource}
|
||||||
@@ -159,11 +166,17 @@ export default function ReverseProxyTargetsPage(props: {
|
|||||||
function ProxyResourceTargetsForm({
|
function ProxyResourceTargetsForm({
|
||||||
orgId,
|
orgId,
|
||||||
initialTargets,
|
initialTargets,
|
||||||
resource
|
resource,
|
||||||
|
targetMode,
|
||||||
|
setTargetMode,
|
||||||
|
updateResource
|
||||||
}: {
|
}: {
|
||||||
initialTargets: LocalTarget[];
|
initialTargets: LocalTarget[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
resource: GetResourceResponse;
|
resource: GetResourceResponse;
|
||||||
|
targetMode: "http" | "ssh" | "rdp" | "vnc";
|
||||||
|
setTargetMode: (mode: "http" | "ssh" | "rdp" | "vnc") => void;
|
||||||
|
updateResource: ResourceContextType["updateResource"];
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
@@ -201,6 +214,11 @@ function ProxyResourceTargetsForm({
|
|||||||
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
||||||
useState<LocalTarget | null>(null);
|
useState<LocalTarget | null>(null);
|
||||||
|
|
||||||
|
const [bgDestination, setBgDestination] = useState("");
|
||||||
|
const [bgDestinationPort, setBgDestinationPort] = useState("");
|
||||||
|
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
|
||||||
|
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
|
||||||
|
|
||||||
const initializeDockerForSite = async (siteId: number) => {
|
const initializeDockerForSite = async (siteId: number) => {
|
||||||
if (dockerStates.has(siteId)) {
|
if (dockerStates.has(siteId)) {
|
||||||
return; // Already initialized
|
return; // Already initialized
|
||||||
@@ -270,6 +288,41 @@ function ProxyResourceTargetsForm({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: bgTargetsResponse } = useQuery({
|
||||||
|
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||||
|
);
|
||||||
|
return res.data.data as {
|
||||||
|
targets: Array<{
|
||||||
|
browserGatewayTargetId: number;
|
||||||
|
resourceId: number;
|
||||||
|
siteId: number;
|
||||||
|
type: string;
|
||||||
|
destination: string;
|
||||||
|
destinationPort: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bgTargetsResponse?.targets?.length) return;
|
||||||
|
const bgt = bgTargetsResponse.targets[0];
|
||||||
|
setTargetMode(bgt.type as "ssh" | "rdp" | "vnc");
|
||||||
|
setBgDestination(bgt.destination);
|
||||||
|
setBgDestinationPort(String(bgt.destinationPort));
|
||||||
|
setBgSiteId(bgt.siteId);
|
||||||
|
setBgTargetId(bgt.browserGatewayTargetId);
|
||||||
|
}, [bgTargetsResponse]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sites.length > 0 && bgSiteId === null) {
|
||||||
|
setBgSiteId(sites[0].siteId);
|
||||||
|
}
|
||||||
|
}, [sites, bgSiteId]);
|
||||||
|
|
||||||
const updateTarget = useCallback(
|
const updateTarget = useCallback(
|
||||||
(targetId: number, data: Partial<LocalTarget>) => {
|
(targetId: number, data: Partial<LocalTarget>) => {
|
||||||
setTargets((prevTargets) => {
|
setTargets((prevTargets) => {
|
||||||
@@ -375,7 +428,6 @@ function ProxyResourceTargetsForm({
|
|||||||
{getStatusText(status)}
|
{getStatusText(status)}
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
) : (
|
) : (
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
)}
|
)}
|
||||||
@@ -404,9 +456,15 @@ function ProxyResourceTargetsForm({
|
|||||||
pathMatchType: row.original.pathMatchType
|
pathMatchType: row.original.pathMatchType
|
||||||
}}
|
}}
|
||||||
onChange={(config) =>
|
onChange={(config) =>
|
||||||
updateTarget(row.original.targetId,
|
updateTarget(
|
||||||
config.path === null && config.pathMatchType === null
|
row.original.targetId,
|
||||||
? { ...config, rewritePath: null, rewritePathType: null }
|
config.path === null &&
|
||||||
|
config.pathMatchType === null
|
||||||
|
? {
|
||||||
|
...config,
|
||||||
|
rewritePath: null,
|
||||||
|
rewritePathType: null
|
||||||
|
}
|
||||||
: config
|
: config
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -432,9 +490,15 @@ function ProxyResourceTargetsForm({
|
|||||||
pathMatchType: row.original.pathMatchType
|
pathMatchType: row.original.pathMatchType
|
||||||
}}
|
}}
|
||||||
onChange={(config) =>
|
onChange={(config) =>
|
||||||
updateTarget(row.original.targetId,
|
updateTarget(
|
||||||
config.path === null && config.pathMatchType === null
|
row.original.targetId,
|
||||||
? { ...config, rewritePath: null, rewritePathType: null }
|
config.path === null &&
|
||||||
|
config.pathMatchType === null
|
||||||
|
? {
|
||||||
|
...config,
|
||||||
|
rewritePath: null,
|
||||||
|
rewritePathType: null
|
||||||
|
}
|
||||||
: config
|
: config
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -717,6 +781,55 @@ function ProxyResourceTargetsForm({
|
|||||||
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
|
||||||
|
|
||||||
async function saveTargets() {
|
async function saveTargets() {
|
||||||
|
if (targetMode !== "http") {
|
||||||
|
try {
|
||||||
|
if (!bgDestination || !bgDestinationPort) {
|
||||||
|
if (bgTargetId) {
|
||||||
|
await api.delete(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${bgTargetId}`
|
||||||
|
);
|
||||||
|
setBgTargetId(null);
|
||||||
|
}
|
||||||
|
} else if (bgTargetId) {
|
||||||
|
await api.post(
|
||||||
|
`/org/${orgId}/browser-gateway-target/${bgTargetId}`,
|
||||||
|
{
|
||||||
|
type: targetMode,
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort),
|
||||||
|
siteId: bgSiteId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const res = await api.put(
|
||||||
|
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||||
|
{
|
||||||
|
siteId: bgSiteId ?? sites[0]?.siteId,
|
||||||
|
type: targetMode,
|
||||||
|
destination: bgDestination,
|
||||||
|
destinationPort: Number(bgDestinationPort)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setBgTargetId(res.data.data.browserGatewayTargetId);
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: t("settingsUpdated"),
|
||||||
|
description: t("settingsUpdatedDescription")
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settingsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("settingsErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate that no targets have blank IPs or invalid ports
|
// Validate that no targets have blank IPs or invalid ports
|
||||||
const targetsWithInvalidFields = targets.filter(
|
const targetsWithInvalidFields = targets.filter(
|
||||||
(target) =>
|
(target) =>
|
||||||
@@ -791,10 +904,12 @@ function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: targets.length === 0
|
title:
|
||||||
|
targets.length === 0
|
||||||
? t("targetTargetsCleared")
|
? t("targetTargetsCleared")
|
||||||
: t("settingsUpdated"),
|
: t("settingsUpdated"),
|
||||||
description: targets.length === 0
|
description:
|
||||||
|
targets.length === 0
|
||||||
? t("targetTargetsClearedDescription")
|
? t("targetTargetsClearedDescription")
|
||||||
: t("settingsUpdatedDescription")
|
: t("settingsUpdatedDescription")
|
||||||
});
|
});
|
||||||
@@ -829,6 +944,48 @@ function ProxyResourceTargetsForm({
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<span className="text-sm font-medium">Target Type</span>
|
||||||
|
<Select
|
||||||
|
value={targetMode}
|
||||||
|
onValueChange={async (v) => {
|
||||||
|
const mode = v as
|
||||||
|
| "http"
|
||||||
|
| "ssh"
|
||||||
|
| "rdp"
|
||||||
|
| "vnc";
|
||||||
|
setTargetMode(mode);
|
||||||
|
try {
|
||||||
|
await api.post(
|
||||||
|
`/resource/${resource.resourceId}`,
|
||||||
|
{ browserAccessType: mode }
|
||||||
|
);
|
||||||
|
updateResource({ browserAccessType: mode });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settingsErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("settingsErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="http">HTTP</SelectItem>
|
||||||
|
<SelectItem value="ssh">SSH</SelectItem>
|
||||||
|
<SelectItem value="rdp">RDP</SelectItem>
|
||||||
|
<SelectItem value="vnc">VNC</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{targetMode === "http" ? (
|
||||||
|
<>
|
||||||
{targets.length > 0 ? (
|
{targets.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -837,11 +994,14 @@ function ProxyResourceTargetsForm({
|
|||||||
{table
|
{table
|
||||||
.getHeaderGroups()
|
.getHeaderGroups()
|
||||||
.map((headerGroup) => (
|
.map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow
|
||||||
|
key={headerGroup.id}
|
||||||
|
>
|
||||||
{headerGroup.headers.map(
|
{headerGroup.headers.map(
|
||||||
(header) => {
|
(header) => {
|
||||||
const isActionsColumn =
|
const isActionsColumn =
|
||||||
header.column
|
header
|
||||||
|
.column
|
||||||
.id ===
|
.id ===
|
||||||
"actions";
|
"actions";
|
||||||
return (
|
return (
|
||||||
@@ -872,16 +1032,23 @@ function ProxyResourceTargetsForm({
|
|||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows
|
||||||
|
?.length ? (
|
||||||
table
|
table
|
||||||
.getRowModel()
|
.getRowModel()
|
||||||
.rows.map((row) => (
|
.rows.map((row) => (
|
||||||
<TableRow key={row.id}>
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
>
|
||||||
{row
|
{row
|
||||||
.getVisibleCells()
|
.getVisibleCells()
|
||||||
.map((cell) => {
|
.map(
|
||||||
|
(
|
||||||
|
cell
|
||||||
|
) => {
|
||||||
const isActionsColumn =
|
const isActionsColumn =
|
||||||
cell.column
|
cell
|
||||||
|
.column
|
||||||
.id ===
|
.id ===
|
||||||
"actions";
|
"actions";
|
||||||
return (
|
return (
|
||||||
@@ -904,13 +1071,16 @@ function ProxyResourceTargetsForm({
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={columns.length}
|
colSpan={
|
||||||
|
columns.length
|
||||||
|
}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
{t("targetNoOne")}
|
{t("targetNoOne")}
|
||||||
@@ -936,7 +1106,9 @@ function ProxyResourceTargetsForm({
|
|||||||
<Switch
|
<Switch
|
||||||
id="advanced-mode-toggle"
|
id="advanced-mode-toggle"
|
||||||
checked={isAdvancedMode}
|
checked={isAdvancedMode}
|
||||||
onCheckedChange={setIsAdvancedMode}
|
onCheckedChange={
|
||||||
|
setIsAdvancedMode
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="advanced-mode-toggle"
|
htmlFor="advanced-mode-toggle"
|
||||||
@@ -953,7 +1125,10 @@ function ProxyResourceTargetsForm({
|
|||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
{t("targetNoOne")}
|
{t("targetNoOne")}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={addNewTarget} variant="outline">
|
<Button
|
||||||
|
onClick={addNewTarget}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
{t("addTarget")}
|
{t("addTarget")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -961,7 +1136,8 @@ function ProxyResourceTargetsForm({
|
|||||||
)}
|
)}
|
||||||
{build === "saas" &&
|
{build === "saas" &&
|
||||||
targets.length > 1 &&
|
targets.length > 1 &&
|
||||||
new Set(targets.map((t) => t.siteId)).size > 1 && (
|
new Set(targets.map((t) => t.siteId)).size >
|
||||||
|
1 && (
|
||||||
<p className="text-sm text-muted-foreground mt-3">
|
<p className="text-sm text-muted-foreground mt-3">
|
||||||
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
|
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
|
||||||
<a
|
<a
|
||||||
@@ -976,6 +1152,71 @@ function ProxyResourceTargetsForm({
|
|||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Destination
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
value={bgDestination}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBgDestination(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Port
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={
|
||||||
|
targetMode === "rdp"
|
||||||
|
? "3389"
|
||||||
|
: targetMode === "ssh"
|
||||||
|
? "22"
|
||||||
|
: "5900"
|
||||||
|
}
|
||||||
|
value={bgDestinationPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBgDestinationPort(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sites.length > 1 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Site
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={bgSiteId ? String(bgSiteId) : ""}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setBgSiteId(Number(v))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a site" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<SelectItem
|
||||||
|
key={site.siteId}
|
||||||
|
value={String(site.siteId)}
|
||||||
|
>
|
||||||
|
{site.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
|
|
||||||
<form className="self-end mt-4" action={formAction}>
|
<form className="self-end mt-4" action={formAction}>
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export default async function ProxyResourcesPage(
|
|||||||
fullDomain: resource.fullDomain ?? null,
|
fullDomain: resource.fullDomain ?? null,
|
||||||
ssl: resource.ssl,
|
ssl: resource.ssl,
|
||||||
wildcard: resource.wildcard,
|
wildcard: resource.wildcard,
|
||||||
|
browserAccessType: resource.browserAccessType,
|
||||||
targets: resource.targets?.map((target) => ({
|
targets: resource.targets?.map((target) => ({
|
||||||
targetId: target.targetId,
|
targetId: target.targetId,
|
||||||
ip: target.ip,
|
ip: target.ip,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,527 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import type {
|
||||||
|
UserInteraction,
|
||||||
|
IronError,
|
||||||
|
FileTransferProvider
|
||||||
|
} from "@devolutions/iron-remote-desktop/dist";
|
||||||
|
import type {
|
||||||
|
RdpFileTransferProvider,
|
||||||
|
FileInfo
|
||||||
|
} from "@devolutions/iron-remote-desktop-rdp/dist";
|
||||||
|
|
||||||
|
declare module "react" {
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
"iron-remote-desktop": React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLElement> & {
|
||||||
|
scale?: string;
|
||||||
|
verbose?: string;
|
||||||
|
flexcenter?: string;
|
||||||
|
module?: unknown;
|
||||||
|
},
|
||||||
|
HTMLElement
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Target = {
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
authToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
domain: string;
|
||||||
|
kdcProxyUrl: string;
|
||||||
|
pcb: string;
|
||||||
|
enableClipboard: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isIronError = (error: unknown): error is IronError => {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
typeof (error as IronError).backtrace === "function" &&
|
||||||
|
typeof (error as IronError).kind === "function"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RdpClient({
|
||||||
|
target,
|
||||||
|
error
|
||||||
|
}: {
|
||||||
|
target: Target | null;
|
||||||
|
error: string | null;
|
||||||
|
}) {
|
||||||
|
const STORAGE_KEY = "pangolin_rdp_credentials";
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) return JSON.parse(saved) as FormState;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
domain: "",
|
||||||
|
kdcProxyUrl: "",
|
||||||
|
pcb: "",
|
||||||
|
enableClipboard: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showLogin, setShowLogin] = useState(true);
|
||||||
|
const [moduleReady, setModuleReady] = useState(false);
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [unicodeMode, setUnicodeMode] = useState(false);
|
||||||
|
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
|
||||||
|
|
||||||
|
const userInteractionRef = useRef<UserInteraction | null>(null);
|
||||||
|
const backendRef = useRef<unknown>(null);
|
||||||
|
// Holds the RdpFileTransferProvider constructor so we can create a fresh
|
||||||
|
// instance per session (avoids stale upload state across reconnects).
|
||||||
|
const fileTransferClassRef = useRef<typeof RdpFileTransferProvider | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
// Active session's provider instance; replaced on each connect.
|
||||||
|
const fileTransferRef = useRef<RdpFileTransferProvider | null>(null);
|
||||||
|
const extensionsRef = useRef<{
|
||||||
|
displayControl: (enable: boolean) => unknown;
|
||||||
|
preConnectionBlob: (pcb: string) => unknown;
|
||||||
|
kdcProxyUrl: (url: string) => unknown;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Load the iron-remote-desktop modules client-side and register the
|
||||||
|
// `<iron-remote-desktop>` custom element.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [coreMod, rdpMod] = await Promise.all([
|
||||||
|
import("@devolutions/iron-remote-desktop/dist"),
|
||||||
|
import("@devolutions/iron-remote-desktop-rdp/dist")
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
await rdpMod.init("INFO");
|
||||||
|
|
||||||
|
backendRef.current = rdpMod.Backend;
|
||||||
|
extensionsRef.current = {
|
||||||
|
displayControl: rdpMod.displayControl,
|
||||||
|
preConnectionBlob: rdpMod.preConnectionBlob,
|
||||||
|
kdcProxyUrl: rdpMod.kdcProxyUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the class; a fresh instance is created per session.
|
||||||
|
fileTransferClassRef.current =
|
||||||
|
rdpMod.RdpFileTransferProvider as unknown as typeof RdpFileTransferProvider;
|
||||||
|
|
||||||
|
// Importing the package registers the custom element as a side
|
||||||
|
// effect. Touch the default export to avoid tree-shaking.
|
||||||
|
void coreMod;
|
||||||
|
|
||||||
|
setModuleReady(true);
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error("Failed to load iron-remote-desktop modules", err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to load RDP module",
|
||||||
|
description: `${err}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Attach the "ready" listener synchronously the moment the custom
|
||||||
|
// element mounts. The custom element dispatches `ready` from its own
|
||||||
|
// `onMount`, so a deferred useEffect can race and miss it.
|
||||||
|
const remoteElementRef = (el: HTMLElement | null) => {
|
||||||
|
if (!el) return;
|
||||||
|
const onReady = (e: Event) => {
|
||||||
|
const event = e as CustomEvent;
|
||||||
|
userInteractionRef.current = event.detail.irgUserInteraction;
|
||||||
|
};
|
||||||
|
el.addEventListener("ready", onReady);
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startSession = async () => {
|
||||||
|
setConnecting(true);
|
||||||
|
const userInteraction = userInteractionRef.current;
|
||||||
|
const exts = extensionsRef.current;
|
||||||
|
if (!userInteraction || !exts) {
|
||||||
|
setConnecting(false);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Not ready",
|
||||||
|
description: "RDP module is still initializing"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userInteraction.setEnableClipboard(form.enableClipboard);
|
||||||
|
|
||||||
|
// Dispose any previous session's provider and create a fresh one so
|
||||||
|
// there is no stale upload state from a prior connection.
|
||||||
|
fileTransferRef.current?.dispose();
|
||||||
|
const ProviderClass = fileTransferClassRef.current;
|
||||||
|
const fileTransfer = ProviderClass ? new ProviderClass() : null;
|
||||||
|
fileTransferRef.current = fileTransfer;
|
||||||
|
|
||||||
|
if (fileTransfer) {
|
||||||
|
// Auto-download files when the remote copies them to clipboard.
|
||||||
|
fileTransfer.on("files-available", (files: FileInfo[]) => {
|
||||||
|
const downloadable = files.filter((f) => !f.isDirectory);
|
||||||
|
if (downloadable.length === 0) return;
|
||||||
|
toast({
|
||||||
|
title: `Downloading ${downloadable.length} file(s) from remote…`
|
||||||
|
});
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
if (file.isDirectory) continue;
|
||||||
|
const { completion } = fileTransfer.downloadFile(file, i);
|
||||||
|
completion
|
||||||
|
.then((blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = file.name;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: `Download failed: ${file.name}`,
|
||||||
|
description: `${err}`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify when individual uploads complete (remote pasted a file).
|
||||||
|
fileTransfer.on("upload-complete", (file: File) => {
|
||||||
|
toast({ title: `Uploaded: ${file.name}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register with the web component so CLIPRDR extensions are
|
||||||
|
// wired up before connect() builds the session.
|
||||||
|
userInteraction.enableFileTransfer(
|
||||||
|
fileTransfer as unknown as FileTransferProvider
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
setConnecting(false);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "No target",
|
||||||
|
description: "No connection target available"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = `${target.ip}:${target.port}`;
|
||||||
|
|
||||||
|
const builder = userInteraction
|
||||||
|
.configBuilder()
|
||||||
|
.withUsername(form.username)
|
||||||
|
.withPassword(form.password)
|
||||||
|
.withDestination(destination)
|
||||||
|
.withProxyAddress(
|
||||||
|
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
|
||||||
|
)
|
||||||
|
.withServerDomain(form.domain)
|
||||||
|
.withAuthToken(target.authToken)
|
||||||
|
.withDesktopSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
})
|
||||||
|
.withExtension(exts.displayControl(true));
|
||||||
|
|
||||||
|
if (form.pcb !== "") {
|
||||||
|
builder.withExtension(exts.preConnectionBlob(form.pcb));
|
||||||
|
}
|
||||||
|
if (form.kdcProxyUrl !== "") {
|
||||||
|
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionInfo = await userInteraction.connect(builder.build());
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setConnecting(false);
|
||||||
|
setShowLogin(false);
|
||||||
|
userInteraction.setVisibility(true);
|
||||||
|
|
||||||
|
const termInfo = await sessionInfo.run();
|
||||||
|
fileTransferRef.current?.dispose();
|
||||||
|
fileTransferRef.current = null;
|
||||||
|
setShowLogin(true);
|
||||||
|
} catch (err) {
|
||||||
|
setConnecting(false);
|
||||||
|
setShowLogin(true);
|
||||||
|
if (isIronError(err)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Connection failed",
|
||||||
|
description: err.backtrace()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Connection failed",
|
||||||
|
description: `${err}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ui = () => userInteractionRef.current;
|
||||||
|
|
||||||
|
const toggleCursorKind = () => {
|
||||||
|
const u = ui();
|
||||||
|
if (!u) return;
|
||||||
|
if (cursorOverrideActive) {
|
||||||
|
u.setCursorStyleOverride(null);
|
||||||
|
} else {
|
||||||
|
u.setCursorStyleOverride('url("crosshair.png") 7 7, default');
|
||||||
|
}
|
||||||
|
setCursorOverrideActive((v) => !v);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{showLogin && (
|
||||||
|
<div className="mx-auto max-w-2xl p-6">
|
||||||
|
<h1 className="mb-4 text-2xl font-semibold">RDP</h1>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label="Domain" id="domain">
|
||||||
|
<Input
|
||||||
|
id="domain"
|
||||||
|
value={form.domain}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("domain", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Username" id="username">
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("username", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Password" id="password">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("password", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{/*
|
||||||
|
<Field label="Pre Connection Blob (optional)" id="pcb">
|
||||||
|
<Input
|
||||||
|
id="pcb"
|
||||||
|
value={form.pcb}
|
||||||
|
onChange={(e) => update("pcb", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field> */}
|
||||||
|
|
||||||
|
{/* <Field
|
||||||
|
label="KDC Proxy URL (optional)"
|
||||||
|
id="kdcProxyUrl"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="kdcProxyUrl"
|
||||||
|
value={form.kdcProxyUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("kdcProxyUrl", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field> */}
|
||||||
|
{/* <div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enable_clipboard"
|
||||||
|
checked={form.enableClipboard}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
update("enableClipboard", checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="enable_clipboard">
|
||||||
|
Enable Clipboard
|
||||||
|
</Label>
|
||||||
|
</div> */}
|
||||||
|
<Button
|
||||||
|
onClick={startSession}
|
||||||
|
disabled={!moduleReady}
|
||||||
|
loading={connecting}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{moduleReady ? "Connect" : "Loading module..."}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-screen flex-col bg-neutral-900"
|
||||||
|
style={{ display: showLogin ? "none" : "flex" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => ui()?.setScale(1)}
|
||||||
|
>
|
||||||
|
Fit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => ui()?.setScale(2)}
|
||||||
|
>
|
||||||
|
Full
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => ui()?.setScale(3)}
|
||||||
|
>
|
||||||
|
Real
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => ui()?.ctrlAltDel()}
|
||||||
|
>
|
||||||
|
Ctrl+Alt+Del
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => ui()?.metaKey()}
|
||||||
|
>
|
||||||
|
Meta
|
||||||
|
</Button>
|
||||||
|
{/* <Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={toggleCursorKind}
|
||||||
|
>
|
||||||
|
Toggle cursor
|
||||||
|
</Button> */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
const ft = fileTransferRef.current;
|
||||||
|
if (!ft) return;
|
||||||
|
const files = await ft.showFilePicker({
|
||||||
|
multiple: true
|
||||||
|
});
|
||||||
|
if (files.length === 0) return;
|
||||||
|
try {
|
||||||
|
ft.uploadFiles(files);
|
||||||
|
toast({
|
||||||
|
title: "Files ready to paste",
|
||||||
|
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Upload failed",
|
||||||
|
description: `${err}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload files
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
ui()?.shutdown();
|
||||||
|
setShowLogin(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Terminate
|
||||||
|
</Button>
|
||||||
|
<label className="ml-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={unicodeMode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUnicodeMode(e.target.checked);
|
||||||
|
ui()?.setKeyboardUnicodeMode(e.target.checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Unicode keyboard mode
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{moduleReady && (
|
||||||
|
<iron-remote-desktop
|
||||||
|
ref={remoteElementRef}
|
||||||
|
verbose="true"
|
||||||
|
scale="fit"
|
||||||
|
flexcenter="true"
|
||||||
|
module={backendRef.current}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { priv } from "@app/lib/api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||||
|
import RdpClient from "./RdpClient";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "RDP"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RdpPage() {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get("host") || "";
|
||||||
|
const hostname = host.split(":")[0];
|
||||||
|
|
||||||
|
let target: { ip: string; port: number; authToken: string } | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||||
|
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||||
|
);
|
||||||
|
target = res.data.data;
|
||||||
|
console.log("Fetched browser target:", target);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching browser target:", error);
|
||||||
|
error = "No resource found for this domain";
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RdpClient target={target} error={error} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
type Target = {
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
authToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
privateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SshClient({
|
||||||
|
target,
|
||||||
|
error
|
||||||
|
}: {
|
||||||
|
target: Target | null;
|
||||||
|
error: string | null;
|
||||||
|
}) {
|
||||||
|
const STORAGE_KEY = "pangolin_ssh_credentials";
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) return JSON.parse(saved) as FormState;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { username: "", password: "", privateKey: "" };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const text = ev.target?.result;
|
||||||
|
if (typeof text === "string") {
|
||||||
|
setForm((prev) => ({ ...prev, privateKey: text }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
// Reset input so the same file can be re-selected if needed.
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [connectError, setConnectError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
|
||||||
|
const fitAddonRef = useRef<import("@xterm/addon-fit").FitAddon | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
// Mount the terminal div once connected.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connected || !terminalRef.current) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] =
|
||||||
|
await Promise.all([
|
||||||
|
import("@xterm/xterm"),
|
||||||
|
import("@xterm/addon-fit"),
|
||||||
|
import("@xterm/addon-web-links")
|
||||||
|
]);
|
||||||
|
if (cancelled || !terminalRef.current) return;
|
||||||
|
|
||||||
|
const terminal = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
||||||
|
theme: {
|
||||||
|
background: "#0d0d0d",
|
||||||
|
foreground: "#f0f0f0"
|
||||||
|
},
|
||||||
|
scrollback: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
const webLinksAddon = new WebLinksAddon();
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
|
terminal.open(terminalRef.current);
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
xtermRef.current = terminal;
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
|
||||||
|
// Send user keystrokes to the WebSocket.
|
||||||
|
terminal.onData((data) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: "data", data }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send resize events.
|
||||||
|
terminal.onResize(({ cols, rows }) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(
|
||||||
|
JSON.stringify({ type: "resize", cols, rows })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the initial size once the terminal is rendered.
|
||||||
|
const { cols, rows } = terminal;
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(
|
||||||
|
JSON.stringify({ type: "resize", cols, rows })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})().catch(console.error);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [connected]);
|
||||||
|
|
||||||
|
// Refit terminal when the window resizes.
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => fitAddonRef.current?.fit();
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => window.removeEventListener("resize", onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
wsRef.current?.close();
|
||||||
|
xtermRef.current?.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
setConnectError(null);
|
||||||
|
setConnecting(true);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
setConnectError("No target specified");
|
||||||
|
setConnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
|
||||||
|
const url = new URL(proxyAddress);
|
||||||
|
url.searchParams.set("host", target.ip ?? "");
|
||||||
|
url.searchParams.set("port", String(target.port ?? 22));
|
||||||
|
url.searchParams.set("username", form.username);
|
||||||
|
url.searchParams.set("authToken", target.authToken ?? "");
|
||||||
|
|
||||||
|
const ws = new WebSocket(url.toString(), ["ssh"]);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
// Send credentials as the first frame so the proxy can complete
|
||||||
|
// SSH authentication before piping pty data.
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "auth",
|
||||||
|
password: form.password,
|
||||||
|
privateKey: form.privateKey
|
||||||
|
})
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setConnecting(false);
|
||||||
|
setConnected(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
if (typeof evt.data === "string") {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(evt.data as string) as {
|
||||||
|
type: string;
|
||||||
|
data?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (msg.type === "data" && msg.data) {
|
||||||
|
xtermRef.current?.write(msg.data);
|
||||||
|
} else if (msg.type === "error") {
|
||||||
|
xtermRef.current?.writeln(
|
||||||
|
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
xtermRef.current?.write(evt.data);
|
||||||
|
}
|
||||||
|
} else if (evt.data instanceof Blob) {
|
||||||
|
evt.data.text().then((t) => xtermRef.current?.write(t));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setConnecting(false);
|
||||||
|
setConnected(false);
|
||||||
|
setConnectError("WebSocket connection failed");
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (evt) => {
|
||||||
|
setConnecting(false);
|
||||||
|
setConnected(false);
|
||||||
|
xtermRef.current?.writeln(
|
||||||
|
`\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
wsRef.current?.close();
|
||||||
|
xtermRef.current?.dispose();
|
||||||
|
xtermRef.current = null;
|
||||||
|
setConnected(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{!connected && (
|
||||||
|
<div className="mx-auto max-w-2xl p-6">
|
||||||
|
<h1 className="mb-4 text-2xl font-semibold">SSH</h1>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label="Username" id="username">
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
username: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="root"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Password" id="password">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
password: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
form.privateKey
|
||||||
|
? "Optional with key auth"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Private Key (optional)" id="privateKey">
|
||||||
|
<Textarea
|
||||||
|
id="privateKey"
|
||||||
|
value={form.privateKey}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
privateKey: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Paste your private key here (PEM format)…"
|
||||||
|
rows={5}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Upload key file
|
||||||
|
</Button>
|
||||||
|
{form.privateKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-muted-foreground underline"
|
||||||
|
onClick={() =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
privateKey: ""
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept=".pem,.key,.pub,*"
|
||||||
|
onChange={handleKeyFile}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{connectError && (
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{connectError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={connect}
|
||||||
|
loading={connecting}
|
||||||
|
disabled={
|
||||||
|
!form.username ||
|
||||||
|
(!form.password && !form.privateKey)
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{connecting ? "Connecting..." : "Connect"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connected && (
|
||||||
|
<div className="flex h-screen flex-col bg-neutral-900">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={disconnect}
|
||||||
|
>
|
||||||
|
Terminate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={terminalRef}
|
||||||
|
className="flex-1 overflow-hidden"
|
||||||
|
style={{ minHeight: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { priv } from "@app/lib/api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||||
|
import SshClient from "./SshClient";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "SSH"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SshPage() {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get("host") || "";
|
||||||
|
const hostname = host.split(":")[0];
|
||||||
|
|
||||||
|
let target: { ip: string; port: number; authToken: string } | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||||
|
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||||
|
);
|
||||||
|
target = res.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching browser target:", error);
|
||||||
|
error = "No resource found for this domain";
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SshClient target={target} error={error} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
|
||||||
|
type Target = {
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
authToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VncClient({
|
||||||
|
target,
|
||||||
|
error
|
||||||
|
}: {
|
||||||
|
target: Target | null;
|
||||||
|
error: string | null;
|
||||||
|
}) {
|
||||||
|
const STORAGE_KEY = "pangolin_vnc_credentials";
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) return JSON.parse(saved) as FormState;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { password: "" };
|
||||||
|
});
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const rfbRef = useRef<any>(null);
|
||||||
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disconnect and clean up the RFB instance.
|
||||||
|
const disconnect = () => {
|
||||||
|
if (rfbRef.current) {
|
||||||
|
rfbRef.current.disconnect();
|
||||||
|
rfbRef.current = null;
|
||||||
|
}
|
||||||
|
setConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => disconnect();
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
if (!target) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "No target",
|
||||||
|
description: "No resource target is available"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!screenRef.current) return;
|
||||||
|
|
||||||
|
// Disconnect any existing session first.
|
||||||
|
disconnect();
|
||||||
|
|
||||||
|
// noVNC has no ESM default export — import the module dynamically to
|
||||||
|
// keep it out of the server bundle, then grab the default export.
|
||||||
|
let RFB: new (
|
||||||
|
target: HTMLElement,
|
||||||
|
url: string,
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
) => unknown;
|
||||||
|
try {
|
||||||
|
// @ts-expect-error — @novnc/novnc ships plain JS with no bundled types
|
||||||
|
const mod = await import("@novnc/novnc");
|
||||||
|
RFB = mod.default ?? mod;
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to load noVNC",
|
||||||
|
description: `${err}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the proxy WebSocket URL:
|
||||||
|
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
|
||||||
|
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
|
||||||
|
const base = proxyAddress.replace(/\/$/, "");
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
host: target.ip,
|
||||||
|
port: String(target.port),
|
||||||
|
authToken: target.authToken
|
||||||
|
});
|
||||||
|
const wsUrl = `${base}?${params.toString()}`;
|
||||||
|
|
||||||
|
// Clear the container so noVNC gets a clean mount point.
|
||||||
|
screenRef.current.innerHTML = "";
|
||||||
|
|
||||||
|
const options: Record<string, unknown> = {};
|
||||||
|
if (form.password) {
|
||||||
|
options.credentials = { password: form.password };
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const rfb: any = new RFB(screenRef.current, wsUrl, options);
|
||||||
|
|
||||||
|
rfb.scaleViewport = true;
|
||||||
|
rfb.resizeSession = true;
|
||||||
|
|
||||||
|
rfb.addEventListener("connect", () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
rfb.addEventListener(
|
||||||
|
"disconnect",
|
||||||
|
(e: { detail: { clean: boolean } }) => {
|
||||||
|
rfbRef.current = null;
|
||||||
|
setConnected(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
rfb.addEventListener(
|
||||||
|
"securityfailure",
|
||||||
|
(e: { detail: { status: number; reason?: string } }) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Authentication failed",
|
||||||
|
description: e.detail.reason ?? `Status ${e.detail.status}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
rfbRef.current = rfb;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<p className="text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{!connected && (
|
||||||
|
<div className="mx-auto max-w-2xl p-6">
|
||||||
|
<h1 className="mb-4 text-2xl font-semibold">VNC</h1>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label="Password (optional)" id="password">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("password", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button onClick={connect} className="w-full">
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-screen flex-col bg-neutral-900"
|
||||||
|
style={{ display: connected ? "flex" : "none" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (rfbRef.current) {
|
||||||
|
rfbRef.current.sendCtrlAltDel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ctrl+Alt+Del
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard
|
||||||
|
?.readText()
|
||||||
|
.then((text) => {
|
||||||
|
rfbRef.current?.clipboardPasteFrom(text);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Paste clipboard
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={disconnect}
|
||||||
|
>
|
||||||
|
Terminate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* noVNC mounts a <canvas> inside this div */}
|
||||||
|
<div
|
||||||
|
ref={screenRef}
|
||||||
|
className="flex-1 overflow-hidden"
|
||||||
|
style={{ background: "#000" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { priv } from "@app/lib/api";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { GetBrowserTargetResponse } from "@server/routers/resource";
|
||||||
|
import VncClient from "./VncClient";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "VNC"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function VncPage() {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get("host") || "";
|
||||||
|
const hostname = host.split(":")[0];
|
||||||
|
|
||||||
|
let target: { ip: string; port: number; authToken: string } | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||||
|
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||||
|
);
|
||||||
|
target = res.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching browser target:", error);
|
||||||
|
error = "No resource found for this domain";
|
||||||
|
}
|
||||||
|
|
||||||
|
return <VncClient target={target} error={error} />;
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ export type ResourceRow = {
|
|||||||
name: string;
|
name: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
browserAccessType: string | null;
|
||||||
authState: string;
|
authState: string;
|
||||||
http: boolean;
|
http: boolean;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
@@ -411,6 +412,12 @@ export default function ProxyResourcesTable({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
|
if (
|
||||||
|
!resourceRow.http ||
|
||||||
|
resourceRow.browserAccessType !== "http"
|
||||||
|
) {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<TargetStatusCell
|
<TargetStatusCell
|
||||||
targets={resourceRow.targets}
|
targets={resourceRow.targets}
|
||||||
@@ -439,6 +446,12 @@ export default function ProxyResourcesTable({
|
|||||||
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
|
if (
|
||||||
|
!resourceRow.http ||
|
||||||
|
resourceRow.browserAccessType !== "http"
|
||||||
|
) {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
|
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+3
@@ -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" {}
|
||||||
Reference in New Issue
Block a user