Files
2026-07-13 12:46:20 +08:00

505 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SSH agent integration tests.
*
* These tests confirm that SSH agent authentication continues to work
* correctly across the following scenarios that are NOT covered by the
* existing session-ssh.spec.js suite:
*
* 1. Agent when sshKeysPath contains NON-matching key files.
* The createAuthHandler isMethodAllowed fix (commit f05ef847) makes the
* agent method eligible whenever the server advertises "publickey". A
* regression to that fix would cause all agent logins to fail silently
* whenever the user has any key files on disk.
*
* 2. Agent discovered via process.env.SSH_AUTH_SOCK (no explicit sshAgent
* option). The existing tests always pass sshAgent explicitly; this
* covers the implicit path used by most desktop setups.
*
* 3. exports.test() (connection probe) with SSH agent.
* Before commit a357c4a3 the ws object was not forwarded to the probe,
* so the host-key verification prompt (added in 409f3291) would wait
* forever when connecting to a new host.
*
* All tests mock the electron environment and run exclusively with Node.js
* built-ins + the packages already bundled with electerm.
*/
process.env.NODE_ENV = 'development'
const { describe, test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { once } = require('node:events')
const { spawnSync } = require('node:child_process')
const { Server, utils } = require('@electerm/ssh2')
const { session, test: sshTest } = require('../../src/app/server/session-ssh')
// ─── constants ────────────────────────────────────────────────────────────────
const USERNAME = 'agent-tester'
// A fresh server host key for every test run.
const HOST_KEY = utils.generateKeyPairSync('ed25519', {
comment: 'electerm-agent-test-host'
})
// ─── helpers ──────────────────────────────────────────────────────────────────
function parseKey (key) {
let parsed = utils.parseKey(key)
if (Array.isArray(parsed)) parsed = parsed[0]
if (parsed instanceof Error) throw parsed
return parsed
}
function publicKeyMatchesCtx (ctx, publicKeyPem) {
return Buffer.compare(
parseKey(publicKeyPem).getPublicSSH(),
ctx.key.data
) === 0
}
function runCommand (command, args, options = {}) {
const result = spawnSync(command, args, { encoding: 'utf8', ...options })
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(' ')} exited ${result.status}: ${result.stderr || result.stdout}`
)
}
return result.stdout
}
function generateKey ({ dir, name, type = 'ed25519', passphrase = '', bits }) {
const keyPath = path.join(dir, name)
const args = ['-q', '-t', type]
if (bits) args.push('-b', String(bits))
args.push('-N', passphrase, '-f', keyPath, '-C', `electerm-${name}`)
runCommand('ssh-keygen', args)
return {
keyPath,
privateKey: fs.readFileSync(keyPath, 'utf8'),
publicKey: fs.readFileSync(`${keyPath}.pub`, 'utf8')
}
}
/**
* Start ssh-agent on a unique socket and load the given key into it.
* Returns { env, kill }.
*/
function startAgent (keyPath) {
// Use a socket path that is predictable enough for debugging but unique.
const socketPath = path.join(
os.tmpdir(),
`ea-agent-${process.pid}-${Date.now()}.sock`
)
const output = runCommand('ssh-agent', ['-a', socketPath, '-s'])
const sockMatch = output.match(/SSH_AUTH_SOCK=([^;]+)/)
const pidMatch = output.match(/SSH_AGENT_PID=([^;]+)/)
if (!sockMatch || !pidMatch) {
throw new Error(`Cannot parse ssh-agent output:\n${output}`)
}
const env = {
...process.env,
SSH_AUTH_SOCK: sockMatch[1],
SSH_AGENT_PID: pidMatch[1]
}
if (keyPath) {
runCommand('ssh-add', [keyPath], { env })
}
return {
env,
kill () {
try { runCommand('ssh-agent', ['-k'], { env }) } catch (_) { /* ignore */ }
}
}
}
/**
* Start a minimal SSH server that only accepts publickey auth for USERNAME
* using the provided public-key string.
*
* Returns { port, close }.
*/
async function startServer (allowedPublicKey, { debug = false } = {}) {
const clients = new Set()
const server = new Server({ hostKeys: [HOST_KEY.private] }, (client) => {
clients.add(client)
client.on('close', () => clients.delete(client))
client.on('end', () => clients.delete(client))
client.on('authentication', (ctx) => {
if (debug) {
console.log('[server] auth method:', ctx.method, 'user:', ctx.username)
}
if (ctx.method === 'none') {
return ctx.reject(['publickey'])
}
if (
ctx.method === 'publickey' &&
ctx.username === USERNAME &&
publicKeyMatchesCtx(ctx, allowedPublicKey)
) {
if (debug) console.log('[server] accepted publickey auth')
return ctx.accept()
}
// reject but keep publickey in the advertised list so the client can
// try the ssh agent after failing with a file key
return ctx.reject(['publickey'])
})
client.on('ready', () => {
client.on('session', (accept) => {
const sess = accept()
sess.on('env', (accept) => accept())
sess.on('pty', (accept) => accept())
sess.on('shell', (accept) => {
const stream = accept()
stream.write('electerm-agent-test ready\n')
})
})
})
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const { port } = server.address()
if (debug) console.log('[server] listening on port', port)
return {
port,
async close () {
for (const c of clients) c.end()
await new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()))
})
}
}
}
/**
* Minimal ws mock that automatically trusts unknown host keys.
* Any non-confirm interactive prompt rejects with an error so tests fail
* loudly if an unexpected prompt appears.
*/
function makeWs ({ allowNonConfirmPrompts = false, debug = false } = {}) {
let pendingHandler = null
let pendingOptions = null
return {
s (payload) {
if (payload?.action !== 'session-interactive') return
pendingOptions = payload.options
if (debug) {
console.log('[ws] session-interactive', JSON.stringify({
mode: payload.options?.mode,
name: payload.options?.name,
prompts: (payload.options?.prompts || []).map(p => p.prompt)
}))
}
},
once (handler) {
const opts = pendingOptions
queueMicrotask(() => {
if (opts?.mode === 'confirm') {
// Auto-trust the host key
if (debug) console.log('[ws] auto-trusting host key')
handler({ results: ['trust'] })
} else if (allowNonConfirmPrompts) {
handler({ results: pendingHandler ? pendingHandler(opts) : [] })
} else {
// Fail loudly agent login should not need interactive prompts
handler({ results: [] }) // empty results → reject('User cancel')
}
})
pendingHandler = null
},
close () {}
}
}
function setEnv (name, value) {
if (value === undefined) delete process.env[name]
else process.env[name] = value
}
// ─── fixture management ────────────────────────────────────────────────────────
describe('SSH agent auth', () => {
let tmpDir
let savedEnv
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-agent-test-'))
savedEnv = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK,
SSH_AGENT_PID: process.env.SSH_AGENT_PID,
sshKeysPath: process.env.sshKeysPath
}
const homeDir = path.join(tmpDir, 'home')
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
fs.mkdirSync(homeDir, { recursive: true })
fs.mkdirSync(sshKeysDir, { recursive: true })
setEnv('HOME', homeDir)
setEnv('USERPROFILE', homeDir)
setEnv('SSH_AUTH_SOCK', undefined)
setEnv('SSH_AGENT_PID', undefined)
setEnv('sshKeysPath', sshKeysDir)
})
afterEach(() => {
setEnv('HOME', savedEnv.HOME)
setEnv('USERPROFILE', savedEnv.USERPROFILE)
setEnv('SSH_AUTH_SOCK', savedEnv.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', savedEnv.SSH_AGENT_PID)
setEnv('sshKeysPath', savedEnv.sshKeysPath)
fs.rmSync(tmpDir, { recursive: true, force: true })
})
// ── test 1 ─────────────────────────────────────────────────────────────────
test(
'agent auth succeeds even when sshKeysPath contains non-matching key files',
async (t) => {
// This is the primary regression test for the isMethodAllowed fix
// (commit f05ef847). Without that fix the auth handler would return
// false as soon as the key-file publickey attempt failed, because
// "agent" is not literally in the server's ["publickey"] allow-list,
// and the connection would be rejected with "All configured
// authentication methods failed" before the agent was ever tried.
let agent
try {
agent = startAgent() // start without a key first we load it below
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const sshKeysDir = process.env.sshKeysPath
// Generate the key the server will accept and load it into the agent.
const agentKey = generateKey({ dir: tmpDir, name: 'agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
// Generate a DIFFERENT key that lives in sshKeysPath.
// The code will try this one first (publickey auth) and it will fail.
// Agent must then be tried automatically.
const wrongKey = generateKey({ dir: sshKeysDir, name: 'wrong-key' })
// Also place the matching .pub file so getSSHKeys() finds it.
fs.copyFileSync(`${wrongKey.keyPath}.pub`, path.join(sshKeysDir, 'wrong-key.pub'))
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
sshAgent: agent.env.SSH_AUTH_SOCK,
useSshAgent: true,
enableSsh: false,
readyTimeout: 10000,
debug: true // enables ssh2 protocol debug output
}, makeWs({ debug: true }))
// If we reach here the agent key was accepted.
assert.ok(term, 'session should resolve when agent auth succeeds')
} finally {
term?.kill()
await server.close()
agent.kill()
}
}
)
// ── test 2 ─────────────────────────────────────────────────────────────────
test(
'agent auth works via implicit process.env.SSH_AUTH_SOCK (no explicit sshAgent option)',
async (t) => {
// The getAgent() method falls back to process.env.SSH_AUTH_SOCK when
// initOptions.sshAgent is not set. This covers the typical desktop
// user who relies on a system-managed agent socket.
let agent
try {
agent = startAgent()
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const agentKey = generateKey({ dir: tmpDir, name: 'implicit-agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
// Expose the agent socket via the environment variable only do NOT
// set initOptions.sshAgent.
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
// No sshAgent option should discover SSH_AUTH_SOCK automatically
useSshAgent: true,
enableSsh: false,
readyTimeout: 10000,
debug: true
}, makeWs({ debug: true }))
assert.ok(term, 'session should resolve when implicit agent is used')
} finally {
term?.kill()
await server.close()
agent.kill()
}
}
)
// ── test 3 ─────────────────────────────────────────────────────────────────
test(
'exports.test (connection probe) returns true with SSH agent on a new host',
async (t) => {
// Before commit a357c4a3, exports.test() did not forward ws to the
// TerminalSsh instance. The host-key verifier added in 409f3291 calls
// onKeyboardEvent() which sends a session-interactive event to ws and
// then awaits a reply. With ws === undefined the await never resolved,
// causing the probe to hang. This test would time out under that bug.
let agent
try {
agent = startAgent()
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const agentKey = generateKey({ dir: tmpDir, name: 'probe-agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
try {
const ok = await sshTest({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
sshAgent: agent.env.SSH_AUTH_SOCK,
useSshAgent: true,
readyTimeout: 10000,
debug: true
}, makeWs({ debug: true })) // ws must be forwarded (fixed in a357c4a3)
assert.equal(ok, true, 'sshTest should return true when agent auth succeeds')
} finally {
await server.close()
agent.kill()
}
}
)
// ── test 4 ─────────────────────────────────────────────────────────────────
test(
'agent auth with a new host requires host-key confirmation and then succeeds',
async (t) => {
// Confirm that the host-key verification dialog (409f3291) does not
// interfere with subsequent agent-based authentication. A fresh HOME
// dir is used so known_hosts starts empty.
let agent
try {
agent = startAgent()
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const agentKey = generateKey({ dir: tmpDir, name: 'new-host-agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
let confirmCount = 0
const ws = {
s (payload) {
if (payload?.action !== 'session-interactive') return
if (payload.options?.mode === 'confirm') {
confirmCount++
console.log('[ws] host-key confirm dialog received (#' + confirmCount + ')')
} else {
// Any non-confirm prompt during agent login is unexpected
console.error('[ws] unexpected non-confirm prompt:', payload.options)
}
},
once (handler) {
queueMicrotask(() => handler({ results: ['trust'] }))
},
close () {}
}
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
sshAgent: agent.env.SSH_AUTH_SOCK,
useSshAgent: true,
enableSsh: false,
readyTimeout: 10000,
debug: true
}, ws)
assert.ok(term, 'session should resolve after trusting the host key')
assert.equal(
confirmCount,
1,
'exactly one host-key confirmation prompt should appear for a new host'
)
// Verify the host was saved to known_hosts
const knownHostsPath = path.join(process.env.HOME, '.ssh', 'known_hosts')
const knownHostsContent = fs.readFileSync(knownHostsPath, 'utf8')
assert.match(
knownHostsContent,
/127\.0\.0\.1/,
'server should be recorded in known_hosts after trusting'
)
} finally {
term?.kill()
await server.close()
agent.kill()
}
}
)
})