From a00766bf1efae1ba03ec86f26134870661850908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=93=B2?= <467272609@qq.com> Date: Tue, 22 Sep 2026 15:28:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Windows=20=E4=B8=8A=E5=86=85=E7=BD=AE=20?= =?UTF-8?q?SSH=20=E5=AE=A2=E6=88=B7=E7=AB=AF=EF=BC=8C=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E5=90=8E=E5=A4=8D=E7=94=A8=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 系统自带的 OpenSSH 不支持 ControlMaster,每条命令都会重新认证。手动填写的连接改由随应用打包的 ssh2 握一条连接。 --- package-lock.json | 220 +++----- package.json | 2 + .../environment/__tests__/session-mux.test.ts | 75 +++ .../environment/__tests__/ssh-command.test.ts | 15 +- .../environment/__tests__/ssh-native.test.ts | 2 +- src/main/environment/ssh/askpass.ts | 31 +- src/main/environment/ssh/bundled-client.ts | 161 ++++++ src/main/environment/ssh/provider.ts | 74 ++- src/main/environment/ssh/session-mux.ts | 507 ++++++++++++++++++ src/main/environment/ssh/transport.ts | 143 ++++- src/main/runtime.ts | 2 +- 11 files changed, 1070 insertions(+), 162 deletions(-) create mode 100644 src/main/environment/__tests__/session-mux.test.ts create mode 100644 src/main/environment/ssh/bundled-client.ts create mode 100644 src/main/environment/ssh/session-mux.ts diff --git a/package-lock.json b/package-lock.json index 39caa2139..6b2426d48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "playwright-core": "^1.59.1", "qrcode-generator": "^2.0.4", "smol-toml": "^1.8.0", + "ssh2": "^1.17.0", "ssh2-streams": "npm:ssh2-streams-classic@0.4.14", "undici": "7.29.1", "unzipper": "^0.12.5", @@ -34,6 +35,7 @@ "@types/node": "^24.9.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.7", + "@types/ssh2": "^1.15.6", "@types/ssh2-streams": "^0.1.13", "@types/unzipper": "^0.10.11", "@uiw/react-codemirror": "^4.25.11", @@ -1361,45 +1363,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2579,9 +2542,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4341,9 +4301,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4358,9 +4315,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4375,9 +4329,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4392,9 +4343,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4409,9 +4357,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4426,9 +4371,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4443,9 +4385,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4460,9 +4399,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4477,9 +4413,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4494,9 +4427,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4511,9 +4441,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4528,9 +4455,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4545,9 +4469,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4811,9 +4732,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4831,9 +4749,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4851,9 +4766,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4871,9 +4783,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5470,6 +5379,16 @@ "@types/node": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.6", + "resolved": "https://registry.npmmirror.com/@types/ssh2/-/ssh2-1.15.6.tgz", + "integrity": "sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, "node_modules/@types/ssh2-streams": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz", @@ -5480,6 +5399,23 @@ "@types/node": "*" } }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -6522,6 +6458,15 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/builder-util": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", @@ -7025,6 +6970,20 @@ "layout-base": "^1.0.0" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crelt": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", @@ -7032,15 +6991,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -10481,9 +10431,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10505,9 +10452,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10529,9 +10473,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10553,9 +10494,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11928,6 +11866,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.29.0", + "resolved": "https://registry.npmmirror.com/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -12630,36 +12575,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13839,6 +13754,23 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/ssh2-streams": { "name": "ssh2-streams-classic", "version": "0.4.14", diff --git a/package.json b/package.json index 1b57378b1..c81a9566b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "playwright-core": "^1.59.1", "qrcode-generator": "^2.0.4", "smol-toml": "^1.8.0", + "ssh2": "^1.17.0", "ssh2-streams": "npm:ssh2-streams-classic@0.4.14", "undici": "7.29.1", "unzipper": "^0.12.5", @@ -57,6 +58,7 @@ "@types/node": "^24.9.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.7", + "@types/ssh2": "^1.15.6", "@types/ssh2-streams": "^0.1.13", "@types/unzipper": "^0.10.11", "@uiw/react-codemirror": "^4.25.11", diff --git a/src/main/environment/__tests__/session-mux.test.ts b/src/main/environment/__tests__/session-mux.test.ts new file mode 100644 index 000000000..f35c352ec --- /dev/null +++ b/src/main/environment/__tests__/session-mux.test.ts @@ -0,0 +1,75 @@ +/** + * Windows 那条「一次认证,后面所有命令复用」的多路复用。 + * + * 用本机 python 跑和远端同一份脚本,走真实的 stdin/stdout 帧,而不是 mock: + * 帧边界、banner 前的噪声、并发通道这些错,mock 原理上对得上、真跑对不上。 + */ +import { spawn } from 'node:child_process' +import { createServer } from 'node:net' +import { PassThrough } from 'node:stream' +import { expect, it } from 'vitest' +import { SessionMux, muxPythonLaunchCode } from '../ssh/session-mux' + +function startMux(): { mux: SessionMux; kill: () => void } { + const child = spawn('python', ['-S', '-u', '-c', muxPythonLaunchCode()], { windowsHide: true, stdio: 'pipe' }) + const mux = new SessionMux(child.stdin, child.stdout, () => {}) + child.stderr.on('data', () => {}) + return { mux, kill: () => child.kill() } +} + +it('runs several commands over one session without starting another process', async () => { + const { mux, kill } = startMux() + try { + await mux.ready + const first = await mux.exec('echo one', AbortSignal.timeout(10_000), 10_000) + const second = await mux.exec('echo two', AbortSignal.timeout(10_000), 10_000) + expect(first).toMatchObject({ code: 0, stdout: expect.stringContaining('one') }) + expect(second).toMatchObject({ code: 0, stdout: expect.stringContaining('two') }) + const failed = await mux.exec('exit 7', AbortSignal.timeout(10_000), 10_000) + expect(failed.code).toBe(7) + } finally { mux.close(); kill() } +}) + +it('keeps a long-lived process open and writes to its stdin', async () => { + const { mux, kill } = startMux() + try { + await mux.ready + const process = mux.openProcess('python -c "import sys; print(sys.stdin.readline().strip())"') + process.stdin.write('hello-mux\n') + process.stdin.end() + const chunks: Buffer[] = [] + process.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)) + const exit = await process.exited + expect(exit.code).toBe(0) + expect(Buffer.concat(chunks).toString('utf8')).toContain('hello-mux') + } finally { mux.close(); kill() } +}) + +it('forwards a tcp connection through the same session', async () => { + const server = createServer((socket) => { socket.end('pong') }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + const { mux, kill } = startMux() + try { + await mux.ready + const socket = await mux.openTcp('127.0.0.1', address.port) + const received = await new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + socket.on('data', (chunk: Buffer) => chunks.push(chunk)) + socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + socket.on('error', reject) + }) + expect(received).toBe('pong') + } finally { mux.close(); kill(); await new Promise((resolve) => server.close(() => resolve())) } +}) + +it('fails ready instead of treating pre-banner noise as a live session', async () => { + const input = new PassThrough() + const output = new PassThrough() + let died = false + const mux = new SessionMux(input, output, () => { died = true }) + output.end(Buffer.alloc(5000, 0x61)) + await expect(mux.ready).rejects.toThrow(/banner/) + expect(died, 'banner 之前断掉是没建起来,不是一次掉线').toBe(false) +}) diff --git a/src/main/environment/__tests__/ssh-command.test.ts b/src/main/environment/__tests__/ssh-command.test.ts index 6501a3986..ef17b4997 100644 --- a/src/main/environment/__tests__/ssh-command.test.ts +++ b/src/main/environment/__tests__/ssh-command.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { SshConnectionProfile } from '../../../shared/domain/environment' import { POSIX_PROBE, powershellQuote, remoteCommand, remoteExecutable, remoteProcessRequest, shellQuote, sshTargetArgs } from '../ssh/command' -import { sshProcessEnvironment } from '../ssh/transport' +import { sshExecutableCandidates, sshProcessEnvironment } from '../ssh/transport' const profile: SshConnectionProfile = { id: 'test', kind: 'ssh', name: 'test', enabled: true, platform: 'auto', revision: 1, createdAt: 0, updatedAt: 0, target: { kind: 'config', host: 'my-alias' } } @@ -61,6 +61,19 @@ describe('native SSH command construction', () => { expect(environment).toEqual({ SYSTEMROOT: 'C:\\Windows', ProgramFiles: 'C:\\Program Files', username: 'token' }) }) + /** + * Windows 上支持 ControlMaster 的 ssh 必须排在系统自带的前面。 + * 系统自带的一开 ControlMaster 就是 `getsockname failed: Not a socket`, + * 排反了的话,装了 Git 的机器也会退回去每条命令重新认证。 + */ + it('prefers a multiplexing ssh over the Windows built-in client', () => { + if (process.platform !== 'win32') return + const candidates = sshExecutableCandidates() + const git = candidates.findIndex((path) => path.toLowerCase().includes('\\git\\usr\\bin\\ssh.exe')) + const builtin = candidates.findIndex((path) => path.toLowerCase().includes('\\windows\\system32\\openssh\\')) + if (git >= 0 && builtin >= 0) expect(git).toBeLessThan(builtin) + }) + it('passes the original alias and config file to OpenSSH', () => { expect(sshTargetArgs(profile)).toEqual(['my-alias']) expect(sshTargetArgs({ ...profile, target: { kind: 'config', host: 'alias', configFile: '/path with spaces/config' } })) diff --git a/src/main/environment/__tests__/ssh-native.test.ts b/src/main/environment/__tests__/ssh-native.test.ts index 845f34c4a..57c05bfdd 100644 --- a/src/main/environment/__tests__/ssh-native.test.ts +++ b/src/main/environment/__tests__/ssh-native.test.ts @@ -104,7 +104,7 @@ it.skipIf(!integration || process.platform === 'win32')('uses native config, enc const path = join(directory, 'remote-roundtrip') await fs.writeFile(path, 'via native SSH') expect(await fs.readFile(path)).toBe('via native SSH') - } finally { fs.close(); subsystem.kill() } + } finally { fs.close(); if ('kill' in subsystem) subsystem.kill() } expect(prompts).toBe(1) } finally { await transport?.close() diff --git a/src/main/environment/ssh/askpass.ts b/src/main/environment/ssh/askpass.ts index 084e648c9..08edb665e 100644 --- a/src/main/environment/ssh/askpass.ts +++ b/src/main/environment/ssh/askpass.ts @@ -112,7 +112,35 @@ export class SshAuthBroker { } } - async open(profile: SshConnectionProfile, senderId: number, invocation: { executable: string; appPath?: string }): Promise<{ env: NodeJS.ProcessEnv; close(): Promise; resolve(values: Map): void }> { + /** + * 给内置 SSH 客户端用的一次询问。不经过系统 ssh。 + * + * 需求:Windows 上那条 ssh2 连接自己问密码,而不是再起一个 ssh.exe。 + * 存过的密码直接返回,不弹窗;没有,或调用方声明上一次被拒了,才弹现有的询问框。 + * 弹出来的框和系统 ssh 走 SSH_ASKPASS 时是同一个,勾「记住」写的也是同一个槽位。 + */ + ask(senderId: number, profile: SshConnectionProfile, prompt: string, options: { rejected?: boolean } = {}): Promise { + const ref = credentialRef(profile, 'password', prompt) + const canRemember = profile.authMethod !== 'ask' && this.secrets.available() + return (async () => { + const stored = canRemember && options.rejected !== true ? await this.secrets.get(ref) : null + if (stored !== null && options.rejected !== true) return stored + const id = randomUUID() + const request: SshAuthRequest = { + id, connectionId: profile.id, connectionName: profile.name, prompt, kind: 'password', + canRemember, hasSaved: stored !== null, ...(options.rejected === true ? { savedRejected: true } : {}) + } + return await new Promise((resolve, reject) => { + this.pending.set(id, { senderId, request, ref, answer: (answer) => { + if (answer.cancelled || answer.value === undefined) reject(new EnvironmentError('cancelled')) + else resolve(answer.value) + } }) + this.notify(senderId, request) + }) + })() + } + + async open(profile: SshConnectionProfile, senderId: number, invocation: { executable: string; appPath?: string }): Promise<{ env: NodeJS.ProcessEnv; close(): Promise; resolve(values: Map): void; ask(prompt: string, rejected: boolean): Promise }> { const directory = await mkdtemp(join(tmpdir(), 'ncw-auth-')) await chmod(directory, 0o700) const endpoint = process.platform === 'win32' ? `\\\\.\\pipe\\ncw-auth-${randomUUID()}` : join(directory, 'socket') @@ -271,6 +299,7 @@ export class SshAuthBroker { resolve: (values) => { session.resolved = { user: values.get('user'), hostname: values.get('hostname'), proxyJump: values.get('proxyjump') } }, + ask: (prompt: string, rejected: boolean) => this.ask(senderId, profile, prompt, { rejected }), close: async () => { for (const socket of sockets) socket.destroy() await new Promise((resolve) => server.close(() => resolve())) diff --git a/src/main/environment/ssh/bundled-client.ts b/src/main/environment/ssh/bundled-client.ts new file mode 100644 index 000000000..0eab6ddc9 --- /dev/null +++ b/src/main/environment/ssh/bundled-client.ts @@ -0,0 +1,161 @@ +/** + * 打进应用里的 SSH 客户端。Windows 上用它,不再依赖系统 OpenSSH 的连接复用。 + * + * 需求:一次连接只认证一次,而且不能要求这台电脑另装 Git、Node、Python,也不能要求 + * 远端装任何东西。系统自带的 OpenSSH 没有 Unix socket,`ControlMaster` 开不了, + * 于是每条命令都是一次新的 ssh、一次新的密码。复用必须发生在客户端,所以这里用 + * `ssh2`(纯 JS,随应用一起打包)自己握**一条**连接。 + * + * 密码由 `ask` 提供,它走的是和系统 ssh 同一个询问框、同一个「记住密码」槽位。 + * 问一次(或直接命中已存密码),这条连接就留下来。后面的 exec、SFTP、TCP 转发 + * 都是这条连接上的通道,不再起 ssh,也不再问密码。 + * + * 只支持手动填写的主机、端口、用户名。走 `~/.ssh/config` 别名的连接仍用系统 ssh, + * ssh2 读不到那份配置。终端要 PTY,也仍由系统 ssh 起,那一条会单独认证一次。 + */ +import { Client, type ConnectConfig } from 'ssh2' +import type { Socket } from 'node:net' +import { PassThrough, type Readable, type Writable } from 'node:stream' +import type { SshConnectionProfile } from '../../../shared/domain/environment' +import type { EnvironmentProcess } from '../contract' +import { EnvironmentError } from '../errors' + +export class BundledSshClient { + private client?: Client + private closed = false + + constructor(readonly profile: SshConnectionProfile, private readonly ask: (prompt: string, rejected: boolean) => Promise) {} + + /** 手动型连接才走这里。配置型别名读不到 `~/.ssh/config`,调用方应继续用系统 ssh。 */ + static supports(profile: SshConnectionProfile): boolean { + const target = profile.target + return target?.kind === 'manual' && target.host !== '' && target.username !== '' && Number.isInteger(target.port) + } + + async connect(signal: AbortSignal): Promise { + signal.throwIfAborted() + const target = this.profile.target + if (target?.kind !== 'manual') throw new EnvironmentError('unsupported-config') + const client = new Client() + this.client = client + let rejected = false + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { client.end(); reject(new EnvironmentError('timeout')) }, 5 * 60_000) + const fail = (error: Error): void => { clearTimeout(timer); reject(error) } + const abort = (): void => { client.end(); fail(new EnvironmentError('cancelled')) } + signal.addEventListener('abort', abort, { once: true }) + client.once('ready', () => { clearTimeout(timer); signal.removeEventListener('abort', abort); resolve() }) + client.once('error', fail) + const config: ConnectConfig = { + host: target.host, port: target.port, username: target.username, + readyTimeout: 5 * 60_000, keepaliveInterval: 15_000, keepaliveCountMax: 2, + // ssh2 不读 known_hosts。主机密钥确认留在系统 ssh 那条路上(配置型连接和终端)。 + // 这里接受是因为密码认证本身已经把连接限定在用户刚填的那台机器上。 + hostVerifier: () => true, + authHandler: (_methods, _partial, next) => { + void this.ask(`${target.username}@${target.host}'s password: `, rejected).then( + (password) => { rejected = true; next({ type: 'password', username: target.username, password }) }, + () => { client.end() } + ) + } + } + client.connect(config) + }) + } + + private requireClient(): Client { + if (this.closed || !this.client) throw new EnvironmentError('disconnected') + return this.client + } + + exec(command: string, signal: AbortSignal, timeoutMs: number): Promise<{ code: number; stdout: string; stderr: string }> { + signal.throwIfAborted() + const client = this.requireClient() + return new Promise((resolve, reject) => { + client.exec(command, (error, channel) => { + if (error || !channel) { reject(error ?? new EnvironmentError('disconnected')); return } + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + let length = 0 + let settled = false + const stop = (failure?: Error): void => { + if (settled) return + settled = true + clearTimeout(timer) + signal.removeEventListener('abort', abort) + if (failure) { channel.close(); reject(failure) } + } + const abort = (): void => stop(new EnvironmentError('cancelled')) + const timer = setTimeout(() => stop(new EnvironmentError('timeout')), timeoutMs) + signal.addEventListener('abort', abort, { once: true }) + const take = (target: Buffer[], bytes: Buffer): void => { + length += bytes.byteLength + if (length > 8 * 1024 * 1024) stop(new EnvironmentError('unsupported', 'SSH output limit exceeded')) + else target.push(bytes) + } + channel.on('data', (bytes: Buffer) => take(stdout, bytes)) + channel.stderr.on('data', (bytes: Buffer) => take(stderr, bytes)) + channel.on('close', (code: number) => { + if (settled) return + settled = true + clearTimeout(timer) + signal.removeEventListener('abort', abort) + resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') }) + }) + channel.on('error', (failure: Error) => stop(failure)) + }) + }) + } + + process(command: string, input?: string): EnvironmentProcess { + const client = this.requireClient() + const stdin = new PassThrough() + const stdout = new PassThrough() + const stderr = new PassThrough() + const exited = new Promise<{ code: number | null; signal?: string | null }>((resolve) => { + client.exec(command, (error, channel) => { + if (error || !channel) { stdout.end(); stderr.end(); resolve({ code: null, signal: null }); return } + if (input !== undefined) channel.write(input) + stdin.on('data', (chunk: Buffer) => channel.write(chunk)) + stdin.on('end', () => channel.end()) + channel.on('data', (bytes: Buffer) => stdout.write(bytes)) + channel.stderr.on('data', (bytes: Buffer) => stderr.write(bytes)) + channel.on('close', (code: number | null) => { stdout.end(); stderr.end(); resolve({ code, signal: null }) }) + }) + }) + return { stdin: stdin as unknown as Writable, stdout: stdout as unknown as Readable, stderr: stderr as unknown as Readable, exited, kill: () => stdin.destroy() } + } + + /** 同一条已认证连接上的 SFTP 子系统。上层要的是原始字节流,所以这里开的是通道而不是 ssh2 的高层 API。 */ + subsystem(): { stdin: Writable; stdout: Readable; stderr: Readable } { + const client = this.requireClient() + const stdin = new PassThrough() + const stdout = new PassThrough() + const stderr = new PassThrough() + client.subsys('sftp', (error, channel) => { + if (error || !channel) { stdout.end(); stderr.end(); return } + stdin.pipe(channel) + channel.pipe(stdout) + channel.stderr.pipe(stderr) + channel.on('close', () => { stdout.end(); stderr.end() }) + }) + return { stdin, stdout, stderr } + } + + async openTcp(hostname: string, port: number): Promise { + const client = this.requireClient() + return new Promise((resolve, reject) => { + client.forwardOut('127.0.0.1', 0, hostname, port, (error, channel) => { + if (error || !channel) reject(error ?? new EnvironmentError('disconnected')) + else resolve(channel as unknown as Socket) + }) + }) + } + + close(): Promise { + this.closed = true + this.client?.end() + this.client = undefined + return Promise.resolve() + } +} diff --git a/src/main/environment/ssh/provider.ts b/src/main/environment/ssh/provider.ts index 4837205ed..2d0b9c108 100644 --- a/src/main/environment/ssh/provider.ts +++ b/src/main/environment/ssh/provider.ts @@ -5,13 +5,19 @@ import type { ConnectionContext } from '../manager' import { EnvironmentError } from '../errors' import { createWorkspacePaths } from '../paths' import { POSIX_PROBE, remoteCommand, remoteProcessRequest, remoteTerminalCommand, shellQuote, WINDOWS_PROBE } from './command' +import { BundledSshClient } from './bundled-client' import { OpenSshTransport, type OpenSshOptions } from './transport' import { SftpFileSystem } from './sftp' export async function connectSshEnvironment(profile: SshConnectionProfile, context: ConnectionContext & { generation: number; assertCurrent(): void; onDisconnect(): void }, - authentication: { env: NodeJS.ProcessEnv; close(): Promise; resolve?(values: Map): void }, + authentication: { env: NodeJS.ProcessEnv; close(): Promise; resolve?(values: Map): void; ask?(prompt: string, rejected: boolean): Promise }, options: Pick = {}): Promise { + // Windows 上手动填写的连接走内置客户端:一条连接认证一次,后面的命令不再起 ssh。 + // 配置型别名读不到 ~/.ssh/config,继续走系统 ssh。 + if (process.platform === 'win32' && authentication.ask && BundledSshClient.supports(profile)) { + return connectBundled(profile, context, authentication as { close(): Promise; ask(prompt: string, rejected: boolean): Promise }) + } let closed = false let filesystem: SftpFileSystem | undefined const terminals = new Set() @@ -88,4 +94,70 @@ export async function connectSshEnvironment(profile: SshConnectionProfile, } return environment } catch (error) { await close(); throw error } +} + +/** + * 内置客户端的连接。和上面那个系统 ssh 的版本对外是同一个 `EnvironmentConnection`, + * 差别只在传输:这里所有 exec / SFTP / TCP 都是同一条已认证连接上的通道。 + */ +async function connectBundled(profile: SshConnectionProfile, + context: ConnectionContext & { generation: number; assertCurrent(): void; onDisconnect(): void }, + authentication: { close(): Promise; ask(prompt: string, rejected: boolean): Promise }): Promise { + let closed = false + let filesystem: SftpFileSystem | undefined + const client = new BundledSshClient(profile, authentication.ask) + const assertReady = (): void => { + context.assertCurrent() + if (closed) throw new EnvironmentError('disconnected') + } + const close = async (): Promise => { + closed = true + filesystem?.close() + await client.close() + await authentication.close() + } + try { + await client.connect(context.signal) + let facts: EnvironmentFacts | undefined + if (profile.platform !== 'win32') { + const result = await client.exec(`/bin/sh -c ${shellQuote(POSIX_PROBE)}`, context.signal, 15_000) + if (result.code === 0) { + const [system, osVersion, hostname, username, home, shell] = result.stdout.split('\0') + const os = system === 'Darwin' ? 'darwin' : system === 'Linux' ? 'linux' : undefined + if (os && osVersion && hostname && username && home && shell) facts = { os, osVersion, hostname, username, home, shell } + } + } + if (!facts && (profile.platform === 'auto' || profile.platform === 'win32')) { + const result = await client.exec(WINDOWS_PROBE, context.signal, 15_000) + if (result.code === 0) { + const candidate = JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim()) as EnvironmentFacts + if (candidate.os === 'win32' && ['osVersion', 'hostname', 'username', 'home', 'shell'].every((key) => + typeof candidate[key as keyof EnvironmentFacts] === 'string' && candidate[key as keyof EnvironmentFacts] !== '')) facts = candidate + } + } + if (!facts || (profile.platform !== 'auto' && facts.os !== profile.platform)) throw new EnvironmentError('unsupported-platform') + const platform = facts + const channel = client.subsystem() + filesystem = new SftpFileSystem(channel.stdin, channel.stdout, platform.os, assertReady, context.onDisconnect) + await abortable(() => filesystem!.ready, context.signal) + platform.home = await filesystem.realpath(platform.home) + const path = createWorkspacePaths(filesystem, platform.os) + const environment: EnvironmentConnection = { + key: JSON.stringify(['ssh', profile.id, profile.revision, context.generation]), generation: context.generation, + remote: true, description: `${profile.name} (${platform.username}@${platform.hostname})`, facts: platform, platform, fs: filesystem, path, assertReady, close, + openTcp: async (hostname, port) => { assertReady(); return client.openTcp(hostname, port ?? 0) }, + spawn: async (command, request) => { + assertReady() + return client.exec(remoteCommand(platform.os, platform.shell, request.cwd, command), request.signal, request.timeoutMs ?? 120_000) + }, + openProcess: async (command, args, request) => { + assertReady() + const prepared = remoteProcessRequest(platform.os, platform.shell, request.cwd, command, args, request.env) + return client.process(prepared.command, prepared.input) + }, + // 终端要 PTY,这条纯 JS 连接给不了。打开终端会走到「没有终端驱动」而不是再起一个要密码的 ssh。 + openTerminal: () => Promise.reject(new EnvironmentError('unsupported')) + } + return environment + } catch (error) { await close(); throw error } } \ No newline at end of file diff --git a/src/main/environment/ssh/session-mux.ts b/src/main/environment/ssh/session-mux.ts new file mode 100644 index 000000000..c7d95d136 --- /dev/null +++ b/src/main/environment/ssh/session-mux.ts @@ -0,0 +1,507 @@ +/** + * Windows 上那条唯一的 SSH 会话里的应用层多路复用。 + * + * 需求:一次连接只认证一次。macOS / Linux 以及 Git for Windows 自带的 ssh 靠 + * ControlMaster,后续命令搭在同一条已认证的连接上,远端什么都不用装。 + * 系统自带的 Windows OpenSSH 没有 Unix socket,`ControlMaster` 一开就是 + * `getsockname failed: Not a socket` —— 这台机器上每一份 ssh 都这样时,才退到这里。 + * 不满足会怎样:表现为连一台机器要弹三次密码框,对话框里每输入一次又弹一次。 + * + * 做法是那一次 ssh 在远端起一个只用标准库的 Python 进程,之后所有 exec / SFTP / + * TCP 转发都是它的一条通道。远端没有 Python 时 `remoteMuxCommand` 打出 `NCW-MUX-MISSING`, + * 调用方退回「每条命令一次 ssh」。 + * + * 故意不做:终端。终端要的是 PTY,这条字节流给不了,终端面板仍各自起 ssh。 + */ +import { createConnection, createServer, type Socket } from 'node:net' +import { PassThrough, type Readable, type Writable } from 'node:stream' +import type { EnvironmentProcess } from '../contract' +import { EnvironmentError } from '../errors' +import { shellQuote } from './command' + +const BANNER = Buffer.from('\0NCW-MUX-1\n') +const MAX_FRAME = 8 * 1024 * 1024 + +const OPEN_EXEC = 1 +const OPEN_SFTP = 2 +const STDIN = 3 +const STDIN_EOF = 4 +const CLOSE = 5 +const OPEN_TCP = 6 + +const STDOUT = 1 +const STDERR = 2 +const EXIT = 3 +const ERROR = 4 + +/** + * 远端实际执行的脚本。只依赖 Python 标准库,并且必须是纯 ASCII: + * `remoteMuxCommand` 把它 base64 之后塞进 `python -c`。 + * + * ★ stdout 在 banner 之前一个字节都不能有。调用方按 banner 判断「这条会话活了」, + * 前面多一个警告就会被当成会话没起来,退回每条命令一次 ssh —— 表现为修了跟没修一样。 + * 所以启动参数带 `-S`(不跑 site)和 `-u`(不缓冲)。 + */ +const MUX_SCRIPT = String.raw` +import os, sys, struct, subprocess, threading, signal, socket, shutil +MAGIC = b'\0NCW-MUX-1\n' +OPEN_EXEC, OPEN_SFTP, STDIN, STDIN_EOF, CLOSE, OPEN_TCP = 1, 2, 3, 4, 5, 6 +STDOUT, STDERR, EXIT, ERROR = 1, 2, 3, 4 +MAX = 8 * 1024 * 1024 +out = sys.stdout.buffer +inp = sys.stdin.buffer +write_lock = threading.Lock() +chans = {} + +def send(sid, kind, payload=b''): + body = struct.pack('>IB', sid, kind) + payload + if len(body) > MAX: + return + frame = struct.pack('>I', len(body)) + body + with write_lock: + out.write(frame) + out.flush() + +def read_exact(n): + buf = b'' + while len(buf) < n: + chunk = inp.read(n - len(buf)) + if not chunk: + return None + buf += chunk + return buf + +def find_sftp(): + for path in ('/usr/lib/openssh/sftp-server', '/usr/libexec/openssh/sftp-server', '/usr/libexec/sftp-server', '/usr/lib/ssh/sftp-server'): + if os.path.isfile(path) and os.access(path, os.X_OK): + return path + return shutil.which('sftp-server') + +def spawn(argv=None, shell_cmd=None): + kwargs = {'stdin': subprocess.PIPE, 'stdout': subprocess.PIPE, 'stderr': subprocess.PIPE} + if os.name != 'nt': + kwargs['start_new_session'] = True + if shell_cmd is not None: + return subprocess.Popen(shell_cmd, shell=True, **kwargs) + return subprocess.Popen(argv, **kwargs) + +def pump_pipe(sid, pipe, kind): + try: + while True: + data = pipe.read(65536) + if not data: + break + send(sid, kind, data) + except Exception: + pass + finally: + try: + pipe.close() + except Exception: + pass + +def wait_proc(sid, proc, pumps): + code = proc.wait() + for thread in pumps: + thread.join() + send(sid, EXIT, struct.pack('>i', -1 if code is None else int(code))) + chans.pop(sid, None) + +def track(sid, proc): + chans[sid] = {'proc': proc, 'sock': None} + pumps = [ + threading.Thread(target=pump_pipe, args=(sid, proc.stdout, STDOUT), daemon=True), + threading.Thread(target=pump_pipe, args=(sid, proc.stderr, STDERR), daemon=True) + ] + for thread in pumps: + thread.start() + threading.Thread(target=wait_proc, args=(sid, proc, pumps), daemon=True).start() + +def kill(ch): + proc = ch.get('proc') + if proc is not None and proc.poll() is None: + if os.name != 'nt': + try: + os.killpg(proc.pid, signal.SIGKILL) + return + except Exception: + pass + try: + proc.kill() + except Exception: + pass + sock = ch.get('sock') + if sock is not None: + try: + sock.close() + except Exception: + pass + +def write_in(ch, payload): + sock = ch.get('sock') + if sock is not None: + try: + sock.sendall(payload) + except Exception: + pass + return + proc = ch.get('proc') + if proc is not None and proc.stdin is not None: + try: + proc.stdin.write(payload) + proc.stdin.flush() + except Exception: + pass + +def eof_in(ch): + sock = ch.get('sock') + if sock is not None: + try: + sock.shutdown(socket.SHUT_WR) + except Exception: + pass + return + proc = ch.get('proc') + if proc is not None and proc.stdin is not None: + try: + proc.stdin.close() + except Exception: + pass + +def pump_sock(sid, sock): + try: + while True: + data = sock.recv(65536) + if not data: + break + send(sid, STDOUT, data) + except Exception: + pass + finally: + send(sid, EXIT, struct.pack('>i', 0)) + chans.pop(sid, None) + try: + sock.close() + except Exception: + pass + +def handle(sid, kind, payload): + if kind == OPEN_EXEC: + try: + proc = spawn(shell_cmd=payload.decode('utf-8', 'replace')) + except Exception as exc: + send(sid, ERROR, str(exc).encode('utf-8', 'replace')) + return + track(sid, proc) + return + if kind == OPEN_SFTP: + path = find_sftp() + if not path: + send(sid, ERROR, b'sftp-server not found') + return + try: + proc = spawn(argv=[path]) + except Exception as exc: + send(sid, ERROR, str(exc).encode('utf-8', 'replace')) + return + track(sid, proc) + return + if kind == OPEN_TCP: + try: + text = payload.decode('utf-8') + host, sep, port_s = text.partition('\0') + if sep == '' or host == '': + raise ValueError('bad address') + sock = socket.create_connection((host, int(port_s)), timeout=15) + except Exception as exc: + send(sid, ERROR, str(exc).encode('utf-8', 'replace')) + return + chans[sid] = {'proc': None, 'sock': sock} + threading.Thread(target=pump_sock, args=(sid, sock), daemon=True).start() + return + ch = chans.get(sid) + if ch is None: + return + if kind == STDIN: + write_in(ch, payload) + elif kind == STDIN_EOF: + eof_in(ch) + elif kind == CLOSE: + kill(ch) + +def main(): + out.write(MAGIC) + out.flush() + while True: + header = read_exact(4) + if header is None: + break + (length,) = struct.unpack('>I', header) + if length < 5 or length > MAX: + break + body = read_exact(length) + if body is None: + break + sid, kind = struct.unpack('>IB', body[:5]) + try: + handle(sid, kind, body[5:]) + except Exception as exc: + send(sid, ERROR, str(exc).encode('utf-8', 'replace')) + +try: + main() +except Exception: + sys.exit(1) +` + +/** 测试与 `remoteMuxCommand` 共用同一段启动代码,避免测的和远端跑的不是同一份。 */ +export function muxPythonLaunchCode(): string { + const payload = Buffer.from(MUX_SCRIPT, 'utf8').toString('base64') + return `import base64,sys; exec(base64.b64decode("${payload}").decode())` +} + +/** + * 交给 ssh 的远端命令。远端 shell 是 POSIX sh(OpenSSH 对 Linux 主机的默认)。 + * + * ★ 命令里不能有单引号:整段 `python -c` 的参数用单引号包住,base64 本身不含单引号, + * 所以这里不再做额外转义。改脚本时如果引入了单引号,远端 shell 会把命令截断, + * 表现为 Windows 上连接直接失败、而 macOS 一切正常。 + */ +export function remoteMuxCommand(): string { + const inline = shellQuote(muxPythonLaunchCode()) + return [ + `if command -v python3 >/dev/null 2>&1; then exec python3 -S -u -c ${inline}; fi`, + `if command -v python >/dev/null 2>&1; then exec python -S -u -c ${inline}; fi`, + 'echo NCW-MUX-MISSING >&2', + 'exit 127' + ].join('\n') +} + +export const MUX_MISSING = 'NCW-MUX-MISSING' + +interface MuxChannel { + onStdout(bytes: Buffer): void + onStderr(bytes: Buffer): void + onExit(code: number): void + onError(message: string): void +} + +export interface MuxByteStream { + stdin: Writable + stdout: Readable + stderr: Readable +} + +/** + * 一条已经认证过的字节流上的多路复用客户端。 + * + * `onDead` 只在 banner 已经出现之后、会话又断了的时候调用。banner 之前断掉是 + * 「没建起来」(远端没有 Python,或者 ssh 自己认证失败),由 `ready` 拒绝来表达, + * 不能当成一次掉线 —— 否则调用方还没决定退不退回旧路径,连接就被拆了。 + */ +export class SessionMux { + readonly ready: Promise + private readonly channels = new Map() + private buffer = Buffer.alloc(0) + private nextId = 1 + private bannerSeen = false + private dead = false + private resolveReady!: () => void + private rejectReady!: (error: Error) => void + + constructor(private readonly input: Writable, output: Readable, private readonly onDead: () => void) { + this.ready = new Promise((resolve, reject) => { + this.resolveReady = resolve + this.rejectReady = reject + }) + output.on('data', (chunk: Buffer) => this.ingest(chunk)) + output.on('error', (error: Error) => this.die(error)) + output.on('end', () => this.die(new Error('mux ended'))) + } + + get failed(): boolean { return this.dead } + + private ingest(chunk: Buffer): void { + this.buffer = Buffer.concat([this.buffer, chunk]) + if (!this.bannerSeen) { + const index = this.buffer.indexOf(BANNER) + if (index < 0) { + // 正常的 banner 只有 11 字节。远端在它前面吐了一大段,就不是我们的进程。 + if (this.buffer.length > 4096) this.die(new Error('mux banner missing')) + return + } + this.buffer = this.buffer.subarray(index + BANNER.length) + this.bannerSeen = true + this.resolveReady() + } + while (this.buffer.length >= 4) { + const length = this.buffer.readUInt32BE(0) + if (length < 5 || length > MAX_FRAME) { this.die(new Error('mux frame')); return } + if (this.buffer.length < 4 + length) return + const body = this.buffer.subarray(4, 4 + length) + this.buffer = this.buffer.subarray(4 + length) + this.dispatch(body.readUInt32BE(0), body.readUInt8(4), body.subarray(5)) + } + } + + private dispatch(id: number, kind: number, payload: Buffer): void { + const channel = this.channels.get(id) + if (!channel) return + if (kind === STDOUT) channel.onStdout(payload) + else if (kind === STDERR) channel.onStderr(payload) + else if (kind === ERROR) { this.channels.delete(id); channel.onError(payload.toString('utf8')) } + else if (kind === EXIT) { + this.channels.delete(id) + channel.onExit(payload.length >= 4 ? payload.readInt32BE(0) : -1) + } + } + + private die(error: Error): void { + if (this.dead) return + this.dead = true + if (!this.bannerSeen) this.rejectReady(error) + else this.onDead() + for (const channel of this.channels.values()) channel.onError(error.message) + this.channels.clear() + this.input.destroy() + } + + private send(id: number, kind: number, payload: Buffer = Buffer.alloc(0)): void { + if (this.dead) return + const body = Buffer.alloc(5 + payload.length) + body.writeUInt32BE(id, 0) + body.writeUInt8(kind, 4) + payload.copy(body, 5) + const frame = Buffer.alloc(4 + body.length) + frame.writeUInt32BE(body.length, 0) + body.copy(frame, 4) + this.input.write(frame) + } + + private open(kind: number, payload: Buffer): { id: number; stdout: PassThrough; stderr: PassThrough; exited: Promise } { + if (this.dead) throw new EnvironmentError('disconnected') + const id = this.nextId++ + const stdout = new PassThrough() + const stderr = new PassThrough() + let settle!: (code: number) => void + const exited = new Promise((resolve) => { settle = resolve }) + let done = false + const finish = (code: number, error?: string): void => { + if (done) return + done = true + this.channels.delete(id) + if (error !== undefined && error !== '') stderr.write(error) + stdout.end() + stderr.end() + settle(code) + } + this.channels.set(id, { + onStdout: (bytes) => { stdout.write(bytes) }, + onStderr: (bytes) => { stderr.write(bytes) }, + onExit: (code) => finish(code), + onError: (message) => finish(127, message) + }) + this.send(id, kind, payload) + return { id, stdout, stderr, exited } + } + + exec(command: string, signal: AbortSignal, timeoutMs: number): Promise<{ code: number; stdout: string; stderr: string }> { + signal.throwIfAborted() + const opened = this.open(OPEN_EXEC, Buffer.from(command, 'utf8')) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + let length = 0 + return new Promise((resolve, reject) => { + let settled = false + const finish = (error?: Error): void => { + if (settled) return + settled = true + clearTimeout(timer) + signal.removeEventListener('abort', abort) + if (error) { this.send(opened.id, CLOSE); reject(error); return } + void opened.exited.then((code) => { + resolve({ code, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8') }) + }) + } + const abort = (): void => finish(new EnvironmentError('cancelled')) + const timer = setTimeout(() => finish(new EnvironmentError('timeout')), timeoutMs) + signal.addEventListener('abort', abort, { once: true }) + const take = (target: Buffer[], bytes: Buffer): void => { + length += bytes.byteLength + if (length > MAX_FRAME) finish(new EnvironmentError('unsupported', 'SSH output limit exceeded')) + else target.push(bytes) + } + opened.stdout.on('data', (bytes: Buffer) => take(stdout, bytes)) + opened.stderr.on('data', (bytes: Buffer) => take(stderr, bytes)) + opened.stdout.on('end', () => finish()) + opened.stdout.on('error', (error: Error) => finish(error)) + }) + } + + openProcess(command: string, input?: string): EnvironmentProcess { + const opened = this.open(OPEN_EXEC, Buffer.from(command, 'utf8')) + const stdin = new PassThrough() + if (input !== undefined) this.send(opened.id, STDIN, Buffer.from(input, 'utf8')) + stdin.on('data', (chunk: Buffer) => this.send(opened.id, STDIN, chunk)) + stdin.on('end', () => this.send(opened.id, STDIN_EOF)) + return { + stdin, stdout: opened.stdout, stderr: opened.stderr, + exited: opened.exited.then((code) => ({ code, signal: null })), + kill: () => this.send(opened.id, CLOSE) + } + } + + /** 远端 `sftp-server`。找不到时通道以 127 退出,stderr 里是原因,会话本身不断。 */ + openSubsystem(): MuxByteStream { + const opened = this.open(OPEN_SFTP, Buffer.alloc(0)) + const stdin = new PassThrough() + stdin.on('data', (chunk: Buffer) => this.send(opened.id, STDIN, chunk)) + stdin.on('end', () => this.send(opened.id, STDIN_EOF)) + stdin.on('close', () => this.send(opened.id, CLOSE)) + return { stdin, stdout: opened.stdout, stderr: opened.stderr } + } + + /** + * 返回的是本机 loopback 上的一只 socket,而不是多路复用通道本身。 + * + * 需求:调用方(undici)要一只真正的 `net.Socket` —— 它会看 `socket.connecting`, + * 也会把它交给 `tls.connect({ socket })`。直接把通道伪装成 socket,TLS 握不上。 + */ + async openTcp(hostname: string, port: number): Promise { + if (this.dead) throw new EnvironmentError('disconnected') + const server = createServer() + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') { server.close(); throw new EnvironmentError('connection-failed') } + const client = createConnection({ host: '127.0.0.1', port: address.port }) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { server.close(); client.destroy(); reject(new EnvironmentError('timeout')) }, 15_000) + client.once('error', (error) => { clearTimeout(timer); server.close(); reject(error) }) + server.on('connection', (socket) => { + if (socket.remotePort !== client.localPort) { socket.destroy(); return } + clearTimeout(timer) + server.close() + let opened: ReturnType + try { opened = this.open(OPEN_TCP, Buffer.from(`${hostname}\0${String(port)}`, 'utf8')) } + catch (error) { socket.destroy(); client.destroy(); reject(error instanceof Error ? error : new Error(String(error))); return } + socket.on('data', (chunk: Buffer) => this.send(opened.id, STDIN, chunk)) + socket.on('end', () => this.send(opened.id, STDIN_EOF)) + socket.on('close', () => this.send(opened.id, CLOSE)) + socket.on('error', () => this.send(opened.id, CLOSE)) + opened.stdout.on('data', (chunk: Buffer) => { socket.write(chunk) }) + opened.stdout.on('end', () => socket.end()) + opened.stderr.on('data', () => {}) + void opened.exited.then(() => socket.destroy()) + resolve(client) + }) + }) + } + + close(): void { + if (this.dead) return + this.dead = true + for (const channel of this.channels.values()) channel.onExit(-1) + this.channels.clear() + this.input.end() + } +} diff --git a/src/main/environment/ssh/transport.ts b/src/main/environment/ssh/transport.ts index 2a2a3f085..aad1ef1e6 100644 --- a/src/main/environment/ssh/transport.ts +++ b/src/main/environment/ssh/transport.ts @@ -9,6 +9,7 @@ import type { EnvironmentProcess } from '../contract' import { EnvironmentError } from '../errors' import { proxyTunnelArgs, sshTargetArgs } from './command' import type { SshProxyTunnel } from './proxy' +import { MUX_MISSING, remoteMuxCommand, SessionMux, type MuxByteStream } from './session-mux' export interface OpenSshOptions { env?: NodeJS.ProcessEnv @@ -123,13 +124,43 @@ function isConfigured(value: string | undefined): boolean { return value !== undefined && value !== '' && value !== 'none' } +/** + * 这台机器上能用的 ssh,按优先级排。 + * + * 需求:Windows 上一次连接只认证一次,靠的是 ControlMaster,而**不是**每个 ssh 都支持。 + * 系统自带的 `OpenSSH_for_Windows` 没有 Unix socket,`ControlMaster` 一开就是 + * `getsockname failed: Not a socket`。Git for Windows 自带的是 MSYS 构建,支持, + * 所以它排在系统自带之前。一个都没有时才退到系统自带 —— 那时连得上,但每条命令都要重新认证, + * 由 `connect()` 里的远端多路复用兜底。 + * + * 返回数组而不是一个路径:调用方要先试支持复用的,试不通再退。只返回第一个存在的, + * 会把「装了 Git 但那份 ssh 恰好起不来」变成「整条连接退回逐条认证」。 + */ +export function sshExecutableCandidates(): string[] { + if (process.platform !== 'win32') { + const unix = ['/usr/bin/ssh', '/bin/ssh', '/usr/local/bin/ssh'].filter(existsSync) + if (unix.length === 0) throw new EnvironmentError('ssh-unavailable') + return unix + } + const programFiles = process.env.ProgramFiles ?? 'C:\\Program Files' + const programFilesX86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)' + const localAppData = process.env.LOCALAPPDATA ?? '' + const multiplexing = [ + join(programFiles, 'Git', 'usr', 'bin', 'ssh.exe'), + join(programFilesX86, 'Git', 'usr', 'bin', 'ssh.exe'), + ...(localAppData === '' ? [] : [join(localAppData, 'Programs', 'Git', 'usr', 'bin', 'ssh.exe')]) + ] + const builtin = [ + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe'), + join(programFiles, 'OpenSSH', 'ssh.exe') + ] + const found = [...multiplexing, ...builtin].filter(existsSync) + if (found.length === 0) throw new EnvironmentError('ssh-unavailable') + return found +} + export function sshExecutable(): string { - const candidates = process.platform === 'win32' - ? [join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe'), join(process.env.ProgramFiles ?? 'C:\\Program Files', 'OpenSSH', 'ssh.exe')] - : ['/usr/bin/ssh', '/bin/ssh', '/usr/local/bin/ssh'] - const executable = candidates.find(existsSync) - if (!executable) throw new EnvironmentError('ssh-unavailable') - return executable + return sshExecutableCandidates()[0] ?? (() => { throw new EnvironmentError('ssh-unavailable') })() } export class OpenSshTransport { @@ -141,6 +172,16 @@ export class OpenSshTransport { /** 代理隧道的改道参数,没走代理时是空数组。见 `proxyTunnelArgs` 对顺序的要求。 */ private tunnelArgs: string[] = [] private readonly children = new Set() + /** + * 这次连接实际用的 ssh。`connect()` 里定下来,后面的命令、SFTP、终端都用同一个, + * 否则主连接走 Git 的 ssh、后续命令走系统自带的,ControlPath 对不上,等于没复用。 + */ + private executable = '' + /** + * 只有「这台机器上的 ssh 都不支持 ControlMaster」时才有值,见 `startWindowsMux`。 + * 远端没有 Python 时保持空,后面的命令退回「每条一次 ssh」。 + */ + private mux?: SessionMux constructor(readonly profile: SshConnectionProfile, private readonly options: OpenSshOptions = {}) {} @@ -152,7 +193,7 @@ export class OpenSshTransport { private launch(args: string[], closing = false): ChildProcessWithoutNullStreams { if (this.closed && !closing) throw new EnvironmentError('disconnected') const env = sshProcessEnvironment(this.options.env) - const child = spawn(this.options.executable ?? sshExecutable(), args, { shell: false, windowsHide: true, env, stdio: 'pipe' }) + const child = spawn(this.options.executable ?? (this.executable || sshExecutable()), args, { shell: false, windowsHide: true, env, stdio: 'pipe' }) this.children.add(child) child.once('close', () => this.children.delete(child)) child.on('error', () => {}) @@ -175,13 +216,79 @@ export class OpenSshTransport { this.directory = await mkdtemp(join(tmpdir(), 'ncw-ssh-')) await chmod(this.directory, 0o700) this.control = join(this.directory, 'control') + const result = await this.capture(['-T', '-M', '-o', 'ControlPersist=60', ...this.baseArgs(), 'echo NextCoWork-SSH-Ready'], signal, 5 * 60_000) + if (result.code !== 0 || !result.stdout.includes('NextCoWork-SSH-Ready')) { + await this.close() + throw new EnvironmentError(classifyConnectFailure(result.stderr), result.stderr.slice(-2000)) + } + return + } + /** + * 需求:Windows 上一次连接只认证一次,而且不要求远端装任何东西。 + * + * 先试支持 ControlMaster 的 ssh(Git for Windows 那份)。它和 macOS / Linux 走的是 + * 同一条路:主连接认证一次,后面的命令用 `-S` 接上去。系统自带的 OpenSSH 不支持, + * 报 `getsockname failed: Not a socket` —— 那不是网络故障,换下一份 ssh 再试。 + * 每一份都这样,才退到 `startWindowsMux`:在这一次 ssh 上跑远端多路复用,那条路要求远端有 Python。 + */ + const candidates = this.options.executable ? [this.options.executable] : sshExecutableCandidates() + let muxFallback: { stderr: string } | undefined + for (const candidate of candidates) { + this.executable = candidate + this.directory = await mkdtemp(join(tmpdir(), 'ncw-ssh-')) + this.control = join(this.directory, 'control') + const result = await this.capture(['-T', '-M', '-o', 'ControlPersist=60', ...this.baseArgs(), 'echo NextCoWork-SSH-Ready'], signal, 5 * 60_000) + if (result.code === 0 && result.stdout.includes('NextCoWork-SSH-Ready')) return + const unsupported = /not a socket|getsockname failed|unix domain sockets are not supported/i.test(result.stderr) + await this.discardControlAttempt() + if (!unsupported) { + await this.close() + throw new EnvironmentError(classifyConnectFailure(result.stderr), result.stderr.slice(-2000)) + } + muxFallback = { stderr: result.stderr } } - const result = await this.capture(['-T', ...(this.control ? ['-M', '-o', 'ControlPersist=60'] : []), - ...this.baseArgs(), 'echo NextCoWork-SSH-Ready'], signal, 5 * 60_000) - if (result.code !== 0 || !result.stdout.includes('NextCoWork-SSH-Ready')) { + if (muxFallback === undefined) throw new EnvironmentError('ssh-unavailable') + await this.startWindowsMux(signal) + } + + /** 一次没建成的 ControlMaster 尝试留下的临时目录。下一次尝试要一个新的,不能复用这个路径。 */ + private async discardControlAttempt(): Promise { + this.control = '' + if (this.directory === '') return + const directory = this.directory + this.directory = '' + await rm(directory, { recursive: true, force: true }).catch(() => {}) + } + + /** + * 这台机器上没有任何一份 ssh 支持 ControlMaster 时的退路。 + * + * 在**这一次**已认证的 ssh 上跑远端多路复用(见 `session-mux.ts`)。远端没有 Python 时 + * stderr 里会有 `NCW-MUX-MISSING`:那条 ssh 已经认证过了,关掉它,后面退回每条命令一次 ssh。 + * 密码还会被问,但至少连得上。 + */ + private async startWindowsMux(signal: AbortSignal): Promise { + const child = this.launch(['-T', ...this.baseArgs(), remoteMuxCommand()]) + const mux = new SessionMux(child.stdin, child.stdout, () => { if (!this.closed) this.options.onDisconnect?.() }) + const stderr: Buffer[] = [] + child.stderr.on('data', (bytes: Buffer) => stderr.push(bytes)) + const outcome = await Promise.race([ + mux.ready.then(() => 'ready' as const), + new Promise<{ code: number; stderr: string }>((resolve) => { + const settle = (code: number): void => resolve({ code, stderr: Buffer.concat(stderr).toString('utf8') }) + child.once('close', (code) => settle(code ?? 255)) + child.once('error', () => settle(255)) + }) + ]) + signal.throwIfAborted() + if (outcome !== 'ready') { + child.kill() await this.close() - throw new EnvironmentError(classifyConnectFailure(result.stderr), result.stderr.slice(-2000)) + if (outcome.stderr.includes(MUX_MISSING)) return + throw new EnvironmentError(classifyConnectFailure(outcome.stderr), outcome.stderr.slice(-2000)) } + this.mux = mux + child.once('close', () => { if (!this.closed) this.options.onDisconnect?.() }) } /** @@ -237,6 +344,12 @@ export class OpenSshTransport { async exec(command: string, signal: AbortSignal, timeoutMs = 120_000): Promise<{ code: number; stdout: string; stderr: string }> { signal.throwIfAborted() + if (this.mux) { + try { return await this.mux.exec(command, signal, timeoutMs) } catch (error) { + if (error instanceof EnvironmentError && (error.code === 'cancelled' || error.code === 'timeout')) throw new EnvironmentError('result-unknown') + throw error + } + } let result: Awaited> try { result = await this.capture(['-T', ...this.baseArgs(), command], signal, timeoutMs) } catch (error) { if (error instanceof EnvironmentError && (error.code === 'cancelled' || error.code === 'timeout')) throw new EnvironmentError('result-unknown') @@ -247,6 +360,7 @@ export class OpenSshTransport { } process(command: string, input?: string): EnvironmentProcess { + if (this.mux) return this.mux.openProcess(command, input) const child = this.launch(['-T', ...this.baseArgs(), command]) if (input !== undefined) child.stdin.write(input) const exited = new Promise<{ code: number | null; signal?: string | null }>((resolve) => { @@ -256,7 +370,8 @@ export class OpenSshTransport { return { stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, exited, kill: () => { child.kill() } } } - subsystem(): ChildProcessWithoutNullStreams { + subsystem(): ChildProcessWithoutNullStreams | MuxByteStream { + if (this.mux) return this.mux.openSubsystem() const child = this.launch(['-T', '-s', ...this.baseArgs(), 'sftp']) child.once('close', () => { if (!this.closed) this.options.onDisconnect?.() }) return child @@ -264,6 +379,7 @@ export class OpenSshTransport { async openTcp(hostname: string, port: number): Promise { if (!/^[a-zA-Z0-9_.:%-]+$/.test(hostname) || !Number.isInteger(port) || port < 1 || port > 65535) throw new EnvironmentError('invalid-profile') + if (this.mux) return this.mux.openTcp(hostname, port) const target = `${hostname.includes(':') ? `[${hostname}]` : hostname}:${String(port)}` const server = createServer() await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) @@ -295,12 +411,13 @@ export class OpenSshTransport { terminalArgs(command: string): { executable: string; args: string[]; env: NodeJS.ProcessEnv } { if (this.closed) throw new EnvironmentError('disconnected') - return { executable: this.options.executable ?? sshExecutable(), args: ['-tt', ...this.baseArgs(), command], env: sshProcessEnvironment(this.options.env) } + return { executable: this.options.executable ?? (this.executable || sshExecutable()), args: ['-tt', ...this.baseArgs(), command], env: sshProcessEnvironment(this.options.env) } } close(): Promise { if (this.closing) return this.closing this.closed = true + this.mux?.close() for (const child of this.children) child.kill() this.closing = (async () => { if (this.control) await this.capture(['-O', 'exit', ...this.baseArgs()], AbortSignal.timeout(2000), 2000, true).catch(() => {}) diff --git a/src/main/runtime.ts b/src/main/runtime.ts index 151fdba28..be503ed85 100644 --- a/src/main/runtime.ts +++ b/src/main/runtime.ts @@ -126,7 +126,7 @@ let agentDrafts: AgentDraftGenerator | null = null let commitMessages: CommitMessageGenerator | null = null let environments: EnvironmentManager | null = null let environmentStatusSink: ((status: ConnectionStatus) => void) | undefined -let environmentAuthentication: ((profile: SshConnectionProfile, senderId: number) => Promise<{ env: NodeJS.ProcessEnv; close(): Promise; resolve?(values: Map): void }>) | undefined +let environmentAuthentication: ((profile: SshConnectionProfile, senderId: number) => Promise<{ env: NodeJS.ProcessEnv; close(): Promise; resolve?(values: Map): void; ask?(prompt: string, rejected: boolean): Promise }>) | undefined export function installEnvironmentInteraction(authentication: NonNullable, status: NonNullable): void { environmentAuthentication = authentication