SOCKS5 support
Adding single and chain socks5 proxy support
This commit is contained in:
68
package-lock.json
generated
68
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "1.8.1",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "1.8.1",
|
||||
"version": "1.9.0",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.7",
|
||||
"@codemirror/commands": "^6.3.3",
|
||||
@@ -84,6 +84,7 @@
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"recharts": "^3.2.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socks": "^2.8.7",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
@@ -154,6 +155,7 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -439,6 +441,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz",
|
||||
"integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -487,6 +490,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
@@ -513,6 +517,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
@@ -540,6 +545,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
|
||||
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
@@ -740,6 +746,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
|
||||
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
@@ -816,6 +823,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
|
||||
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
@@ -837,6 +845,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
|
||||
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -1164,6 +1173,7 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -1550,7 +1560,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1572,7 +1581,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -2529,7 +2537,8 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz",
|
||||
"integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@lezer/cpp": {
|
||||
"version": "1.1.3",
|
||||
@@ -2569,6 +2578,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
@@ -2600,6 +2610,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
@@ -2622,6 +2633,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
|
||||
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
@@ -4845,6 +4857,7 @@
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -5002,6 +5015,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
|
||||
"integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -5124,6 +5138,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz",
|
||||
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -5166,6 +5181,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -5176,6 +5192,7 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -5343,6 +5360,7 @@
|
||||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
@@ -5719,7 +5737,8 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
@@ -5754,6 +5773,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6176,6 +6196,7 @@
|
||||
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@@ -6310,6 +6331,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@@ -7313,6 +7335,7 @@
|
||||
"integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"env-paths": "^2.2.1",
|
||||
"import-fresh": "^3.3.0",
|
||||
@@ -7389,8 +7412,7 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -7849,6 +7871,7 @@
|
||||
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.0.12",
|
||||
"builder-util": "26.0.11",
|
||||
@@ -7946,8 +7969,7 @@
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
|
||||
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
},
|
||||
"node_modules/dot-prop": {
|
||||
"version": "5.3.0",
|
||||
@@ -8299,7 +8321,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -8320,7 +8341,6 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -8336,7 +8356,6 @@
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
@@ -8347,7 +8366,6 @@
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
@@ -8610,6 +8628,7 @@
|
||||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10208,6 +10227,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -10432,7 +10452,6 @@
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -11749,7 +11768,6 @@
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
@@ -13777,7 +13795,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -13795,7 +13812,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -14247,6 +14263,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14256,6 +14273,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -14282,6 +14300,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -14429,6 +14448,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -14637,7 +14657,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -15420,7 +15441,6 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
@@ -15431,7 +15451,6 @@
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.0.1",
|
||||
@@ -15958,7 +15977,6 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -15999,7 +16017,6 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -16014,7 +16031,6 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -16119,6 +16135,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16324,6 +16341,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16736,6 +16754,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
|
||||
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16827,6 +16846,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"recharts": "^3.2.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socks": "^2.8.7",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
|
||||
@@ -208,6 +208,11 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
force_keyboard_interactive TEXT,
|
||||
stats_config TEXT,
|
||||
terminal_config TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
socks5_port INTEGER,
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
@@ -487,6 +492,14 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
|
||||
|
||||
// SOCKS5 Proxy columns
|
||||
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
|
||||
addColumnIfNotExists("ssh_data", "socks5_host", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "socks5_port", "INTEGER");
|
||||
addColumnIfNotExists("ssh_data", "socks5_username", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "socks5_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "socks5_proxy_chain", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
||||
|
||||
@@ -90,6 +90,14 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
statsConfig: text("stats_config"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
quickActions: text("quick_actions"),
|
||||
|
||||
useSocks5: integer("use_socks5", { mode: "boolean" }),
|
||||
socks5Host: text("socks5_host"),
|
||||
socks5Port: integer("socks5_port"),
|
||||
socks5Username: text("socks5_username"),
|
||||
socks5Password: text("socks5_password"),
|
||||
socks5ProxyChain: text("socks5_proxy_chain"), // JSON array for proxy chains
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -242,7 +242,20 @@ router.post(
|
||||
statsConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
} = hostData;
|
||||
|
||||
console.log("POST /db/ssh - Received SOCKS5 data:", {
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
!isNonEmptyString(ip) ||
|
||||
@@ -284,6 +297,12 @@ router.post(
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
useSocks5: useSocks5 ? 1 : 0,
|
||||
socks5Host: socks5Host || null,
|
||||
socks5Port: socks5Port || null,
|
||||
socks5Username: socks5Username || null,
|
||||
socks5Password: socks5Password || null,
|
||||
socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null,
|
||||
};
|
||||
|
||||
if (effectiveAuthType === "password") {
|
||||
@@ -464,6 +483,12 @@ router.put(
|
||||
statsConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
} = hostData;
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
@@ -507,6 +532,12 @@ router.put(
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
useSocks5: useSocks5 ? 1 : 0,
|
||||
socks5Host: socks5Host || null,
|
||||
socks5Port: socks5Port || null,
|
||||
socks5Username: socks5Username || null,
|
||||
socks5Password: socks5Password || null,
|
||||
socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null,
|
||||
};
|
||||
|
||||
if (effectiveAuthType === "password") {
|
||||
@@ -690,6 +721,9 @@ router.get(
|
||||
? JSON.parse(row.terminalConfig as string)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: row.forceKeyboardInteractive === "true",
|
||||
socks5ProxyChain: row.socks5ProxyChain
|
||||
? JSON.parse(row.socks5ProxyChain as string)
|
||||
: [],
|
||||
};
|
||||
|
||||
return (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
@@ -765,6 +799,9 @@ router.get(
|
||||
? JSON.parse(host.terminalConfig)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||
socks5ProxyChain: host.socks5ProxyChain
|
||||
? JSON.parse(host.socks5ProxyChain)
|
||||
: [],
|
||||
};
|
||||
|
||||
res.json((await resolveHostCredentials(result)) || result);
|
||||
@@ -836,6 +873,9 @@ router.get(
|
||||
tunnelConnections: resolvedHost.tunnelConnections
|
||||
? JSON.parse(resolvedHost.tunnelConnections as string)
|
||||
: [],
|
||||
socks5ProxyChain: resolvedHost.socks5ProxyChain
|
||||
? JSON.parse(resolvedHost.socks5ProxyChain as string)
|
||||
: [],
|
||||
};
|
||||
|
||||
sshLogger.success("Host exported with decrypted credentials", {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fileLogger, sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
|
||||
function isExecutableFile(permissions: string, fileName: string): boolean {
|
||||
const hasExecutePermission =
|
||||
@@ -356,6 +357,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
userProvidedPassword,
|
||||
forceKeyboardInteractive,
|
||||
jumpHosts,
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
} = req.body;
|
||||
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -808,6 +815,83 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
},
|
||||
);
|
||||
|
||||
fileLogger.info("SFTP connection request received", {
|
||||
operation: "sftp_connect_request",
|
||||
sessionId,
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
hasSocks5ProxyChain: !!(socks5ProxyChain && (socks5ProxyChain as any).length > 0),
|
||||
proxyChainLength: socks5ProxyChain ? (socks5ProxyChain as any).length : 0,
|
||||
});
|
||||
|
||||
// Check if SOCKS5 proxy is enabled (either single proxy or chain)
|
||||
if (useSocks5 && (socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0))) {
|
||||
fileLogger.info("SOCKS5 enabled for SFTP, creating connection", {
|
||||
operation: "sftp_socks5_enabled",
|
||||
sessionId,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
hasChain: !!(socks5ProxyChain && (socks5ProxyChain as any).length > 0),
|
||||
});
|
||||
|
||||
try {
|
||||
const socks5Socket = await createSocks5Connection(
|
||||
ip,
|
||||
port,
|
||||
{
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain: socks5ProxyChain as any,
|
||||
},
|
||||
);
|
||||
|
||||
if (socks5Socket) {
|
||||
fileLogger.info("SOCKS5 socket created for SFTP", {
|
||||
operation: "sftp_socks5_socket_ready",
|
||||
sessionId,
|
||||
});
|
||||
config.sock = socks5Socket;
|
||||
client.connect(config);
|
||||
return;
|
||||
} else {
|
||||
fileLogger.error("SOCKS5 socket is null for SFTP", undefined, {
|
||||
operation: "sftp_socks5_socket_null",
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
} catch (socks5Error) {
|
||||
fileLogger.error("SOCKS5 connection failed", socks5Error, {
|
||||
operation: "socks5_connect",
|
||||
sessionId,
|
||||
hostId,
|
||||
proxyHost: socks5Host,
|
||||
proxyPort: socks5Port || 1080,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error:
|
||||
"SOCKS5 proxy connection failed: " +
|
||||
(socks5Error instanceof Error
|
||||
? socks5Error.message
|
||||
: "Unknown error"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
fileLogger.info("SOCKS5 NOT enabled for SFTP connection", {
|
||||
operation: "sftp_no_socks5",
|
||||
sessionId,
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
hasChain: !!(socks5ProxyChain && (socks5ProxyChain as any).length > 0),
|
||||
});
|
||||
}
|
||||
|
||||
if (jumpHosts && jumpHosts.length > 0 && userId) {
|
||||
try {
|
||||
const jumpClient = await createJumpHostChain(jumpHosts, userId);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { statsLogger, sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js";
|
||||
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
|
||||
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
|
||||
import { collectDiskMetrics } from "./widgets/disk-collector.js";
|
||||
@@ -18,6 +18,7 @@ import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
|
||||
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
|
||||
import { collectSystemMetrics } from "./widgets/system-collector.js";
|
||||
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
|
||||
async function resolveJumpHost(
|
||||
hostId: number,
|
||||
@@ -209,21 +210,44 @@ class SSHConnectionPool {
|
||||
}
|
||||
|
||||
private getHostKey(host: SSHHostWithCredentials): string {
|
||||
return `${host.ip}:${host.port}:${host.username}`;
|
||||
// Include SOCKS5 settings in the key to ensure separate connection pools
|
||||
// for direct connections vs SOCKS5 connections
|
||||
const socks5Key = host.useSocks5
|
||||
? `:socks5:${host.socks5Host}:${host.socks5Port}:${JSON.stringify(host.socks5ProxyChain || [])}`
|
||||
: "";
|
||||
return `${host.ip}:${host.port}:${host.username}${socks5Key}`;
|
||||
}
|
||||
|
||||
async getConnection(host: SSHHostWithCredentials): Promise<Client> {
|
||||
const hostKey = this.getHostKey(host);
|
||||
const connections = this.connections.get(hostKey) || [];
|
||||
|
||||
statsLogger.info("Getting connection from pool", {
|
||||
operation: "get_connection_from_pool",
|
||||
hostKey: hostKey,
|
||||
availableConnections: connections.length,
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
hasSocks5ProxyChain: !!(host.socks5ProxyChain && host.socks5ProxyChain.length > 0),
|
||||
hostId: host.id,
|
||||
});
|
||||
|
||||
const available = connections.find((conn) => !conn.inUse);
|
||||
if (available) {
|
||||
statsLogger.info("Reusing existing connection from pool", {
|
||||
operation: "reuse_connection",
|
||||
hostKey,
|
||||
});
|
||||
available.inUse = true;
|
||||
available.lastUsed = Date.now();
|
||||
return available.client;
|
||||
}
|
||||
|
||||
if (connections.length < this.maxConnectionsPerHost) {
|
||||
statsLogger.info("Creating new connection for pool", {
|
||||
operation: "create_new_connection",
|
||||
hostKey,
|
||||
});
|
||||
const client = await this.createConnection(host);
|
||||
const pooled: PooledConnection = {
|
||||
client,
|
||||
@@ -311,6 +335,68 @@ class SSHConnectionPool {
|
||||
try {
|
||||
const config = buildSshConfig(host);
|
||||
|
||||
// Check if SOCKS5 proxy is enabled (either single proxy or chain)
|
||||
if (
|
||||
host.useSocks5 &&
|
||||
(host.socks5Host || (host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
|
||||
) {
|
||||
statsLogger.info("Using SOCKS5 proxy for connection", {
|
||||
operation: "socks5_enabled",
|
||||
hostIp: host.ip,
|
||||
hostPort: host.port,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
hasChain: !!(host.socks5ProxyChain && host.socks5ProxyChain.length > 0),
|
||||
chainLength: host.socks5ProxyChain?.length || 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const socks5Socket = await createSocks5Connection(
|
||||
host.ip,
|
||||
host.port,
|
||||
{
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
},
|
||||
);
|
||||
|
||||
if (socks5Socket) {
|
||||
statsLogger.info("SOCKS5 socket created successfully", {
|
||||
operation: "socks5_socket_ready",
|
||||
hostIp: host.ip,
|
||||
});
|
||||
config.sock = socks5Socket;
|
||||
client.connect(config);
|
||||
return;
|
||||
} else {
|
||||
statsLogger.error("SOCKS5 socket is null", undefined, {
|
||||
operation: "socks5_socket_null",
|
||||
hostIp: host.ip,
|
||||
});
|
||||
}
|
||||
} catch (socks5Error) {
|
||||
clearTimeout(timeout);
|
||||
statsLogger.error("SOCKS5 connection error", socks5Error, {
|
||||
operation: "socks5_connection_error",
|
||||
hostIp: host.ip,
|
||||
errorMessage: socks5Error instanceof Error ? socks5Error.message : "Unknown",
|
||||
});
|
||||
reject(
|
||||
new Error(
|
||||
"SOCKS5 proxy connection failed: " +
|
||||
(socks5Error instanceof Error
|
||||
? socks5Error.message
|
||||
: "Unknown error"),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
host.jumpHosts,
|
||||
@@ -364,6 +450,29 @@ class SSHConnectionPool {
|
||||
}
|
||||
}
|
||||
|
||||
clearHostConnections(host: SSHHostWithCredentials): void {
|
||||
const hostKey = this.getHostKey(host);
|
||||
const connections = this.connections.get(hostKey) || [];
|
||||
|
||||
statsLogger.info("Clearing all connections for host", {
|
||||
operation: "clear_host_connections",
|
||||
hostKey,
|
||||
connectionCount: connections.length,
|
||||
});
|
||||
|
||||
for (const conn of connections) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch (error) {
|
||||
statsLogger.error("Error closing connection during cleanup", error, {
|
||||
operation: "clear_connection_error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.connections.delete(hostKey);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
const maxAge = 10 * 60 * 1000;
|
||||
@@ -387,6 +496,27 @@ class SSHConnectionPool {
|
||||
}
|
||||
}
|
||||
|
||||
clearAllConnections(): void {
|
||||
statsLogger.info("Clearing ALL connections from pool", {
|
||||
operation: "clear_all_connections",
|
||||
totalHosts: this.connections.size,
|
||||
});
|
||||
|
||||
for (const [hostKey, connections] of this.connections.entries()) {
|
||||
for (const conn of connections) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch (error) {
|
||||
statsLogger.error("Error closing connection during full cleanup", error, {
|
||||
operation: "clear_all_error",
|
||||
hostKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.connections.clear();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
clearInterval(this.cleanupInterval);
|
||||
for (const connections of this.connections.values()) {
|
||||
@@ -604,6 +734,14 @@ interface SSHHostWithCredentials {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId: string;
|
||||
|
||||
// SOCKS5 Proxy configuration
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
}
|
||||
|
||||
type StatusEntry = {
|
||||
@@ -742,33 +880,51 @@ class PollingManager {
|
||||
}
|
||||
|
||||
private async pollHostStatus(host: SSHHostWithCredentials): Promise<void> {
|
||||
// Refresh host data from database to get latest settings
|
||||
const refreshedHost = await fetchHostById(host.id, host.userId);
|
||||
if (!refreshedHost) {
|
||||
statsLogger.warn("Host not found during status polling", {
|
||||
operation: "poll_host_status",
|
||||
hostId: host.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isOnline = await tcpPing(host.ip, host.port, 5000);
|
||||
const isOnline = await tcpPing(refreshedHost.ip, refreshedHost.port, 5000);
|
||||
const statusEntry: StatusEntry = {
|
||||
status: isOnline ? "online" : "offline",
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
this.statusStore.set(host.id, statusEntry);
|
||||
this.statusStore.set(refreshedHost.id, statusEntry);
|
||||
} catch (error) {
|
||||
const statusEntry: StatusEntry = {
|
||||
status: "offline",
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
this.statusStore.set(host.id, statusEntry);
|
||||
this.statusStore.set(refreshedHost.id, statusEntry);
|
||||
}
|
||||
}
|
||||
|
||||
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
|
||||
const config = this.pollingConfigs.get(host.id);
|
||||
// Refresh host data from database to get latest SOCKS5 and other settings
|
||||
const refreshedHost = await fetchHostById(host.id, host.userId);
|
||||
if (!refreshedHost) {
|
||||
statsLogger.warn("Host not found during metrics polling", {
|
||||
operation: "poll_host_metrics",
|
||||
hostId: host.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.pollingConfigs.get(refreshedHost.id);
|
||||
if (!config || !config.statsConfig.metricsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHost = config.host;
|
||||
|
||||
try {
|
||||
const metrics = await collectMetrics(currentHost);
|
||||
this.metricsStore.set(currentHost.id, {
|
||||
const metrics = await collectMetrics(refreshedHost);
|
||||
this.metricsStore.set(refreshedHost.id, {
|
||||
data: metrics,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -776,12 +932,12 @@ class PollingManager {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
const latestConfig = this.pollingConfigs.get(currentHost.id);
|
||||
const latestConfig = this.pollingConfigs.get(refreshedHost.id);
|
||||
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
||||
statsLogger.warn("Failed to collect metrics for host", {
|
||||
operation: "metrics_poll_failed",
|
||||
hostId: currentHost.id,
|
||||
hostName: currentHost.name,
|
||||
hostId: refreshedHost.id,
|
||||
hostName: refreshedHost.name,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
@@ -1007,6 +1163,15 @@ async function resolveHostCredentials(
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
userId: host.userId,
|
||||
// SOCKS5 proxy settings
|
||||
useSocks5: !!host.useSocks5,
|
||||
socks5Host: host.socks5Host || undefined,
|
||||
socks5Port: host.socks5Port || undefined,
|
||||
socks5Username: host.socks5Username || undefined,
|
||||
socks5Password: host.socks5Password || undefined,
|
||||
socks5ProxyChain: host.socks5ProxyChain
|
||||
? JSON.parse(host.socks5ProxyChain as string)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (host.credentialId) {
|
||||
@@ -1057,6 +1222,16 @@ async function resolveHostCredentials(
|
||||
addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
|
||||
statsLogger.info("Resolved host credentials with SOCKS5 settings", {
|
||||
operation: "resolve_host",
|
||||
hostId: host.id as number,
|
||||
useSocks5: baseHost.useSocks5,
|
||||
socks5Host: baseHost.socks5Host,
|
||||
socks5Port: baseHost.socks5Port,
|
||||
hasSocks5ProxyChain: !!(baseHost.socks5ProxyChain && (baseHost.socks5ProxyChain as any[]).length > 0),
|
||||
proxyChainLength: baseHost.socks5ProxyChain ? (baseHost.socks5ProxyChain as any[]).length : 0,
|
||||
});
|
||||
|
||||
return baseHost as unknown as SSHHostWithCredentials;
|
||||
} catch (error) {
|
||||
statsLogger.error(
|
||||
@@ -1194,6 +1369,7 @@ async function withSshConnection<T>(
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await connectionPool.getConnection(host);
|
||||
|
||||
try {
|
||||
const result = await fn(client);
|
||||
return result;
|
||||
@@ -1402,6 +1578,20 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
res.json(statusEntry);
|
||||
});
|
||||
|
||||
app.post("/clear-connections", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
connectionPool.clearAllConnections();
|
||||
res.json({ message: "All SSH connections cleared" });
|
||||
});
|
||||
|
||||
app.post("/refresh", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
@@ -1412,6 +1602,9 @@ app.post("/refresh", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all connections to ensure fresh connections with updated settings
|
||||
connectionPool.clearAllConnections();
|
||||
|
||||
await pollingManager.refreshHostPolling(userId);
|
||||
res.json({ message: "Polling refreshed" });
|
||||
});
|
||||
@@ -1434,6 +1627,9 @@ app.post("/host-updated", async (req, res) => {
|
||||
try {
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
if (host) {
|
||||
// Clear existing connections for this host to ensure new settings (like SOCKS5) are used
|
||||
connectionPool.clearHostConnections(host);
|
||||
|
||||
await pollingManager.startPollingForHost(host);
|
||||
res.json({ message: "Host polling started" });
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
|
||||
interface ConnectToHostData {
|
||||
cols: number;
|
||||
@@ -32,6 +33,12 @@ interface ConnectToHostData {
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
jumpHosts?: Array<{ hostId: number }>;
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
};
|
||||
initialPath?: string;
|
||||
executeCommand?: string;
|
||||
@@ -1183,6 +1190,53 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if SOCKS5 proxy is enabled (either single proxy or chain)
|
||||
if (
|
||||
hostConfig.useSocks5 &&
|
||||
(hostConfig.socks5Host ||
|
||||
(hostConfig.socks5ProxyChain && (hostConfig.socks5ProxyChain as any).length > 0))
|
||||
) {
|
||||
try {
|
||||
const socks5Socket = await createSocks5Connection(
|
||||
ip,
|
||||
port,
|
||||
{
|
||||
useSocks5: hostConfig.useSocks5,
|
||||
socks5Host: hostConfig.socks5Host,
|
||||
socks5Port: hostConfig.socks5Port,
|
||||
socks5Username: hostConfig.socks5Username,
|
||||
socks5Password: hostConfig.socks5Password,
|
||||
socks5ProxyChain: hostConfig.socks5ProxyChain as any,
|
||||
},
|
||||
);
|
||||
|
||||
if (socks5Socket) {
|
||||
connectConfig.sock = socks5Socket;
|
||||
sshConn.connect(connectConfig);
|
||||
return;
|
||||
}
|
||||
} catch (socks5Error) {
|
||||
sshLogger.error("SOCKS5 connection failed", socks5Error, {
|
||||
operation: "socks5_connect",
|
||||
hostId: id,
|
||||
proxyHost: hostConfig.socks5Host,
|
||||
proxyPort: hostConfig.socks5Port || 1080,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"SOCKS5 proxy connection failed: " +
|
||||
(socks5Error instanceof Error
|
||||
? socks5Error.message
|
||||
: "Unknown error"),
|
||||
}),
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hostConfig.jumpHosts &&
|
||||
hostConfig.jumpHosts.length > 0 &&
|
||||
|
||||
@@ -19,6 +19,7 @@ import { tunnelLogger, sshLogger } from "../utils/logger.js";
|
||||
import { SystemCrypto } from "../utils/system-crypto.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
@@ -1016,6 +1017,51 @@ async function connectSSHTunnel(
|
||||
});
|
||||
}
|
||||
|
||||
// Check if SOCKS5 proxy is enabled (either single proxy or chain)
|
||||
if (
|
||||
tunnelConfig.useSocks5 &&
|
||||
(tunnelConfig.socks5Host ||
|
||||
(tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0))
|
||||
) {
|
||||
try {
|
||||
const socks5Socket = await createSocks5Connection(
|
||||
tunnelConfig.sourceIP,
|
||||
tunnelConfig.sourceSSHPort,
|
||||
{
|
||||
useSocks5: tunnelConfig.useSocks5,
|
||||
socks5Host: tunnelConfig.socks5Host,
|
||||
socks5Port: tunnelConfig.socks5Port,
|
||||
socks5Username: tunnelConfig.socks5Username,
|
||||
socks5Password: tunnelConfig.socks5Password,
|
||||
socks5ProxyChain: tunnelConfig.socks5ProxyChain,
|
||||
},
|
||||
);
|
||||
|
||||
if (socks5Socket) {
|
||||
connOptions.sock = socks5Socket;
|
||||
conn.connect(connOptions);
|
||||
return;
|
||||
}
|
||||
} catch (socks5Error) {
|
||||
tunnelLogger.error("SOCKS5 connection failed for tunnel", socks5Error, {
|
||||
operation: "socks5_connect",
|
||||
tunnelName,
|
||||
proxyHost: tunnelConfig.socks5Host,
|
||||
proxyPort: tunnelConfig.socks5Port || 1080,
|
||||
});
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason:
|
||||
"SOCKS5 proxy connection failed: " +
|
||||
(socks5Error instanceof Error
|
||||
? socks5Error.message
|
||||
: "Unknown error"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
|
||||
@@ -1248,7 +1294,57 @@ async function killRemoteTunnelByMarker(
|
||||
callback(err);
|
||||
});
|
||||
|
||||
conn.connect(connOptions);
|
||||
// Check if SOCKS5 proxy is enabled (either single proxy or chain)
|
||||
if (
|
||||
tunnelConfig.useSocks5 &&
|
||||
(tunnelConfig.socks5Host ||
|
||||
(tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0))
|
||||
) {
|
||||
(async () => {
|
||||
try {
|
||||
const socks5Socket = await createSocks5Connection(
|
||||
tunnelConfig.sourceIP,
|
||||
tunnelConfig.sourceSSHPort,
|
||||
{
|
||||
useSocks5: tunnelConfig.useSocks5,
|
||||
socks5Host: tunnelConfig.socks5Host,
|
||||
socks5Port: tunnelConfig.socks5Port,
|
||||
socks5Username: tunnelConfig.socks5Username,
|
||||
socks5Password: tunnelConfig.socks5Password,
|
||||
socks5ProxyChain: tunnelConfig.socks5ProxyChain,
|
||||
},
|
||||
);
|
||||
|
||||
if (socks5Socket) {
|
||||
connOptions.sock = socks5Socket;
|
||||
conn.connect(connOptions);
|
||||
} else {
|
||||
callback(new Error("Failed to create SOCKS5 connection"));
|
||||
}
|
||||
} catch (socks5Error) {
|
||||
tunnelLogger.error(
|
||||
"SOCKS5 connection failed for killing tunnel",
|
||||
socks5Error,
|
||||
{
|
||||
operation: "socks5_connect_kill",
|
||||
tunnelName,
|
||||
proxyHost: tunnelConfig.socks5Host,
|
||||
proxyPort: tunnelConfig.socks5Port || 1080,
|
||||
},
|
||||
);
|
||||
callback(
|
||||
new Error(
|
||||
"SOCKS5 proxy connection failed: " +
|
||||
(socks5Error instanceof Error
|
||||
? socks5Error.message
|
||||
: "Unknown error"),
|
||||
),
|
||||
);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
}
|
||||
|
||||
app.get("/ssh/tunnel/status", (req, res) => {
|
||||
@@ -1453,6 +1549,11 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
retryInterval: tunnelConnection.retryInterval * 1000,
|
||||
autoStart: tunnelConnection.autoStart,
|
||||
isPinned: host.pin,
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
};
|
||||
|
||||
autoStartTunnels.push(tunnelConfig);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity";
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity" | "socks5_proxy_presets";
|
||||
|
||||
class SimpleDBOps {
|
||||
static async insert<T extends Record<string, unknown>>(
|
||||
|
||||
160
src/backend/utils/socks5-helper.ts
Normal file
160
src/backend/utils/socks5-helper.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { SocksClient } from "socks";
|
||||
import type { SocksClientOptions } from "socks";
|
||||
import net from "net";
|
||||
import { sshLogger } from "./logger.js";
|
||||
import type { ProxyNode } from "../../types/index.js";
|
||||
|
||||
export interface SOCKS5Config {
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SOCKS5 connection through a single proxy or a chain of proxies
|
||||
* @param targetHost - Target SSH server hostname/IP
|
||||
* @param targetPort - Target SSH server port
|
||||
* @param socks5Config - SOCKS5 proxy configuration
|
||||
* @returns Promise with connected socket or null if SOCKS5 is not enabled
|
||||
*/
|
||||
export async function createSocks5Connection(
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
socks5Config: SOCKS5Config,
|
||||
): Promise<net.Socket | null> {
|
||||
// If SOCKS5 is not enabled, return null
|
||||
if (!socks5Config.useSocks5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If proxy chain is provided, use chain connection
|
||||
if (socks5Config.socks5ProxyChain && socks5Config.socks5ProxyChain.length > 0) {
|
||||
return createProxyChainConnection(targetHost, targetPort, socks5Config.socks5ProxyChain);
|
||||
}
|
||||
|
||||
// If single proxy is configured, use single proxy connection
|
||||
if (socks5Config.socks5Host) {
|
||||
return createSingleProxyConnection(targetHost, targetPort, socks5Config);
|
||||
}
|
||||
|
||||
// No proxy configured
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a connection through a single SOCKS proxy
|
||||
*/
|
||||
async function createSingleProxyConnection(
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
socks5Config: SOCKS5Config,
|
||||
): Promise<net.Socket> {
|
||||
const socksOptions: SocksClientOptions = {
|
||||
proxy: {
|
||||
host: socks5Config.socks5Host!,
|
||||
port: socks5Config.socks5Port || 1080,
|
||||
type: 5,
|
||||
userId: socks5Config.socks5Username,
|
||||
password: socks5Config.socks5Password,
|
||||
},
|
||||
command: "connect",
|
||||
destination: {
|
||||
host: targetHost,
|
||||
port: targetPort,
|
||||
},
|
||||
};
|
||||
|
||||
sshLogger.info("Creating SOCKS5 connection", {
|
||||
operation: "socks5_connect",
|
||||
proxyHost: socks5Config.socks5Host,
|
||||
proxyPort: socks5Config.socks5Port || 1080,
|
||||
targetHost,
|
||||
targetPort,
|
||||
hasAuth: !!(socks5Config.socks5Username && socks5Config.socks5Password),
|
||||
});
|
||||
|
||||
try {
|
||||
const info = await SocksClient.createConnection(socksOptions);
|
||||
|
||||
sshLogger.info("SOCKS5 connection established", {
|
||||
operation: "socks5_connected",
|
||||
targetHost,
|
||||
targetPort,
|
||||
});
|
||||
|
||||
return info.socket;
|
||||
} catch (error) {
|
||||
sshLogger.error("SOCKS5 connection failed", error, {
|
||||
operation: "socks5_connect_failed",
|
||||
proxyHost: socks5Config.socks5Host,
|
||||
proxyPort: socks5Config.socks5Port || 1080,
|
||||
targetHost,
|
||||
targetPort,
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a connection through a chain of SOCKS proxies
|
||||
* Each proxy in the chain connects through the previous one
|
||||
*/
|
||||
async function createProxyChainConnection(
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
proxyChain: ProxyNode[],
|
||||
): Promise<net.Socket> {
|
||||
if (proxyChain.length === 0) {
|
||||
throw new Error("Proxy chain is empty");
|
||||
}
|
||||
|
||||
const chainPath = proxyChain.map((p) => `${p.host}:${p.port}`).join(" → ");
|
||||
sshLogger.info(`Creating SOCKS proxy chain: ${chainPath} → ${targetHost}:${targetPort}`, {
|
||||
operation: "socks5_chain_connect",
|
||||
chainLength: proxyChain.length,
|
||||
targetHost,
|
||||
targetPort,
|
||||
proxies: proxyChain.map((p) => `${p.host}:${p.port}`),
|
||||
});
|
||||
|
||||
try {
|
||||
const info = await SocksClient.createConnectionChain({
|
||||
proxies: proxyChain.map((p) => ({
|
||||
host: p.host,
|
||||
port: p.port,
|
||||
type: p.type,
|
||||
userId: p.username,
|
||||
password: p.password,
|
||||
timeout: 10000, // 10-second timeout for each hop
|
||||
})),
|
||||
command: "connect",
|
||||
destination: {
|
||||
host: targetHost,
|
||||
port: targetPort,
|
||||
},
|
||||
});
|
||||
|
||||
sshLogger.info(`✓ Proxy chain established: ${chainPath} → ${targetHost}:${targetPort}`, {
|
||||
operation: "socks5_chain_connected",
|
||||
chainLength: proxyChain.length,
|
||||
targetHost,
|
||||
targetPort,
|
||||
fullPath: `${chainPath} → ${targetHost}:${targetPort}`,
|
||||
});
|
||||
|
||||
return info.socket;
|
||||
} catch (error) {
|
||||
sshLogger.error("SOCKS proxy chain connection failed", error, {
|
||||
operation: "socks5_chain_connect_failed",
|
||||
chainLength: proxyChain.length,
|
||||
targetHost,
|
||||
targetPort,
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -901,6 +901,47 @@
|
||||
"searchServers": "Search servers...",
|
||||
"noServerFound": "No server found",
|
||||
"jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server",
|
||||
"socks5Proxy": "SOCKS5 Proxy",
|
||||
"socks5Description": "Configure SOCKS5 proxy for SSH connection. All traffic will be routed through the specified proxy server.",
|
||||
"enableSocks5": "Enable SOCKS5 Proxy",
|
||||
"enableSocks5Description": "Use SOCKS5 proxy for this SSH connection",
|
||||
"socks5Host": "Proxy Host",
|
||||
"socks5Port": "Proxy Port",
|
||||
"socks5Username": "Proxy Username",
|
||||
"socks5Password": "Proxy Password",
|
||||
"socks5UsernameOptional": "Optional: leave empty if proxy doesn't require authentication",
|
||||
"socks5PasswordOptional": "Optional: leave empty if proxy doesn't require authentication",
|
||||
"socks5ProxyChain": "Proxy Chain",
|
||||
"socks5ProxyChainDescription": "Configure a chain of SOCKS proxies. Each proxy in the chain will connect through the previous one.",
|
||||
"socks5ProxyMode": "Proxy Mode",
|
||||
"socks5UseSingleProxy": "Use Single Proxy",
|
||||
"socks5UseProxyChain": "Use Proxy Chain",
|
||||
"socks5UsePreset": "Use Saved Preset",
|
||||
"socks5SelectPreset": "Select Preset",
|
||||
"socks5ManagePresets": "Manage Presets",
|
||||
"socks5ProxyNode": "Proxy {{number}}",
|
||||
"socks5AddProxy": "Add Proxy to Chain",
|
||||
"socks5RemoveProxy": "Remove Proxy",
|
||||
"socks5ProxyType": "Proxy Type",
|
||||
"socks5SaveAsPreset": "Save as Preset",
|
||||
"socks5SavePresetTitle": "Save Proxy Chain as Preset",
|
||||
"socks5SavePresetDescription": "Save the current proxy chain configuration as a reusable preset",
|
||||
"socks5PresetName": "Preset Name",
|
||||
"socks5PresetDescription": "Description (optional)",
|
||||
"socks5PresetCreated": "Proxy chain preset created",
|
||||
"socks5PresetUpdated": "Proxy chain preset updated",
|
||||
"socks5PresetDeleted": "Proxy chain preset deleted",
|
||||
"socks5PresetSaved": "Preset \"{{name}}\" saved successfully",
|
||||
"socks5PresetSaveError": "Failed to save preset",
|
||||
"socks5PresetNameRequired": "Preset name is required",
|
||||
"socks5EmptyChainError": "Cannot save an empty proxy chain",
|
||||
"socks5ProxyChainEmpty": "Add at least one proxy to the chain",
|
||||
"socks5HostDescription": "Hostname or IP address of the SOCKS proxy server",
|
||||
"socks5PortDescription": "Port number of the SOCKS proxy server (default: 1080)",
|
||||
"addProxyNode": "Add Proxy Node",
|
||||
"noProxyNodes": "No proxy nodes configured. Click 'Add Proxy Node' to add one.",
|
||||
"proxyNode": "Proxy Node",
|
||||
"proxyType": "Proxy Type",
|
||||
"quickActions": "Quick Actions",
|
||||
"quickActionsDescription": "Quick actions allow you to create custom buttons that execute SSH snippets on this server. These buttons will appear at the top of the Server Stats page for quick access.",
|
||||
"quickActionsList": "Quick Actions List",
|
||||
@@ -1632,7 +1673,12 @@
|
||||
"folderName": "Enter folder name",
|
||||
"fullPath": "Enter full path to item",
|
||||
"currentPath": "Enter current path to item",
|
||||
"newName": "Enter new name"
|
||||
"newName": "Enter new name",
|
||||
"socks5Host": "127.0.0.1",
|
||||
"socks5Username": "proxy username",
|
||||
"socks5Password": "proxy password",
|
||||
"socks5PresetName": "e.g., Work VPN Chain",
|
||||
"socks5PresetDescription": "e.g., Proxy chain for accessing work servers"
|
||||
},
|
||||
"leftSidebar": {
|
||||
"failedToLoadHosts": "Failed to load hosts",
|
||||
|
||||
@@ -858,6 +858,47 @@
|
||||
"searchServers": "Поиск серверов...",
|
||||
"noServerFound": "Сервер не найден",
|
||||
"jumpHostsOrder": "Подключения будут выполнены в порядке: Промежуточный хост 1 → Промежуточный хост 2 → ... → Целевой сервер",
|
||||
"socks5Proxy": "SOCKS5 Прокси",
|
||||
"socks5Description": "Настройте SOCKS5 прокси для SSH подключения. Весь трафик будет направлен через указанный прокси-сервер.",
|
||||
"enableSocks5": "Включить SOCKS5 Прокси",
|
||||
"enableSocks5Description": "Использовать SOCKS5 прокси для этого SSH подключения",
|
||||
"socks5Host": "Хост прокси",
|
||||
"socks5Port": "Порт прокси",
|
||||
"socks5Username": "Имя пользователя прокси",
|
||||
"socks5Password": "Пароль прокси",
|
||||
"socks5UsernameOptional": "Необязательно: оставьте пустым, если прокси не требует аутентификации",
|
||||
"socks5PasswordOptional": "Необязательно: оставьте пустым, если прокси не требует аутентификации",
|
||||
"socks5ProxyChain": "Цепочка Прокси",
|
||||
"socks5ProxyChainDescription": "Настройте цепочку SOCKS прокси. Каждый прокси в цепочке будет подключаться через предыдущий.",
|
||||
"socks5ProxyMode": "Режим Прокси",
|
||||
"socks5UseSingleProxy": "Использовать Один Прокси",
|
||||
"socks5UseProxyChain": "Использовать Цепочку Прокси",
|
||||
"socks5UsePreset": "Использовать Сохраненный Пресет",
|
||||
"socks5SelectPreset": "Выбрать Пресет",
|
||||
"socks5ManagePresets": "Управление Пресетами",
|
||||
"socks5ProxyNode": "Прокси {{number}}",
|
||||
"socks5AddProxy": "Добавить Прокси в Цепочку",
|
||||
"socks5RemoveProxy": "Удалить Прокси",
|
||||
"socks5ProxyType": "Тип Прокси",
|
||||
"socks5SaveAsPreset": "Сохранить как Пресет",
|
||||
"socks5SavePresetTitle": "Сохранить Цепочку Прокси как Пресет",
|
||||
"socks5SavePresetDescription": "Сохраните текущую конфигурацию цепочки прокси как переиспользуемый пресет",
|
||||
"socks5PresetName": "Название Пресета",
|
||||
"socks5PresetDescription": "Описание (необязательно)",
|
||||
"socks5PresetCreated": "Пресет цепочки прокси создан",
|
||||
"socks5PresetUpdated": "Пресет цепочки прокси обновлен",
|
||||
"socks5PresetDeleted": "Пресет цепочки прокси удален",
|
||||
"socks5PresetSaved": "Пресет \"{{name}}\" успешно сохранен",
|
||||
"socks5PresetSaveError": "Не удалось сохранить пресет",
|
||||
"socks5PresetNameRequired": "Необходимо указать название пресета",
|
||||
"socks5EmptyChainError": "Невозможно сохранить пустую цепочку прокси",
|
||||
"socks5ProxyChainEmpty": "Добавьте хотя бы один прокси в цепочку",
|
||||
"socks5HostDescription": "Имя хоста или IP-адрес SOCKS прокси сервера",
|
||||
"socks5PortDescription": "Номер порта SOCKS прокси сервера (по умолчанию: 1080)",
|
||||
"addProxyNode": "Добавить узел прокси",
|
||||
"noProxyNodes": "Узлы прокси не настроены. Нажмите 'Добавить узел прокси' чтобы добавить.",
|
||||
"proxyNode": "Узел прокси",
|
||||
"proxyType": "Тип прокси",
|
||||
"advancedAuthSettings": "Расширенные настройки аутентификации"
|
||||
},
|
||||
"terminal": {
|
||||
@@ -1530,7 +1571,12 @@
|
||||
"folderName": "Введите имя папки",
|
||||
"fullPath": "Введите полный путь к элементу",
|
||||
"currentPath": "Введите текущий путь к элементу",
|
||||
"newName": "Введите новое имя"
|
||||
"newName": "Введите новое имя",
|
||||
"socks5Host": "127.0.0.1",
|
||||
"socks5Username": "имя пользователя прокси",
|
||||
"socks5Password": "пароль прокси",
|
||||
"socks5PresetName": "например, Рабочая VPN Цепочка",
|
||||
"socks5PresetDescription": "например, Цепочка прокси для доступа к рабочим серверам"
|
||||
},
|
||||
"leftSidebar": {
|
||||
"failedToLoadHosts": "Не удалось загрузить хосты",
|
||||
|
||||
@@ -44,8 +44,16 @@ export interface SSHHost {
|
||||
tunnelConnections: TunnelConnection[];
|
||||
jumpHosts?: JumpHost[];
|
||||
quickActions?: QuickAction[];
|
||||
statsConfig?: string;
|
||||
statsConfig?: string | Record<string, unknown>;
|
||||
terminalConfig?: TerminalConfig;
|
||||
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -59,6 +67,14 @@ export interface QuickActionData {
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
export interface ProxyNode {
|
||||
host: string;
|
||||
port: number;
|
||||
type: 4 | 5; // SOCKS4 or SOCKS5
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface SSHHostData {
|
||||
name?: string;
|
||||
ip: string;
|
||||
@@ -84,6 +100,14 @@ export interface SSHHostData {
|
||||
quickActions?: QuickActionData[];
|
||||
statsConfig?: string | Record<string, unknown>;
|
||||
terminalConfig?: TerminalConfig;
|
||||
|
||||
// SOCKS5 Proxy configuration
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
}
|
||||
|
||||
export interface SSHFolder {
|
||||
@@ -182,6 +206,14 @@ export interface TunnelConfig {
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
isPinned: boolean;
|
||||
|
||||
// SOCKS5 Proxy configuration
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
}
|
||||
|
||||
export interface TunnelStatus {
|
||||
|
||||
@@ -336,6 +336,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
|
||||
useSocks5: currentHost.useSocks5,
|
||||
socks5Host: currentHost.socks5Host,
|
||||
socks5Port: currentHost.socks5Port,
|
||||
socks5Username: currentHost.socks5Username,
|
||||
socks5Password: currentHost.socks5Password,
|
||||
socks5ProxyChain: currentHost.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_totp) {
|
||||
@@ -760,14 +766,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
if (!status.connected) {
|
||||
const result = await connectSSH(currentSessionId, {
|
||||
hostId: currentHost.id,
|
||||
host: currentHost.ip,
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
authType: currentHost.authType,
|
||||
password: currentHost.password,
|
||||
key: currentHost.key,
|
||||
sshKey: currentHost.key,
|
||||
keyPassword: currentHost.keyPassword,
|
||||
credentialId: currentHost.credentialId,
|
||||
useSocks5: currentHost.useSocks5,
|
||||
socks5Host: currentHost.socks5Host,
|
||||
socks5Port: currentHost.socks5Port,
|
||||
socks5Username: currentHost.socks5Username,
|
||||
socks5Password: currentHost.socks5Password,
|
||||
socks5ProxyChain: currentHost.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -1313,6 +1325,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
authType: currentHost.authType,
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
useSocks5: currentHost.useSocks5,
|
||||
socks5Host: currentHost.socks5Host,
|
||||
socks5Port: currentHost.socks5Port,
|
||||
socks5Username: currentHost.socks5Username,
|
||||
socks5Password: currentHost.socks5Password,
|
||||
socks5ProxyChain: currentHost.socks5ProxyChain,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1464,7 +1482,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
authType: credentials.password ? "password" : "key",
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
userProvidedPassword: true,
|
||||
useSocks5: currentHost.useSocks5,
|
||||
socks5Host: currentHost.socks5Host,
|
||||
socks5Port: currentHost.socks5Port,
|
||||
socks5Username: currentHost.socks5Username,
|
||||
socks5Password: currentHost.socks5Password,
|
||||
socks5ProxyChain: currentHost.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_totp) {
|
||||
|
||||
@@ -69,6 +69,14 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
TERMINAL_FONTS,
|
||||
@@ -78,8 +86,9 @@ import {
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
} from "@/constants/terminal-themes";
|
||||
import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx";
|
||||
import type { TerminalConfig } from "@/types";
|
||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||
import type { TerminalConfig, SSHHost, Credential } from "@/types";
|
||||
import { Plus, X, Check, ChevronsUpDown, Save } from "lucide-react";
|
||||
import { Socks5ProxyConfig } from "./Socks5ProxyConfig.tsx";
|
||||
|
||||
interface JumpHostItemProps {
|
||||
jumpHost: { hostId: number };
|
||||
@@ -277,46 +286,6 @@ function QuickActionItem({
|
||||
);
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: Array<{
|
||||
sourcePort: number;
|
||||
endpointPort: number;
|
||||
endpointHost: string;
|
||||
maxRetries: number;
|
||||
retryInterval: number;
|
||||
autoStart: boolean;
|
||||
}>;
|
||||
jumpHosts?: Array<{
|
||||
hostId: number;
|
||||
}>;
|
||||
quickActions?: Array<{
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}>;
|
||||
statsConfig?: StatsConfig;
|
||||
terminalConfig?: TerminalConfig;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
credentialId?: number;
|
||||
}
|
||||
|
||||
interface SSHManagerHostEditorProps {
|
||||
editingHost?: SSHHost | null;
|
||||
onFormSubmit?: (updatedHost?: SSHHost) => void;
|
||||
@@ -330,12 +299,13 @@ export function HostManagerEditor({
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [credentials, setCredentials] = useState<
|
||||
Array<{ id: number; username: string; authType: string }>
|
||||
>([]);
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [snippets, setSnippets] = useState<
|
||||
Array<{ id: number; name: string; content: string }>
|
||||
>([]);
|
||||
const [proxyMode, setProxyMode] = useState<"single" | "chain">(
|
||||
"single",
|
||||
);
|
||||
|
||||
const [authTab, setAuthTab] = useState<
|
||||
"password" | "key" | "credential" | "none"
|
||||
@@ -369,7 +339,7 @@ export function HostManagerEditor({
|
||||
getSnippets(),
|
||||
]);
|
||||
setHosts(hostsData);
|
||||
setCredentials(credentialsData);
|
||||
setCredentials(credentialsData as Credential[]);
|
||||
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
|
||||
|
||||
const uniqueFolders = [
|
||||
@@ -565,6 +535,22 @@ export function HostManagerEditor({
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
useSocks5: z.boolean().optional(),
|
||||
socks5Host: z.string().optional(),
|
||||
socks5Port: z.coerce.number().min(1).max(65535).optional(),
|
||||
socks5Username: z.string().optional(),
|
||||
socks5Password: z.string().optional(),
|
||||
socks5ProxyChain: z
|
||||
.array(
|
||||
z.object({
|
||||
host: z.string().min(1),
|
||||
port: z.number().min(1).max(65535),
|
||||
type: z.union([z.literal(4), z.literal(5)]),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.authType === "none") {
|
||||
@@ -601,11 +587,7 @@ export function HostManagerEditor({
|
||||
});
|
||||
}
|
||||
} else if (data.authType === "credential") {
|
||||
if (
|
||||
!data.credentialId ||
|
||||
(typeof data.credentialId === "string" &&
|
||||
data.credentialId.trim() === "")
|
||||
) {
|
||||
if (!data.credentialId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.credentialRequired"),
|
||||
@@ -657,6 +639,12 @@ export function HostManagerEditor({
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: false,
|
||||
useSocks5: false,
|
||||
socks5Host: "",
|
||||
socks5Port: 1080,
|
||||
socks5Username: "",
|
||||
socks5Password: "",
|
||||
socks5ProxyChain: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -673,7 +661,7 @@ export function HostManagerEditor({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [authTab, credentials, form.getValues, form.setValue]);
|
||||
}, [authTab, credentials, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingHost) {
|
||||
@@ -697,13 +685,13 @@ export function HostManagerEditor({
|
||||
: "none";
|
||||
setAuthTab(defaultAuthType);
|
||||
|
||||
let parsedStatsConfig = DEFAULT_STATS_CONFIG;
|
||||
let parsedStatsConfig: StatsConfig = DEFAULT_STATS_CONFIG;
|
||||
try {
|
||||
if (cleanedHost.statsConfig) {
|
||||
parsedStatsConfig =
|
||||
typeof cleanedHost.statsConfig === "string"
|
||||
? JSON.parse(cleanedHost.statsConfig)
|
||||
: cleanedHost.statsConfig;
|
||||
: (cleanedHost.statsConfig as StatsConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse statsConfig:", error);
|
||||
@@ -711,7 +699,7 @@ export function HostManagerEditor({
|
||||
|
||||
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
|
||||
|
||||
const formData = {
|
||||
const formData: Partial<FormData> = {
|
||||
name: cleanedHost.name || "",
|
||||
ip: cleanedHost.ip || "",
|
||||
port: cleanedHost.port || 22,
|
||||
@@ -720,7 +708,7 @@ export function HostManagerEditor({
|
||||
tags: Array.isArray(cleanedHost.tags) ? cleanedHost.tags : [],
|
||||
pin: Boolean(cleanedHost.pin),
|
||||
authType: defaultAuthType as "password" | "key" | "credential" | "none",
|
||||
credentialId: null,
|
||||
credentialId: cleanedHost.credentialId,
|
||||
overrideCredentialUsername: Boolean(
|
||||
cleanedHost.overrideCredentialUsername,
|
||||
),
|
||||
@@ -752,8 +740,26 @@ export function HostManagerEditor({
|
||||
: [],
|
||||
},
|
||||
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
|
||||
useSocks5: Boolean(cleanedHost.useSocks5),
|
||||
socks5Host: cleanedHost.socks5Host || "",
|
||||
socks5Port: cleanedHost.socks5Port || 1080,
|
||||
socks5Username: cleanedHost.socks5Username || "",
|
||||
socks5Password: cleanedHost.socks5Password || "",
|
||||
socks5ProxyChain: Array.isArray(cleanedHost.socks5ProxyChain)
|
||||
? cleanedHost.socks5ProxyChain
|
||||
: [],
|
||||
};
|
||||
|
||||
// Determine proxy mode based on existing data
|
||||
if (
|
||||
Array.isArray(cleanedHost.socks5ProxyChain) &&
|
||||
cleanedHost.socks5ProxyChain.length > 0
|
||||
) {
|
||||
setProxyMode("chain");
|
||||
} else {
|
||||
setProxyMode("single");
|
||||
}
|
||||
|
||||
if (defaultAuthType === "password") {
|
||||
formData.password = cleanedHost.password || "";
|
||||
} else if (defaultAuthType === "key") {
|
||||
@@ -772,13 +778,13 @@ export function HostManagerEditor({
|
||||
| "ssh-rsa-sha2-512") || "auto";
|
||||
} else if (defaultAuthType === "credential") {
|
||||
formData.credentialId =
|
||||
cleanedHost.credentialId || "existing_credential";
|
||||
cleanedHost.credentialId;
|
||||
}
|
||||
|
||||
form.reset(formData);
|
||||
form.reset(formData as FormData);
|
||||
} else {
|
||||
setAuthTab("password");
|
||||
const defaultFormData = {
|
||||
const defaultFormData: Partial<FormData> = {
|
||||
name: "",
|
||||
ip: "",
|
||||
port: 22,
|
||||
@@ -805,9 +811,9 @@ export function HostManagerEditor({
|
||||
forceKeyboardInteractive: false,
|
||||
};
|
||||
|
||||
form.reset(defaultFormData);
|
||||
form.reset(defaultFormData as FormData);
|
||||
}
|
||||
}, [editingHost?.id]);
|
||||
}, [editingHost, form]);
|
||||
|
||||
useEffect(() => {
|
||||
const focusTimer = setTimeout(() => {
|
||||
@@ -820,6 +826,8 @@ export function HostManagerEditor({
|
||||
}, [editingHost]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
await form.trigger();
|
||||
console.log("onSubmit called with data:", data);
|
||||
try {
|
||||
isSubmittingRef.current = true;
|
||||
setFormError(null);
|
||||
@@ -849,65 +857,52 @@ export function HostManagerEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const submitData: Record<string, unknown> = {
|
||||
name: data.name,
|
||||
ip: data.ip,
|
||||
port: data.port,
|
||||
username: data.username,
|
||||
folder: data.folder || "",
|
||||
tags: data.tags || [],
|
||||
pin: Boolean(data.pin),
|
||||
authType: data.authType,
|
||||
overrideCredentialUsername: Boolean(data.overrideCredentialUsername),
|
||||
enableTerminal: Boolean(data.enableTerminal),
|
||||
enableTunnel: Boolean(data.enableTunnel),
|
||||
enableFileManager: Boolean(data.enableFileManager),
|
||||
defaultPath: data.defaultPath || "/",
|
||||
tunnelConnections: data.tunnelConnections || [],
|
||||
jumpHosts: data.jumpHosts || [],
|
||||
quickActions: data.quickActions || [],
|
||||
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive),
|
||||
const submitData: Partial<SSHHost> = {
|
||||
...data,
|
||||
};
|
||||
|
||||
submitData.credentialId = null;
|
||||
submitData.password = null;
|
||||
submitData.key = null;
|
||||
submitData.keyPassword = null;
|
||||
submitData.keyType = null;
|
||||
console.log("Submitting host data:", {
|
||||
useSocks5: submitData.useSocks5,
|
||||
socks5Host: submitData.socks5Host,
|
||||
socks5ProxyChain: submitData.socks5ProxyChain,
|
||||
proxyMode,
|
||||
});
|
||||
|
||||
if (data.authType === "credential") {
|
||||
if (
|
||||
data.credentialId === "existing_credential" &&
|
||||
editingHost &&
|
||||
editingHost.id
|
||||
) {
|
||||
delete submitData.credentialId;
|
||||
} else {
|
||||
submitData.credentialId = data.credentialId;
|
||||
}
|
||||
} else if (data.authType === "password") {
|
||||
submitData.password = data.password;
|
||||
} else if (data.authType === "key") {
|
||||
if (proxyMode === "single") {
|
||||
submitData.socks5ProxyChain = [];
|
||||
} else if (proxyMode === "chain") {
|
||||
submitData.socks5Host = "";
|
||||
submitData.socks5Port = 1080;
|
||||
submitData.socks5Username = "";
|
||||
submitData.socks5Password = "";
|
||||
}
|
||||
|
||||
if (data.authType !== "credential") {
|
||||
submitData.credentialId = undefined;
|
||||
}
|
||||
if (data.authType !== "password") {
|
||||
submitData.password = undefined;
|
||||
}
|
||||
if (data.authType !== "key") {
|
||||
submitData.key = undefined;
|
||||
submitData.keyPassword = undefined;
|
||||
submitData.keyType = undefined;
|
||||
}
|
||||
|
||||
if (data.authType === "key") {
|
||||
if (data.key instanceof File) {
|
||||
const keyContent = await data.key.text();
|
||||
submitData.key = keyContent;
|
||||
submitData.key = await data.key.text();
|
||||
} else if (data.key === "existing_key") {
|
||||
delete submitData.key;
|
||||
} else {
|
||||
submitData.key = data.key;
|
||||
}
|
||||
submitData.keyPassword = data.keyPassword;
|
||||
submitData.keyType = data.keyType;
|
||||
}
|
||||
|
||||
let savedHost;
|
||||
if (editingHost && editingHost.id) {
|
||||
savedHost = await updateSSHHost(editingHost.id, submitData);
|
||||
savedHost = await updateSSHHost(editingHost.id, submitData as any);
|
||||
toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name }));
|
||||
} else {
|
||||
savedHost = await createSSHHost(submitData);
|
||||
savedHost = await createSSHHost(submitData as any);
|
||||
toast.success(t("hosts.hostAddedSuccessfully", { name: data.name }));
|
||||
}
|
||||
|
||||
@@ -953,7 +948,8 @@ export function HostManagerEditor({
|
||||
notifyHostCreatedOrUpdated(savedHost.id);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("hosts.failedToSaveHost"));
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
toast.error(t("hosts.failedToSaveHost") + ": " + errorMessage);
|
||||
console.error("Failed to save host:", error);
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
@@ -1485,7 +1481,7 @@ export function HostManagerEditor({
|
||||
<span
|
||||
className="truncate"
|
||||
title={
|
||||
field.value?.name || t("hosts.upload")
|
||||
(field.value as File)?.name || t("hosts.upload")
|
||||
}
|
||||
>
|
||||
{field.value === "existing_key"
|
||||
@@ -1493,7 +1489,7 @@ export function HostManagerEditor({
|
||||
: field.value
|
||||
? editingHost
|
||||
? t("hosts.updateKey")
|
||||
: field.value.name
|
||||
: (field.value as File).name
|
||||
: t("hosts.upload")}
|
||||
</span>
|
||||
</Button>
|
||||
@@ -1532,8 +1528,6 @@ export function HostManagerEditor({
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
highlightSelectionMatches: false,
|
||||
searchKeymap: false,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
extensions={[
|
||||
EditorView.theme({
|
||||
@@ -1782,159 +1776,64 @@ export function HostManagerEditor({
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabsContent>
|
||||
<TabsContent value="terminal" className="space-y-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableTerminal"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.enableTerminal")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.enableTerminalDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Alert className="mt-4 mb-4">
|
||||
<AlertDescription>
|
||||
{t("hosts.terminalCustomizationNotice")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<h1 className="text-xl font-semibold mt-7">
|
||||
{t("hosts.terminalCustomization")}
|
||||
</h1>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<AccordionItem value="appearance">
|
||||
|
||||
<AccordionItem value="socks5">
|
||||
<AccordionTrigger>
|
||||
{t("hosts.appearance")}
|
||||
{t("hosts.socks5Proxy")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("hosts.themePreview")}
|
||||
</label>
|
||||
<TerminalPreview
|
||||
theme={form.watch("terminalConfig.theme")}
|
||||
fontSize={form.watch("terminalConfig.fontSize")}
|
||||
fontFamily={form.watch("terminalConfig.fontFamily")}
|
||||
cursorStyle={form.watch(
|
||||
"terminalConfig.cursorStyle",
|
||||
)}
|
||||
cursorBlink={form.watch(
|
||||
"terminalConfig.cursorBlink",
|
||||
)}
|
||||
letterSpacing={form.watch(
|
||||
"terminalConfig.letterSpacing",
|
||||
)}
|
||||
lineHeight={form.watch("terminalConfig.lineHeight")}
|
||||
/>
|
||||
</div>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("hosts.socks5Description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.theme"
|
||||
name="useSocks5"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.theme")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("hosts.selectTheme")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(TERMINAL_THEMES).map(
|
||||
([key, theme]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t("hosts.chooseColorTheme")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.fontFamily"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.fontFamily")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("hosts.selectFont")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{TERMINAL_FONTS.map((font) => (
|
||||
<SelectItem
|
||||
key={font.value}
|
||||
value={font.value}
|
||||
>
|
||||
{font.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t("hosts.selectFontDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.fontSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("hosts.fontSizeValue", {
|
||||
value: field.value,
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
{t("hosts.enableSocks5")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.enableSocks5Description")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={8}
|
||||
max={24}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) =>
|
||||
field.onChange(value)
|
||||
}
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.adjustFontSize")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("useSocks5") && (
|
||||
<Socks5ProxyConfig
|
||||
control={form.control}
|
||||
watch={form.watch}
|
||||
setValue={form.setValue}
|
||||
proxyMode={proxyMode}
|
||||
onProxyModeChange={setProxyMode}
|
||||
/>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terminal">
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full"
|
||||
defaultValue={["appearance", "behavior", "advanced"]}
|
||||
>
|
||||
<AccordionItem value="appearance">
|
||||
<AccordionTrigger>{t("hosts.appearance")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.letterSpacing"
|
||||
|
||||
244
src/ui/desktop/apps/host-manager/Socks5ProxyConfig.tsx
Normal file
244
src/ui/desktop/apps/host-manager/Socks5ProxyConfig.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormField, FormItem, FormLabel, FormControl, FormDescription } from "@/components/ui/form.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select.tsx";
|
||||
import type { Control, UseFormWatch, UseFormSetValue } from "react-hook-form";
|
||||
import type { ProxyNode } from "@/types";
|
||||
|
||||
interface Socks5ProxyConfigProps {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
proxyMode: "single" | "chain";
|
||||
onProxyModeChange: (mode: "single" | "chain") => void;
|
||||
}
|
||||
|
||||
export function Socks5ProxyConfig({
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
proxyMode,
|
||||
onProxyModeChange,
|
||||
}: Socks5ProxyConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const proxyChain = watch("socks5ProxyChain") || [];
|
||||
|
||||
const addProxyNode = () => {
|
||||
const currentChain = watch("socks5ProxyChain") || [];
|
||||
const newNode: ProxyNode = {
|
||||
host: "",
|
||||
port: 1080,
|
||||
type: 5,
|
||||
username: "",
|
||||
password: "",
|
||||
};
|
||||
setValue("socks5ProxyChain", [...currentChain, newNode]);
|
||||
};
|
||||
|
||||
const removeProxyNode = (index: number) => {
|
||||
const currentChain = watch("socks5ProxyChain") || [];
|
||||
const newChain = currentChain.filter((_: any, i: number) => i !== index);
|
||||
setValue("socks5ProxyChain", newChain);
|
||||
};
|
||||
|
||||
const updateProxyNode = (index: number, field: keyof ProxyNode, value: any) => {
|
||||
const currentChain = watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = { ...newChain[index], [field]: value };
|
||||
setValue("socks5ProxyChain", newChain);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("hosts.socks5ProxyMode")}</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={proxyMode === "single" ? "default" : "outline"}
|
||||
onClick={() => onProxyModeChange("single")}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("hosts.socks5UseSingleProxy")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={proxyMode === "chain" ? "default" : "outline"}
|
||||
onClick={() => onProxyModeChange("chain")}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("hosts.socks5UseProxyChain")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{proxyMode === "single" && (
|
||||
<div className="space-y-4 p-4 border rounded-lg">
|
||||
<FormField
|
||||
control={control}
|
||||
name="socks5Host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.socks5Host")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="proxy.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.socks5HostDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="socks5Port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.socks5Port")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1080"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 1080)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.socks5PortDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="socks5Username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.socks5Username")} ({t("hosts.optional")})</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("hosts.username")} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name="socks5Password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.socks5Password")} ({t("hosts.optional")})</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={t("hosts.password")} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proxyMode === "chain" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>{t("hosts.socks5ProxyChain")}</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addProxyNode}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("hosts.addProxyNode")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{proxyChain.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground text-center p-4 border rounded-lg border-dashed">
|
||||
{t("hosts.noProxyNodes")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proxyChain.map((node: ProxyNode, index: number) => (
|
||||
<div key={index} className="p-4 border rounded-lg space-y-3 relative">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("hosts.proxyNode")} {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeProxyNode(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("hosts.socks5Host")}</FormLabel>
|
||||
<Input
|
||||
placeholder="proxy.example.com"
|
||||
value={node.host}
|
||||
onChange={(e) => updateProxyNode(index, "host", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("hosts.socks5Port")}</FormLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1080"
|
||||
value={node.port}
|
||||
onChange={(e) => updateProxyNode(index, "port", parseInt(e.target.value) || 1080)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("hosts.proxyType")}</FormLabel>
|
||||
<Select
|
||||
value={String(node.type)}
|
||||
onValueChange={(value) => updateProxyNode(index, "type", parseInt(value) as 4 | 5)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="4">SOCKS4</SelectItem>
|
||||
<SelectItem value="5">SOCKS5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("hosts.socks5Username")} ({t("hosts.optional")})</FormLabel>
|
||||
<Input
|
||||
placeholder={t("hosts.username")}
|
||||
value={node.username || ""}
|
||||
onChange={(e) => updateProxyNode(index, "username", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("hosts.socks5Password")} ({t("hosts.optional")})</FormLabel>
|
||||
<PasswordInput
|
||||
placeholder={t("hosts.password")}
|
||||
value={node.password || ""}
|
||||
onChange={(e) => updateProxyNode(index, "password", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -183,6 +183,12 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
retryInterval: tunnel.retryInterval * 1000,
|
||||
autoStart: tunnel.autoStart,
|
||||
isPinned: host.pin,
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
};
|
||||
|
||||
await connectTunnel(tunnelConfig);
|
||||
|
||||
@@ -867,6 +867,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
: null,
|
||||
terminalConfig: hostData.terminalConfig || null,
|
||||
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
||||
useSocks5: Boolean(hostData.useSocks5),
|
||||
socks5Host: hostData.socks5Host || null,
|
||||
socks5Port: hostData.socks5Port || null,
|
||||
socks5Username: hostData.socks5Username || null,
|
||||
socks5Password: hostData.socks5Password || null,
|
||||
socks5ProxyChain: hostData.socks5ProxyChain || null,
|
||||
};
|
||||
|
||||
if (!submitData.enableTunnel) {
|
||||
@@ -933,6 +939,12 @@ export async function updateSSHHost(
|
||||
: null,
|
||||
terminalConfig: hostData.terminalConfig || null,
|
||||
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
||||
useSocks5: Boolean(hostData.useSocks5),
|
||||
socks5Host: hostData.socks5Host || null,
|
||||
socks5Port: hostData.socks5Port || null,
|
||||
socks5Username: hostData.socks5Username || null,
|
||||
socks5Password: hostData.socks5Password || null,
|
||||
socks5ProxyChain: hostData.socks5ProxyChain || null,
|
||||
};
|
||||
|
||||
if (!submitData.enableTunnel) {
|
||||
@@ -1244,6 +1256,12 @@ export async function connectSSH(
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
@@ -3109,3 +3127,4 @@ export async function unlinkOIDCFromPasswordAccount(
|
||||
throw handleApiError(error, "unlink OIDC from password account");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user