From aab7e285a901da63ad662a5686a64c5fd72a2711 Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 18 Oct 2024 20:19:41 +0200 Subject: [PATCH] replace ssh2 with russh --- .github/workflows/build.yml | 39 +- .gitignore | 1 - app/package.json | 3 +- app/yarn.lock | 100 ++- package.json | 1 - patches/ssh2+1.11.0.patch | 39 - tabby-core/src/api/platform.ts | 20 +- tabby-core/src/services/vault.service.ts | 2 +- .../src/services/platform.service.ts | 8 +- tabby-electron/src/sftpContextMenu.ts | 33 +- .../components/vaultSettingsTab.component.ts | 2 +- tabby-ssh/package.json | 10 +- tabby-ssh/src/algorithms.ts | 61 +- tabby-ssh/src/api/index.ts | 1 - tabby-ssh/src/api/interfaces.ts | 10 - tabby-ssh/src/api/proxyStream.ts | 61 -- ...keyboardInteractiveAuthPanel.component.pug | 12 +- .../keyboardInteractiveAuthPanel.component.ts | 13 +- tabby-ssh/src/components/sshTab.component.pug | 1 + tabby-ssh/src/components/sshTab.component.ts | 29 +- tabby-ssh/src/index.ts | 2 - tabby-ssh/src/polyfills.ts | 12 - tabby-ssh/src/profiles.ts | 20 +- tabby-ssh/src/services/ssh.service.ts | 180 +---- tabby-ssh/src/session/sftp.ts | 130 ++-- tabby-ssh/src/session/shell.ts | 33 +- tabby-ssh/src/session/ssh.ts | 723 ++++++++++-------- tabby-ssh/webpack.config.mjs | 5 - tabby-ssh/yarn.lock | 183 +---- tabby-web/src/platform.ts | 6 +- webpack.plugin.config.mjs | 1 + yarn.lock | 35 +- 32 files changed, 718 insertions(+), 1058 deletions(-) delete mode 100644 patches/ssh2+1.11.0.patch delete mode 100644 tabby-ssh/src/api/proxyStream.ts delete mode 100644 tabby-ssh/src/polyfills.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 342f7e89..3c9fbfd0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,9 +37,15 @@ jobs: matrix: include: - arch: x86_64 + rust_triple: x86_64-apple-darwin - arch: arm64 + rust_triple: aarch64-apple-darwin fail-fast: false + env: + ARCH: ${{matrix.arch}} + RUST_TARGET_TRIPLE: ${{matrix.rust_triple}} + steps: - name: Checkout uses: actions/checkout@v3 @@ -51,6 +57,8 @@ jobs: with: node-version: 18 + - run: rustup target add ${{matrix.rust_triple}} + - name: Install deps run: | sudo -H pip3 install setuptools @@ -59,12 +67,6 @@ jobs: env: ARCH: ${{matrix.arch}} - - name: Fix cross build - run: | - rm -rf app/node_modules/cpu-features - rm -rf app/node_modules/ssh2/crypto/build - if: matrix.arch == 'arm64' - - name: Webpack run: yarn run build @@ -136,18 +138,24 @@ jobs: include: - build-arch: x64 arch: amd64 + rust_triple: x86_64-unknown-linux-gnu - build-arch: arm64 arch: arm64 + rust_triple: aarch64-unknown-linux-gnu triplet: aarch64-linux-gnu- - build-arch: arm arch: armhf + rust_triple: arm-unknown-linux-gnueabihf triplet: arm-linux-gnueabihf- + fail-fast: false + env: CC: ${{matrix.triplet}}gcc CXX: ${{matrix.triplet}}g++ ARCH: ${{matrix.build-arch}} npm_config_arch: ${{matrix.build-arch}} npm_config_target_arch: ${{matrix.build-arch}} + RUST_TARGET_TRIPLE: ${{matrix.rust_triple}} steps: - name: Checkout @@ -160,6 +168,8 @@ jobs: with: node-version: 18 + - run: rustup target add ${{matrix.rust_triple}} + - name: Install dependencies run: | sudo apt-get update @@ -280,17 +290,22 @@ jobs: path: tabby-web.tar.gz if: matrix.build-arch == 'x64' - Windows-Build: - runs-on: windows-2022 + runs-on: windows-latest needs: Lint strategy: matrix: include: - arch: x64 + rust_triple: x86_64-pc-windows-msvc - arch: arm64 + rust_triple: aarch64-pc-windows-msvc fail-fast: false + env: + RUST_TARGET_TRIPLE: ${{matrix.rust_triple}} + ARCH: ${{matrix.arch}} + steps: - name: Checkout uses: actions/checkout@v3 @@ -302,6 +317,14 @@ jobs: with: node-version: 18 + - run: npm i -g npx + - run: rustup target add ${{matrix.rust_triple}} + + - name: Update node-gyp + run: | + npm install --global node-gyp@10.2.0 + npm prefix -g | % {npm config set node_gyp "$_\node_modules\node-gyp\bin\node-gyp.js"} + - name: Build shell: powershell run: | diff --git a/.gitignore b/.gitignore index 103bc1cd..312e4406 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,6 @@ docs/api sentry.properties sentry-symbols.js -tabby-ssh/util/pagent.exe *.psd crowdin.yml diff --git a/app/package.json b/app/package.json index 63891ec0..bba15673 100644 --- a/app/package.json +++ b/app/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@electron/remote": "^2", - "node-pty": "^1.0", + "node-pty": "^1.1.0-beta.14", "any-promise": "^1.3.0", "electron-config": "2.0.0", "electron-debug": "^3.2.0", @@ -30,6 +30,7 @@ "native-process-working-directory": "^1.0.2", "npm": "6", "rxjs": "^7.5.7", + "russh": "0.0.3", "source-map-support": "^0.5.20", "v8-compile-cache": "^2.3.0", "yargs": "^17.7.2" diff --git a/app/yarn.lock b/app/yarn.lock index 1aa949c3..fcb34419 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -28,6 +28,11 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" +"@napi-rs/cli@^2.18.3": + version "2.18.4" + resolved "https://registry.yarnpkg.com/@napi-rs/cli/-/cli-2.18.4.tgz#12bebfb7995902fa7ab43cc0b155a7f5a2caa873" + integrity sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg== + "@ngx-translate/core@^14.0.0": version "14.0.0" resolved "https://registry.yarnpkg.com/@ngx-translate/core/-/core-14.0.0.tgz#af421d0e1a28376843f0fed375cd2fae7630a5ff" @@ -1490,25 +1495,26 @@ glasstron@0.1.1: x11 "^2.3.0" glob@^10.2.2, glob@^10.3.10: - version "10.3.10" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b" - integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g== + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== dependencies: foreground-child "^3.1.0" - jackspeak "^2.3.5" - minimatch "^9.0.1" - minipass "^5.0.0 || ^6.0.2 || ^7.0.0" - path-scurry "^1.10.1" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: - version "7.1.6" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" inherits "2" - minimatch "^3.0.4" + minimatch "^3.1.1" once "^1.3.0" path-is-absolute "^1.0.0" @@ -1931,10 +1937,10 @@ isstream@~0.1.2: resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz" integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= -jackspeak@^2.3.5: - version "2.3.6" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" - integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== dependencies: "@isaacs/cliui" "^8.0.2" optionalDependencies: @@ -2286,13 +2292,18 @@ lowercase-keys@^1.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== -lru-cache@^10.0.1, "lru-cache@^9.1.1 || ^10.0.0": +lru-cache@^10.0.1: version "10.0.2" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.2.tgz#34504678cc3266b09b8dfd6fab4e1515258271b7" integrity sha512-Yj9mA8fPiVgOUpByoTZO5pNrcl5Yk37FcSHsUINpAsaBIEZIuqcCclDZJCVxqQShDsmYX8QG63svJiTbOATZwg== dependencies: semver "^7.3.5" +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + lru-cache@^4.0.1: version "4.1.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" @@ -2412,10 +2423,17 @@ minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" -minimatch@^9.0.1: - version "9.0.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" - integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== +minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== dependencies: brace-expansion "^2.0.1" @@ -2488,6 +2506,11 @@ minipass@^5.0.0: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== +minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + minizlib@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" @@ -2640,6 +2663,11 @@ node-addon-api@^4.0.0, node-addon-api@^4.3.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== +node-addon-api@^7.1.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + node-fetch-npm@^2.0.2: version "2.0.4" resolved "https://registry.yarnpkg.com/node-fetch-npm/-/node-fetch-npm-2.0.4.tgz#6507d0e17a9ec0be3bec516958a497cec54bf5a4" @@ -2670,12 +2698,12 @@ node-gyp@^10.0.0, node-gyp@^5.0.2, node-gyp@^5.1.0: tar "^6.1.2" which "^4.0.0" -node-pty@^1.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.0.0.tgz#7daafc0aca1c4ca3de15c61330373af4af5861fd" - integrity sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA== +node-pty@^1.1.0-beta.14: + version "1.1.0-beta9" + resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.1.0-beta9.tgz#ed643cb3b398d031b4e31c216e8f3b0042435f1d" + integrity sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw== dependencies: - nan "^2.17.0" + node-addon-api "^7.1.0" nopt@^4.0.3: version "4.0.3" @@ -3097,6 +3125,11 @@ p-try@^2.0.0: resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +package-json-from-dist@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" + integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== + package-json@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed" @@ -3209,12 +3242,12 @@ path-parse@^1.0.6: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-scurry@^1.10.1: - version "1.10.1" - resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.10.1.tgz#9ba6bf5aa8500fe9fd67df4f0d9483b2b0bfc698" - integrity sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ== +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== dependencies: - lru-cache "^9.1.1 || ^10.0.0" + lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-type@^2.0.0: @@ -3603,6 +3636,13 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" +russh@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/russh/-/russh-0.0.3.tgz#bcb53d2efbe2b216857171bc5ca2131001ddfa46" + integrity sha512-iTW4M5W856zYjbjQYjlDFaSFSQ6pLBy+zsCYFwhivYuj8U5mZ7kF7TeGOUat9t4l25HVxAS36ivCt5l79p9lcQ== + dependencies: + "@napi-rs/cli" "^2.18.3" + rxjs@^7.5.2, rxjs@^7.5.7: version "7.5.7" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39" diff --git a/package.json b/package.json index eaef6e3e..ddb78623 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "source-code-pro": "^2.38.0", "source-map-loader": "^4.0.1", "source-sans-pro": "3.6.0", - "ssh2": "^1.14.0", "style-loader": "^3.3.1", "svg-inline-loader": "^0.8.2", "thenby": "^1.3.4", diff --git a/patches/ssh2+1.11.0.patch b/patches/ssh2+1.11.0.patch deleted file mode 100644 index eb049c0b..00000000 --- a/patches/ssh2+1.11.0.patch +++ /dev/null @@ -1,39 +0,0 @@ -diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib/protocol/keyParser.js -index 9860e3f..ee82e51 100644 ---- a/node_modules/ssh2/lib/protocol/keyParser.js -+++ b/node_modules/ssh2/lib/protocol/keyParser.js -@@ -15,6 +15,7 @@ const { - sign: sign_, - verify: verify_, - } = require('crypto'); -+const { createVerify: createVerifyDSS } = require('browserify-sign') - const supportedOpenSSLCiphers = getCiphers(); - - const { Ber } = require('asn1'); -@@ -404,6 +405,17 @@ const BaseKey = { - return new Error('No public key available'); - if (!algo || typeof algo !== 'string') - algo = this[SYM_HASH_ALGO]; -+ -+ if (algo === 'dss1') { -+ const verifier = createVerifyDSS('DSA-SHA1'); -+ verifier.update(data); -+ try { -+ return verifier.verify(pem, signature); -+ } catch (ex) { -+ return ex; -+ } -+ } -+ - try { - return verify_(algo, data, pem, signature); - } catch (ex) { -@@ -1343,7 +1355,7 @@ function parseDER(data, baseType, comment, fullType) { - return new Error('Malformed OpenSSH public key'); - pubPEM = genOpenSSLDSAPub(p, q, g, y); - pubSSH = genOpenSSHDSAPub(p, q, g, y); -- algo = 'sha1'; -+ algo = 'dss1'; - break; - } - case 'ssh-ed25519': { diff --git a/tabby-core/src/api/platform.ts b/tabby-core/src/api/platform.ts index 6f63154f..2cb33810 100644 --- a/tabby-core/src/api/platform.ts +++ b/tabby-core/src/api/platform.ts @@ -63,22 +63,24 @@ export abstract class FileTransfer { } export abstract class FileDownload extends FileTransfer { - abstract write (buffer: Buffer): Promise + abstract write (buffer: Uint8Array): Promise } export abstract class FileUpload extends FileTransfer { - abstract read (): Promise + abstract read (): Promise - async readAll (): Promise { - const buffers: Buffer[] = [] + async readAll (): Promise { + const result = new Uint8Array(this.getSize()) + let pos = 0 while (true) { const buf = await this.read() if (!buf.length) { break } - buffers.push(Buffer.from(buf)) + result.set(buf, pos) + pos += buf.length } - return Buffer.concat(buffers) + return result } } @@ -261,12 +263,12 @@ export class HTMLFileUpload extends FileUpload { return this.file.size } - async read (): Promise { + async read (): Promise { const result: any = await this.reader.read() if (result.done || !result.value) { - return Buffer.from('') + return new Uint8Array(0) } - const chunk = Buffer.from(result.value) + const chunk = new Uint8Array(result.value) this.increaseProgress(chunk.length) return chunk } diff --git a/tabby-core/src/services/vault.service.ts b/tabby-core/src/services/vault.service.ts index 8602597e..84e5287b 100644 --- a/tabby-core/src/services/vault.service.ts +++ b/tabby-core/src/services/vault.service.ts @@ -306,7 +306,7 @@ export class VaultFileProvider extends FileProvider { id, description: `${description} (${transfer.getName()})`, }, - value: (await transfer.readAll()).toString('base64'), + value: Buffer.from(await transfer.readAll()).toString('base64'), }) return `${this.prefix}${id}` } diff --git a/tabby-electron/src/services/platform.service.ts b/tabby-electron/src/services/platform.service.ts index 52fcf9fc..d08f07ef 100644 --- a/tabby-electron/src/services/platform.service.ts +++ b/tabby-electron/src/services/platform.service.ts @@ -300,12 +300,12 @@ class ElectronFileUpload extends FileUpload { private size: number private mode: number private file: fs.FileHandle - private buffer: Buffer + private buffer: Uint8Array private powerSaveBlocker = 0 constructor (private filePath: string, private electron: ElectronService) { super() - this.buffer = Buffer.alloc(256 * 1024) + this.buffer = new Uint8Array(256 * 1024) this.powerSaveBlocker = electron.powerSaveBlocker.start('prevent-app-suspension') } @@ -328,7 +328,7 @@ class ElectronFileUpload extends FileUpload { return this.size } - async read (): Promise { + async read (): Promise { const result = await this.file.read(this.buffer, 0, this.buffer.length, null) this.increaseProgress(result.bytesRead) return this.buffer.slice(0, result.bytesRead) @@ -370,7 +370,7 @@ class ElectronFileDownload extends FileDownload { return this.size } - async write (buffer: Buffer): Promise { + async write (buffer: Uint8Array): Promise { let pos = 0 while (pos < buffer.length) { const result = await this.file.write(buffer, pos, buffer.length - pos, null) diff --git a/tabby-electron/src/sftpContextMenu.ts b/tabby-electron/src/sftpContextMenu.ts index 2443aad6..52888d50 100644 --- a/tabby-electron/src/sftpContextMenu.ts +++ b/tabby-electron/src/sftpContextMenu.ts @@ -49,19 +49,24 @@ export class EditSFTPContextMenu extends SFTPContextMenuItemProvider { this.platform.openPath(tempPath) const events = new Subject() - const watcher = fs.watch(tempPath, event => events.next(event)) - events.pipe(debounceTime(1000), debounce(async event => { - if (event === 'rename') { - watcher.close() - } - const upload = await this.platform.startUpload({ multiple: false }, [tempPath]) - if (!upload.length) { - return - } - await sftp.upload(item.fullPath, upload[0]) - await sftp.chmod(item.fullPath, item.mode) - })).subscribe() - watcher.on('close', () => events.complete()) - sftp.closed$.subscribe(() => watcher.close()) + fs.chmodSync(tempPath, 0o700) + + // skip the first burst of events + setTimeout(() => { + const watcher = fs.watch(tempPath, event => events.next(event)) + events.pipe(debounceTime(1000), debounce(async event => { + if (event === 'rename') { + watcher.close() + } + const upload = await this.platform.startUpload({ multiple: false }, [tempPath]) + if (!upload.length) { + return + } + await sftp.upload(item.fullPath, upload[0]) + await sftp.chmod(item.fullPath, item.mode) + })).subscribe() + watcher.on('close', () => events.complete()) + sftp.closed$.subscribe(() => watcher.close()) + }, 1000) } } diff --git a/tabby-settings/src/components/vaultSettingsTab.component.ts b/tabby-settings/src/components/vaultSettingsTab.component.ts index 9feea2ea..1bb2c51f 100644 --- a/tabby-settings/src/components/vaultSettingsTab.component.ts +++ b/tabby-settings/src/components/vaultSettingsTab.component.ts @@ -123,7 +123,7 @@ export class VaultSettingsTabComponent extends BaseComponent { } await this.vault.updateSecret(secret, { ...secret, - value: (await transfers[0].readAll()).toString('base64'), + value: Buffer.from(await transfers[0].readAll()).toString('base64'), }) this.loadVault() } diff --git a/tabby-ssh/package.json b/tabby-ssh/package.json index e825b476..31e8e1fd 100644 --- a/tabby-ssh/package.json +++ b/tabby-ssh/package.json @@ -11,22 +11,17 @@ "build": "webpack --progress --color", "watch": "webpack --progress --color --watch", "postinstall": "run-script-os", - "postinstall:darwin:linux": "exit", - "postinstall:win32": "xcopy /i /y ..\\node_modules\\ssh2\\util\\pagent.exe util\\" + "postinstall:darwin:linux": "exit" }, "files": [ "dist", - "util/pagent.exe", "typings" ], "author": "Eugene Pankov", "license": "MIT", "devDependencies": { "@types/node": "20.3.1", - "@types/ssh2": "^0.5.46", "ansi-colors": "^4.1.1", - "diffie-hellman": "^5.0.3", - "sshpk": "Eugeny/node-sshpk#c2b71d1243714d2daf0988f84c3323d180817136", "strip-ansi": "^7.0.0" }, "dependencies": { @@ -45,5 +40,8 @@ "tabby-core": "*", "tabby-settings": "*", "tabby-terminal": "*" + }, + "resolutions": { + "glob": "7.2.3" } } diff --git a/tabby-ssh/src/algorithms.ts b/tabby-ssh/src/algorithms.ts index f7fa2a12..5452e24a 100644 --- a/tabby-ssh/src/algorithms.ts +++ b/tabby-ssh/src/algorithms.ts @@ -1,20 +1,45 @@ -import * as ALGORITHMS from 'ssh2/lib/protocol/constants' -import { ALGORITHM_BLACKLIST, SSHAlgorithmType } from './api' +import * as russh from 'russh' +import { SSHAlgorithmType } from './api' -// Counteracts https://github.com/mscdex/ssh2/commit/f1b5ac3c81734c194740016eab79a699efae83d8 -ALGORITHMS.DEFAULT_CIPHER.push('aes128-gcm') -ALGORITHMS.DEFAULT_CIPHER.push('aes256-gcm') -ALGORITHMS.SUPPORTED_CIPHER.push('aes128-gcm') -ALGORITHMS.SUPPORTED_CIPHER.push('aes256-gcm') - -export const supportedAlgorithms: Record = {} - -for (const k of Object.values(SSHAlgorithmType)) { - const supportedAlg = { - [SSHAlgorithmType.KEX]: 'SUPPORTED_KEX', - [SSHAlgorithmType.HOSTKEY]: 'SUPPORTED_SERVER_HOST_KEY', - [SSHAlgorithmType.CIPHER]: 'SUPPORTED_CIPHER', - [SSHAlgorithmType.HMAC]: 'SUPPORTED_MAC', - }[k] - supportedAlgorithms[k] = ALGORITHMS[supportedAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x)).sort() +export const supportedAlgorithms = { + [SSHAlgorithmType.KEX]: russh.getSupportedKexAlgorithms().filter(x => x !== 'none'), + [SSHAlgorithmType.HOSTKEY]: russh.getSupportedKeyTypes().filter(x => x !== 'none'), + [SSHAlgorithmType.CIPHER]: russh.getSupportedCiphers().filter(x => x !== 'clear'), + [SSHAlgorithmType.HMAC]: russh.getSupportedMACs().filter(x => x !== 'none'), +} + +export const defaultAlgorithms = { + [SSHAlgorithmType.KEX]: [ + 'curve25519-sha256', + 'curve25519-sha256@libssh.org', + 'diffie-hellman-group16-sha512', + 'diffie-hellman-group14-sha256', + 'ext-info-c', + 'ext-info-s', + 'kex-strict-c-v00@openssh.com', + 'kex-strict-s-v00@openssh.com', + ], + [SSHAlgorithmType.HOSTKEY]: [ + 'ssh-ed25519', + 'ecdsa-sha2-nistp256', + 'ecdsa-sha2-nistp521', + 'rsa-sha2-256', + 'rsa-sha2-512', + 'ssh-rsa', + ], + [SSHAlgorithmType.CIPHER]: [ + 'chacha20-poly1305@openssh.com', + 'aes256-gcm@openssh.com', + 'aes256-ctr', + 'aes192-ctr', + 'aes128-ctr', + ], + [SSHAlgorithmType.HMAC]: [ + 'hmac-sha2-512-etm@openssh.com', + 'hmac-sha2-256-etm@openssh.com', + 'hmac-sha2-512', + 'hmac-sha2-256', + 'hmac-sha1-etm@openssh.com', + 'hmac-sha1', + ], } diff --git a/tabby-ssh/src/api/index.ts b/tabby-ssh/src/api/index.ts index f6ce98ea..016e2998 100644 --- a/tabby-ssh/src/api/index.ts +++ b/tabby-ssh/src/api/index.ts @@ -1,5 +1,4 @@ export * from './contextMenu' export * from './interfaces' export * from './importer' -export * from './proxyStream' export { SSHMultiplexerService } from '../services/sshMultiplexer.service' diff --git a/tabby-ssh/src/api/interfaces.ts b/tabby-ssh/src/api/interfaces.ts index 07a77a5c..56cbaf56 100644 --- a/tabby-ssh/src/api/interfaces.ts +++ b/tabby-ssh/src/api/interfaces.ts @@ -51,13 +51,3 @@ export interface ForwardedPortConfig { targetPort: number description: string } - -export let ALGORITHM_BLACKLIST = [ - // cause native crashes in node crypto, use EC instead - 'diffie-hellman-group-exchange-sha256', - 'diffie-hellman-group-exchange-sha1', -] - -if (!process.env.TABBY_ENABLE_SSH_ALG_BLACKLIST) { - ALGORITHM_BLACKLIST = [] -} diff --git a/tabby-ssh/src/api/proxyStream.ts b/tabby-ssh/src/api/proxyStream.ts deleted file mode 100644 index 4ffa520d..00000000 --- a/tabby-ssh/src/api/proxyStream.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Observable, Subject } from 'rxjs' -import { Duplex } from 'stream' - -export class SSHProxyStreamSocket extends Duplex { - constructor (private parent: SSHProxyStream) { - super({ - allowHalfOpen: false, - }) - } - - _read (size: number): void { - this.parent.requestData(size) - } - - _write (chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { - this.parent.consumeInput(chunk).then(() => callback(null), e => callback(e)) - } - - _destroy (error: Error|null, callback: (error: Error|null) => void): void { - this.parent.handleStopRequest(error).then(() => callback(null), e => callback(e)) - } -} - -export abstract class SSHProxyStream { - get message$ (): Observable { return this.message } - get destroyed$ (): Observable { return this.destroyed } - get socket (): SSHProxyStreamSocket|null { return this._socket } - private message = new Subject() - private destroyed = new Subject() - private _socket: SSHProxyStreamSocket|null = null - - async start (): Promise { - if (!this._socket) { - this._socket = new SSHProxyStreamSocket(this) - } - return this._socket - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - abstract requestData (size: number): void - - abstract consumeInput (data: Buffer): Promise - - protected emitMessage (message: string): void { - this.message.next(message) - } - - protected emitOutput (data: Buffer): void { - this._socket?.push(data) - } - - async handleStopRequest (error: Error|null): Promise { - this.destroyed.next(error) - this.destroyed.complete() - this.message.complete() - } - - stop (error?: Error): void { - this._socket?.destroy(error) - } -} diff --git a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug index e203b26b..c6ab0700 100644 --- a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug +++ b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug @@ -14,11 +14,19 @@ input.form-control.mt-2( ) .d-flex.mt-3 - button.btn.btn-secondary( + checkbox( + *ngIf='isPassword()', + [(ngModel)]='remember', + [text]='"Save password"|translate' + ) + + .ms-auto + + button.btn.btn-secondary.me-3( *ngIf='step > 0', (click)='previous()' ) - .ms-auto + button.btn.btn-primary( (click)='next()' ) diff --git a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts index 8d714144..bb2a851d 100644 --- a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts +++ b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts @@ -1,6 +1,7 @@ import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core' import { KeyboardInteractivePrompt } from '../session/ssh' - +import { SSHProfile } from '../api' +import { PasswordStorageService } from '../services/passwordStorage.service' @Component({ selector: 'keyboard-interactive-auth-panel', @@ -9,13 +10,17 @@ import { KeyboardInteractivePrompt } from '../session/ssh' changeDetection: ChangeDetectionStrategy.OnPush, }) export class KeyboardInteractiveAuthComponent { + @Input() profile: SSHProfile @Input() prompt: KeyboardInteractivePrompt @Input() step = 0 @Output() done = new EventEmitter() @ViewChild('input') input: ElementRef + remember = false + + constructor (private passwordStorage: PasswordStorageService) {} isPassword (): boolean { - return this.prompt.prompts[this.step].prompt.toLowerCase().includes('password') || !this.prompt.prompts[this.step].echo + return this.prompt.isAPasswordPrompt(this.step) } previous (): void { @@ -26,6 +31,10 @@ export class KeyboardInteractiveAuthComponent { } next (): void { + if (this.isPassword() && this.remember) { + this.passwordStorage.savePassword(this.profile, this.prompt.responses[this.step]) + } + if (this.step === this.prompt.prompts.length - 1) { this.prompt.respond() this.done.emit() diff --git a/tabby-ssh/src/components/sshTab.component.pug b/tabby-ssh/src/components/sshTab.component.pug index f6dd1aac..bf1d2457 100644 --- a/tabby-ssh/src/components/sshTab.component.pug +++ b/tabby-ssh/src/components/sshTab.component.pug @@ -51,6 +51,7 @@ sftp-panel.bg-dark( keyboard-interactive-auth-panel.bg-dark( *ngIf='activeKIPrompt', [prompt]='activeKIPrompt', + [profile]='profile', (click)='$event.stopPropagation()', (done)='activeKIPrompt = null; frontend?.focus()' ) diff --git a/tabby-ssh/src/components/sshTab.component.ts b/tabby-ssh/src/components/sshTab.component.ts index 738862dd..54bb8c0b 100644 --- a/tabby-ssh/src/components/sshTab.component.ts +++ b/tabby-ssh/src/components/sshTab.component.ts @@ -1,3 +1,4 @@ +import * as russh from 'russh' import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker' import colors from 'ansi-colors' import { Component, Injector, HostListener } from '@angular/core' @@ -94,17 +95,21 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent } }) - session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut( - '127.0.0.1', 0, profile.options.host, profile.options.port ?? 22, - (err, stream) => { - if (err) { - jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`) - reject(err) - return - } - resolve(stream) - }, - )) + if (!(jumpSession.ssh instanceof russh.AuthenticatedSSHClient)) { + throw new Error('Jump session is not authenticated yet somehow') + } + + try { + session.jumpChannel = await jumpSession.ssh.openTCPForwardChannel({ + addressToConnectTo: profile.options.host, + portToConnectTo: profile.options.port ?? 22, + originatorAddress: '127.0.0.1', + originatorPort: 0, + }) + } catch (err) { + jumpSession.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not set up port forward on ${jumpConnection.name}`) + throw err + } } } @@ -125,7 +130,7 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent }) if (!session.open) { - this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.profile.options.host}\r\n`) + this.write('\r\n' + colors.black.bgWhite(' SSH ') + ` Connecting to ${session.profile.name}\r\n`) this.startSpinner(this.translate.instant(_('Connecting'))) diff --git a/tabby-ssh/src/index.ts b/tabby-ssh/src/index.ts index 280a5be4..fa5f529d 100644 --- a/tabby-ssh/src/index.ts +++ b/tabby-ssh/src/index.ts @@ -1,5 +1,3 @@ -import './polyfills' - import { NgModule } from '@angular/core' import { CommonModule } from '@angular/common' import { FormsModule } from '@angular/forms' diff --git a/tabby-ssh/src/polyfills.ts b/tabby-ssh/src/polyfills.ts deleted file mode 100644 index 06235b1f..00000000 --- a/tabby-ssh/src/polyfills.ts +++ /dev/null @@ -1,12 +0,0 @@ -import 'ssh2' -const nodeCrypto = require('crypto') -const browserDH = require('diffie-hellman/browser') -nodeCrypto.createDiffieHellmanGroup = browserDH.createDiffieHellmanGroup -nodeCrypto.createDiffieHellman = browserDH.createDiffieHellman - -// Declare function missing from @types -declare module 'ssh2' { - interface Client { - setNoDelay: (enable?: boolean) => this - } -} diff --git a/tabby-ssh/src/profiles.ts b/tabby-ssh/src/profiles.ts index 36de9ce2..cf75105b 100644 --- a/tabby-ssh/src/profiles.ts +++ b/tabby-ssh/src/profiles.ts @@ -1,11 +1,11 @@ import { Injectable, InjectFlags, Injector } from '@angular/core' import { NewTabParameters, PartialProfile, TranslateService, QuickConnectProfileProvider } from 'tabby-core' -import * as ALGORITHMS from 'ssh2/lib/protocol/constants' import { SSHProfileSettingsComponent } from './components/sshProfileSettings.component' import { SSHTabComponent } from './components/sshTab.component' import { PasswordStorageService } from './services/passwordStorage.service' -import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from './api' +import { SSHAlgorithmType, SSHProfile } from './api' import { SSHProfileImporter } from './api/importer' +import { defaultAlgorithms } from './algorithms' @Injectable({ providedIn: 'root' }) export class SSHProfilesService extends QuickConnectProfileProvider { @@ -29,10 +29,10 @@ export class SSHProfilesService extends QuickConnectProfileProvider agentForward: false, warnOnClose: null, algorithms: { - hmac: [], - kex: [], - cipher: [], - serverHostKey: [], + hmac: [] as string[], + kex: [] as string[], + cipher: [] as string[], + serverHostKey: [] as string[], }, proxyCommand: null, forwardedPorts: [], @@ -54,13 +54,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider ) { super() for (const k of Object.values(SSHAlgorithmType)) { - const defaultAlg = { - [SSHAlgorithmType.KEX]: 'DEFAULT_KEX', - [SSHAlgorithmType.HOSTKEY]: 'DEFAULT_SERVER_HOST_KEY', - [SSHAlgorithmType.CIPHER]: 'DEFAULT_CIPHER', - [SSHAlgorithmType.HMAC]: 'DEFAULT_MAC', - }[k] - this.configDefaults.options.algorithms[k] = ALGORITHMS[defaultAlg].filter(x => !ALGORITHM_BLACKLIST.includes(x)) + this.configDefaults.options.algorithms[k] = [...defaultAlgorithms[k]] this.configDefaults.options.algorithms[k].sort() } } diff --git a/tabby-ssh/src/services/ssh.service.ts b/tabby-ssh/src/services/ssh.service.ts index f8a3b0fc..24c9b11e 100644 --- a/tabby-ssh/src/services/ssh.service.ts +++ b/tabby-ssh/src/services/ssh.service.ts @@ -1,15 +1,9 @@ -import * as shellQuote from 'shell-quote' -import * as net from 'net' -import * as fs from 'fs/promises' +// import * as fs from 'fs/promises' import * as tmp from 'tmp-promise' -import socksv5 from '@luminati-io/socksv5' -import { Duplex } from 'stream' import { Injectable } from '@angular/core' -import { spawn } from 'child_process' -import { ChildProcess } from 'node:child_process' import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core' import { SSHSession } from '../session/ssh' -import { SSHProfile, SSHProxyStream, SSHProxyStreamSocket } from '../api' +import { SSHProfile } from '../api' import { PasswordStorageService } from './passwordStorage.service' @Injectable({ providedIn: 'root' }) @@ -55,7 +49,7 @@ export class SSHService { let tmpFile: tmp.FileResult|null = null if (session.activePrivateKey) { tmpFile = await tmp.file() - await fs.writeFile(tmpFile.path, session.activePrivateKey) + // await fs.writeFile(tmpFile.path, session.activePrivateKey) const winSCPcom = path.slice(0, -3) + 'com' await this.platform.exec(winSCPcom, ['/keygen', tmpFile.path, `/output=${tmpFile.path}`]) args.push(`/privatekey=${tmpFile.path}`) @@ -64,171 +58,3 @@ export class SSHService { tmpFile?.cleanup() } } - -export class ProxyCommandStream extends SSHProxyStream { - private process: ChildProcess|null - - constructor (private command: string) { - super() - } - - async start (): Promise { - const argv = shellQuote.parse(this.command) - this.process = spawn(argv[0], argv.slice(1), { - windowsHide: true, - stdio: ['pipe', 'pipe', 'pipe'], - }) - this.process.on('error', error => { - this.stop(new Error(`Proxy command has failed to start: ${error.message}`)) - }) - this.process.on('exit', code => { - this.stop(new Error(`Proxy command has exited with code ${code}`)) - }) - this.process.stdout?.on('data', data => { - this.emitOutput(data) - }) - this.process.stdout?.on('error', (err) => { - this.stop(err) - }) - this.process.stderr?.on('data', data => { - this.emitMessage(data.toString()) - }) - return super.start() - } - - requestData (size: number): void { - this.process?.stdout?.read(size) - } - - async consumeInput (data: Buffer): Promise { - const process = this.process - if (process) { - await new Promise(resolve => process.stdin?.write(data, resolve)) - } - } - - async stop (error?: Error): Promise { - this.process?.kill() - super.stop(error) - } -} - -export class SocksProxyStream extends SSHProxyStream { - private client: Duplex|null - private header: Buffer|null - - constructor (private profile: SSHProfile) { - super() - } - - async start (): Promise { - this.client = await new Promise((resolve, reject) => { - const connector = socksv5.connect({ - host: this.profile.options.host, - port: this.profile.options.port, - proxyHost: this.profile.options.socksProxyHost ?? '127.0.0.1', - proxyPort: this.profile.options.socksProxyPort ?? 5000, - auths: [socksv5.auth.None()], - strictLocalDNS: false, - }, s => { - resolve(s) - this.header = s.read() - if (this.header) { - this.emitOutput(this.header) - } - }) - connector.on('error', (err) => { - reject(err) - this.stop(new Error(`SOCKS connection failed: ${err.message}`)) - }) - }) - this.client?.on('data', data => { - if (!this.header || data !== this.header) { - // socksv5 doesn't reliably emit the first data event - this.emitOutput(data) - this.header = null - } - }) - this.client?.on('close', error => { - this.stop(error) - }) - - return super.start() - } - - requestData (size: number): void { - this.client?.read(size) - } - - async consumeInput (data: Buffer): Promise { - return new Promise((resolve, reject) => { - this.client?.write(data, undefined, err => err ? reject(err) : resolve()) - }) - } - - async stop (error?: Error): Promise { - this.client?.destroy() - super.stop(error) - } -} - -export class HTTPProxyStream extends SSHProxyStream { - private client: Duplex|null - private connected = false - - constructor (private profile: SSHProfile) { - super() - } - - async start (): Promise { - this.client = await new Promise((resolve, reject) => { - const connector = net.createConnection({ - host: this.profile.options.httpProxyHost!, - port: this.profile.options.httpProxyPort!, - }, () => resolve(connector)) - connector.on('error', error => { - reject(error) - this.stop(new Error(`Proxy connection failed: ${error.message}`)) - }) - }) - this.client?.write(Buffer.from(`CONNECT ${this.profile.options.host}:${this.profile.options.port} HTTP/1.1\r\n\r\n`)) - this.client?.on('data', (data: Buffer) => { - if (this.connected) { - this.emitOutput(data) - } else { - if (data.slice(0, 5).equals(Buffer.from('HTTP/'))) { - const idx = data.indexOf('\n\n') - const headers = data.slice(0, idx).toString() - const code = parseInt(headers.split(' ')[1]) - if (code >= 200 && code < 300) { - this.emitMessage('Connected') - this.emitOutput(data.slice(idx + 2)) - this.connected = true - } else { - this.stop(new Error(`Connection failed, code ${code}`)) - } - } - } - }) - this.client?.on('close', error => { - this.stop(error) - }) - - return super.start() - } - - requestData (size: number): void { - this.client?.read(size) - } - - async consumeInput (data: Buffer): Promise { - return new Promise((resolve, reject) => { - this.client?.write(data, undefined, err => err ? reject(err) : resolve()) - }) - } - - async stop (error?: Error): Promise { - this.client?.destroy() - super.stop(error) - } -} diff --git a/tabby-ssh/src/session/sftp.ts b/tabby-ssh/src/session/sftp.ts index c07fb645..5a3b1e6e 100644 --- a/tabby-ssh/src/session/sftp.ts +++ b/tabby-ssh/src/session/sftp.ts @@ -1,12 +1,9 @@ -import * as C from 'constants' +/* eslint-disable @typescript-eslint/no-unused-vars */ import { Subject, Observable } from 'rxjs' import { posix as posixPath } from 'path' -import { Injector, NgZone } from '@angular/core' -import { FileDownload, FileUpload, Logger, LogService, wrapPromise } from 'tabby-core' -import { SFTPWrapper } from 'ssh2' -import { promisify } from 'util' - -import type { FileEntry, Stats } from 'ssh2-streams' +import { Injector } from '@angular/core' +import { FileDownload, FileUpload, Logger, LogService } from 'tabby-core' +import * as russh from 'russh' export interface SFTPFile { name: string @@ -22,63 +19,37 @@ export class SFTPFileHandle { position = 0 constructor ( - private sftp: SFTPWrapper, - private handle: Buffer, - private zone: NgZone, + private inner: russh.SFTPFile|null, ) { } - read (): Promise { - const buffer = Buffer.alloc(256 * 1024) - return wrapPromise(this.zone, new Promise((resolve, reject) => { - while (true) { - const wait = this.sftp.read(this.handle, buffer, 0, buffer.length, this.position, (err, read) => { - if (err) { - reject(err) - return - } - this.position += read - resolve(buffer.slice(0, read)) - }) - if (!wait) { - break - } - } - })) + async read (): Promise { + if (!this.inner) { + return Promise.resolve(new Uint8Array(0)) + } + return this.inner.read(256 * 1024) } - write (chunk: Buffer): Promise { - return wrapPromise(this.zone, new Promise((resolve, reject) => { - while (true) { - const wait = this.sftp.write(this.handle, chunk, 0, chunk.length, this.position, err => { - if (err) { - reject(err) - return - } - this.position += chunk.length - resolve() - }) - if (!wait) { - break - } - } - })) + async write (chunk: Uint8Array): Promise { + if (!this.inner) { + throw new Error('File handle is closed') + } + await this.inner.writeAll(chunk) } - close (): Promise { - return wrapPromise(this.zone, promisify(this.sftp.close.bind(this.sftp))(this.handle)) + async close (): Promise { + await this.inner?.shutdown() + this.inner = null } } export class SFTPSession { get closed$ (): Observable { return this.closed } private closed = new Subject() - private zone: NgZone private logger: Logger - constructor (private sftp: SFTPWrapper, injector: Injector) { - this.zone = injector.get(NgZone) + constructor (private sftp: russh.SFTP, injector: Injector) { this.logger = injector.get(LogService).create('sftp') - sftp.on('close', () => { + sftp.closed$.subscribe(() => { this.closed.next() this.closed.complete() }) @@ -86,67 +57,64 @@ export class SFTPSession { async readdir (p: string): Promise { this.logger.debug('readdir', p) - const entries = await wrapPromise(this.zone, promisify(f => this.sftp.readdir(p, f))()) + const entries = await this.sftp.readDirectory(p) return entries.map(entry => this._makeFile( - posixPath.join(p, entry.filename), entry, + posixPath.join(p, entry.name), entry, )) } readlink (p: string): Promise { this.logger.debug('readlink', p) - return wrapPromise(this.zone, promisify(f => this.sftp.readlink(p, f))()) + return this.sftp.readlink(p) } async stat (p: string): Promise { this.logger.debug('stat', p) - const stats = await wrapPromise(this.zone, promisify(f => this.sftp.stat(p, f))()) + const stats = await this.sftp.stat(p) return { name: posixPath.basename(p), fullPath: p, - isDirectory: stats.isDirectory(), - isSymlink: stats.isSymbolicLink(), - mode: stats.mode, + isDirectory: stats.type === russh.SFTPFileType.Directory, + isSymlink: stats.type === russh.SFTPFileType.Symlink, + mode: stats.permissions ?? 0, size: stats.size, - modified: new Date(stats.mtime * 1000), + modified: new Date((stats.mtime ?? 0) * 1000), } } - async open (p: string, mode: string): Promise { - this.logger.debug('open', p) - const handle = await wrapPromise(this.zone, promisify(f => this.sftp.open(p, mode, f))()) - return new SFTPFileHandle(this.sftp, handle, this.zone) + async open (p: string, mode: number): Promise { + this.logger.debug('open', p, mode) + const handle = await this.sftp.open(p, mode) + return new SFTPFileHandle(handle) } async rmdir (p: string): Promise { - this.logger.debug('rmdir', p) - await promisify((f: any) => this.sftp.rmdir(p, f))() + await this.sftp.removeDirectory(p) } async mkdir (p: string): Promise { - this.logger.debug('mkdir', p) - await promisify((f: any) => this.sftp.mkdir(p, f))() + await this.sftp.createDirectory(p) } async rename (oldPath: string, newPath: string): Promise { this.logger.debug('rename', oldPath, newPath) - await promisify((f: any) => this.sftp.rename(oldPath, newPath, f))() + await this.sftp.rename(oldPath, newPath) } async unlink (p: string): Promise { - this.logger.debug('unlink', p) - await promisify((f: any) => this.sftp.unlink(p, f))() + await this.sftp.removeFile(p) } async chmod (p: string, mode: string|number): Promise { this.logger.debug('chmod', p, mode) - await promisify((f: any) => this.sftp.chmod(p, mode, f))() + await this.sftp.chmod(p, mode) } async upload (path: string, transfer: FileUpload): Promise { this.logger.info('Uploading into', path) const tempPath = path + '.tabby-upload' try { - const handle = await this.open(tempPath, 'w') + const handle = await this.open(tempPath, russh.OPEN_WRITE | russh.OPEN_CREATE) while (true) { const chunk = await transfer.read() if (!chunk.length) { @@ -154,15 +122,13 @@ export class SFTPSession { } await handle.write(chunk) } - handle.close() - try { - await this.unlink(path) - } catch { } + await handle.close() + await this.unlink(path).catch(() => null) await this.rename(tempPath, path) transfer.close() } catch (e) { transfer.cancel() - this.unlink(tempPath) + this.unlink(tempPath).catch(() => null) throw e } } @@ -170,7 +136,7 @@ export class SFTPSession { async download (path: string, transfer: FileDownload): Promise { this.logger.info('Downloading', path) try { - const handle = await this.open(path, 'r') + const handle = await this.open(path, russh.OPEN_READ) while (true) { const chunk = await handle.read() if (!chunk.length) { @@ -186,15 +152,15 @@ export class SFTPSession { } } - private _makeFile (p: string, entry: FileEntry): SFTPFile { + private _makeFile (p: string, entry: russh.SFTPDirectoryEntry): SFTPFile { return { fullPath: p, name: posixPath.basename(p), - isDirectory: (entry.attrs.mode & C.S_IFDIR) === C.S_IFDIR, - isSymlink: (entry.attrs.mode & C.S_IFLNK) === C.S_IFLNK, - mode: entry.attrs.mode, - size: entry.attrs.size, - modified: new Date(entry.attrs.mtime * 1000), + isDirectory: entry.metadata.type === russh.SFTPFileType.Directory, + isSymlink: entry.metadata.type === russh.SFTPFileType.Symlink, + mode: entry.metadata.permissions ?? 0, + size: entry.metadata.size, + modified: new Date((entry.metadata.mtime ?? 0) * 1000), } } } diff --git a/tabby-ssh/src/session/shell.ts b/tabby-ssh/src/session/shell.ts index 5b942cad..11b76f42 100644 --- a/tabby-ssh/src/session/shell.ts +++ b/tabby-ssh/src/session/shell.ts @@ -1,15 +1,15 @@ import { Observable, Subject } from 'rxjs' import stripAnsi from 'strip-ansi' -import { ClientChannel } from 'ssh2' import { Injector } from '@angular/core' import { LogService } from 'tabby-core' import { BaseSession, UTF8SplitterMiddleware, InputProcessor } from 'tabby-terminal' import { SSHSession } from './ssh' import { SSHProfile } from '../api' +import * as russh from 'russh' export class SSHShellSession extends BaseSession { - shell?: ClientChannel + shell?: russh.Channel get serviceMessage$ (): Observable { return this.serviceMessage } private serviceMessage = new Subject() private ssh: SSHSession|null @@ -53,19 +53,11 @@ export class SSHShellSession extends BaseSession { this.loginScriptProcessor?.executeUnconditionalScripts() - this.shell.on('greeting', greeting => { - this.emitServiceMessage(`Shell greeting: ${greeting}`) + this.shell.data$.subscribe(data => { + this.emitOutput(Buffer.from(data)) }) - this.shell.on('banner', banner => { - this.emitServiceMessage(`Shell banner: ${banner}`) - }) - - this.shell.on('data', data => { - this.emitOutput(data) - }) - - this.shell.on('end', () => { + this.shell.eof$.subscribe(() => { this.logger.info('Shell session ended') if (this.open) { this.destroy() @@ -79,19 +71,22 @@ export class SSHShellSession extends BaseSession { } resize (columns: number, rows: number): void { - if (this.shell) { - this.shell.setWindow(rows, columns, rows, columns) - } + this.shell?.resizePTY({ + columns, + rows, + pixHeight: 0, + pixWidth: 0, + }) } write (data: Buffer): void { if (this.shell) { - this.shell.write(data) + this.shell.write(new Uint8Array(data)) } } - kill (signal?: string): void { - this.shell?.signal(signal ?? 'TERM') + kill (_signal?: string): void { + // this.shell?.signal(signal ?? 'TERM') } async destroy (): Promise { diff --git a/tabby-ssh/src/session/ssh.ts b/tabby-ssh/src/session/ssh.ts index 4b446239..8444795e 100644 --- a/tabby-ssh/src/session/ssh.ts +++ b/tabby-ssh/src/session/ssh.ts @@ -1,24 +1,22 @@ import * as fs from 'mz/fs' import * as crypto from 'crypto' -import * as sshpk from 'sshpk' import colors from 'ansi-colors' import stripAnsi from 'strip-ansi' -import { Injector, NgZone } from '@angular/core' +import * as shellQuote from 'shell-quote' +import { Injector } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { ConfigService, FileProvidersService, HostAppService, NotificationsService, Platform, PlatformService, wrapPromise, PromptModalComponent, LogService, Logger, TranslateService } from 'tabby-core' +import { ConfigService, FileProvidersService, NotificationsService, PromptModalComponent, LogService, Logger, TranslateService, Platform, HostAppService } from 'tabby-core' import { Socket } from 'net' -import { Client, ClientChannel, SFTPWrapper } from 'ssh2' import { Subject, Observable } from 'rxjs' import { HostKeyPromptModalComponent } from '../components/hostKeyPromptModal.component' -import { HTTPProxyStream, ProxyCommandStream, SocksProxyStream } from '../services/ssh.service' import { PasswordStorageService } from '../services/passwordStorage.service' import { SSHKnownHostsService } from '../services/sshKnownHosts.service' -import { promisify } from 'util' import { SFTPSession } from './sftp' -import { SSHAlgorithmType, PortForwardType, SSHProfile, SSHProxyStream, AutoPrivateKeyLocator } from '../api' +import { SSHAlgorithmType, SSHProfile, AutoPrivateKeyLocator, PortForwardType } from '../api' import { ForwardedPort } from './forwards' import { X11Socket } from './x11' import { supportedAlgorithms } from '../algorithms' +import * as russh from 'russh' const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent' @@ -27,48 +25,74 @@ export interface Prompt { echo?: boolean } -interface AuthMethod { - type: 'none'|'publickey'|'agent'|'password'|'keyboard-interactive'|'hostbased' - name?: string - contents?: Buffer -} - -interface Handshake { - kex: string - serverHostKey: string +type AuthMethod = { + type: 'none'|'prompt-password'|'hostbased' +} | { + type: 'keyboard-interactive', + savedPassword?: string +} | { + type: 'saved-password', + password: string +} | { + type: 'publickey' + name: string + contents: Buffer +} | { + type: 'agent', + kind: 'unix-socket', + path: string +} | { + type: 'agent', + kind: 'named-pipe', + path: string +} | { + type: 'agent', + kind: 'pageant', } export class KeyboardInteractivePrompt { - responses: string[] = [] + readonly responses: string[] = [] + + private _resolve: (value: string[]) => void + private _reject: (reason: any) => void + readonly promise = new Promise((resolve, reject) => { + this._resolve = resolve + this._reject = reject + }) constructor ( public name: string, public instruction: string, public prompts: Prompt[], - private callback: (_: string[]) => void, ) { this.responses = new Array(this.prompts.length).fill('') } + isAPasswordPrompt (index: number): boolean { + return this.prompts[index].prompt.toLowerCase().includes('password') && !this.prompts[index].echo + } + respond (): void { - this.callback(this.responses) + this._resolve(this.responses) + } + + reject (): void { + this._reject(new Error('Keyboard-interactive auth rejected')) } } export class SSHSession { - shell?: ClientChannel - ssh: Client - sftp?: SFTPWrapper + shell?: russh.Channel + ssh: russh.SSHClient|russh.AuthenticatedSSHClient + sftp?: russh.SFTP forwardedPorts: ForwardedPort[] = [] - jumpStream: any - proxyCommandStream: SSHProxyStream|null = null + jumpChannel: russh.Channel|null = null savedPassword?: string get serviceMessage$ (): Observable { return this.serviceMessage } get keyboardInteractivePrompt$ (): Observable { return this.keyboardInteractivePrompt } get willDestroy$ (): Observable { return this.willDestroy } - agentPath?: string - activePrivateKey: string|null = null + activePrivateKey: russh.KeyPair|null = null authUsername: string|null = null open = false @@ -79,15 +103,11 @@ export class SSHSession { private serviceMessage = new Subject() private keyboardInteractivePrompt = new Subject() private willDestroy = new Subject() - private keychainPasswordUsed = false - private hostKeyDigest = '' private passwordStorage: PasswordStorageService private ngbModal: NgbModal private hostApp: HostAppService - private platform: PlatformService private notifications: NotificationsService - private zone: NgZone private fileProviders: FileProvidersService private config: ConfigService private translate: TranslateService @@ -103,9 +123,7 @@ export class SSHSession { this.passwordStorage = injector.get(PasswordStorageService) this.ngbModal = injector.get(NgbModal) this.hostApp = injector.get(HostAppService) - this.platform = injector.get(PlatformService) this.notifications = injector.get(NotificationsService) - this.zone = injector.get(NgZone) this.fileProviders = injector.get(FileProvidersService) this.config = injector.get(ConfigService) this.translate = injector.get(TranslateService) @@ -120,27 +138,6 @@ export class SSHSession { } async init (): Promise { - if (this.hostApp.platform === Platform.Windows) { - if (this.config.store.ssh.agentType === 'auto') { - if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) { - this.agentPath = WINDOWS_OPENSSH_AGENT_PIPE - } else { - if ( - await this.platform.isProcessRunning('pageant.exe') || - await this.platform.isProcessRunning('gpg-agent.exe') - ) { - this.agentPath = 'pageant' - } - } - } else if (this.config.store.ssh.agentType === 'pageant') { - this.agentPath = 'pageant' - } else { - this.agentPath = this.config.store.ssh.agentPath || WINDOWS_OPENSSH_AGENT_PIPE - } - } else { - this.agentPath = process.env.SSH_AUTH_SOCK! - } - this.remainingAuthMethods = [{ type: 'none' }] if (!this.profile.options.auth || this.profile.options.auth === 'publicKey') { if (this.profile.options.privateKeys?.length) { @@ -167,184 +164,192 @@ export class SSHSession { } } } + if (!this.profile.options.auth || this.profile.options.auth === 'agent') { - if (!this.agentPath) { - this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running agent is detected`) + const spec = await this.getAgentConnectionSpec() + if (!spec) { + this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running Agent process is found`) } else { - this.remainingAuthMethods.push({ type: 'agent' }) + this.remainingAuthMethods.push({ + type: 'agent', + ...spec, + }) } } if (!this.profile.options.auth || this.profile.options.auth === 'password') { - this.remainingAuthMethods.push({ type: 'password' }) + if (this.profile.options.password) { + this.remainingAuthMethods.push({ type: 'saved-password', password: this.profile.options.password }) + } + const password = await this.passwordStorage.loadPassword(this.profile) + if (password) { + this.remainingAuthMethods.push({ type: 'saved-password', password }) + } + this.remainingAuthMethods.push({ type: 'prompt-password' }) } if (!this.profile.options.auth || this.profile.options.auth === 'keyboardInteractive') { + const savedPassword = this.profile.options.password ?? await this.passwordStorage.loadPassword(this.profile) + if (savedPassword) { + this.remainingAuthMethods.push({ type: 'keyboard-interactive', savedPassword }) + } this.remainingAuthMethods.push({ type: 'keyboard-interactive' }) } this.remainingAuthMethods.push({ type: 'hostbased' }) } + private async getAgentConnectionSpec (): Promise { + if (this.hostApp.platform === Platform.Windows) { + if (this.config.store.ssh.agentType === 'auto') { + if (await fs.exists(WINDOWS_OPENSSH_AGENT_PIPE)) { + return { + kind: 'named-pipe', + path: WINDOWS_OPENSSH_AGENT_PIPE, + } + } else if (russh.isPageantRunning()) { + return { + kind: 'pageant', + } + } else { + this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Agent auth selected, but no running Agent process is found`) + } + } else if (this.config.store.ssh.agentType === 'pageant') { + return { + kind: 'pageant', + } + } else { + return { + kind: 'named-pipe', + path: this.config.store.ssh.agentPath || WINDOWS_OPENSSH_AGENT_PIPE, + } + } + } else { + return { + kind: 'unix-socket', + path: process.env.SSH_AUTH_SOCK!, + } + } + return null + } + async openSFTP (): Promise { + if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) { + throw new Error('Cannot open SFTP session before auth') + } if (!this.sftp) { - this.sftp = await wrapPromise(this.zone, promisify(f => this.ssh.sftp(f))()) + this.sftp = await this.ssh.openSFTPChannel() } return new SFTPSession(this.sftp, this.injector) } - async start (): Promise { - const log = (s: any) => this.emitServiceMessage(s) - - const ssh = new Client() - this.ssh = ssh await this.init() - let connected = false const algorithms = {} for (const key of Object.values(SSHAlgorithmType)) { algorithms[key] = this.profile.options.algorithms![key].filter(x => supportedAlgorithms[key].includes(x)) } - const hostVerifiedPromise: Promise = new Promise((resolve, reject) => { - ssh.on('handshake', async handshake => { - if (!await this.verifyHostKey(handshake)) { - this.ssh.end() - reject(new Error('Host key verification failed')) - } - this.logger.info('Handshake complete:', handshake) - resolve() - }) - }) + // eslint-disable-next-line @typescript-eslint/init-declarations + let transport: russh.SshTransport + if (this.profile.options.proxyCommand) { + this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${this.profile.options.proxyCommand}`) - const resultPromise: Promise = new Promise(async (resolve, reject) => { - ssh.on('ready', () => { - connected = true - // Fix SSH Lagging - ssh.setNoDelay(true) - if (this.savedPassword) { - this.passwordStorage.savePassword(this.profile, this.savedPassword) - } - - this.zone.run(resolve) - }) - ssh.on('error', error => { - if (error.message === 'All configured authentication methods failed') { - this.passwordStorage.deletePassword(this.profile) - } - this.zone.run(() => { - if (connected) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - this.notifications.error(error.toString()) - } else { - reject(error) - } - }) - }) - ssh.on('close', () => { - if (this.open) { - this.destroy() - } - }) - - ssh.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => this.zone.run(async () => { - this.emitKeyboardInteractivePrompt(new KeyboardInteractivePrompt( - name, - instructions, - prompts, - finish, - )) - })) - - ssh.on('greeting', greeting => { - if (!this.profile.options.skipBanner) { - log('Greeting: ' + greeting) - } - }) - - ssh.on('banner', banner => { - if (!this.profile.options.skipBanner) { - log(banner) - } - }) - }) - - try { - if (this.profile.options.socksProxyHost) { - this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`) - this.proxyCommandStream = new SocksProxyStream(this.profile) - } - if (this.profile.options.httpProxyHost) { - this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.httpProxyHost}:${this.profile.options.httpProxyPort}`) - this.proxyCommandStream = new HTTPProxyStream(this.profile) - } - if (this.profile.options.proxyCommand) { - this.emitServiceMessage(colors.bgBlue.black(' Proxy command ') + ` Using ${this.profile.options.proxyCommand}`) - this.proxyCommandStream = new ProxyCommandStream(this.profile.options.proxyCommand) - } - if (this.proxyCommandStream) { - this.proxyCommandStream.destroyed$.subscribe(err => { - if (err) { - this.emitServiceMessage(colors.bgRed.black(' X ') + ` ${err.message}`) - this.destroy() - } - }) - - this.proxyCommandStream.message$.subscribe(message => { - this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ' ' + message.trim()) - }) - - await this.proxyCommandStream.start() - } - - this.authUsername ??= this.profile.options.user - if (!this.authUsername) { - const modal = this.ngbModal.open(PromptModalComponent) - modal.componentInstance.prompt = `Username for ${this.profile.options.host}` - try { - const result = await modal.result.catch(() => null) - this.authUsername = result?.value ?? null - } catch { - this.authUsername = 'root' - } - } - if (this.authUsername?.startsWith('$')) { - try { - const result = process.env[this.authUsername.slice(1)] - this.authUsername = result ?? this.authUsername - } catch { - this.authUsername = 'root' - } - } - - ssh.connect({ - host: this.profile.options.host.trim(), - port: this.profile.options.port ?? 22, - sock: this.proxyCommandStream?.socket ?? this.jumpStream, - username: this.authUsername ?? undefined, - tryKeyboard: true, - agent: this.agentPath, - agentForward: this.profile.options.agentForward && !!this.agentPath, - keepaliveInterval: this.profile.options.keepaliveInterval ?? 15000, - keepaliveCountMax: this.profile.options.keepaliveCountMax, - readyTimeout: this.profile.options.readyTimeout, - hostVerifier: (key: any) => { - this.hostKeyDigest = crypto.createHash('sha256').update(key).digest('base64') - return true - }, - algorithms, - authHandler: (methodsLeft, partialSuccess, callback) => { - this.zone.run(async () => { - await hostVerifiedPromise - callback(await this.handleAuth(methodsLeft)) - }) - }, - }) - } catch (e) { - this.notifications.error(e.message) - throw e + const argv = shellQuote.parse(this.profile.options.proxyCommand) + transport = await russh.SshTransport.newCommand(argv[0], argv.slice(1)) + } else if (this.jumpChannel) { + transport = await russh.SshTransport.newSshChannel(await this.jumpChannel.take()) + this.jumpChannel = null + } else if (this.profile.options.socksProxyHost) { + this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.socksProxyHost}:${this.profile.options.socksProxyPort}`) + transport = await russh.SshTransport.newSocksProxy( + this.profile.options.socksProxyHost, + this.profile.options.socksProxyPort ?? 1080, + this.profile.options.host, + this.profile.options.port ?? 22, + ) + } else if (this.profile.options.httpProxyHost) { + this.emitServiceMessage(colors.bgBlue.black(' Proxy ') + ` Using ${this.profile.options.httpProxyHost}:${this.profile.options.httpProxyPort}`) + transport = await russh.SshTransport.newHttpProxy( + this.profile.options.httpProxyHost, + this.profile.options.httpProxyPort ?? 8080, + this.profile.options.host, + this.profile.options.port ?? 22, + ) + } else { + transport = await russh.SshTransport.newSocket(`${this.profile.options.host.trim()}:${this.profile.options.port ?? 22}`) } - await resultPromise - await hostVerifiedPromise + this.ssh = await russh.SSHClient.connect( + transport, + async key => { + if (!await this.verifyHostKey(key)) { + return false + } + this.logger.info('Host key verified') + return true + }, + { + preferred: { + ciphers: this.profile.options.algorithms?.[SSHAlgorithmType.CIPHER]?.filter(x => supportedAlgorithms[SSHAlgorithmType.CIPHER].includes(x)), + kex: this.profile.options.algorithms?.[SSHAlgorithmType.KEX]?.filter(x => supportedAlgorithms[SSHAlgorithmType.KEX].includes(x)), + mac: this.profile.options.algorithms?.[SSHAlgorithmType.HMAC]?.filter(x => supportedAlgorithms[SSHAlgorithmType.HMAC].includes(x)), + key: this.profile.options.algorithms?.[SSHAlgorithmType.HOSTKEY]?.filter(x => supportedAlgorithms[SSHAlgorithmType.HOSTKEY].includes(x)), + }, + keepaliveIntervalSeconds: Math.round((this.profile.options.keepaliveInterval ?? 15000) / 1000), + keepaliveCountMax: this.profile.options.keepaliveCountMax, + connectionTimeoutSeconds: this.profile.options.readyTimeout ? Math.round(this.profile.options.readyTimeout / 1000) : undefined, + }, + ) + + this.ssh.banner$.subscribe(banner => { + if (!this.profile.options.skipBanner) { + this.emitServiceMessage(banner) + } + }) + + this.ssh.disconnect$.subscribe(() => { + if (this.open) { + this.destroy() + } + }) + + // Authentication + + this.authUsername ??= this.profile.options.user + if (!this.authUsername) { + const modal = this.ngbModal.open(PromptModalComponent) + modal.componentInstance.prompt = `Username for ${this.profile.options.host}` + try { + const result = await modal.result.catch(() => null) + this.authUsername = result?.value ?? null + } catch { + this.authUsername = 'root' + } + } + + if (this.authUsername?.startsWith('$')) { + try { + const result = process.env[this.authUsername.slice(1)] + this.authUsername = result ?? this.authUsername + } catch { + this.authUsername = 'root' + } + } + + const authenticatedClient = await this.handleAuth() + if (authenticatedClient) { + this.ssh = authenticatedClient + } else { + this.ssh.disconnect() + this.passwordStorage.deletePassword(this.profile) + // eslint-disable-next-line @typescript-eslint/no-base-to-string + throw new Error('Authentication rejected') + } + + // auth success + + if (this.savedPassword) { + this.passwordStorage.savePassword(this.profile, this.savedPassword) + } for (const fw of this.profile.options.forwardedPorts ?? []) { this.addPortForward(Object.assign(new ForwardedPort(), fw)) @@ -352,12 +357,11 @@ export class SSHSession { this.open = true - this.ssh.on('tcp connection', (details, accept, reject) => { - this.logger.info(`Incoming forwarded connection: (remote) ${details.srcIP}:${details.srcPort} -> (local) ${details.destIP}:${details.destPort}`) - const forward = this.forwardedPorts.find(x => x.port === details.destPort) + this.ssh.tcpChannelOpen$.subscribe(async event => { + this.logger.info(`Incoming forwarded connection: ${event.clientAddress}:${event.clientPort} -> ${event.targetAddress}:${event.targetPort}`) + const forward = this.forwardedPorts.find(x => x.port === event.targetPort && x.host === event.targetAddress) if (!forward) { - this.emitServiceMessage(colors.bgRed.black(' X ') + ` Rejected incoming forwarded connection for unrecognized port ${details.destPort}`) - reject() + this.emitServiceMessage(colors.bgRed.black(' X ') + ` Rejected incoming forwarded connection for unrecognized port ${event.targetAddress}:${event.targetPort}`) return } const socket = new Socket() @@ -365,24 +369,19 @@ export class SSHSession { socket.on('error', e => { // eslint-disable-next-line @typescript-eslint/no-base-to-string this.emitServiceMessage(colors.bgRed.black(' X ') + ` Could not forward the remote connection to ${forward.targetAddress}:${forward.targetPort}: ${e}`) - reject() + event.channel.close() }) + event.channel.data$.subscribe(data => socket.write(data)) + socket.on('data', data => event.channel.write(Uint8Array.from(data))) + event.channel.closed$.subscribe(() => socket.destroy()) + socket.on('close', () => event.channel.close()) socket.on('connect', () => { this.logger.info('Connection forwarded') - const stream = accept() - stream.pipe(socket) - socket.pipe(stream) - stream.on('close', () => { - socket.destroy() - }) - socket.on('close', () => { - stream.close() - }) }) }) - this.ssh.on('x11', async (details, accept, reject) => { - this.logger.info(`Incoming X11 connection from ${details.srcIP}:${details.srcPort}`) + this.ssh.x11ChannelOpen$.subscribe(async event => { + this.logger.info(`Incoming X11 connection from ${event.clientAddress}:${event.clientPort}`) const displaySpec = (this.config.store.ssh.x11Display || process.env.DISPLAY) ?? 'localhost:0' this.logger.debug(`Trying display ${displaySpec}`) @@ -390,14 +389,18 @@ export class SSHSession { try { const x11Stream = await socket.connect(displaySpec) this.logger.info('Connection forwarded') - const stream = accept() - stream.pipe(x11Stream) - x11Stream.pipe(stream) - stream.on('close', () => { + + event.channel.data$.subscribe(data => { + x11Stream.write(data) + }) + x11Stream.on('data', data => { + event.channel.write(Uint8Array.from(data)) + }) + event.channel.closed$.subscribe(() => { socket.destroy() }) x11Stream.on('close', () => { - stream.close() + event.channel.close() }) } catch (e) { // eslint-disable-next-line @typescript-eslint/no-base-to-string @@ -408,27 +411,43 @@ export class SSHSession { this.emitServiceMessage(' * VcXsrv: https://sourceforge.net/projects/vcxsrv/') this.emitServiceMessage(' * Xming: https://sourceforge.net/projects/xming/') } - reject() + event.channel.close() } }) + + this.ssh.agentChannelOpen$.subscribe(async channel => { + const spec = await this.getAgentConnectionSpec() + if (!spec) { + await channel.close() + return + } + + const agent = await russh.SSHAgentStream.connect(spec) + channel.data$.subscribe(data => agent.write(data)) + agent.data$.subscribe(data => channel.write(data), undefined, () => channel.close()) + channel.closed$.subscribe(() => agent.close()) + }) } - private async verifyHostKey (handshake: Handshake): Promise { + private async verifyHostKey (key: russh.SshPublicKey): Promise { this.emitServiceMessage('Host key fingerprint:') - this.emitServiceMessage(colors.white.bgBlack(` ${handshake.serverHostKey} `) + colors.bgBlackBright(' ' + this.hostKeyDigest + ' ')) + this.emitServiceMessage(colors.white.bgBlack(` ${key.algorithm()} `) + colors.bgBlackBright(' ' + key.fingerprint() + ' ')) if (!this.config.store.ssh.verifyHostKeys) { return true } const selector = { host: this.profile.options.host, port: this.profile.options.port ?? 22, - type: handshake.serverHostKey, + type: key.algorithm(), } + + const keyDigest = crypto.createHash('sha256').update(key.bytes()).digest('base64') + const knownHost = this.knownHosts.getFor(selector) - if (!knownHost || knownHost.digest !== this.hostKeyDigest) { + if (!knownHost || knownHost.digest !== keyDigest) { const modal = this.ngbModal.open(HostKeyPromptModalComponent) modal.componentInstance.selector = selector - modal.componentInstance.digest = this.hostKeyDigest + modal.componentInstance.digest = keyDigest return modal.result.catch(() => false) } return true @@ -450,57 +469,49 @@ export class SSHSession { this.keyboardInteractivePrompt.next(prompt) } - async handleAuth (methodsLeft?: string[] | null): Promise { + async handleAuth (methodsLeft?: string[] | null): Promise { this.activePrivateKey = null + if (!(this.ssh instanceof russh.SSHClient)) { + throw new Error('Wrong state for auth handling') + } + + if (!this.authUsername) { + throw new Error('No username') + } + while (true) { const method = this.remainingAuthMethods.shift() if (!method) { - return false + return null } if (methodsLeft && !methodsLeft.includes(method.type) && method.type !== 'agent') { // Agent can still be used even if not in methodsLeft this.logger.info('Server does not support auth method', method.type) continue } - if (method.type === 'password') { - if (this.profile.options.password) { - this.emitServiceMessage(this.translate.instant('Using preset password')) - return { - type: 'password', - username: this.authUsername, - password: this.profile.options.password, - } + if (method.type === 'saved-password') { + this.emitServiceMessage(this.translate.instant('Using saved password')) + const result = await this.ssh.authenticateWithPassword(this.authUsername, method.password) + if (result) { + return result } - - if (!this.keychainPasswordUsed && this.profile.options.user) { - const password = await this.passwordStorage.loadPassword(this.profile) - if (password) { - this.emitServiceMessage(this.translate.instant('Trying saved password')) - this.keychainPasswordUsed = true - return { - type: 'password', - username: this.authUsername, - password, - } - } - } - + } + if (method.type === 'prompt-password') { const modal = this.ngbModal.open(PromptModalComponent) modal.componentInstance.prompt = `Password for ${this.authUsername}@${this.profile.options.host}` modal.componentInstance.password = true modal.componentInstance.showRememberCheckbox = true try { - const result = await modal.result.catch(() => null) - if (result) { - if (result.remember) { - this.savedPassword = result.value + const promptResult = await modal.result.catch(() => null) + if (promptResult) { + if (promptResult.remember) { + this.savedPassword = promptResult.value } - return { - type: 'password', - username: this.authUsername, - password: result.value, + const result = await this.ssh.authenticateWithPassword(this.authUsername, promptResult.value) + if (result) { + return result } } else { continue @@ -509,50 +520,104 @@ export class SSHSession { continue } } - if (method.type === 'publickey' && method.contents) { + if (method.type === 'publickey') { try { - const key = await this.loadPrivateKey(method.name!, method.contents) - return { - type: 'publickey', - username: this.authUsername, - key, + const key = await this.loadPrivateKey(method.name, method.contents) + const result = await this.ssh.authenticateWithKeyPair(this.authUsername, key) + if (result) { + return result } } catch (e) { this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to load private key ${method.name}: ${e}`) continue } } - return method.type + if (method.type === 'keyboard-interactive') { + let state: russh.AuthenticatedSSHClient|russh.KeyboardInteractiveAuthenticationState = await this.ssh.startKeyboardInteractiveAuthentication(this.authUsername) + + while (true) { + if (state.state === 'failure') { + break + } + + const prompts = state.prompts() + + let responses: string[] = [] + // OpenSSH can send a k-i request without prompts + // just respond ok to it + if (prompts.length > 0) { + const prompt = new KeyboardInteractivePrompt( + state.name, + state.instructions, + state.prompts(), + ) + + if (method.savedPassword) { + // eslint-disable-next-line max-depth + for (let i = 0; i < prompt.prompts.length; i++) { + // eslint-disable-next-line max-depth + if (prompt.isAPasswordPrompt(i)) { + prompt.responses[i] = method.savedPassword + } + } + } + + this.emitKeyboardInteractivePrompt(prompt) + + try { + // eslint-disable-next-line @typescript-eslint/await-thenable + responses = await prompt.promise + } catch { + break // this loop + } + } + + state = await this.ssh .continueKeyboardInteractiveAuthentication(responses) + + if (state instanceof russh.AuthenticatedSSHClient) { + return state + } + } + } + if (method.type === 'agent') { + try { + const result = await this.ssh.authenticateWithAgent(this.authUsername, method) + if (result) { + return result + } + } catch (e) { + this.emitServiceMessage(colors.bgYellow.yellow.black(' ! ') + ` Failed to authenticate using agent: ${e}`) + continue + } + } } + return null } async addPortForward (fw: ForwardedPort): Promise { if (fw.type === PortForwardType.Local || fw.type === PortForwardType.Dynamic) { - await fw.startLocalListener((accept, reject, sourceAddress, sourcePort, targetAddress, targetPort) => { + await fw.startLocalListener(async (accept, reject, sourceAddress, sourcePort, targetAddress, targetPort) => { this.logger.info(`New connection on ${fw}`) - this.ssh.forwardOut( - sourceAddress ?? '127.0.0.1', - sourcePort ?? 0, - targetAddress, - targetPort, - (err, stream) => { - if (err) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote has rejected the forwarded connection to ${targetAddress}:${targetPort} via ${fw}: ${err}`) - reject() - return - } - const socket = accept() - stream.pipe(socket) - socket.pipe(stream) - stream.on('close', () => { - socket.destroy() - }) - socket.on('close', () => { - stream.close() - }) - }, - ) + if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) { + this.logger.error(`Connection while unauthenticated on ${fw}`) + reject() + return + } + const channel = await this.ssh.openTCPForwardChannel({ + addressToConnectTo: targetAddress, + portToConnectTo: targetPort, + originatorAddress: sourceAddress ?? '127.0.0.1', + originatorPort: sourcePort ?? 0, + }).catch(err => { + this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote has rejected the forwarded connection to ${targetAddress}:${targetPort} via ${fw}: ${err}`) + reject() + throw err + }) + const socket = accept() + channel.data$.subscribe(data => socket.write(data)) + socket.on('data', data => channel.write(Uint8Array.from(data))) + channel.closed$.subscribe(() => socket.destroy()) + socket.on('close', () => channel.close()) }).then(() => { this.emitServiceMessage(colors.bgGreen.black(' -> ') + ` Forwarded ${fw}`) this.forwardedPorts.push(fw) @@ -562,17 +627,16 @@ export class SSHSession { }) } if (fw.type === PortForwardType.Remote) { - await new Promise((resolve, reject) => { - this.ssh.forwardIn(fw.host, fw.port, err => { - if (err) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected port forwarding for ${fw}: ${err}`) - reject(err) - return - } - resolve() - }) - }) + if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) { + throw new Error('Cannot add remote port forward before auth') + } + try { + await this.ssh.forwardTCPPort(fw.host, fw.port) + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + this.emitServiceMessage(colors.bgRed.black(' X ') + ` Remote rejected port forwarding for ${fw}: ${err}`) + return + } this.emitServiceMessage(colors.bgGreen.black(' <- ') + ` Forwarded ${fw}`) this.forwardedPorts.push(fw) } @@ -584,7 +648,10 @@ export class SSHSession { this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw) } if (fw.type === PortForwardType.Remote) { - this.ssh.unforwardIn(fw.host, fw.port) + if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) { + throw new Error('Cannot remove remote port forward before auth') + } + this.ssh.stopForwardingTCPPort(fw.host, fw.port) this.forwardedPorts = this.forwardedPorts.filter(x => x !== fw) } this.emitServiceMessage(`Stopped forwarding ${fw}`) @@ -595,43 +662,55 @@ export class SSHSession { this.willDestroy.next() this.willDestroy.complete() this.serviceMessage.complete() - this.proxyCommandStream?.stop() - this.ssh.end() + this.ssh.disconnect() } - openShellChannel (options: { x11: boolean }): Promise { - return new Promise((resolve, reject) => { - this.ssh.shell({ term: 'xterm-256color' }, options, (err, shell) => { - if (err) { - reject(err) - } else { - resolve(shell) - } - }) + async openShellChannel (options: { x11: boolean }): Promise { + if (!(this.ssh instanceof russh.AuthenticatedSSHClient)) { + throw new Error('Cannot open shell channel before auth') + } + const ch = await this.ssh.openSessionChannel() + await ch.requestPTY('xterm-256color', { + columns: 80, + rows: 24, + pixHeight: 0, + pixWidth: 0, }) + if (options.x11) { + await ch.requestX11Forwarding({ + singleConnection: false, + authProtocol: 'MIT-MAGIC-COOKIE-1', + authCookie: crypto.randomBytes(16).toString('hex'), + screenNumber: 0, + }) + } + if (this.profile.options.agentForward) { + await ch.requestAgentForwarding() + } + await ch.requestShell() + return ch } - async loadPrivateKey (name: string, privateKeyContents: Buffer): Promise { + async loadPrivateKey (name: string, privateKeyContents: Buffer): Promise { this.emitServiceMessage(`Loading private key: ${name}`) - const parsedKey = await this.parsePrivateKey(privateKeyContents.toString()) - this.activePrivateKey = parsedKey.toString('openssh') + this.activePrivateKey = await this.loadPrivateKeyWithPassphraseMaybe(privateKeyContents.toString()) return this.activePrivateKey } - async parsePrivateKey (privateKey: string): Promise { + async loadPrivateKeyWithPassphraseMaybe (privateKey: string): Promise { const keyHash = crypto.createHash('sha512').update(privateKey).digest('hex') let triedSavedPassphrase = false let passphrase: string|null = null while (true) { try { - return sshpk.parsePrivateKey(privateKey, 'auto', { passphrase }) + return await russh.KeyPair.parse(privateKey, passphrase ?? undefined) } catch (e) { if (!triedSavedPassphrase) { passphrase = await this.passwordStorage.loadPrivateKeyPassword(keyHash) triedSavedPassphrase = true continue } - if (e instanceof sshpk.KeyEncryptedError || e instanceof sshpk.KeyParseError) { + if (e.toString() === 'Error: Keys(KeyIsEncrypted)' || e.toString() === 'Error: Keys(SshKey(Crypto))') { await this.passwordStorage.deletePrivateKeyPassword(keyHash) const modal = this.ngbModal.open(PromptModalComponent) diff --git a/tabby-ssh/webpack.config.mjs b/tabby-ssh/webpack.config.mjs index 16ce381b..028b4e0e 100644 --- a/tabby-ssh/webpack.config.mjs +++ b/tabby-ssh/webpack.config.mjs @@ -7,9 +7,4 @@ import config from '../webpack.plugin.config.mjs' export default () => config({ name: 'ssh', dirname: __dirname, - alias: { - 'cpu-features': false, - './crypto/build/Release/sshcrypto.node': false, - '../build/Release/cpufeatures.node': false, - }, }) diff --git a/tabby-ssh/yarn.lock b/tabby-ssh/yarn.lock index 34474ca1..523a4285 100644 --- a/tabby-ssh/yarn.lock +++ b/tabby-ssh/yarn.lock @@ -9,33 +9,11 @@ dependencies: ipv6 "*" -"@types/node@*": - version "22.1.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.1.0.tgz#6d6adc648b5e03f0e83c78dc788c2b037d0ad94b" - integrity sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw== - dependencies: - undici-types "~6.13.0" - "@types/node@20.3.1": version "20.3.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.3.1.tgz#e8a83f1aa8b649377bb1fb5d7bac5cb90e784dfe" integrity sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg== -"@types/ssh2-streams@*": - version "0.1.12" - resolved "https://registry.yarnpkg.com/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz#e68795ba2bf01c76b93f9c9809e1f42f0eaaec5f" - integrity sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg== - dependencies: - "@types/node" "*" - -"@types/ssh2@^0.5.46": - version "0.5.52" - resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-0.5.52.tgz#9dbd8084e2a976e551d5e5e70b978ed8b5965741" - integrity sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg== - dependencies: - "@types/node" "*" - "@types/ssh2-streams" "*" - ansi-colors@^4.1.1: version "4.1.3" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" @@ -46,40 +24,16 @@ ansi-regex@^6.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== -asn1@~0.2.3: - version "0.2.4" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" - integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== - dependencies: - safer-buffer "~2.1.0" - -assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= - async@0.2.x: version "0.2.10" resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" - integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E= + integrity sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ== balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= -bcrypt-pbkdf@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= - dependencies: - tweetnacl "^0.14.3" - -bn.js@^4.0.0, bn.js@^4.1.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" - integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== - brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -88,22 +42,17 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -brorand@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" - integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w== - cli@0.4.x: version "0.4.5" resolved "https://registry.yarnpkg.com/cli/-/cli-0.4.5.tgz#78f9485cd161b566e9a6c72d7170c4270e81db61" - integrity sha1-ePlIXNFhtWbppsctcXDEJw6B22E= + integrity sha512-dbn5HyeJWSOU58RwOEiF1VWrl7HRvDsKLpu0uiI/vExH6iNoyUzjB5Mr3IJY5DVUfnbpe9793xw4DFJVzC9nWQ== dependencies: glob ">= 3.1.4" cliff@0.1.x: version "0.1.10" resolved "https://registry.yarnpkg.com/cliff/-/cliff-0.1.10.tgz#53be33ea9f59bec85609ee300ac4207603e52013" - integrity sha1-U74z6p9ZvshWCe4wCsQgdgPlIBM= + integrity sha512-roZWcC2Cxo/kKjRXw7YUpVNtxJccbvcl7VzTjUYgLQk6Ot0R8bm2netbhSZYWWNrKlOO/7HD6GXHl8dtzE6SiQ== dependencies: colors "~1.0.3" eyes "~0.1.8" @@ -112,12 +61,12 @@ cliff@0.1.x: colors@0.6.x: version "0.6.2" resolved "https://registry.yarnpkg.com/colors/-/colors-0.6.2.tgz#2423fe6678ac0c5dae8852e5d0e5be08c997abcc" - integrity sha1-JCP+ZnisDF2uiFLl0OW+CMmXq8w= + integrity sha512-OsSVtHK8Ir8r3+Fxw/b4jS1ZLPXkV6ZxDRJQzeD7qo0SqMXWrHDM71DgYzPMHY8SFJ0Ao+nNU2p1MmwdzKqPrw== colors@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" - integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs= + integrity sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw== concat-map@0.0.1: version "0.0.1" @@ -127,62 +76,19 @@ concat-map@0.0.1: cycle@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2" - integrity sha1-IegLK+hYD5i0aPN5QwZisEbDStI= - -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= - dependencies: - assert-plus "^1.0.0" - -diffie-hellman@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" - integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== - dependencies: - bn.js "^4.1.0" - miller-rabin "^4.0.0" - randombytes "^2.0.0" - -ecc-jsbn@~0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= - dependencies: - jsbn "~0.1.0" - safer-buffer "^2.1.0" + integrity sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA== eyes@0.1.x, eyes@~0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" - integrity sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A= + integrity sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ== fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= - dependencies: - assert-plus "^1.0.0" - -"glob@>= 3.1.4": - version "7.1.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^7.1.3: +glob@7.2.3, "glob@>= 3.1.4", glob@^7.1.3: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -210,7 +116,7 @@ inherits@2: ipv6@*: version "3.1.3" resolved "https://registry.yarnpkg.com/ipv6/-/ipv6-3.1.3.tgz#4d9064f9c2dafa0dd10b8b7d76ffca4aad31b3b9" - integrity sha1-TZBk+cLa+g3RC4t9dv/KSq0xs7k= + integrity sha512-TmLbUIURMAZ161GZDddTtAAb3aceRNLn7PRmP8fANp8xDRCW9oIQva8eenA48bRvw347jBqSREXMI38DybbUiQ== dependencies: cli "0.4.x" cliff "0.1.x" @@ -219,27 +125,7 @@ ipv6@*: isstream@0.1.x: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= - -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= - -miller-rabin@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" - integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== - dependencies: - bn.js "^4.0.0" - brorand "^1.0.1" - -minimatch@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" - integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== - dependencies: - brace-expansion "^1.1.7" + integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== minimatch@^3.1.1: version "3.1.2" @@ -263,14 +149,7 @@ path-is-absolute@^1.0.0: pkginfo@0.3.x: version "0.3.1" resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.3.1.tgz#5b29f6a81f70717142e09e765bbeab97b4f81e21" - integrity sha1-Wyn2qB9wcXFC4J52W76rl7T4HiE= - -randombytes@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" + integrity sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A== rimraf@^3.0.0: version "3.0.2" @@ -284,39 +163,15 @@ run-script-os@^1.1.3: resolved "https://registry.yarnpkg.com/run-script-os/-/run-script-os-1.1.6.tgz#8b0177fb1b54c99a670f95c7fdc54f18b9c72347" integrity sha512-ql6P2LzhBTTDfzKts+Qo4H94VUKpxKDFz6QxxwaUZN0mwvi7L3lpOI7BqPCq7lgDh3XLl0dpeXwfcVIitlrYrw== -safe-buffer@^5.1.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - sprintf@0.1.x: version "0.1.5" resolved "https://registry.yarnpkg.com/sprintf/-/sprintf-0.1.5.tgz#8f83e39a9317c1a502cb7db8050e51c679f6edcf" - integrity sha1-j4PjmpMXwaUCy324BQ5Rxnn27c8= - -sshpk@Eugeny/node-sshpk#c2b71d1243714d2daf0988f84c3323d180817136: - version "1.18.0" - resolved "https://codeload.github.com/Eugeny/node-sshpk/tar.gz/c2b71d1243714d2daf0988f84c3323d180817136" - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - bcrypt-pbkdf "^1.0.0" - dashdash "^1.12.0" - ecc-jsbn "~0.1.1" - getpass "^0.1.1" - jsbn "~0.1.0" - safer-buffer "^2.0.2" - tweetnacl "~0.14.0" + integrity sha512-4X5KsuXFQ7f+d7Y+bi4qSb6eI+YoifDTGr0MQJXRoYO7BO7evfRCjds6kk3z7l5CiJYxgDN1x5Er4WiyCt+zTQ== stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" - integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= + integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== strip-ansi@^7.0.0: version "7.1.0" @@ -339,20 +194,10 @@ tmp@^0.2.0: dependencies: rimraf "^3.0.0" -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= - -undici-types@~6.13.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.13.0.tgz#e3e79220ab8c81ed1496b5812471afd7cf075ea5" - integrity sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg== - winston@0.8.x: version "0.8.3" resolved "https://registry.yarnpkg.com/winston/-/winston-0.8.3.tgz#64b6abf4cd01adcaefd5009393b1d8e8bec19db0" - integrity sha1-ZLar9M0Brcrv1QCTk7HY6L7BnbA= + integrity sha512-fPoamsHq8leJ62D1M9V/f15mjQ1UHe4+7j1wpAT3fqgA5JqhJkk4aIfPEjfMTI9x6ZTjaLOpMAjluLtmgO5b6g== dependencies: async "0.2.x" colors "0.6.x" diff --git a/tabby-web/src/platform.ts b/tabby-web/src/platform.ts index 50958dae..54a7c6db 100644 --- a/tabby-web/src/platform.ts +++ b/tabby-web/src/platform.ts @@ -149,7 +149,7 @@ export class WebPlatformService extends PlatformService { } class HTMLFileDownload extends FileDownload { - private buffers: Buffer[] = [] + private buffers: Uint8Array[] = [] constructor ( private name: string, @@ -171,8 +171,8 @@ class HTMLFileDownload extends FileDownload { return this.size } - async write (buffer: Buffer): Promise { - this.buffers.push(Buffer.from(buffer)) + async write (buffer: Uint8Array): Promise { + this.buffers.push(Uint8Array.from(buffer)) this.increaseProgress(buffer.length) if (this.isComplete()) { this.finish() diff --git a/webpack.plugin.config.mjs b/webpack.plugin.config.mjs index d8e78f57..e4cab027 100644 --- a/webpack.plugin.config.mjs +++ b/webpack.plugin.config.mjs @@ -157,6 +157,7 @@ export default options => { 'os', 'path', 'readline', + 'russh', '@luminati-io/socksv5', 'stream', 'windows-native-registry', diff --git a/yarn.lock b/yarn.lock index 3d00402e..9e3ec19f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1553,13 +1553,6 @@ asn1.js@^5.2.0: minimalistic-assert "^1.0.0" safer-buffer "^2.1.0" -asn1@^0.2.6: - version "0.2.6" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" - integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== - dependencies: - safer-buffer "~2.1.0" - asn1@~0.2.3: version "0.2.4" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" @@ -1694,7 +1687,7 @@ base64-js@^1.3.1, base64-js@^1.5.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -bcrypt-pbkdf@^1.0.0, bcrypt-pbkdf@^1.0.2: +bcrypt-pbkdf@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= @@ -1914,11 +1907,6 @@ buffer@^5.1.0: base64-js "^1.3.1" ieee754 "^1.1.13" -buildcheck@~0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.6.tgz#89aa6e417cfd1e2196e3f8fe915eb709d2fe4238" - integrity sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A== - builder-util-runtime@9.2.1: version "9.2.1" resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.2.1.tgz#3184dcdf7ed6c47afb8df733813224ced4f624fd" @@ -2507,14 +2495,6 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= -cpu-features@~0.0.9: - version "0.0.10" - resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5" - integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA== - dependencies: - buildcheck "~0.0.6" - nan "^2.19.0" - crc@^3.8.0: version "3.8.0" resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6" @@ -5985,7 +5965,7 @@ mute-stream@~0.0.4: resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz" integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= -nan@2.17.0, nan@^2.18.0, nan@^2.19.0: +nan@2.17.0: version "2.17.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== @@ -8220,17 +8200,6 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -ssh2@^1.14.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.15.0.tgz#2f998455036a7f89e0df5847efb5421748d9871b" - integrity sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw== - dependencies: - asn1 "^0.2.6" - bcrypt-pbkdf "^1.0.2" - optionalDependencies: - cpu-features "~0.0.9" - nan "^2.18.0" - sshpk@^1.7.0: version "1.16.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"