diff --git a/package-lock.json b/package-lock.json
index 7af026e6..c5a6bd57 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,10 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@types/bcryptjs": "^2.4.6",
+ "@uiw/codemirror-extensions-hyper-link": "^4.24.1",
+ "@uiw/codemirror-extensions-langs": "^4.24.1",
+ "@uiw/codemirror-themes": "^4.24.1",
+ "@uiw/react-codemirror": "^4.24.1",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
@@ -37,7 +41,9 @@
"chalk": "^4.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "cookie-parser": "^1.4.7",
"cors": "^2.8.5",
+ "dotenv": "^17.2.0",
"drizzle-orm": "^0.44.3",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
@@ -91,6 +97,423 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.27.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
+ "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.18.6",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
+ "integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz",
+ "integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.4.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-angular": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz",
+ "integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.1.2",
+ "@codemirror/language": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.3"
+ }
+ },
+ "node_modules/@codemirror/lang-cpp": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
+ "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/cpp": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-css": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
+ "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.2",
+ "@lezer/css": "^1.1.7"
+ }
+ },
+ "node_modules/@codemirror/lang-go": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
+ "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/go": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-html": {
+ "version": "6.4.9",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
+ "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/lang-css": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.0.0",
+ "@codemirror/language": "^6.4.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/css": "^1.1.0",
+ "@lezer/html": "^1.3.0"
+ }
+ },
+ "node_modules/@codemirror/lang-java": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
+ "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/java": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-javascript": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
+ "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/javascript": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-json": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
+ "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/json": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-less": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz",
+ "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-css": "^6.2.0",
+ "@codemirror/language": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-lezer": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-lezer/-/lang-lezer-6.0.2.tgz",
+ "integrity": "sha512-mcVAf8lw+sCfSlr2ivMqV8JtNmOQjSXdA1vHKRtoW0OZsz1k6qhF+DX0K2TbWlAThqiGgRkRSZyYzIoEtKB2uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/lezer": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-liquid": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.2.3.tgz",
+ "integrity": "sha512-yeN+nMSrf/lNii3FJxVVEGQwFG0/2eDyH6gNOj+TGCa0hlNO4bhQnoO5ISnd7JOG+7zTEcI/GOoyraisFVY7jQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.1"
+ }
+ },
+ "node_modules/@codemirror/lang-markdown": {
+ "version": "6.3.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.3.tgz",
+ "integrity": "sha512-1fn1hQAPWlSSMCvnF810AkhWpNLkJpl66CRfIy3vVl20Sl4NwChkorCHqpMtNbXr1EuMJsrDnhEpjZxKZ2UX3A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.7.1",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.3.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.2.1",
+ "@lezer/markdown": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-php": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
+ "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/php": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-python": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
+ "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.3.2",
+ "@codemirror/language": "^6.8.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.1",
+ "@lezer/python": "^1.1.4"
+ }
+ },
+ "node_modules/@codemirror/lang-rust": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
+ "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/rust": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-sass": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz",
+ "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-css": "^6.2.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.2",
+ "@lezer/sass": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-sql": {
+ "version": "6.9.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.9.0.tgz",
+ "integrity": "sha512-xmtpWqKSgum1B1J3Ro6rf7nuPqf2+kJQg5SjrofCAcyCThOe0ihSktSoXfXuhQBnwx1QbmreBbLJM5Jru6zitg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-vue": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz",
+ "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.1.2",
+ "@codemirror/language": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.1"
+ }
+ },
+ "node_modules/@codemirror/lang-wast": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz",
+ "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-xml": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
+ "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.4.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/xml": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-yaml": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz",
+ "integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.2.0",
+ "@lezer/lr": "^1.0.0",
+ "@lezer/yaml": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.11.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz",
+ "integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.1.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/language-data": {
+ "version": "6.5.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.1.tgz",
+ "integrity": "sha512-0sWxeUSNlBr6OmkqybUTImADFUP0M3P0IiSde4nc24bz/6jIYzqYSgkOSLS+CBIoW1vU8Q9KUWXscBXeoMVC9w==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-angular": "^0.1.0",
+ "@codemirror/lang-cpp": "^6.0.0",
+ "@codemirror/lang-css": "^6.0.0",
+ "@codemirror/lang-go": "^6.0.0",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/lang-java": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.0.0",
+ "@codemirror/lang-json": "^6.0.0",
+ "@codemirror/lang-less": "^6.0.0",
+ "@codemirror/lang-liquid": "^6.0.0",
+ "@codemirror/lang-markdown": "^6.0.0",
+ "@codemirror/lang-php": "^6.0.0",
+ "@codemirror/lang-python": "^6.0.0",
+ "@codemirror/lang-rust": "^6.0.0",
+ "@codemirror/lang-sass": "^6.0.0",
+ "@codemirror/lang-sql": "^6.0.0",
+ "@codemirror/lang-vue": "^0.1.1",
+ "@codemirror/lang-wast": "^6.0.0",
+ "@codemirror/lang-xml": "^6.0.0",
+ "@codemirror/lang-yaml": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/legacy-modes": "^6.4.0"
+ }
+ },
+ "node_modules/@codemirror/legacy-modes": {
+ "version": "6.5.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.1.tgz",
+ "integrity": "sha512-DJYQQ00N1/KdESpZV7jg9hafof/iBNp9h7TYo1SLMk86TWl9uDsVdho2dzd81K+v4retmK6mdC7WpuOQDytQqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.8.5",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
+ "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.35.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.5.11",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
+ "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
+ "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/theme-one-dark": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.38.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
+ "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.5.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -848,6 +1271,218 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lezer/common": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
+ "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/cpp": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.3.tgz",
+ "integrity": "sha512-ykYvuFQKGsRi6IcE+/hCSGUhb/I4WPjd3ELhEblm2wS2cOznDFzO+ubK2c+ioysOnlZ3EduV+MVQFCPzAIoY3w==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/css": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
+ "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/go": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
+ "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
+ "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/html": {
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
+ "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/java": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz",
+ "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/javascript": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz",
+ "integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.1.3",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/json": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
+ "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/lezer": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@lezer/lezer/-/lezer-1.1.2.tgz",
+ "integrity": "sha512-O8yw3CxPhzYHB1hvwbdozjnAslhhR8A5BH7vfEMof0xk3p+/DFDfZkA9Tde6J+88WgtwaHy4Sy6ThZSkaI0Evw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
+ "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/markdown": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.3.tgz",
+ "integrity": "sha512-kfw+2uMrQ/wy/+ONfrH83OkdFNM0ye5Xq96cLlaCy7h5UT9FO54DU4oRoIc0CSBh5NWmWuiIJA7NGLMJbQ+Oxg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/php": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.3.tgz",
+ "integrity": "sha512-NDwgktd5qh/EfEn7Dogf2N6eNnC5WPJ5NslB8nKhgXSuH2uSJamCEom1g4VGo+ibfoADK8D69NMCMhuuYbVskg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.1.0"
+ }
+ },
+ "node_modules/@lezer/python": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
+ "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/rust": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz",
+ "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/sass": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz",
+ "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/xml": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
+ "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/yaml": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.3.tgz",
+ "integrity": "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.4.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+ "license": "MIT"
+ },
+ "node_modules/@nextjournal/lang-clojure": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@nextjournal/lang-clojure/-/lang-clojure-1.0.0.tgz",
+ "integrity": "sha512-gOCV71XrYD0DhwGoPMWZmZ0r92/lIHsqQu9QWdpZYYBwiChNwMO4sbVMP7eTuAqffFB2BTtCSC+1skSH9d3bNg==",
+ "license": "ISC",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@nextjournal/lezer-clojure": "1.0.0"
+ }
+ },
+ "node_modules/@nextjournal/lezer-clojure": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@nextjournal/lezer-clojure/-/lezer-clojure-1.0.0.tgz",
+ "integrity": "sha512-VZyuGu4zw5mkTOwQBTaGVNWmsOZAPw5ZRxu1/Knk/Xfs7EDBIogwIs5UXTYkuECX5ZQB8eOB+wKA2pc7VyqaZQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@lezer/lr": "^1.0.0"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1918,6 +2553,67 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
+ "node_modules/@replit/codemirror-lang-csharp": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-csharp/-/codemirror-lang-csharp-6.2.0.tgz",
+ "integrity": "sha512-6utbaWkoymhoAXj051mkRp+VIJlpwUgCX9Toevz3YatiZsz512fw3OVCedXQx+WcR0wb6zVHjChnuxqfCLtFVQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@replit/codemirror-lang-nix": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-nix/-/codemirror-lang-nix-6.0.1.tgz",
+ "integrity": "sha512-lvzjoYn9nfJzBD5qdm3Ut6G3+Or2wEacYIDJ49h9+19WSChVnxv4ojf+rNmQ78ncuxIt/bfbMvDLMeMP0xze6g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@replit/codemirror-lang-solidity": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-solidity/-/codemirror-lang-solidity-6.0.2.tgz",
+ "integrity": "sha512-/dpTVH338KFV6SaDYYSadkB4bI/0B0QRF/bkt1XS3t3QtyR49mn6+2k0OUQhvt2ZSO7kt10J+OPilRAtgbmX0w==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/highlight": "^1.2.0"
+ },
+ "peerDependencies": {
+ "@codemirror/language": "^6.0.0"
+ }
+ },
+ "node_modules/@replit/codemirror-lang-svelte": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-svelte/-/codemirror-lang-svelte-6.0.0.tgz",
+ "integrity": "sha512-U2OqqgMM6jKelL0GNWbAmqlu1S078zZNoBqlJBW+retTc5M4Mha6/Y2cf4SVg6ddgloJvmcSpt4hHrVoM4ePRA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/lang-css": "^6.0.1",
+ "@codemirror/lang-html": "^6.2.0",
+ "@codemirror/lang-javascript": "^6.1.1",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/javascript": "^1.2.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.11",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
@@ -3186,6 +3882,133 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@uiw/codemirror-extensions-basic-setup": {
+ "version": "4.24.1",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.24.1.tgz",
+ "integrity": "sha512-o1m1a8eUS3fWERMbDFvN8t8sZUFPgDKNemmlQ5Ot2vKm+Ax84lKP1dhEFgkiOaZ1bDHk4T5h6SjHuTghrJHKww==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@codemirror/autocomplete": ">=6.0.0",
+ "@codemirror/commands": ">=6.0.0",
+ "@codemirror/language": ">=6.0.0",
+ "@codemirror/lint": ">=6.0.0",
+ "@codemirror/search": ">=6.0.0",
+ "@codemirror/state": ">=6.0.0",
+ "@codemirror/view": ">=6.0.0"
+ }
+ },
+ "node_modules/@uiw/codemirror-extensions-hyper-link": {
+ "version": "4.24.1",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-hyper-link/-/codemirror-extensions-hyper-link-4.24.1.tgz",
+ "integrity": "sha512-qf3docpmsHHM0OKLO5m2Fc8t4G+pr1+k9QwrhlM2iolku/INbz+B1JzbRcSU0ow1EcxKtHRtCFE4Lnu6DwP7CQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@codemirror/state": ">=6.0.0",
+ "@codemirror/view": ">=6.0.0"
+ }
+ },
+ "node_modules/@uiw/codemirror-extensions-langs": {
+ "version": "4.24.1",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-langs/-/codemirror-extensions-langs-4.24.1.tgz",
+ "integrity": "sha512-8Q33k/UhNni2u5VvAHD+2mxe4hNIqZTNySSUcnJ7urV2lXXau+0fimsQlI+GQLF7gy5F1BUzIi+yvOMrEPK9Ig==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-angular": "^0.1.0",
+ "@codemirror/lang-cpp": "^6.0.0",
+ "@codemirror/lang-css": "^6.2.0",
+ "@codemirror/lang-html": "^6.4.0",
+ "@codemirror/lang-java": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.1.0",
+ "@codemirror/lang-json": "^6.0.0",
+ "@codemirror/lang-less": "^6.0.1",
+ "@codemirror/lang-lezer": "^6.0.0",
+ "@codemirror/lang-liquid": "^6.0.1",
+ "@codemirror/lang-markdown": "^6.1.0",
+ "@codemirror/lang-php": "^6.0.0",
+ "@codemirror/lang-python": "^6.1.0",
+ "@codemirror/lang-rust": "^6.0.0",
+ "@codemirror/lang-sass": "^6.0.1",
+ "@codemirror/lang-sql": "^6.4.0",
+ "@codemirror/lang-vue": "^0.1.1",
+ "@codemirror/lang-wast": "^6.0.0",
+ "@codemirror/lang-xml": "^6.0.0",
+ "@codemirror/language-data": ">=6.0.0",
+ "@codemirror/legacy-modes": ">=6.0.0",
+ "@nextjournal/lang-clojure": "^1.0.0",
+ "@replit/codemirror-lang-csharp": "^6.1.0",
+ "@replit/codemirror-lang-nix": "^6.0.1",
+ "@replit/codemirror-lang-solidity": "^6.0.1",
+ "@replit/codemirror-lang-svelte": "^6.0.0",
+ "codemirror-lang-mermaid": "^0.5.0"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@codemirror/language-data": ">=6.0.0",
+ "@codemirror/legacy-modes": ">=6.0.0"
+ }
+ },
+ "node_modules/@uiw/codemirror-themes": {
+ "version": "4.24.1",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.24.1.tgz",
+ "integrity": "sha512-hduBbFNiWNW6nYa2/giKQ9YpzhWNw87BGpCjC+cXYMZ7bCD6q5DC6Hw+7z7ZwSzEaOQvV91lmirOjJ8hn9+pkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@codemirror/language": ">=6.0.0",
+ "@codemirror/state": ">=6.0.0",
+ "@codemirror/view": ">=6.0.0"
+ }
+ },
+ "node_modules/@uiw/react-codemirror": {
+ "version": "4.24.1",
+ "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.24.1.tgz",
+ "integrity": "sha512-BivF4NLqbuBQK5gPVhSkOARi9nPXw8X5r25EnInPeY+I9l1dfEX8O9V6+0xHTlGHyUo0cNfGEF9t1KHEicUfJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.6",
+ "@codemirror/commands": "^6.1.0",
+ "@codemirror/state": "^6.1.1",
+ "@codemirror/theme-one-dark": "^6.0.0",
+ "@uiw/codemirror-extensions-basic-setup": "4.24.1",
+ "codemirror": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.11.0",
+ "@codemirror/state": ">=6.0.0",
+ "@codemirror/theme-one-dark": ">=6.0.0",
+ "@codemirror/view": ">=6.0.0",
+ "codemirror": ">=6.0.0",
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/@vitejs/plugin-react-swc": {
"version": "3.10.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.10.2.tgz",
@@ -3726,6 +4549,32 @@
"node": ">=6"
}
},
+ "node_modules/codemirror": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
+ "node_modules/codemirror-lang-mermaid": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/codemirror-lang-mermaid/-/codemirror-lang-mermaid-0.5.0.tgz",
+ "integrity": "sha512-Taw/2gPCyNArQJCxIP/HSUif+3zrvD+6Ugt7KJZ2dUKou/8r3ZhcfG8krNTZfV2iu8AuGnymKuo7bLPFyqsh/A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.9.0",
+ "@lezer/highlight": "^1.1.6",
+ "@lezer/lr": "^1.3.10"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3793,6 +4642,25 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-parser/node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -3836,6 +4704,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3949,6 +4823,18 @@
"node": ">=0.3.1"
}
},
+ "node_modules/dotenv": {
+ "version": "17.2.0",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz",
+ "integrity": "sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/drizzle-orm": {
"version": "0.44.3",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.3.tgz",
@@ -6636,6 +7522,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/style-mod": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
+ "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
+ "license": "MIT"
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -7175,6 +8067,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/package.json b/package.json
index 04c48aac..5df3cddb 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,10 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@types/bcryptjs": "^2.4.6",
+ "@uiw/codemirror-extensions-hyper-link": "^4.24.1",
+ "@uiw/codemirror-extensions-langs": "^4.24.1",
+ "@uiw/codemirror-themes": "^4.24.1",
+ "@uiw/react-codemirror": "^4.24.1",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
@@ -41,7 +45,9 @@
"chalk": "^4.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "cookie-parser": "^1.4.7",
"cors": "^2.8.5",
+ "dotenv": "^17.2.0",
"drizzle-orm": "^0.44.3",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
diff --git a/src/apps/Config Editor/ConfigCodeEditor.tsx b/src/apps/Config Editor/ConfigCodeEditor.tsx
new file mode 100644
index 00000000..75e43a0e
--- /dev/null
+++ b/src/apps/Config Editor/ConfigCodeEditor.tsx
@@ -0,0 +1,128 @@
+import React, { useState, useEffect } from "react";
+import CodeMirror from "@uiw/react-codemirror";
+import type { LanguageName } from '@uiw/codemirror-extensions-langs';
+import { loadLanguage } from '@uiw/codemirror-extensions-langs';
+import { hyperLink } from '@uiw/codemirror-extensions-hyper-link';
+import { EditorView } from '@codemirror/view';
+import { createTheme } from '@uiw/codemirror-themes';
+import { tags as t } from '@lezer/highlight';
+
+interface ConfigCodeEditorProps {
+ content: string;
+ fileName: string;
+ onContentChange: (value: string) => void;
+}
+
+export function ConfigCodeEditor({content, fileName, onContentChange,}: ConfigCodeEditorProps) {
+ const langName = getLanguageName(fileName);
+ const langExt = langName ? loadLanguage(langName) : null;
+ const extensions = [hyperLink];
+ if (langExt) extensions.unshift(langExt);
+
+ // Custom theme based on built-in 'dark', with overrides for background, gutter, and font size
+ const customDarkTheme = [
+ createTheme({
+ theme: 'dark',
+ settings: {
+ background: '#09090b',
+ gutterBackground: '#18181b',
+ gutterForeground: 'oklch(0.985 0 0)',
+ foreground: '#e0e0e0',
+ caret: '#ffcc00',
+ selection: '#22223b99',
+ selectionMatch: '#22223b66',
+ lineHighlight: '#18181b',
+ gutterBorder: '1px solid #22223b',
+ },
+ styles: [
+ { tag: t.keyword, color: '#ff5370' }, // red
+ { tag: t.string, color: '#c3e88d' }, // green
+ { tag: t.number, color: '#82aaff' }, // blue
+ { tag: t.comment, color: '#5c6370' }, // gray
+ { tag: t.variableName, color: '#f78c6c' }, // orange
+ { tag: t.function(t.variableName), color: '#82aaff' }, // blue
+ { tag: t.typeName, color: '#ffcb6b' }, // yellow
+ { tag: t.className, color: '#ffcb6b' }, // yellow
+ { tag: t.definition(t.typeName), color: '#ffcb6b' }, // yellow
+ { tag: t.operator, color: '#89ddff' }, // cyan
+ { tag: t.bool, color: '#f78c6c' }, // orange
+ { tag: t.null, color: '#f78c6c' }, // orange
+ { tag: t.tagName, color: '#ff5370' }, // red
+ { tag: t.attributeName, color: '#c792ea' }, // purple
+ { tag: t.angleBracket, color: '#89ddff' }, // cyan
+ ],
+ }),
+ EditorView.theme({
+ '&': {
+ fontSize: '13px',
+ },
+ }),
+ ];
+
+ function getLanguageName(filename: string): LanguageName | undefined {
+ const ext = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase();
+ switch (ext) {
+ case 'js':
+ case 'mjs':
+ case 'cjs': return 'javascript';
+ case 'ts': return 'typescript';
+ case 'tsx': return 'tsx';
+ case 'json': return 'json';
+ case 'css': return 'css';
+ case 'html':
+ case 'htm': return 'html';
+ case 'md': return 'markdown';
+ case 'py': return 'python';
+ case 'sh': return 'shell';
+ case 'yaml':
+ case 'yml': return 'yaml';
+ case 'go': return 'go';
+ case 'java': return 'java';
+ case 'c': return 'c';
+ case 'cpp':
+ case 'cc':
+ case 'cxx': return 'cpp';
+ case 'rs': return 'rust';
+ case 'php': return 'php';
+ case 'rb': return 'ruby';
+ case 'swift': return 'swift';
+ case 'lua': return 'lua';
+ case 'xml': return 'xml';
+ case 'sql': return 'sql';
+ default: return undefined;
+ }
+ }
+
+ useEffect(() => {
+ document.body.style.overflowX = 'hidden';
+ return () => {
+ document.body.style.overflowX = '';
+ };
+ }, []);
+
+ return (
+
+
+ onContentChange(value)}
+ theme={customDarkTheme}
+ height="100%"
+ basicSetup={{ lineNumbers: true }}
+ style={{ minHeight: '100%', minWidth: '100%', flex: 1 }}
+ />
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/apps/Config Editor/ConfigEditor.tsx b/src/apps/Config Editor/ConfigEditor.tsx
index db0ddc8b..dafe1efa 100644
--- a/src/apps/Config Editor/ConfigEditor.tsx
+++ b/src/apps/Config Editor/ConfigEditor.tsx
@@ -1,18 +1,33 @@
+import React, { useState } from "react";
import {ConfigEditorSidebar} from "@/apps/Config Editor/ConfigEditorSidebar.tsx";
-import React from "react";
+import {ConfigCodeEditor} from "@/apps/Config Editor/ConfigCodeEditor.tsx";
+import {ConfigTopbar} from "@/apps/Config Editor/ConfigTopbar.tsx";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
-export function ConfigEditor({ onSelectView }: ConfigEditorProps): React.ReactElement {
+export function ConfigEditor({onSelectView}: ConfigEditorProps): React.ReactElement {
+ const [content, setContent] = useState("");
+ const [fileName, setFileName] = useState("config.yaml");
return (
-
-
-
- Config Editor
+
+ {/* Sidebar - fixed width, full height */}
+
+
+
+ {/* Topbar - fixed height, full width minus sidebar */}
+
+
+
+ {/* Editor area - fills remaining space, with padding for sidebar and topbar */}
+
+
+
)
}
\ No newline at end of file
diff --git a/src/apps/Config Editor/ConfigTopbar.tsx b/src/apps/Config Editor/ConfigTopbar.tsx
new file mode 100644
index 00000000..8d4975b0
--- /dev/null
+++ b/src/apps/Config Editor/ConfigTopbar.tsx
@@ -0,0 +1,16 @@
+import React from "react";
+
+export function ConfigTopbar(): React.ReactElement {
+ return (
+
+ test
+
+ )
+}
\ No newline at end of file
diff --git a/src/apps/Homepage/HomepageAuth.tsx b/src/apps/Homepage/HomepageAuth.tsx
index f07747e5..ec3e0e8a 100644
--- a/src/apps/Homepage/HomepageAuth.tsx
+++ b/src/apps/Homepage/HomepageAuth.tsx
@@ -211,6 +211,14 @@ export function HomepageAuth({ className, setLoggedIn, setIsAdmin, setUsername,
>
Discord
+
+
diff --git a/src/apps/SSH Tunnel/SSHTunnel.tsx b/src/apps/SSH Tunnel/SSHTunnel.tsx
index a095381c..0bafb493 100644
--- a/src/apps/SSH Tunnel/SSHTunnel.tsx
+++ b/src/apps/SSH Tunnel/SSHTunnel.tsx
@@ -1,18 +1,225 @@
-import React from "react";
-import {SSHTunnelSidebar} from "@/apps/SSH Tunnel/SSHTunnelSidebar.tsx";
+import React, { useState, useEffect, useCallback } from "react";
+import { SSHTunnelSidebar } from "@/apps/SSH Tunnel/SSHTunnelSidebar.tsx";
+import { SSHTunnelViewer } from "@/apps/SSH Tunnel/SSHTunnelViewer.tsx";
+import axios from "axios";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
-export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
- return (
-
-
+interface SSHTunnel {
+ id: number;
+ name: string;
+ folder: string;
+ sourcePort: number;
+ endpointPort: number;
+ sourceIP: string;
+ sourceSSHPort: number;
+ sourceUsername: string;
+ sourcePassword: string;
+ sourceAuthMethod: string;
+ sourceSSHKey: string;
+ sourceKeyPassword: string;
+ sourceKeyType: string;
+ endpointIP: string;
+ endpointSSHPort: number;
+ endpointUsername: string;
+ endpointPassword: string;
+ endpointAuthMethod: string;
+ endpointSSHKey: string;
+ endpointKeyPassword: string;
+ endpointKeyType: string;
+ maxRetries: number;
+ retryInterval: number;
+ connectionState: string;
+ autoStart: boolean;
+ isPinned: boolean;
+}
- SSH Tunnel
+export function SSHTunnel({ onSelectView }: ConfigEditorProps): React.ReactElement {
+ const [tunnels, setTunnels] = useState
([]);
+ const [tunnelsLoading, setTunnelsLoading] = useState(false);
+ const [tunnelsError, setTunnelsError] = useState(null);
+ const [tunnelStatusMap, setTunnelStatusMap] = useState>({});
+ const sidebarRef = React.useRef(null);
+
+ const fetchTunnels = useCallback(async () => {
+ setTunnelsLoading(true);
+ setTunnelsError(null);
+ try {
+ const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
+ const res = await axios.get(
+ (window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + '/ssh_tunnel/tunnel',
+ { headers: { Authorization: `Bearer ${jwt}` } }
+ );
+ const tunnelData = res.data || [];
+ setTunnels(tunnelData.map((tunnel: any) => ({
+ id: tunnel.id,
+ name: tunnel.name,
+ folder: tunnel.folder || '',
+ sourcePort: tunnel.sourcePort,
+ endpointPort: tunnel.endpointPort,
+ sourceIP: tunnel.sourceIP,
+ sourceSSHPort: tunnel.sourceSSHPort,
+ sourceUsername: tunnel.sourceUsername || '',
+ sourcePassword: tunnel.sourcePassword || '',
+ sourceAuthMethod: tunnel.sourceAuthMethod || 'password',
+ sourceSSHKey: tunnel.sourceSSHKey || '',
+ sourceKeyPassword: tunnel.sourceKeyPassword || '',
+ sourceKeyType: tunnel.sourceKeyType || '',
+ endpointIP: tunnel.endpointIP,
+ endpointSSHPort: tunnel.endpointSSHPort,
+ endpointUsername: tunnel.endpointUsername || '',
+ endpointPassword: tunnel.endpointPassword || '',
+ endpointAuthMethod: tunnel.endpointAuthMethod || 'password',
+ endpointSSHKey: tunnel.endpointSSHKey || '',
+ endpointKeyPassword: tunnel.endpointKeyPassword || '',
+ endpointKeyType: tunnel.endpointKeyType || '',
+ maxRetries: tunnel.maxRetries || 3,
+ retryInterval: tunnel.retryInterval || 5000,
+ connectionState: tunnel.connectionState || 'DISCONNECTED',
+ autoStart: tunnel.autoStart || false,
+ isPinned: tunnel.isPinned || false
+ })));
+ } catch (err: any) {
+ setTunnelsError('Failed to load tunnels');
+ } finally {
+ setTunnelsLoading(false);
+ }
+ }, []);
+
+ // Poll backend for tunnel statuses
+ const fetchTunnelStatuses = useCallback(async () => {
+ try {
+ const res = await axios.get('http://localhost:8083/status');
+ setTunnelStatusMap(res.data || {});
+ } catch (err) {
+ // Optionally handle error
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchTunnels();
+ const interval = setInterval(fetchTunnels, 10000);
+ return () => clearInterval(interval);
+ }, [fetchTunnels]);
+
+ useEffect(() => {
+ fetchTunnelStatuses();
+ const interval = setInterval(fetchTunnelStatuses, 500);
+ return () => clearInterval(interval);
+ }, [fetchTunnelStatuses]);
+
+ // Merge backend status into tunnels
+ const tunnelsWithStatus = tunnels.map(tunnel => {
+ const status = tunnelStatusMap[tunnel.name] || {};
+ return {
+ ...tunnel,
+ connectionState: status.status ? status.status.toUpperCase() : tunnel.connectionState,
+ statusReason: status.reason || '',
+ statusErrorType: status.errorType || '',
+ statusManualDisconnect: status.manualDisconnect || false,
+ statusRetryCount: status.retryCount,
+ statusMaxRetries: status.maxRetries,
+ statusNextRetryIn: status.nextRetryIn,
+ statusRetryExhausted: status.retryExhausted,
+ };
+ });
+
+ const handleConnect = async (tunnelId: string) => {
+ // Immediately set to CONNECTING for instant UI feedback
+ setTunnels(prev => prev.map(t =>
+ t.id.toString() === tunnelId
+ ? { ...t, connectionState: "CONNECTING" }
+ : t
+ ));
+ const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
+ if (!tunnel) return;
+ try {
+ await axios.post('http://localhost:8083/connect', {
+ ...tunnel,
+ name: tunnel.name
+ });
+ // No need to update state here; polling will update real status
+ } catch (err) {
+ // Optionally handle error
+ }
+ };
+
+ const handleDisconnect = async (tunnelId: string) => {
+ // Immediately set to DISCONNECTING for instant UI feedback
+ setTunnels(prev => prev.map(t =>
+ t.id.toString() === tunnelId
+ ? { ...t, connectionState: "DISCONNECTING" }
+ : t
+ ));
+ const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
+ if (!tunnel) return;
+ try {
+ await axios.post('http://localhost:8083/disconnect', {
+ tunnelName: tunnel.name
+ });
+ // No need to update state here; polling will update real status
+ } catch (err) {
+ // Optionally handle error
+ }
+ };
+
+ const handleDeleteTunnel = async (tunnelId: string) => {
+ try {
+ const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
+ await axios.delete(
+ (window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`,
+ { headers: { Authorization: `Bearer ${jwt}` } }
+ );
+ fetchTunnels();
+ } catch (err: any) {
+ console.error('Failed to delete tunnel:', err);
+ }
+ };
+
+ const handleEditTunnel = async (tunnelId: string, data: any) => {
+ try {
+ const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
+ await axios.put(
+ (window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${tunnelId}`,
+ data,
+ { headers: { Authorization: `Bearer ${jwt}` } }
+ );
+ fetchTunnels();
+ } catch (err: any) {
+ console.error('Failed to edit tunnel:', err);
+ }
+ };
+
+ const handleEditTunnelClick = (tunnelId: string) => {
+ // Find the tunnel data and pass it to the sidebar
+ const tunnel = tunnels.find(t => t.id.toString() === tunnelId);
+ if (tunnel && sidebarRef.current) {
+ // Call the sidebar's openEditSheet function
+ sidebarRef.current.openEditSheet(tunnel);
+ }
+ };
+
+ return (
+
- )
+ );
}
\ No newline at end of file
diff --git a/src/apps/SSH Tunnel/SSHTunnelObject.tsx b/src/apps/SSH Tunnel/SSHTunnelObject.tsx
new file mode 100644
index 00000000..358fd3e8
--- /dev/null
+++ b/src/apps/SSH Tunnel/SSHTunnelObject.tsx
@@ -0,0 +1,186 @@
+import React from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import { Loader2, Edit, Trash2 } from "lucide-react";
+
+const CONNECTION_STATES = {
+ DISCONNECTED: "disconnected",
+ CONNECTING: "connecting",
+ CONNECTED: "connected",
+ VERIFYING: "verifying",
+ FAILED: "failed",
+ UNSTABLE: "unstable",
+ RETRYING: "retrying",
+ DISCONNECTING: "disconnecting"
+};
+
+interface SSHTunnelObjectProps {
+ hostConfig: any;
+ onConnect?: () => void;
+ onDisconnect?: () => void;
+ onDelete?: () => void;
+ onEdit?: () => void;
+ connectionState?: keyof typeof CONNECTION_STATES;
+ isPinned?: boolean;
+}
+
+export function SSHTunnelObject({
+ hostConfig = {},
+ onConnect,
+ onDisconnect,
+ onDelete,
+ onEdit,
+ connectionState = "DISCONNECTED",
+ isPinned = false
+}: SSHTunnelObjectProps): React.ReactElement {
+ const getStatusColor = (state: keyof typeof CONNECTION_STATES) => {
+ switch (state) {
+ case "CONNECTED":
+ return "bg-green-500";
+ case "CONNECTING":
+ case "VERIFYING":
+ case "RETRYING":
+ return "bg-yellow-500";
+ case "FAILED":
+ return "bg-red-500";
+ case "UNSTABLE":
+ return "bg-orange-500";
+ default:
+ return "bg-gray-500";
+ }
+ };
+
+ const getStatusText = (state: keyof typeof CONNECTION_STATES) => {
+ switch (state) {
+ case "CONNECTED":
+ return "Connected";
+ case "CONNECTING":
+ return "Connecting";
+ case "VERIFYING":
+ return "Verifying";
+ case "FAILED":
+ return "Failed";
+ case "UNSTABLE":
+ return "Unstable";
+ case "RETRYING":
+ return "Retrying";
+ default:
+ return "Disconnected";
+ }
+ };
+
+
+
+ const isConnected = connectionState === "CONNECTED";
+ const isConnecting = ["CONNECTING", "VERIFYING", "RETRYING"].includes(connectionState);
+ const isDisconnecting = connectionState === "DISCONNECTING";
+
+ return (
+
+ {/* Hover overlay buttons */}
+
+
+
+
+
+
+
+
+
+ {isPinned && ★}
+ {hostConfig.name || "My SSH Tunnel"}
+
+
+
+
+
+
+ {getStatusText(connectionState)}
+
+
+
+
+
+
+
+ Source:
+
+ {hostConfig.source || "localhost:22"}
+
+
+
+ Endpoint:
+
+ {hostConfig.endpoint || "test:224"}
+
+
+
+
+
+ {/* Error/Status Reason */}
+ {((connectionState === "FAILED" || connectionState === "UNSTABLE") && hostConfig.statusReason) && (
+
+ {hostConfig.statusReason}
+ {typeof hostConfig.statusReason === 'string' && hostConfig.statusReason.includes('Max retries exhausted') && (
+ <>
+
+
+ Check your Docker logs for the error reason, join the Discord or create a GitHub issue for help.
+
+ >
+ )}
+
+ )}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/apps/SSH Tunnel/SSHTunnelSidebar.tsx b/src/apps/SSH Tunnel/SSHTunnelSidebar.tsx
index 86a7f792..074333d8 100644
--- a/src/apps/SSH Tunnel/SSHTunnelSidebar.tsx
+++ b/src/apps/SSH Tunnel/SSHTunnelSidebar.tsx
@@ -1,7 +1,12 @@
-import React from 'react';
+import React, { useState } from 'react';
+import { useForm, Controller } from "react-hook-form";
import {
- CornerDownLeft
+ CornerDownLeft,
+ Plus,
+ ArrowRight,
+ AlertTriangle,
+ Info
} from "lucide-react"
import {
@@ -21,13 +26,452 @@ import {
import {
Separator,
} from "@/components/ui/separator.tsx"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger
+} from "@/components/ui/sheet.tsx";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form.tsx";
+import { Input } from "@/components/ui/input.tsx";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx";
+import { Switch } from "@/components/ui/switch.tsx";
+import axios from "axios";
import Icon from "../../../public/icon.svg";
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
interface SidebarProps {
onSelectView: (view: string) => void;
+ onTunnelAdded?: () => void;
+ onEditTunnel?: (tunnelId: string, data: any) => void;
}
-export function SSHTunnelSidebar({ onSelectView }: SidebarProps): React.ReactElement {
+interface AddTunnelFormData {
+ tunnelName: string;
+ folder: string;
+ sourcePort: number;
+ endpointPort: number;
+ sourceIP: string;
+ sourceSSHPort: number;
+ sourceUsername: string;
+ sourcePassword: string;
+ sourceAuthMethod: string;
+ sourceSSHKeyFile: File | null;
+ sourceSSHKeyContent?: string;
+ sourceKeyPassword?: string;
+ sourceKeyType?: string;
+ endpointIP: string;
+ endpointSSHPort: number;
+ endpointUsername: string;
+ endpointPassword: string;
+ endpointAuthMethod: string;
+ endpointSSHKeyFile: File | null;
+ endpointSSHKeyContent?: string;
+ endpointKeyPassword?: string;
+ endpointKeyType?: string;
+ maxRetries: number;
+ retryInterval: number;
+ autoStart: boolean;
+ isPinned: boolean;
+}
+
+export const SSHTunnelSidebar = React.forwardRef<{ openEditSheet: (tunnel: any) => void }, SidebarProps>(
+ ({ onSelectView, onTunnelAdded, onEditTunnel }, ref) => {
+ const addTunnelForm = useForm({
+ defaultValues: {
+ tunnelName: 'My SSH Tunnel',
+ folder: '',
+ sourcePort: 22,
+ endpointPort: 224,
+ sourceIP: 'localhost',
+ sourceSSHPort: 22,
+ sourceUsername: 'test',
+ sourcePassword: '',
+ sourceAuthMethod: 'password',
+ sourceSSHKeyFile: null,
+ endpointIP: 'test',
+ endpointSSHPort: 22,
+ endpointUsername: 'test',
+ endpointPassword: '',
+ endpointAuthMethod: 'password',
+ endpointSSHKeyFile: null,
+ maxRetries: 3,
+ retryInterval: 5000,
+ autoStart: false,
+ isPinned: false
+ }
+ });
+
+ const editTunnelForm = useForm({
+ defaultValues: {
+ tunnelName: '',
+ folder: '',
+ sourcePort: 22,
+ endpointPort: 224,
+ sourceIP: '',
+ sourceSSHPort: 22,
+ sourceUsername: '',
+ sourcePassword: '',
+ sourceAuthMethod: 'password',
+ sourceSSHKeyFile: null,
+ endpointIP: '',
+ endpointSSHPort: 22,
+ endpointUsername: '',
+ endpointPassword: '',
+ endpointAuthMethod: 'password',
+ endpointSSHKeyFile: null,
+ maxRetries: 3,
+ retryInterval: 5000,
+ autoStart: false,
+ isPinned: false
+ }
+ });
+
+ const [submitting, setSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+ const [sheetOpen, setSheetOpen] = useState(false);
+ const [editSheetOpen, setEditSheetOpen] = useState(false);
+ const [editTunnelData, setEditTunnelData] = useState(null);
+ const [folders, setFolders] = useState([]);
+ const [foldersLoading, setFoldersLoading] = useState(false);
+ const [foldersError, setFoldersError] = useState(null);
+
+ React.useEffect(() => {
+ if (!sheetOpen) {
+ setSubmitError(null);
+ }
+ }, [sheetOpen]);
+
+ React.useEffect(() => {
+ if (!editSheetOpen) {
+ setEditFolderDropdownOpen(false);
+ }
+ }, [editSheetOpen]);
+
+ React.useEffect(() => {
+ async function fetchFolders() {
+ setFoldersLoading(true);
+ setFoldersError(null);
+ try {
+ const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
+ const res = await axios.get(
+ (window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + '/ssh_tunnel/folders',
+ { headers: { Authorization: `Bearer ${jwt}` } }
+ );
+ setFolders(res.data || []);
+ } catch (err: any) {
+ setFoldersError('Failed to load folders');
+ } finally {
+ setFoldersLoading(false);
+ }
+ }
+ fetchFolders();
+ }, []);
+
+ const onAddTunnelSubmit = async (data: AddTunnelFormData) => {
+ setSubmitting(true);
+ setSubmitError(null);
+ try {
+ let sourceSSHKeyContent = data.sourceSSHKeyContent;
+ if (data.sourceSSHKeyFile instanceof File) {
+ sourceSSHKeyContent = await data.sourceSSHKeyFile.text();
+ }
+
+ let endpointSSHKeyContent = data.endpointSSHKeyContent;
+ if (data.endpointSSHKeyFile instanceof File) {
+ endpointSSHKeyContent = await data.endpointSSHKeyFile.text();
+ }
+
+ const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
+ await axios.post(
+ (window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + '/ssh_tunnel/tunnel',
+ {
+ name: data.tunnelName,
+ folder: data.folder,
+ sourcePort: data.sourcePort,
+ endpointPort: data.endpointPort,
+ sourceIP: data.sourceIP,
+ sourceSSHPort: data.sourceSSHPort,
+ sourceUsername: data.sourceUsername,
+ sourcePassword: data.sourcePassword,
+ sourceAuthMethod: data.sourceAuthMethod,
+ sourceSSHKey: sourceSSHKeyContent,
+ sourceKeyPassword: data.sourceKeyPassword,
+ sourceKeyType: data.sourceKeyType === 'auto' ? '' : data.sourceKeyType,
+ endpointIP: data.endpointIP,
+ endpointSSHPort: data.endpointSSHPort,
+ endpointUsername: data.endpointUsername,
+ endpointPassword: data.endpointPassword,
+ endpointAuthMethod: data.endpointAuthMethod,
+ endpointSSHKey: endpointSSHKeyContent,
+ endpointKeyPassword: data.endpointKeyPassword,
+ endpointKeyType: data.endpointKeyType === 'auto' ? '' : data.endpointKeyType,
+ maxRetries: data.maxRetries,
+ retryInterval: data.retryInterval,
+ autoStart: data.autoStart,
+ isPinned: data.isPinned
+ },
+ { headers: { Authorization: `Bearer ${jwt}` } }
+ );
+ setSheetOpen(false);
+ addTunnelForm.reset();
+ if (data.folder && !folders.includes(data.folder)) {
+ setFolders(prev => [...prev, data.folder]);
+ }
+ onTunnelAdded?.();
+ } catch (err: any) {
+ setSubmitError(err?.response?.data?.error || 'Failed to create SSH tunnel');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const onEditTunnelSubmit = async (data: AddTunnelFormData) => {
+ setSubmitting(true);
+ setSubmitError(null);
+ try {
+ let sourceSSHKeyContent = data.sourceSSHKeyContent;
+ if (data.sourceSSHKeyFile instanceof File) {
+ sourceSSHKeyContent = await data.sourceSSHKeyFile.text();
+ }
+
+ let endpointSSHKeyContent = data.endpointSSHKeyContent;
+ if (data.endpointSSHKeyFile instanceof File) {
+ endpointSSHKeyContent = await data.endpointSSHKeyFile.text();
+ }
+
+ const jwt = document.cookie.split('; ').find(row => row.startsWith('jwt='))?.split('=')[1];
+ if (!editTunnelData?.id) {
+ throw new Error('No tunnel ID found for editing');
+ }
+ await axios.put(
+ (window.location.hostname === 'localhost' ? 'http://localhost:8081' : '') + `/ssh_tunnel/tunnel/${editTunnelData.id}`,
+ {
+ name: data.tunnelName,
+ folder: data.folder,
+ sourcePort: data.sourcePort,
+ endpointPort: data.endpointPort,
+ sourceIP: data.sourceIP,
+ sourceSSHPort: data.sourceSSHPort,
+ sourceUsername: data.sourceUsername,
+ sourcePassword: data.sourcePassword,
+ sourceAuthMethod: data.sourceAuthMethod,
+ sourceSSHKey: sourceSSHKeyContent,
+ sourceKeyPassword: data.sourceKeyPassword,
+ sourceKeyType: data.sourceKeyType === 'auto' ? '' : data.sourceKeyType,
+ endpointIP: data.endpointIP,
+ endpointSSHPort: data.endpointSSHPort,
+ endpointUsername: data.endpointUsername,
+ endpointPassword: data.endpointPassword,
+ endpointAuthMethod: data.endpointAuthMethod,
+ endpointSSHKey: endpointSSHKeyContent,
+ endpointKeyPassword: data.endpointKeyPassword,
+ endpointKeyType: data.endpointKeyType === 'auto' ? '' : data.endpointKeyType,
+ maxRetries: data.maxRetries,
+ retryInterval: data.retryInterval,
+ autoStart: data.autoStart,
+ isPinned: data.isPinned
+ },
+ { headers: { Authorization: `Bearer ${jwt}` } }
+ );
+ setEditSheetOpen(false);
+ editTunnelForm.reset();
+ onTunnelAdded?.();
+ } catch (err: any) {
+ setSubmitError(err?.response?.data?.error || 'Failed to update SSH tunnel');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const sourcePort = addTunnelForm.watch('sourcePort');
+ const endpointPort = addTunnelForm.watch('endpointPort');
+ const folderValue = addTunnelForm.watch('folder');
+ const filteredFolders = React.useMemo(() => {
+ if (!folderValue) return folders;
+ return folders.filter(f => f.toLowerCase().includes(folderValue.toLowerCase()));
+ }, [folderValue, folders]);
+
+ // Key type options
+ const keyTypeOptions = [
+ { value: 'auto', label: 'Auto-detect' },
+ { value: 'ssh-rsa', label: 'RSA' },
+ { value: 'ssh-ed25519', label: 'ED25519' },
+ { value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256' },
+ { value: 'ecdsa-sha2-nistp384', label: 'ECDSA NIST P-384' },
+ { value: 'ecdsa-sha2-nistp521', label: 'ECDSA NIST P-521' },
+ { value: 'ssh-dss', label: 'DSA' },
+ { value: 'ssh-rsa-sha2-256', label: 'RSA SHA2-256' },
+ { value: 'ssh-rsa-sha2-512', label: 'RSA SHA2-512' },
+ ];
+
+ // Key type dropdown state and refs for source
+ const [sourceKeyTypeDropdownOpen, setSourceKeyTypeDropdownOpen] = useState(false);
+ const sourceKeyTypeDropdownRef = React.useRef(null);
+ const sourceKeyTypeButtonRef = React.useRef(null);
+
+ // Key type dropdown state and refs for endpoint
+ const [endpointKeyTypeDropdownOpen, setEndpointKeyTypeDropdownOpen] = useState(false);
+ const endpointKeyTypeDropdownRef = React.useRef(null);
+ const endpointKeyTypeButtonRef = React.useRef(null);
+ const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
+ const folderInputRef = React.useRef(null);
+ const folderDropdownRef = React.useRef(null);
+ const [editSourceKeyTypeDropdownOpen, setEditSourceKeyTypeDropdownOpen] = useState(false);
+ const [editEndpointKeyTypeDropdownOpen, setEditEndpointKeyTypeDropdownOpen] = useState(false);
+ const [editFolderDropdownOpen, setEditFolderDropdownOpen] = useState(false);
+ const editFolderInputRef = React.useRef(null);
+ const editFolderDropdownRef = React.useRef(null);
+
+ // Close dropdown on outside click (source)
+ React.useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (
+ sourceKeyTypeDropdownRef.current &&
+ !sourceKeyTypeDropdownRef.current.contains(event.target as Node) &&
+ sourceKeyTypeButtonRef.current &&
+ !sourceKeyTypeButtonRef.current.contains(event.target as Node)
+ ) {
+ setSourceKeyTypeDropdownOpen(false);
+ }
+ }
+ if (sourceKeyTypeDropdownOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ } else {
+ document.removeEventListener('mousedown', handleClickOutside);
+ }
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [sourceKeyTypeDropdownOpen]);
+
+ // Close dropdown on outside click (endpoint)
+ React.useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (
+ endpointKeyTypeDropdownRef.current &&
+ !endpointKeyTypeDropdownRef.current.contains(event.target as Node) &&
+ endpointKeyTypeButtonRef.current &&
+ !endpointKeyTypeButtonRef.current.contains(event.target as Node)
+ ) {
+ setEditEndpointKeyTypeDropdownOpen(false);
+ }
+ }
+ if (endpointKeyTypeDropdownOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ } else {
+ document.removeEventListener('mousedown', handleClickOutside);
+ }
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [endpointKeyTypeDropdownOpen]);
+
+ // Close dropdown on outside click (folder)
+ React.useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (
+ folderDropdownRef.current &&
+ !folderDropdownRef.current.contains(event.target as Node) &&
+ folderInputRef.current &&
+ !folderInputRef.current.contains(event.target as Node)
+ ) {
+ setFolderDropdownOpen(false);
+ }
+ }
+ if (folderDropdownOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ } else {
+ document.removeEventListener('mousedown', handleClickOutside);
+ }
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [folderDropdownOpen]);
+
+ // Close dropdown on outside click (edit folder)
+ React.useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (
+ editFolderDropdownRef.current &&
+ !editFolderDropdownRef.current.contains(event.target as Node) &&
+ editFolderInputRef.current &&
+ !editFolderInputRef.current.contains(event.target as Node)
+ ) {
+ setEditFolderDropdownOpen(false);
+ }
+ }
+ if (editFolderDropdownOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ } else {
+ document.removeEventListener('mousedown', handleClickOutside);
+ }
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [editFolderDropdownOpen]);
+
+ const handleFolderClick = (folder: string) => {
+ addTunnelForm.setValue('folder', folder);
+ setFolderDropdownOpen(false);
+ };
+
+ // Expose the openEditSheet function through the ref
+ const openEditSheet = React.useCallback((tunnel: any) => {
+ setEditTunnelData(tunnel);
+ setEditSheetOpen(true);
+ }, []);
+
+ // Expose the function through the ref
+ React.useImperativeHandle(ref, () => ({
+ openEditSheet
+ }), [openEditSheet]);
+
+ // Populate edit form when editTunnelData changes
+ React.useEffect(() => {
+ if (editTunnelData) {
+ editTunnelForm.reset({
+ tunnelName: editTunnelData.name || '',
+ folder: editTunnelData.folder || '',
+ sourcePort: editTunnelData.sourcePort || 22,
+ endpointPort: editTunnelData.endpointPort || 22,
+ sourceIP: editTunnelData.sourceIP || '',
+ sourceSSHPort: editTunnelData.sourceSSHPort || 22,
+ sourceUsername: editTunnelData.sourceUsername || '',
+ sourcePassword: editTunnelData.sourcePassword || '',
+ sourceAuthMethod: editTunnelData.sourceAuthMethod || 'password',
+ sourceSSHKeyFile: null,
+ sourceSSHKeyContent: editTunnelData.sourceSSHKey || '',
+ sourceKeyPassword: editTunnelData.sourceKeyPassword || '',
+ sourceKeyType: editTunnelData.sourceKeyType || '',
+ endpointIP: editTunnelData.endpointIP || '',
+ endpointSSHPort: editTunnelData.endpointSSHPort || 22,
+ endpointUsername: editTunnelData.endpointUsername || '',
+ endpointPassword: editTunnelData.endpointPassword || '',
+ endpointAuthMethod: editTunnelData.endpointAuthMethod || 'password',
+ endpointSSHKeyFile: null,
+ endpointSSHKeyContent: editTunnelData.endpointSSHKey || '',
+ endpointKeyPassword: editTunnelData.endpointKeyPassword || '',
+ endpointKeyType: editTunnelData.endpointKeyType || '',
+ maxRetries: editTunnelData.maxRetries || 3,
+ retryInterval: editTunnelData.retryInterval || 5000,
+ autoStart: editTunnelData.autoStart || false,
+ isPinned: editTunnelData.isPinned || false
+ });
+ }
+ }, [editTunnelData, editTunnelForm]);
+
return (
@@ -50,11 +494,1273 @@ export function SSHTunnelSidebar({ onSelectView }: SidebarProps): React.ReactEle
+
+ { if (!submitting) setSheetOpen(open); }}>
+
+
+
+
+
+ Add SSH Tunnel
+
+ Create a new SSH tunnel connection.
+
+
+
+
+ {submitError && (
+
{submitError}
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Edit Tunnel Sheet */}
+ {
+ if (!open) {
+ setTimeout(() => {
+ setEditTunnelData(null);
+ editTunnelForm.reset();
+ }, 100);
+ }
+ setEditSheetOpen(open);
+ }}>
+
+
+ Edit SSH Tunnel
+
+ Modify the SSH tunnel configuration.
+
+
+
+ {submitError && (
+
{submitError}
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- )
-}
\ No newline at end of file
+ );
+});
\ No newline at end of file
diff --git a/src/apps/SSH Tunnel/SSHTunnelViewer.tsx b/src/apps/SSH Tunnel/SSHTunnelViewer.tsx
new file mode 100644
index 00000000..6134539f
--- /dev/null
+++ b/src/apps/SSH Tunnel/SSHTunnelViewer.tsx
@@ -0,0 +1,137 @@
+import React from "react";
+import { SSHTunnelObject } from "./SSHTunnelObject";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
+import { Separator } from "@/components/ui/separator";
+
+interface SSHTunnelViewerProps {
+ tunnels: Array<{
+ id: number;
+ name: string;
+ folder: string;
+ sourcePort: number;
+ endpointPort: number;
+ sourceIP: string;
+ sourceSSHPort: number;
+ sourceUsername: string;
+ sourcePassword: string;
+ sourceAuthMethod: string;
+ sourceSSHKey: string;
+ sourceKeyPassword: string;
+ sourceKeyType: string;
+ endpointIP: string;
+ endpointSSHPort: number;
+ endpointUsername: string;
+ endpointPassword: string;
+ endpointAuthMethod: string;
+ endpointSSHKey: string;
+ endpointKeyPassword: string;
+ endpointKeyType: string;
+ maxRetries: number;
+ retryInterval: number;
+ connectionState?: string;
+ autoStart: boolean;
+ isPinned: boolean;
+ }>;
+ onConnect?: (tunnelId: string) => void;
+ onDisconnect?: (tunnelId: string) => void;
+ onDeleteTunnel?: (tunnelId: string) => void;
+ onEditTunnel?: (tunnelId: string) => void;
+}
+
+export function SSHTunnelViewer({
+ tunnels = [],
+ onConnect,
+ onDisconnect,
+ onDeleteTunnel,
+ onEditTunnel
+}: SSHTunnelViewerProps): React.ReactElement {
+ const handleConnect = (tunnelId: string) => {
+ onConnect?.(tunnelId);
+ };
+
+ const handleDisconnect = (tunnelId: string) => {
+ onDisconnect?.(tunnelId);
+ };
+
+ // Group tunnels by folder and sort
+ const tunnelsByFolder = React.useMemo(() => {
+ const map: Record = {};
+ tunnels.forEach(tunnel => {
+ const folder = tunnel.folder && tunnel.folder.trim() ? tunnel.folder : 'No Folder';
+ if (!map[folder]) map[folder] = [];
+ map[folder].push(tunnel);
+ });
+ return map;
+ }, [tunnels]);
+
+ const sortedFolders = React.useMemo(() => {
+ const folders = Object.keys(tunnelsByFolder);
+ folders.sort((a, b) => {
+ if (a === 'No Folder') return -1;
+ if (b === 'No Folder') return 1;
+ return a.localeCompare(b);
+ });
+ return folders;
+ }, [tunnelsByFolder]);
+
+ const getSortedTunnels = (arr: typeof tunnels) => {
+ const pinned = arr.filter(t => t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+ const rest = arr.filter(t => !t.isPinned).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+ return [...pinned, ...rest];
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+ SSH Tunnels
+
+
+ Manage your SSH tunnel connections
+
+
+
+ {/* Accordion Layout */}
+ {tunnels.length === 0 ? (
+
+
+ No SSH Tunnels
+
+
+ Create your first SSH tunnel to get started. Use the sidebar to add a new tunnel configuration.
+
+
+ ) : (
+
+ {sortedFolders.map((folder, idx) => (
+
+
+ {folder}
+
+
+
+ {getSortedTunnels(tunnelsByFolder[folder]).map((tunnel, tunnelIndex) => (
+
+ handleConnect(tunnel.id.toString())}
+ onDisconnect={() => handleDisconnect(tunnel.id.toString())}
+ onDelete={() => onDeleteTunnel?.(tunnel.id.toString())}
+ onEdit={() => onEditTunnel?.(tunnel.id.toString())}
+ />
+
+ ))}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/backend/db/database.ts b/src/backend/db/database.ts
index b95f63dd..5b785cff 100644
--- a/src/backend/db/database.ts
+++ b/src/backend/db/database.ts
@@ -2,6 +2,7 @@ import express from 'express';
import bodyParser from 'body-parser';
import userRoutes from './routes/users.js';
import sshRoutes from './routes/ssh.js';
+import sshTunnelRoutes from './routes/ssh_tunnel.js';
import chalk from 'chalk';
import cors from 'cors';
@@ -47,6 +48,7 @@ app.get('/health', (req, res) => {
app.use('/users', userRoutes);
app.use('/ssh', sshRoutes);
+app.use('/ssh_tunnel', sshTunnelRoutes);
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', err);
diff --git a/src/backend/db/db/index.ts b/src/backend/db/db/index.ts
index e00e510d..d1ed20d3 100644
--- a/src/backend/db/db/index.ts
+++ b/src/backend/db/db/index.ts
@@ -64,6 +64,36 @@ CREATE TABLE IF NOT EXISTS ssh_data (
is_pinned INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id)
);
+CREATE TABLE IF NOT EXISTS ssh_tunnel_data (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ name TEXT,
+ folder TEXT,
+ source_port INTEGER NOT NULL,
+ endpoint_port INTEGER NOT NULL,
+ source_ip TEXT NOT NULL,
+ source_ssh_port INTEGER NOT NULL,
+ source_username TEXT,
+ source_password TEXT,
+ source_auth_method TEXT,
+ source_ssh_key TEXT,
+ source_key_password TEXT,
+ source_key_type TEXT,
+ endpoint_ip TEXT NOT NULL,
+ endpoint_ssh_port INTEGER NOT NULL,
+ endpoint_username TEXT,
+ endpoint_password TEXT,
+ endpoint_auth_method TEXT,
+ endpoint_ssh_key TEXT,
+ endpoint_key_password TEXT,
+ endpoint_key_type TEXT,
+ max_retries INTEGER NOT NULL DEFAULT 3,
+ retry_interval INTEGER NOT NULL DEFAULT 5000,
+ connection_state TEXT NOT NULL DEFAULT 'DISCONNECTED',
+ auto_start INTEGER NOT NULL DEFAULT 0,
+ is_pinned INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY(user_id) REFERENCES users(id)
+);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
diff --git a/src/backend/db/db/schema.ts b/src/backend/db/db/schema.ts
index 3768525f..71659ee9 100644
--- a/src/backend/db/db/schema.ts
+++ b/src/backend/db/db/schema.ts
@@ -25,6 +25,36 @@ export const sshData = sqliteTable('ssh_data', {
isPinned: integer('is_pinned', { mode: 'boolean' }),
});
+export const sshTunnelData = sqliteTable('ssh_tunnel_data', {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ userId: text('user_id').notNull().references(() => users.id),
+ name: text('name'),
+ folder: text('folder'),
+ sourcePort: integer('source_port').notNull(),
+ endpointPort: integer('endpoint_port').notNull(),
+ sourceIP: text('source_ip').notNull(),
+ sourceSSHPort: integer('source_ssh_port').notNull(),
+ sourceUsername: text('source_username'),
+ sourcePassword: text('source_password'),
+ sourceAuthMethod: text('source_auth_method'),
+ sourceSSHKey: text('source_ssh_key', { length: 8192 }),
+ sourceKeyPassword: text('source_key_password'),
+ sourceKeyType: text('source_key_type'),
+ endpointIP: text('endpoint_ip').notNull(),
+ endpointSSHPort: integer('endpoint_ssh_port').notNull(),
+ endpointUsername: text('endpoint_username'),
+ endpointPassword: text('endpoint_password'),
+ endpointAuthMethod: text('endpoint_auth_method'),
+ endpointSSHKey: text('endpoint_ssh_key', { length: 8192 }),
+ endpointKeyPassword: text('endpoint_key_password'),
+ endpointKeyType: text('endpoint_key_type'),
+ maxRetries: integer('max_retries').notNull().default(3),
+ retryInterval: integer('retry_interval').notNull().default(5000),
+ connectionState: text('connection_state').notNull().default('DISCONNECTED'),
+ autoStart: integer('auto_start', { mode: 'boolean' }).notNull().default(false),
+ isPinned: integer('is_pinned', { mode: 'boolean' }).notNull().default(false),
+});
+
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
diff --git a/src/backend/db/routes/ssh_tunnel.ts b/src/backend/db/routes/ssh_tunnel.ts
new file mode 100644
index 00000000..07759bcc
--- /dev/null
+++ b/src/backend/db/routes/ssh_tunnel.ts
@@ -0,0 +1,306 @@
+import express from 'express';
+import { db } from '../db/index.js';
+import { sshTunnelData } from '../db/schema.js';
+import { eq, and } from 'drizzle-orm';
+import chalk from 'chalk';
+import jwt from 'jsonwebtoken';
+import type { Request, Response, NextFunction } from 'express';
+
+const dbIconSymbol = '🗄️';
+const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
+const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
+ return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
+};
+const logger = {
+ info: (msg: string): void => {
+ console.log(formatMessage('info', chalk.cyan, msg));
+ },
+ warn: (msg: string): void => {
+ console.warn(formatMessage('warn', chalk.yellow, msg));
+ },
+ error: (msg: string, err?: unknown): void => {
+ console.error(formatMessage('error', chalk.redBright, msg));
+ if (err) console.error(err);
+ },
+ success: (msg: string): void => {
+ console.log(formatMessage('success', chalk.greenBright, msg));
+ },
+ debug: (msg: string): void => {
+ if (process.env.NODE_ENV !== 'production') {
+ console.debug(formatMessage('debug', chalk.magenta, msg));
+ }
+ }
+};
+
+const router = express.Router();
+
+function isNonEmptyString(val: any): val is string {
+ return typeof val === 'string' && val.trim().length > 0;
+}
+function isValidPort(val: any): val is number {
+ return typeof val === 'number' && val > 0 && val < 65536;
+}
+
+interface JWTPayload {
+ userId: string;
+ iat?: number;
+ exp?: number;
+}
+
+// JWT authentication middleware
+function authenticateJWT(req: Request, res: Response, next: NextFunction) {
+ // Only allow bypass if X-Internal-Request header is set
+ if (req.headers['x-internal-request'] === '1') {
+ (req as any).userId = 'internal_service';
+ return next();
+ }
+ const authHeader = req.headers['authorization'];
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ logger.warn('Missing or invalid Authorization header');
+ return res.status(401).json({ error: 'Missing or invalid Authorization header' });
+ }
+ const token = authHeader.split(' ')[1];
+ const jwtSecret = process.env.JWT_SECRET || 'secret';
+ try {
+ const payload = jwt.verify(token, jwtSecret) as JWTPayload;
+ (req as any).userId = payload.userId;
+ next();
+ } catch (err) {
+ logger.warn('Invalid or expired token');
+ return res.status(401).json({ error: 'Invalid or expired token' });
+ }
+}
+
+// Route: Create SSH tunnel data (requires JWT)
+// POST /ssh_tunnel/tunnel
+router.post('/tunnel', authenticateJWT, async (req: Request, res: Response) => {
+ const {
+ name, folder, sourcePort, endpointPort, sourceIP, sourceSSHPort, sourceUsername,
+ sourcePassword, sourceAuthMethod, sourceSSHKey, sourceKeyPassword, sourceKeyType,
+ endpointIP, endpointSSHPort, endpointUsername, endpointPassword, endpointAuthMethod,
+ endpointSSHKey, endpointKeyPassword, endpointKeyType, maxRetries, retryInterval, autoStart, isPinned
+ } = req.body;
+ const userId = (req as any).userId;
+
+ if (!isNonEmptyString(userId) || !isNonEmptyString(sourceIP) || !isValidPort(sourcePort) ||
+ !isValidPort(endpointPort) || !isValidPort(sourceSSHPort) || !isNonEmptyString(endpointIP) ||
+ !isValidPort(endpointSSHPort)) {
+ logger.warn('Invalid SSH tunnel data input');
+ return res.status(400).json({ error: 'Invalid SSH tunnel data' });
+ }
+
+ const sshTunnelDataObj: any = {
+ userId: userId,
+ name,
+ folder,
+ sourcePort,
+ endpointPort,
+ sourceIP,
+ sourceSSHPort,
+ sourceUsername,
+ sourceAuthMethod,
+ endpointIP,
+ endpointSSHPort,
+ endpointUsername,
+ endpointAuthMethod,
+ maxRetries: maxRetries || 3,
+ retryInterval: retryInterval || 5000,
+ connectionState: 'DISCONNECTED',
+ autoStart: autoStart || false,
+ isPinned: isPinned || false
+ };
+
+ // Handle source authentication
+ if (sourceAuthMethod === 'password') {
+ sshTunnelDataObj.sourcePassword = sourcePassword;
+ sshTunnelDataObj.sourceSSHKey = null;
+ sshTunnelDataObj.sourceKeyPassword = null;
+ sshTunnelDataObj.sourceKeyType = null;
+ } else if (sourceAuthMethod === 'key') {
+ sshTunnelDataObj.sourceSSHKey = sourceSSHKey;
+ sshTunnelDataObj.sourceKeyPassword = sourceKeyPassword;
+ sshTunnelDataObj.sourceKeyType = sourceKeyType;
+ sshTunnelDataObj.sourcePassword = null;
+ }
+
+ // Handle endpoint authentication
+ if (endpointAuthMethod === 'password') {
+ sshTunnelDataObj.endpointPassword = endpointPassword;
+ sshTunnelDataObj.endpointSSHKey = null;
+ sshTunnelDataObj.endpointKeyPassword = null;
+ sshTunnelDataObj.endpointKeyType = null;
+ } else if (endpointAuthMethod === 'key') {
+ sshTunnelDataObj.endpointSSHKey = endpointSSHKey;
+ sshTunnelDataObj.endpointKeyPassword = endpointKeyPassword;
+ sshTunnelDataObj.endpointKeyType = endpointKeyType;
+ sshTunnelDataObj.endpointPassword = null;
+ }
+
+ try {
+ await db.insert(sshTunnelData).values(sshTunnelDataObj);
+ res.json({ message: 'SSH tunnel data created' });
+ } catch (err) {
+ logger.error('Failed to save SSH tunnel data', err);
+ res.status(500).json({ error: 'Failed to save SSH tunnel data' });
+ }
+});
+
+// Route: Update SSH tunnel data (requires JWT)
+// PUT /ssh_tunnel/tunnel/:id
+router.put('/tunnel/:id', authenticateJWT, async (req: Request, res: Response) => {
+ const {
+ name, folder, sourcePort, endpointPort, sourceIP, sourceSSHPort, sourceUsername,
+ sourcePassword, sourceAuthMethod, sourceSSHKey, sourceKeyPassword, sourceKeyType,
+ endpointIP, endpointSSHPort, endpointUsername, endpointPassword, endpointAuthMethod,
+ endpointSSHKey, endpointKeyPassword, endpointKeyType, maxRetries, retryInterval, autoStart, isPinned
+ } = req.body;
+ const { id } = req.params;
+ const userId = (req as any).userId;
+
+ if (!isNonEmptyString(userId) || !isNonEmptyString(sourceIP) || !isValidPort(sourcePort) ||
+ !isValidPort(endpointPort) || !isValidPort(sourceSSHPort) || !isNonEmptyString(endpointIP) ||
+ !isValidPort(endpointSSHPort) || !id) {
+ logger.warn('Invalid SSH tunnel data input for update');
+ return res.status(400).json({ error: 'Invalid SSH tunnel data' });
+ }
+
+ const sshTunnelDataObj: any = {
+ name,
+ folder,
+ sourcePort,
+ endpointPort,
+ sourceIP,
+ sourceSSHPort,
+ sourceUsername,
+ sourceAuthMethod,
+ endpointIP,
+ endpointSSHPort,
+ endpointUsername,
+ endpointAuthMethod,
+ maxRetries: maxRetries || 3,
+ retryInterval: retryInterval || 5000,
+ autoStart: autoStart || false,
+ isPinned: isPinned || false
+ };
+
+ // Handle source authentication
+ if (sourceAuthMethod === 'password') {
+ sshTunnelDataObj.sourcePassword = sourcePassword;
+ sshTunnelDataObj.sourceSSHKey = null;
+ sshTunnelDataObj.sourceKeyPassword = null;
+ sshTunnelDataObj.sourceKeyType = null;
+ } else if (sourceAuthMethod === 'key') {
+ sshTunnelDataObj.sourceSSHKey = sourceSSHKey;
+ sshTunnelDataObj.sourceKeyPassword = sourceKeyPassword;
+ sshTunnelDataObj.sourceKeyType = sourceKeyType;
+ sshTunnelDataObj.sourcePassword = null;
+ }
+
+ // Handle endpoint authentication
+ if (endpointAuthMethod === 'password') {
+ sshTunnelDataObj.endpointPassword = endpointPassword;
+ sshTunnelDataObj.endpointSSHKey = null;
+ sshTunnelDataObj.endpointKeyPassword = null;
+ sshTunnelDataObj.endpointKeyType = null;
+ } else if (endpointAuthMethod === 'key') {
+ sshTunnelDataObj.endpointSSHKey = endpointSSHKey;
+ sshTunnelDataObj.endpointKeyPassword = endpointKeyPassword;
+ sshTunnelDataObj.endpointKeyType = endpointKeyType;
+ sshTunnelDataObj.endpointPassword = null;
+ }
+
+ try {
+ const result = await db.update(sshTunnelData)
+ .set(sshTunnelDataObj)
+ .where(and(eq(sshTunnelData.id, Number(id)), eq(sshTunnelData.userId, userId)));
+ res.json({ message: 'SSH tunnel data updated' });
+ } catch (err) {
+ logger.error('Failed to update SSH tunnel data', err);
+ res.status(500).json({ error: 'Failed to update SSH tunnel data' });
+ }
+});
+
+// Route: Get SSH tunnel data for the authenticated user (requires JWT)
+// GET /ssh_tunnel/tunnel
+router.get('/tunnel', authenticateJWT, async (req: Request, res: Response) => {
+ // If internal request and allAutoStart=1, return all autoStart tunnels
+ if (req.headers['x-internal-request'] === '1' && req.query.allAutoStart === '1') {
+ try {
+ const data = await db
+ .select()
+ .from(sshTunnelData)
+ .where(eq(sshTunnelData.autoStart, true));
+ return res.json(data);
+ } catch (err) {
+ logger.error('Failed to fetch all auto-start SSH tunnel data', err);
+ return res.status(500).json({ error: 'Failed to fetch auto-start SSH tunnel data' });
+ }
+ }
+ // Default: filter by userId
+ const userId = (req as any).userId;
+ if (!isNonEmptyString(userId)) {
+ logger.warn('Invalid userId for SSH tunnel data fetch');
+ return res.status(400).json({ error: 'Invalid userId' });
+ }
+ try {
+ const data = await db
+ .select()
+ .from(sshTunnelData)
+ .where(eq(sshTunnelData.userId, userId));
+ res.json(data);
+ } catch (err) {
+ logger.error('Failed to fetch SSH tunnel data', err);
+ res.status(500).json({ error: 'Failed to fetch SSH tunnel data' });
+ }
+});
+
+// Route: Get all unique folders for the authenticated user (requires JWT)
+// GET /ssh_tunnel/folders
+router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ if (!isNonEmptyString(userId)) {
+ logger.warn('Invalid userId for SSH tunnel folder fetch');
+ return res.status(400).json({ error: 'Invalid userId' });
+ }
+ try {
+ const data = await db
+ .select({ folder: sshTunnelData.folder })
+ .from(sshTunnelData)
+ .where(eq(sshTunnelData.userId, userId));
+
+ const folderCounts: Record = {};
+ data.forEach(d => {
+ if (d.folder && d.folder.trim() !== '') {
+ folderCounts[d.folder] = (folderCounts[d.folder] || 0) + 1;
+ }
+ });
+
+ const folders = Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
+
+ res.json(folders);
+ } catch (err) {
+ logger.error('Failed to fetch SSH tunnel folders', err);
+ res.status(500).json({ error: 'Failed to fetch SSH tunnel folders' });
+ }
+});
+
+// Route: Delete SSH tunnel by id (requires JWT)
+// DELETE /ssh_tunnel/tunnel/:id
+router.delete('/tunnel/:id', authenticateJWT, async (req: Request, res: Response) => {
+ const userId = (req as any).userId;
+ const { id } = req.params;
+ if (!isNonEmptyString(userId) || !id) {
+ logger.warn('Invalid userId or id for SSH tunnel delete');
+ return res.status(400).json({ error: 'Invalid userId or id' });
+ }
+ try {
+ const result = await db.delete(sshTunnelData)
+ .where(and(eq(sshTunnelData.id, Number(id)), eq(sshTunnelData.userId, userId)));
+ res.json({ message: 'SSH tunnel deleted' });
+ } catch (err) {
+ logger.error('Failed to delete SSH tunnel', err);
+ res.status(500).json({ error: 'Failed to delete SSH tunnel' });
+ }
+});
+
+export default router;
diff --git a/src/backend/db/routes/users.ts b/src/backend/db/routes/users.ts
index d2433dd1..735729e5 100644
--- a/src/backend/db/routes/users.ts
+++ b/src/backend/db/routes/users.ts
@@ -133,7 +133,6 @@ router.post('/get', async (req, res) => {
}
const jwtSecret = process.env.JWT_SECRET || 'secret';
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { expiresIn: '50d' });
- logger.success(`User authenticated: ${username}`);
res.json({ token });
} catch (err) {
logger.error('Failed to get user', err);
diff --git a/src/backend/ssh_tunnel/ssh_tunnel.ts b/src/backend/ssh_tunnel/ssh_tunnel.ts
new file mode 100644
index 00000000..509ff4a1
--- /dev/null
+++ b/src/backend/ssh_tunnel/ssh_tunnel.ts
@@ -0,0 +1,1091 @@
+import express from 'express';
+import cors from 'cors';
+import { Client } from 'ssh2';
+import { exec } from 'child_process';
+import chalk from 'chalk';
+import axios from 'axios';
+
+const app = express();
+app.use(cors({
+ origin: [
+ 'http://localhost:5173', // Vite dev server
+ 'http://localhost:3000', // Common React dev port
+ 'http://127.0.0.1:5173',
+ 'http://127.0.0.1:3000',
+ '*', // Allow all for dev, remove in prod
+ ],
+ credentials: true,
+ methods: 'GET,POST,PUT,DELETE,OPTIONS',
+ allowedHeaders: 'Origin,X-Requested-With,Content-Type,Accept,Authorization',
+}));
+app.use(express.json());
+
+const tunnelIconSymbol = '📡';
+const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
+const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
+ return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${tunnelIconSymbol}]`)} ${message}`;
+};
+const logger = {
+ info: (msg: string): void => {
+ console.log(formatMessage('info', chalk.cyan, msg));
+ },
+ warn: (msg: string): void => {
+ console.warn(formatMessage('warn', chalk.yellow, msg));
+ },
+ error: (msg: string, err?: unknown): void => {
+ console.error(formatMessage('error', chalk.redBright, msg));
+ if (err) console.error(err);
+ },
+ success: (msg: string): void => {
+ console.log(formatMessage('success', chalk.greenBright, msg));
+ },
+ debug: (msg: string): void => {
+ if (process.env.NODE_ENV !== 'production') {
+ console.debug(formatMessage('debug', chalk.magenta, msg));
+ }
+ }
+};
+
+// State management
+const activeTunnels = new Map();
+const retryCounters = new Map();
+const connectionStatus = new Map();
+const tunnelVerifications = new Map();
+const manualDisconnects = new Set();
+const verificationTimers = new Map();
+const activeRetryTimers = new Map();
+const retryExhaustedTunnels = new Set();
+const remoteClosureEvents = new Map();
+const hostConfigs = new Map();
+
+// Types
+interface HostConfig {
+ name: string;
+ sourceIP: string;
+ sourceSSHPort: number;
+ sourceUsername: string;
+ sourcePassword?: string;
+ sourceAuthMethod: string;
+ sourceSSHKey?: string;
+ sourceKeyPassword?: string;
+ sourceKeyType?: string;
+ endpointIP: string;
+ endpointSSHPort: number;
+ endpointUsername: string;
+ endpointPassword?: string;
+ endpointAuthMethod: string;
+ endpointSSHKey?: string;
+ endpointKeyPassword?: string;
+ endpointKeyType?: string;
+ sourcePort: number;
+ endpointPort: number;
+ maxRetries: number;
+ retryInterval: number;
+ autoStart: boolean;
+ isPinned: boolean;
+}
+
+interface TunnelStatus {
+ connected: boolean;
+ status: ConnectionState;
+ retryCount?: number;
+ maxRetries?: number;
+ nextRetryIn?: number;
+ reason?: string;
+ errorType?: ErrorType;
+ manualDisconnect?: boolean;
+ retryExhausted?: boolean;
+ isRemoteRetry?: boolean;
+}
+
+interface VerificationData {
+ conn: Client;
+ timeout: NodeJS.Timeout;
+}
+
+const CONNECTION_STATES = {
+ DISCONNECTED: "disconnected",
+ CONNECTING: "connecting",
+ CONNECTED: "connected",
+ VERIFYING: "verifying",
+ FAILED: "failed",
+ UNSTABLE: "unstable",
+ RETRYING: "retrying"
+} as const;
+
+const ERROR_TYPES = {
+ AUTH: "authentication",
+ NETWORK: "network",
+ PORT: "port_conflict",
+ PERMISSION: "permission",
+ TIMEOUT: "timeout",
+ UNKNOWN: "unknown"
+} as const;
+
+type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES];
+type ErrorType = typeof ERROR_TYPES[keyof typeof ERROR_TYPES];
+
+// Helper functions
+function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
+ if (status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName)) {
+ return;
+ }
+
+ if (retryExhaustedTunnels.has(tunnelName) && status.status === CONNECTION_STATES.FAILED) {
+ status.reason = "Max retries exhausted";
+ }
+
+ // In Express, we'll use a different approach for broadcasting
+ // For now, we'll store the status and provide endpoints to fetch it
+ connectionStatus.set(tunnelName, status);
+}
+
+function getAllTunnelStatus(): Record {
+ const tunnelStatus: Record = {};
+ connectionStatus.forEach((status, key) => {
+ tunnelStatus[key] = status;
+ });
+ return tunnelStatus;
+}
+
+function classifyError(errorMessage: string): ErrorType {
+ if (!errorMessage) return ERROR_TYPES.UNKNOWN;
+
+ const message = errorMessage.toLowerCase();
+
+ if (message.includes("closed by remote host") ||
+ message.includes("connection reset by peer") ||
+ message.includes("connection refused") ||
+ message.includes("broken pipe")) {
+ return ERROR_TYPES.NETWORK;
+ }
+
+ if (message.includes("authentication failed") ||
+ message.includes("permission denied") ||
+ message.includes("incorrect password")) {
+ return ERROR_TYPES.AUTH;
+ }
+
+ if (message.includes("connect etimedout") ||
+ message.includes("timeout") ||
+ message.includes("timed out")) {
+ return ERROR_TYPES.TIMEOUT;
+ }
+
+ if (message.includes("bind: address already in use") ||
+ message.includes("failed for listen port") ||
+ message.includes("port forwarding failed")) {
+ return ERROR_TYPES.PORT;
+ }
+
+ if (message.includes("permission") ||
+ message.includes("access denied")) {
+ return ERROR_TYPES.PERMISSION;
+ }
+
+ return ERROR_TYPES.UNKNOWN;
+}
+
+// Cleanup and disconnect functions
+function cleanupTunnelResources(tunnelName: string): void {
+ if (activeTunnels.has(tunnelName)) {
+ try {
+ const conn = activeTunnels.get(tunnelName);
+ if (conn) conn.end();
+ } catch (e) {}
+ activeTunnels.delete(tunnelName);
+ }
+
+ if (tunnelVerifications.has(tunnelName)) {
+ const verification = tunnelVerifications.get(tunnelName);
+ if (verification?.timeout) clearTimeout(verification.timeout);
+ try {
+ verification?.conn.end();
+ } catch (e) {}
+ tunnelVerifications.delete(tunnelName);
+ }
+
+ const timerKeys = [
+ tunnelName,
+ `${tunnelName}_confirm`,
+ `${tunnelName}_retry`,
+ `${tunnelName}_verify_retry`
+ ];
+
+ timerKeys.forEach(key => {
+ if (verificationTimers.has(key)) {
+ clearTimeout(verificationTimers.get(key)!);
+ verificationTimers.delete(key);
+ }
+ });
+
+ if (activeRetryTimers.has(tunnelName)) {
+ clearTimeout(activeRetryTimers.get(tunnelName)!);
+ activeRetryTimers.delete(tunnelName);
+ }
+}
+
+function resetRetryState(tunnelName: string): void {
+ retryCounters.delete(tunnelName);
+ retryExhaustedTunnels.delete(tunnelName);
+ remoteClosureEvents.delete(tunnelName);
+
+ if (activeRetryTimers.has(tunnelName)) {
+ clearTimeout(activeRetryTimers.get(tunnelName)!);
+ activeRetryTimers.delete(tunnelName);
+ }
+
+ ['', '_confirm', '_retry', '_verify_retry'].forEach(suffix => {
+ const timerKey = `${tunnelName}${suffix}`;
+ if (verificationTimers.has(timerKey)) {
+ clearTimeout(verificationTimers.get(timerKey)!);
+ verificationTimers.delete(timerKey);
+ }
+ });
+}
+
+function handleDisconnect(tunnelName: string, hostConfig: HostConfig | null, shouldRetry = true, isRemoteClosure = false): void {
+ if (tunnelVerifications.has(tunnelName)) {
+ try {
+ const verification = tunnelVerifications.get(tunnelName);
+ if (verification?.timeout) clearTimeout(verification.timeout);
+ verification?.conn.end();
+ } catch (e) {}
+ tunnelVerifications.delete(tunnelName);
+ }
+
+ cleanupTunnelResources(tunnelName);
+
+ if (manualDisconnects.has(tunnelName)) {
+ resetRetryState(tunnelName);
+
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.DISCONNECTED,
+ manualDisconnect: true
+ });
+ return;
+ }
+
+ if (isRemoteClosure) {
+ const currentCount = remoteClosureEvents.get(tunnelName) || 0;
+ remoteClosureEvents.set(tunnelName, currentCount + 1);
+
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.FAILED,
+ reason: "Remote host disconnected"
+ });
+
+ if (currentCount === 0) {
+ retryCounters.delete(tunnelName);
+ }
+ }
+
+ if (isRemoteClosure && retryExhaustedTunnels.has(tunnelName)) {
+ retryExhaustedTunnels.delete(tunnelName);
+ }
+
+ if (retryExhaustedTunnels.has(tunnelName)) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.FAILED,
+ reason: "Max retries already exhausted"
+ });
+ return;
+ }
+
+ if (activeRetryTimers.has(tunnelName)) {
+ return;
+ }
+
+ if (shouldRetry && hostConfig) {
+ const maxRetries = hostConfig.maxRetries || 3;
+ const retryInterval = hostConfig.retryInterval || 5000;
+
+ if (isRemoteClosure) {
+ const currentCount = remoteClosureEvents.get(tunnelName) || 0;
+ remoteClosureEvents.set(tunnelName, currentCount + 1);
+
+ if (currentCount === 0) {
+ retryCounters.delete(tunnelName);
+ }
+ }
+
+ let retryCount = (retryCounters.get(tunnelName) || 0) + 1;
+
+ if (retryCount > maxRetries) {
+ logger.error(`All ${maxRetries} retries failed for ${tunnelName}`);
+
+ retryExhaustedTunnels.add(tunnelName);
+ activeTunnels.delete(tunnelName);
+ retryCounters.delete(tunnelName);
+
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.FAILED,
+ retryExhausted: true,
+ reason: `Max retries exhausted`
+ });
+ return;
+ }
+
+ retryCounters.set(tunnelName, retryCount);
+
+ if (retryCount <= maxRetries) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.RETRYING,
+ retryCount: retryCount,
+ maxRetries: maxRetries,
+ nextRetryIn: retryInterval/1000
+ });
+
+ if (activeRetryTimers.has(tunnelName)) {
+ clearTimeout(activeRetryTimers.get(tunnelName)!);
+ activeRetryTimers.delete(tunnelName);
+ }
+
+ const timer = setTimeout(() => {
+ activeRetryTimers.delete(tunnelName);
+
+ if (!manualDisconnects.has(tunnelName)) {
+ activeTunnels.delete(tunnelName);
+ connectSSHTunnel(hostConfig, retryCount);
+ }
+ }, retryInterval);
+
+ activeRetryTimers.set(tunnelName, timer);
+ }
+ } else {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.FAILED
+ });
+
+ activeTunnels.delete(tunnelName);
+ }
+}
+
+// Tunnel verification function
+function verifyTunnelConnection(tunnelName: string, hostConfig: HostConfig, isPeriodic = false): void {
+ if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
+ return;
+ }
+
+ if (tunnelVerifications.has(tunnelName)) {
+ return;
+ }
+
+ const conn = activeTunnels.get(tunnelName);
+ if (!conn) return;
+
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.VERIFYING
+ });
+
+ const verificationConn = new Client();
+ tunnelVerifications.set(tunnelName, {
+ conn: verificationConn,
+ timeout: setTimeout(() => {
+ logger.error(`Verification timeout for '${tunnelName}'`);
+ cleanupVerification(false, "Verification timeout");
+ }, 10000)
+ });
+
+ function cleanupVerification(isSuccessful: boolean, failureReason = "Unknown verification failure") {
+ const verification = tunnelVerifications.get(tunnelName);
+ if (verification) {
+ clearTimeout(verification.timeout);
+ try {
+ verification.conn.end();
+ } catch (e) {}
+ tunnelVerifications.delete(tunnelName);
+ }
+
+ if (isSuccessful) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: true,
+ status: CONNECTION_STATES.CONNECTED
+ });
+
+ if (!isPeriodic) {
+ setupPingInterval(tunnelName, hostConfig);
+ }
+ } else {
+ logger.error(`Verification failed for '${tunnelName}': ${failureReason}`);
+
+ if (!manualDisconnects.has(tunnelName)) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.FAILED,
+ reason: failureReason
+ });
+ }
+
+ activeTunnels.delete(tunnelName);
+ handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
+ }
+ }
+
+ function attemptVerification() {
+ const testCmd = `nc -z localhost ${hostConfig.sourcePort}`;
+
+ verificationConn.exec(testCmd, (err, stream) => {
+ if (err) {
+ cleanupVerification(false, `Verification command failed: ${err.message}`);
+ return;
+ }
+
+ let output = '';
+ stream.on('data', (data: Buffer) => {
+ output += data.toString();
+ });
+
+ stream.on('close', (code: number) => {
+ if (code === 0 && code !== undefined) {
+ cleanupVerification(true);
+ } else {
+ cleanupVerification(false, `Port ${hostConfig.sourcePort} is not accessible`);
+ }
+ });
+
+ stream.on('error', (err: Error) => {
+ cleanupVerification(false, `Verification stream error: ${err.message}`);
+ });
+ });
+ }
+
+ verificationConn.on('ready', () => {
+ attemptVerification();
+ });
+
+ verificationConn.on('error', (err: Error) => {
+ cleanupVerification(false, `Verification connection error: ${err.message}`);
+ });
+
+ verificationConn.on('close', () => {
+ if (tunnelVerifications.has(tunnelName)) {
+ cleanupVerification(false, "Verification connection closed");
+ }
+ });
+
+ const connOptions: any = {
+ host: hostConfig.sourceIP,
+ port: hostConfig.sourceSSHPort,
+ username: hostConfig.sourceUsername,
+ readyTimeout: 10000,
+ algorithms: {
+ kex: [
+ 'diffie-hellman-group14-sha256',
+ 'diffie-hellman-group14-sha1',
+ 'diffie-hellman-group1-sha1',
+ 'diffie-hellman-group-exchange-sha256',
+ 'diffie-hellman-group-exchange-sha1',
+ 'ecdh-sha2-nistp256',
+ 'ecdh-sha2-nistp384',
+ 'ecdh-sha2-nistp521'
+ ],
+ cipher: [
+ 'aes128-ctr',
+ 'aes192-ctr',
+ 'aes256-ctr',
+ 'aes128-gcm@openssh.com',
+ 'aes256-gcm@openssh.com',
+ 'aes128-cbc',
+ 'aes192-cbc',
+ 'aes256-cbc',
+ '3des-cbc'
+ ],
+ hmac: [
+ 'hmac-sha2-256',
+ 'hmac-sha2-512',
+ 'hmac-sha1',
+ 'hmac-md5'
+ ],
+ compress: [
+ 'none',
+ 'zlib@openssh.com',
+ 'zlib'
+ ]
+ }
+ };
+
+ if (hostConfig.sourceAuthMethod === "key" && hostConfig.sourceSSHKey) {
+ connOptions.privateKey = hostConfig.sourceSSHKey;
+ if (hostConfig.sourceKeyPassword) {
+ connOptions.passphrase = hostConfig.sourceKeyPassword;
+ }
+ } else {
+ connOptions.password = hostConfig.sourcePassword;
+ }
+
+ verificationConn.connect(connOptions);
+}
+
+function setupPingInterval(tunnelName: string, hostConfig: HostConfig): void {
+ const pingInterval = setInterval(() => {
+ if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) {
+ clearInterval(pingInterval);
+ return;
+ }
+
+ const conn = activeTunnels.get(tunnelName);
+ if (!conn) {
+ clearInterval(pingInterval);
+ return;
+ }
+
+ conn.exec('echo "ping"', (err, stream) => {
+ if (err) {
+ clearInterval(pingInterval);
+
+ if (!manualDisconnects.has(tunnelName)) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.UNSTABLE,
+ reason: "Ping failed"
+ });
+ }
+
+ activeTunnels.delete(tunnelName);
+ handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
+ return;
+ }
+
+ stream.on('close', (code: number) => {
+ if (code !== 0) {
+ clearInterval(pingInterval);
+
+ if (!manualDisconnects.has(tunnelName)) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.UNSTABLE,
+ reason: "Ping command failed"
+ });
+ }
+
+ activeTunnels.delete(tunnelName);
+ handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
+ }
+ });
+
+ stream.on('error', (err: Error) => {
+ clearInterval(pingInterval);
+
+ if (!manualDisconnects.has(tunnelName)) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.UNSTABLE,
+ reason: "Ping stream error"
+ });
+ }
+
+ activeTunnels.delete(tunnelName);
+ handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
+ });
+ });
+ }, 30000); // Ping every 30 seconds
+}
+
+// Main SSH tunnel connection function
+function connectSSHTunnel(hostConfig: HostConfig, retryAttempt = 0): void {
+ const tunnelName = hostConfig.name;
+
+ if (manualDisconnects.has(tunnelName)) {
+ return;
+ }
+
+ cleanupTunnelResources(tunnelName);
+
+ if (retryAttempt === 0) {
+ retryExhaustedTunnels.delete(tunnelName);
+ retryCounters.delete(tunnelName);
+ remoteClosureEvents.delete(tunnelName);
+ }
+
+ const isRetryAfterRemoteClosure = remoteClosureEvents.get(tunnelName) && retryAttempt > 0;
+
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.CONNECTING,
+ retryCount: retryAttempt > 0 ? retryAttempt : undefined,
+ isRemoteRetry: !!isRetryAfterRemoteClosure
+ });
+
+ if (!hostConfig || !hostConfig.sourceIP || !hostConfig.sourceUsername || !hostConfig.sourceSSHPort) {
+ logger.error(`Invalid connection details for '${tunnelName}'`);
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.FAILED,
+ reason: "Missing required connection details"
+ });
+ return;
+ }
+
+ const conn = new Client();
+
+ const connectionTimeout = setTimeout(() => {
+ if (conn) {
+ if (activeRetryTimers.has(tunnelName)) {
+ return;
+ }
+
+ try {
+ conn.end();
+ } catch (e) {}
+
+ activeTunnels.delete(tunnelName);
+
+ if (!activeRetryTimers.has(tunnelName)) {
+ handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
+ }
+ }
+ }, 15000);
+
+ conn.on("error", (err) => {
+ clearTimeout(connectionTimeout);
+ logger.error(`SSH error for '${tunnelName}': ${err.message}`);
+
+ if (activeRetryTimers.has(tunnelName)) {
+ return;
+ }
+
+ const errorType = classifyError(err.message);
+ const isRemoteHostClosure = err.message.toLowerCase().includes("closed by remote host") ||
+ err.message.toLowerCase().includes("connection reset by peer") ||
+ err.message.toLowerCase().includes("broken pipe");
+
+ if (!manualDisconnects.has(tunnelName)) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.FAILED,
+ errorType: errorType,
+ reason: err.message
+ });
+ }
+
+ activeTunnels.delete(tunnelName);
+
+ if (isRemoteHostClosure && retryExhaustedTunnels.has(tunnelName)) {
+ retryExhaustedTunnels.delete(tunnelName);
+ }
+
+ const shouldNotRetry = !isRemoteHostClosure && (
+ errorType === ERROR_TYPES.AUTH ||
+ errorType === ERROR_TYPES.PORT ||
+ errorType === ERROR_TYPES.PERMISSION ||
+ manualDisconnects.has(tunnelName)
+ );
+
+ handleDisconnect(tunnelName, hostConfig, !shouldNotRetry, isRemoteHostClosure);
+ });
+
+ conn.on("close", () => {
+ clearTimeout(connectionTimeout);
+
+ if (activeRetryTimers.has(tunnelName)) {
+ return;
+ }
+
+ if (!manualDisconnects.has(tunnelName)) {
+ const currentStatus = connectionStatus.get(tunnelName);
+ if (!currentStatus || currentStatus.status !== CONNECTION_STATES.FAILED) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.DISCONNECTED
+ });
+ }
+
+ if (!activeRetryTimers.has(tunnelName)) {
+ handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName));
+ }
+ }
+ });
+
+ conn.on("ready", () => {
+ clearTimeout(connectionTimeout);
+
+ const isAlreadyVerifying = tunnelVerifications.has(tunnelName);
+ if (isAlreadyVerifying) {
+ return;
+ }
+
+ let tunnelCmd: string;
+ if (hostConfig.endpointAuthMethod === "key" && hostConfig.endpointSSHKey) {
+ tunnelCmd = `ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${hostConfig.endpointPort}:localhost:${hostConfig.sourcePort} ${hostConfig.endpointUsername}@${hostConfig.endpointIP}`;
+ } else {
+ tunnelCmd = `sshpass -p '${hostConfig.endpointPassword || ''}' ssh -T -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -R ${hostConfig.endpointPort}:localhost:${hostConfig.sourcePort} ${hostConfig.endpointUsername}@${hostConfig.endpointIP}`;
+ }
+
+ conn.exec(tunnelCmd, (err, stream) => {
+ if (err) {
+ logger.error(`Connection error for '${tunnelName}': ${err.message}`);
+
+ try { conn.end(); } catch(e) {}
+
+ activeTunnels.delete(tunnelName);
+
+ const errorType = classifyError(err.message);
+ const shouldNotRetry = errorType === ERROR_TYPES.AUTH ||
+ errorType === ERROR_TYPES.PORT ||
+ errorType === ERROR_TYPES.PERMISSION;
+
+ handleDisconnect(tunnelName, hostConfig, !shouldNotRetry);
+ return;
+ }
+
+ activeTunnels.set(tunnelName, conn);
+
+ setTimeout(() => {
+ if (!manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName)) {
+ verifyTunnelConnection(tunnelName, hostConfig, false);
+ }
+ }, 2000);
+
+ stream.on("close", (code: number) => {
+ if (activeRetryTimers.has(tunnelName)) {
+ return;
+ }
+
+ activeTunnels.delete(tunnelName);
+
+ if (tunnelVerifications.has(tunnelName)) {
+ try {
+ const verification = tunnelVerifications.get(tunnelName);
+ if (verification?.timeout) clearTimeout(verification.timeout);
+ verification?.conn.end();
+ } catch (e) {}
+ tunnelVerifications.delete(tunnelName);
+ }
+
+ const isLikelyRemoteClosure = code === 255;
+
+ if (isLikelyRemoteClosure && retryExhaustedTunnels.has(tunnelName)) {
+ retryExhaustedTunnels.delete(tunnelName);
+ }
+
+ if (!manualDisconnects.has(tunnelName) && code !== 0 && code !== undefined) {
+ if (retryExhaustedTunnels.has(tunnelName)) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.FAILED,
+ reason: "Max retries exhausted"
+ });
+ } else {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.FAILED,
+ reason: isLikelyRemoteClosure ? "Connection closed by remote host" : "Connection closed unexpectedly"
+ });
+ }
+ }
+
+ if (!activeRetryTimers.has(tunnelName) && !retryExhaustedTunnels.has(tunnelName)) {
+ handleDisconnect(tunnelName, hostConfig, !manualDisconnects.has(tunnelName), isLikelyRemoteClosure);
+ } else if (retryExhaustedTunnels.has(tunnelName) && isLikelyRemoteClosure) {
+ retryExhaustedTunnels.delete(tunnelName);
+ retryCounters.delete(tunnelName);
+ handleDisconnect(tunnelName, hostConfig, true, true);
+ }
+ });
+
+ stream.stderr.on("data", (data) => {
+ const errorMsg = data.toString();
+
+ const isNonRetryableError = errorMsg.includes("Permission denied") ||
+ errorMsg.includes("Authentication failed") ||
+ errorMsg.includes("failed for listen port") ||
+ errorMsg.includes("address already in use");
+
+ const isRemoteHostClosure = errorMsg.includes("closed by remote host") ||
+ errorMsg.includes("connection reset by peer") ||
+ errorMsg.includes("broken pipe");
+
+ if (isNonRetryableError || isRemoteHostClosure) {
+ if (activeRetryTimers.has(tunnelName)) {
+ return;
+ }
+
+ if (retryExhaustedTunnels.has(tunnelName)) {
+ if (isRemoteHostClosure) {
+ retryExhaustedTunnels.delete(tunnelName);
+ retryCounters.delete(tunnelName);
+ } else {
+ return;
+ }
+ }
+
+ activeTunnels.delete(tunnelName);
+
+ if (!manualDisconnects.has(tunnelName)) {
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.FAILED,
+ errorType: classifyError(errorMsg),
+ reason: errorMsg
+ });
+ }
+
+ const errorType = classifyError(errorMsg);
+ const shouldNotRetry = !isRemoteHostClosure && (
+ errorType === ERROR_TYPES.AUTH ||
+ errorType === ERROR_TYPES.PORT ||
+ errorType === ERROR_TYPES.PERMISSION
+ );
+
+ handleDisconnect(tunnelName, hostConfig, !shouldNotRetry, isRemoteHostClosure);
+ }
+ });
+ });
+ });
+
+ const connOptions: any = {
+ host: hostConfig.sourceIP,
+ port: hostConfig.sourceSSHPort,
+ username: hostConfig.sourceUsername,
+ keepaliveInterval: 5000,
+ keepaliveCountMax: 10,
+ readyTimeout: 10000,
+ tcpKeepAlive: true,
+ algorithms: {
+ kex: [
+ 'diffie-hellman-group14-sha256',
+ 'diffie-hellman-group14-sha1',
+ 'diffie-hellman-group1-sha1',
+ 'diffie-hellman-group-exchange-sha256',
+ 'diffie-hellman-group-exchange-sha1',
+ 'ecdh-sha2-nistp256',
+ 'ecdh-sha2-nistp384',
+ 'ecdh-sha2-nistp521'
+ ],
+ cipher: [
+ 'aes128-ctr',
+ 'aes192-ctr',
+ 'aes256-ctr',
+ 'aes128-gcm@openssh.com',
+ 'aes256-gcm@openssh.com',
+ 'aes128-cbc',
+ 'aes192-cbc',
+ 'aes256-cbc',
+ '3des-cbc'
+ ],
+ hmac: [
+ 'hmac-sha2-256',
+ 'hmac-sha2-512',
+ 'hmac-sha1',
+ 'hmac-md5'
+ ],
+ compress: [
+ 'none',
+ 'zlib@openssh.com',
+ 'zlib'
+ ]
+ }
+ };
+
+ if (hostConfig.sourceAuthMethod === "key" && hostConfig.sourceSSHKey) {
+ connOptions.privateKey = hostConfig.sourceSSHKey;
+ if (hostConfig.sourceKeyPassword) {
+ connOptions.passphrase = hostConfig.sourceKeyPassword;
+ }
+ } else {
+ connOptions.password = hostConfig.sourcePassword;
+ }
+
+ conn.connect(connOptions);
+}
+
+// Express API endpoints
+app.get('/status', (req, res) => {
+ res.json(getAllTunnelStatus());
+});
+
+app.get('/status/:tunnelName', (req, res) => {
+ const { tunnelName } = req.params;
+ const status = connectionStatus.get(tunnelName);
+
+ if (!status) {
+ return res.status(404).json({ error: 'Tunnel not found' });
+ }
+
+ res.json({ name: tunnelName, status });
+});
+
+app.post('/connect', (req, res) => {
+ const hostConfig: HostConfig = req.body;
+
+ if (!hostConfig || !hostConfig.name) {
+ return res.status(400).json({ error: 'Invalid tunnel configuration' });
+ }
+
+ const tunnelName = hostConfig.name;
+
+ // Reset retry state for new connection
+ manualDisconnects.delete(tunnelName);
+ retryCounters.delete(tunnelName);
+ retryExhaustedTunnels.delete(tunnelName);
+
+ // Store host config
+ hostConfigs.set(tunnelName, hostConfig);
+
+ // Start connection
+ connectSSHTunnel(hostConfig, 0);
+
+ res.json({ message: 'Connection request received', tunnelName });
+});
+
+app.post('/disconnect', (req, res) => {
+ const { tunnelName } = req.body;
+
+ if (!tunnelName) {
+ return res.status(400).json({ error: 'Tunnel name required' });
+ }
+
+ manualDisconnects.add(tunnelName);
+ retryCounters.delete(tunnelName);
+ retryExhaustedTunnels.delete(tunnelName);
+
+ if (activeRetryTimers.has(tunnelName)) {
+ clearTimeout(activeRetryTimers.get(tunnelName)!);
+ activeRetryTimers.delete(tunnelName);
+ }
+
+ broadcastTunnelStatus(tunnelName, {
+ connected: false,
+ status: CONNECTION_STATES.DISCONNECTED,
+ manualDisconnect: true
+ });
+
+ const hostConfig = hostConfigs.get(tunnelName) || null;
+ handleDisconnect(tunnelName, hostConfig, false);
+
+ // Clear manual disconnect flag after a delay
+ setTimeout(() => {
+ manualDisconnects.delete(tunnelName);
+ }, 5000);
+
+ res.json({ message: 'Disconnect request received', tunnelName });
+});
+
+// Auto-start functionality
+async function initializeAutoStartTunnels(): Promise {
+ try {
+ // Fetch auto-start tunnels from database
+ const response = await axios.get('http://localhost:8081/ssh_tunnel/tunnel?allAutoStart=1', {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Internal-Request': '1'
+ }
+ });
+
+ const tunnels = response.data || [];
+ const autoStartTunnels = tunnels.filter((tunnel: any) => tunnel.autoStart);
+
+ logger.info(`Found ${autoStartTunnels.length} auto-start tunnels`);
+
+ for (const tunnel of autoStartTunnels) {
+ const hostConfig: HostConfig = {
+ name: tunnel.name,
+ sourceIP: tunnel.sourceIP,
+ sourceSSHPort: tunnel.sourceSSHPort,
+ sourceUsername: tunnel.sourceUsername,
+ sourcePassword: tunnel.sourcePassword,
+ sourceAuthMethod: tunnel.sourceAuthMethod,
+ sourceSSHKey: tunnel.sourceSSHKey,
+ sourceKeyPassword: tunnel.sourceKeyPassword,
+ sourceKeyType: tunnel.sourceKeyType,
+ endpointIP: tunnel.endpointIP,
+ endpointSSHPort: tunnel.endpointSSHPort,
+ endpointUsername: tunnel.endpointUsername,
+ endpointPassword: tunnel.endpointPassword,
+ endpointAuthMethod: tunnel.endpointAuthMethod,
+ endpointSSHKey: tunnel.endpointSSHKey,
+ endpointKeyPassword: tunnel.endpointKeyPassword,
+ endpointKeyType: tunnel.endpointKeyType,
+ sourcePort: tunnel.sourcePort,
+ endpointPort: tunnel.endpointPort,
+ maxRetries: tunnel.maxRetries || 3,
+ retryInterval: tunnel.retryInterval || 5000,
+ autoStart: tunnel.autoStart,
+ isPinned: tunnel.isPinned || false
+ };
+
+ hostConfigs.set(tunnel.name, hostConfig);
+
+ // Start the tunnel
+ setTimeout(() => {
+ connectSSHTunnel(hostConfig, 0);
+ }, 1000); // Stagger startup to avoid overwhelming the system
+ }
+ } catch (error) {
+ logger.error('Failed to initialize auto-start tunnels:', error);
+ }
+}
+
+// Health check endpoint
+app.get('/health', (req, res) => {
+ res.json({
+ status: 'healthy',
+ activeTunnels: activeTunnels.size,
+ timestamp: new Date().toISOString()
+ });
+});
+
+// Get all tunnel configurations
+app.get('/tunnels', (req, res) => {
+ const tunnels = Array.from(hostConfigs.values());
+ res.json(tunnels);
+});
+
+// Update tunnel configuration
+app.put('/tunnel/:name', (req, res) => {
+ const { name } = req.params;
+ const hostConfig: HostConfig = req.body;
+
+ if (!hostConfig || !hostConfig.name) {
+ return res.status(400).json({ error: 'Invalid tunnel configuration' });
+ }
+
+ hostConfigs.set(name, hostConfig);
+
+ // If tunnel is currently connected, disconnect and reconnect with new config
+ if (activeTunnels.has(name)) {
+ manualDisconnects.add(name);
+ handleDisconnect(name, hostConfig, false);
+
+ setTimeout(() => {
+ manualDisconnects.delete(name);
+ connectSSHTunnel(hostConfig, 0);
+ }, 2000);
+ }
+
+ res.json({ message: 'Tunnel configuration updated', name });
+});
+
+// Delete tunnel configuration
+app.delete('/tunnel/:name', (req, res) => {
+ const { name } = req.params;
+
+ // Disconnect if active
+ if (activeTunnels.has(name)) {
+ manualDisconnects.add(name);
+ const hostConfig = hostConfigs.get(name) || null;
+ handleDisconnect(name, hostConfig, false);
+ }
+
+ // Remove from configurations
+ hostConfigs.delete(name);
+
+ res.json({ message: 'Tunnel deleted', name });
+});
+
+// Start the server
+const PORT = process.env.SSH_TUNNEL_PORT || 8083;
+app.listen(PORT, () => {
+ // Initialize auto-start tunnels after a short delay
+ setTimeout(() => {
+ initializeAutoStartTunnels();
+ }, 2000);
+});
\ No newline at end of file
diff --git a/src/backend/starter.ts b/src/backend/starter.ts
index 14f708bb..d2970806 100644
--- a/src/backend/starter.ts
+++ b/src/backend/starter.ts
@@ -3,6 +3,7 @@
import './db/database.js'
import './ssh/ssh.js';
+import './ssh_tunnel/ssh_tunnel.js';
import chalk from 'chalk';
const fixedIconSymbol = '🚀';
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
index 14213546..eda4eee8 100644
--- a/src/components/ui/alert.tsx
+++ b/src/components/ui/alert.tsx
@@ -39,7 +39,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {