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}
+ )} +
+ + {/* Tunnel Name */} +
+

Tunnel Name

+ ( + + + + + + + )} + /> +
+ + {/* Folder */} + ( + + Folder + + { + if (typeof field.ref === 'function') field.ref(el); + (folderInputRef as React.MutableRefObject).current = el; + }} + placeholder="e.g. Work" + autoComplete="off" + value={field.value} + onFocus={() => setFolderDropdownOpen(true)} + onChange={e => { + field.onChange(e); + setFolderDropdownOpen(true); + }} + disabled={foldersLoading} + /> + + {/* Folder dropdown menu */} + {folderDropdownOpen && filteredFolders.length > 0 && ( +
+
+ {filteredFolders.map((folder) => ( + + ))} +
+
+ )} + {foldersLoading &&
Loading folders...
} + {foldersError &&
{foldersError}
} + +
+ )} + /> + + {/* Tunnel Port Configuration */} +
+

Tunnel Port Configuration

+
+ ( + + Source Port (Local) + + field.onChange(Number(e.target.value) || 22)} + /> + + + + )} + /> + + ( + + Endpoint Port (Remote) + + field.onChange(Number(e.target.value) || 224)} + /> + + + + )} + /> +
+

+ This tunnel will forward traffic from port {sourcePort} on the source machine to port {endpointPort} on the endpoint machine. +

+
+ + {/* SSH Pass Warning */} + + Sshpass Required For Password Authentication + + For password-based SSH authentication, sshpass must be installed on both the local and remote servers.
+ Install with: sudo apt install sshpass (Debian/Ubuntu) or the equivalent for your OS. +
+ Other installation methods +
    +
  • CentOS/RHEL/Fedora: sudo yum install sshpass or sudo dnf install sshpass
  • +
  • macOS: brew install hudochenkov/sshpass/sshpass
  • +
  • Windows: Use WSL or consider SSH key authentication
  • +
+
+
+
+ {/* SSH Config Info */} + + SSH Server Configuration Required + + For reverse SSH tunnels, the endpoint SSH server must allow: +
    +
  • GatewayPorts yes (bind remote ports)
  • +
  • AllowTcpForwarding yes (port forwarding)
  • +
  • PermitRootLogin yes (if using root)
  • +
+ Edit /etc/ssh/sshd_config and restart SSH: sudo systemctl restart sshd +
+
+ + {/* Source SSH Configuration */} +
+

Source SSH Configuration (Local Machine)

+ +
+
+ ( + + Source IP + + + + + + )} + /> + ( + + Source SSH Port + + field.onChange(Number(e.target.value) || 22)} + /> + + + + )} + /> +
+ ( + + Source Username + + + + + + )} + /> + ( + + + Password + SSH Key + + + + ( + + Source Password + + + + + + )} + /> + + + + ( + + SSH Private Key + +
+ { + const file = e.target.files?.[0]; + field.onChange(file || null); + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+
+ )} + /> + ( + + Key Password (if protected) + + + + + + )} + /> + ( + + Key Type + +
+ + {editSourceKeyTypeDropdownOpen && ( +
+
+ {keyTypeOptions.map(opt => ( + + ))} +
+
+ )} +
+
+ +
+ )} + /> +
+
+ )} + /> +
+
+ + {/* Endpoint SSH Configuration */} +
+

Endpoint SSH Configuration (Remote Machine)

+ +
+
+ ( + + Endpoint IP + + + + + + )} + /> + ( + + Endpoint SSH Port + + field.onChange(Number(e.target.value) || 22)} + /> + + + + )} + /> +
+ ( + + Endpoint Username + + + + + + )} + /> + ( + + + Password + SSH Key + + + + ( + + Endpoint Password + + + + + + )} + /> + + + + ( + + SSH Private Key + +
+ { + const file = e.target.files?.[0]; + field.onChange(file || null); + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+
+ )} + /> + ( + + Key Password (if protected) + + + + + + )} + /> + ( + + Key Type + +
+ + {editEndpointKeyTypeDropdownOpen && ( +
+
+ {keyTypeOptions.map(opt => ( + + ))} +
+
+ )} +
+
+ +
+ )} + /> +
+
+ )} + /> +
+
+ + {/* Other */} +
+

Other

+ +
+ ( + + Max Retries + + field.onChange(Number(e.target.value) || 3)} + /> + + + + )} + /> + ( + + Retry Interval (ms) + + field.onChange(Number(e.target.value) || 5000)} + /> + + + + )} + /> + ( + + +
+ + Auto Start on Container Launch +
+
+ +
+ )} + /> + ( + + +
+ + Pin Connection +
+
+ +
+ )} + /> +
+
+ + +
+ + + + + + + + + + +
+
+
+
+ + {/* Edit Tunnel Sheet */} + { + if (!open) { + setTimeout(() => { + setEditTunnelData(null); + editTunnelForm.reset(); + }, 100); + } + setEditSheetOpen(open); + }}> + + + Edit SSH Tunnel + + Modify the SSH tunnel configuration. + + +
+ {submitError && ( +
{submitError}
+ )} +
+ + {/* Tunnel Name */} +
+

