Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 76 additions & 144 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
75 changes: 75 additions & 0 deletions src/main/environment/__tests__/session-mux.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<string>((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<void>((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)
})
15 changes: 14 additions & 1 deletion src/main/environment/__tests__/ssh-command.test.ts
Original file line number Diff line number Diff line change
@@ -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' } }
Expand Down Expand Up @@ -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' } }))
Expand Down
2 changes: 1 addition & 1 deletion src/main/environment/__tests__/ssh-native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
31 changes: 30 additions & 1 deletion src/main/environment/ssh/askpass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,35 @@ export class SshAuthBroker {
}
}

async open(profile: SshConnectionProfile, senderId: number, invocation: { executable: string; appPath?: string }): Promise<{ env: NodeJS.ProcessEnv; close(): Promise<void>; resolve(values: Map<string, string>): void }> {
/**
* 给内置 SSH 客户端用的一次询问。不经过系统 ssh。
*
* 需求:Windows 上那条 ssh2 连接自己问密码,而不是再起一个 ssh.exe。
* 存过的密码直接返回,不弹窗;没有,或调用方声明上一次被拒了,才弹现有的询问框。
* 弹出来的框和系统 ssh 走 SSH_ASKPASS 时是同一个,勾「记住」写的也是同一个槽位。
*/
ask(senderId: number, profile: SshConnectionProfile, prompt: string, options: { rejected?: boolean } = {}): Promise<string> {
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<string>((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<void>; resolve(values: Map<string, string>): void; ask(prompt: string, rejected: boolean): Promise<string> }> {
const directory = await mkdtemp(join(tmpdir(), 'ncw-auth-'))
await chmod(directory, 0o700)
const endpoint = process.platform === 'win32' ? `\\\\.\\pipe\\ncw-auth-${randomUUID()}` : join(directory, 'socket')
Expand Down Expand Up @@ -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<void>((resolve) => server.close(() => resolve()))
Expand Down
161 changes: 161 additions & 0 deletions src/main/environment/ssh/bundled-client.ts
Original file line number Diff line number Diff line change
@@ -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<string>) {}

/** 手动型连接才走这里。配置型别名读不到 `~/.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<void> {
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<void>((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<Socket> {
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<void> {
this.closed = true
this.client?.end()
this.client = undefined
return Promise.resolve()
}
}
Loading
Loading