Tunnel Name

+ ( + + + + + + + )} + /> +
+ + {/* Folder */} + ( + + Folder + + setEditFolderDropdownOpen(true)} + onChange={e => { + field.onChange(e); + setEditFolderDropdownOpen(true); + }} + disabled={foldersLoading} + /> + + {/* Folder dropdown menu */} + {editFolderDropdownOpen && filteredFolders.length > 0 && ( +
+
+ {filteredFolders.map((folder) => ( + + ))} +
+
+ )} + {foldersLoading &&
Loading folders...
} + {foldersError &&
{foldersError}
} + +
+ )} + /> + + {/* Tunnel Port Configuration */} +
+

Tunnel Port Configuration

+
+ ( + + Source Port (Local) + + field.onChange(Number(e.target.value) || 22)} + /> + + + + )} + /> + + ( + + Endpoint Port (Remote) + + field.onChange(Number(e.target.value) || 224)} + /> + + + + )} + /> +
+

+ This tunnel will forward traffic from port {editTunnelForm.watch('sourcePort')} on the source machine to port {editTunnelForm.watch('endpointPort')} on the endpoint machine. +

+
+ + {/* SSH Pass Warning */} + + Sshpass Required For Password Authentication + + For password-based SSH authentication, sshpass must be installed on both the local and remote servers.
+ Install with: sudo apt install sshpass (Debian/Ubuntu) or the equivalent for your OS. +
+ Other installation methods +
    +
  • CentOS/RHEL/Fedora: sudo yum install sshpass or sudo dnf install sshpass
  • +
  • macOS: brew install hudochenkov/sshpass/sshpass
  • +
  • Windows: Use WSL or consider SSH key authentication
  • +
+
+
+
+ {/* SSH Config Info */} + + SSH Server Configuration Required + + For reverse SSH tunnels, the endpoint SSH server must allow: +
    +
  • GatewayPorts yes (bind remote ports)
  • +
  • AllowTcpForwarding yes (port forwarding)
  • +
  • PermitRootLogin yes (if using root)
  • +
+ Edit /etc/ssh/sshd_config and restart SSH: sudo systemctl restart sshd +
+
+ + {/* Source SSH Configuration */} +
+

Source SSH Configuration (Local Machine)

+ +
+
+ ( + + Source IP + + + + + + )} + /> + ( + + Source SSH Port + + field.onChange(Number(e.target.value) || 22)} + /> + + + + )} + /> +
+ ( + + Source Username + + + + + + )} + /> + ( + + + Password + SSH Key + + + + ( + + Source Password + + + + + + )} + /> + + + + ( + + SSH Private Key + +
+ { + const file = e.target.files?.[0]; + field.onChange(file || null); + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+
+ )} + /> + ( + + Key Password (if protected) + + + + + + )} + /> + ( + + Key Type + +
+ + {sourceKeyTypeDropdownOpen && ( +
+
+ {keyTypeOptions.map(opt => ( + + ))} +
+
+ )} +
+
+ +
+ )} + /> +
+
+ )} + /> +
+
+ + {/* Endpoint SSH Configuration */} +
+

Endpoint SSH Configuration (Remote Machine)

+ +
+
+ ( + + Endpoint IP + + + + + + )} + /> + ( + + Endpoint SSH Port + + field.onChange(Number(e.target.value) || 22)} + /> + + + + )} + /> +
+ ( + + Endpoint Username + + + + + + )} + /> + ( + + + Password + SSH Key + + + + ( + + Endpoint Password + + + + + + )} + /> + + + + ( + + SSH Private Key + +
+ { + const file = e.target.files?.[0]; + field.onChange(file || null); + }} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+
+
+ )} + /> + ( + + Key Password (if protected) + + + + + + )} + /> + ( + + Key Type + +
+ + {editEndpointKeyTypeDropdownOpen && ( +
+
+ {keyTypeOptions.map(opt => ( + + ))} +
+
+ )} +
+
+ +
+ )} + /> +
+
+ )} + /> +
+
+ + {/* Advanced Options */} +
+

Advanced Options

+ +
+ ( + + Max Retries + + field.onChange(Number(e.target.value) || 3)} + /> + + + + )} + /> + ( + + Retry Interval (ms) + + field.onChange(Number(e.target.value) || 5000)} + /> + + + + )} + /> + ( + + +
+ + Auto Start on Container Launch +
+
+ +
+ )} + /> + ( + + +
+ + Pin Connection +
+
+ +
+ )} + /> +
+
+ + +
+ + + + + + + + + +
+
- ) -} \ 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">) {