chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
const { describe, it, before } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Server-side (CJS) — used by zmodem, xmodem, trzsz
|
||||
const sanitizeApp = require('../../src/app/common/sanitize-filename')
|
||||
|
||||
function runTests (getFn) {
|
||||
it('preserves leading dot (hidden file names)', () => {
|
||||
const s = getFn()
|
||||
assert.strictEqual(s('.env'), '.env')
|
||||
assert.strictEqual(s('.gitignore'), '.gitignore')
|
||||
assert.strictEqual(s('.htaccess'), '.htaccess')
|
||||
assert.strictEqual(s('.bashrc'), '.bashrc')
|
||||
assert.strictEqual(s('.config'), '.config')
|
||||
})
|
||||
|
||||
it('preserves double-leading dots in filenames', () => {
|
||||
assert.strictEqual(getFn()('..hidden'), '..hidden')
|
||||
})
|
||||
|
||||
it('rejects ".." and "..." as all-trailing-dots filenames', () => {
|
||||
// Both are all dots -> trailing strip produces empty -> fallback 'unnamed'
|
||||
const s = getFn()
|
||||
assert.strictEqual(s('..'), 'unnamed')
|
||||
assert.strictEqual(s('...'), 'unnamed')
|
||||
})
|
||||
|
||||
it('preserves leading dot with extension', () => {
|
||||
const s = getFn()
|
||||
assert.strictEqual(s('.env.production'), '.env.production')
|
||||
assert.strictEqual(s('.env.backup'), '.env.backup')
|
||||
assert.strictEqual(s('.npmrc'), '.npmrc')
|
||||
})
|
||||
|
||||
it('strips trailing dots and spaces', () => {
|
||||
const s = getFn()
|
||||
assert.strictEqual(s('file...'), 'file')
|
||||
assert.strictEqual(s('file.'), 'file')
|
||||
assert.strictEqual(s('file '), 'file')
|
||||
assert.strictEqual(s('file. '), 'file')
|
||||
})
|
||||
|
||||
it('strips leading spaces (not dots)', () => {
|
||||
const s = getFn()
|
||||
assert.strictEqual(s(' file.txt'), 'file.txt')
|
||||
assert.strictEqual(s(' .env'), '.env')
|
||||
assert.strictEqual(s(' normal'), 'normal')
|
||||
})
|
||||
|
||||
it('handles combined leading space + trailing dots', () => {
|
||||
const s = getFn()
|
||||
assert.strictEqual(s(' file... '), 'file')
|
||||
assert.strictEqual(s(' .env. '), '.env')
|
||||
})
|
||||
|
||||
it('replaces illegal characters with underscore', () => {
|
||||
const s = getFn()
|
||||
assert.strictEqual(s('file<name>.txt'), 'file_name_.txt')
|
||||
assert.strictEqual(s('a:b.txt'), 'a_b.txt')
|
||||
assert.strictEqual(s('file?.txt'), 'file_.txt')
|
||||
assert.strictEqual(s('a|b.txt'), 'a_b.txt')
|
||||
})
|
||||
|
||||
it('handles reserved Windows device names', () => {
|
||||
const s = getFn()
|
||||
assert.strictEqual(s('CON'), 'CON_')
|
||||
assert.strictEqual(s('PRN'), 'PRN_')
|
||||
assert.strictEqual(s('AUX'), 'AUX_')
|
||||
assert.strictEqual(s('NUL'), 'NUL_')
|
||||
assert.strictEqual(s('COM1'), 'COM1_')
|
||||
assert.strictEqual(s('LPT9'), 'LPT9_')
|
||||
})
|
||||
|
||||
it('does not touch normal filenames', () => {
|
||||
const s = getFn()
|
||||
assert.strictEqual(s('hello.txt'), 'hello.txt')
|
||||
assert.strictEqual(s('document.pdf'), 'document.pdf')
|
||||
assert.strictEqual(s('README.md'), 'README.md')
|
||||
assert.strictEqual(s('index.html'), 'index.html')
|
||||
})
|
||||
|
||||
it('returns "unnamed" for empty/invalid input', () => {
|
||||
const s = getFn()
|
||||
assert.strictEqual(s(''), 'unnamed')
|
||||
assert.strictEqual(s(null), 'unnamed')
|
||||
assert.strictEqual(s(undefined), 'unnamed')
|
||||
assert.strictEqual(s(123), 'unnamed')
|
||||
})
|
||||
|
||||
it('strips control characters', () => {
|
||||
const s = getFn()
|
||||
assert.strictEqual(s('file\x00.txt'), 'file_.txt')
|
||||
assert.strictEqual(s('test\x01file.txt'), 'test_file.txt')
|
||||
// Tab (0x09) is a control char, replaced before space strip
|
||||
assert.strictEqual(s('\tfile.txt'), '_file.txt')
|
||||
})
|
||||
}
|
||||
|
||||
describe('sanitizeFilename', () => {
|
||||
// Client-side (ESM) — used by SFTP drop, session.jsx — loaded async
|
||||
let sanitizeClient
|
||||
before(async () => {
|
||||
const mod = await import('../../src/client/common/sanitize-filename.js')
|
||||
sanitizeClient = mod.default
|
||||
})
|
||||
|
||||
describe('server (CJS) — zmodem/xmodem/trzsz', () => {
|
||||
runTests(() => sanitizeApp)
|
||||
})
|
||||
|
||||
describe('client (ESM) — SFTP drop/session.jsx', () => {
|
||||
runTests(() => sanitizeClient)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { describe, test } = require('node:test')
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const { join } = require('path')
|
||||
const { generateKeyPairSync } = require('@electerm/ssh2/lib/keygen.js')
|
||||
|
||||
const {
|
||||
appendKnownHost,
|
||||
buildHostMismatchPrompt,
|
||||
checkKnownHosts,
|
||||
getHostKeyMeta,
|
||||
matchesKnownHostField,
|
||||
removeKnownHost,
|
||||
replaceKnownHost
|
||||
} = require('../../src/app/server/ssh-known-hosts')
|
||||
|
||||
function createHostKey (label) {
|
||||
const pair = generateKeyPairSync('ed25519')
|
||||
const publicKey = `${pair.public} ${label}`.trim()
|
||||
return Buffer.from(publicKey.split(/\s+/)[1], 'base64')
|
||||
}
|
||||
|
||||
describe('ssh known_hosts verification', () => {
|
||||
test('matches hashed host entries', () => {
|
||||
const salt = crypto.randomBytes(20)
|
||||
const host = 'example.test'
|
||||
const digest = crypto.createHmac('sha1', salt).update(host).digest('base64')
|
||||
const token = `|1|${salt.toString('base64')}|${digest}`
|
||||
assert.equal(matchesKnownHostField(token, host, 22), true)
|
||||
assert.equal(matchesKnownHostField(token, 'other.test', 22), false)
|
||||
})
|
||||
|
||||
test('treats same-type key changes as mismatches', async () => {
|
||||
const tempDir = await fs.promises.mkdtemp(join(os.tmpdir(), 'electerm-known-hosts-'))
|
||||
try {
|
||||
const knownHostsPath = join(tempDir, 'known_hosts')
|
||||
const originalKey = createHostKey('original')
|
||||
const changedKey = createHostKey('changed')
|
||||
await appendKnownHost({
|
||||
host: 'example.test',
|
||||
port: 22,
|
||||
hostKey: originalKey,
|
||||
knownHostsPath
|
||||
})
|
||||
const result = await checkKnownHosts({
|
||||
host: 'example.test',
|
||||
port: 22,
|
||||
hostKey: changedKey,
|
||||
knownHostsPath
|
||||
})
|
||||
assert.equal(result.status, 'mismatch')
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('writes and re-reads a non-default port entry', async () => {
|
||||
const tempDir = await fs.promises.mkdtemp(join(os.tmpdir(), 'electerm-known-hosts-'))
|
||||
try {
|
||||
const knownHostsPath = join(tempDir, 'known_hosts')
|
||||
const hostKey = createHostKey('port-2222')
|
||||
const meta = getHostKeyMeta(hostKey)
|
||||
await appendKnownHost({
|
||||
host: '127.0.0.1',
|
||||
port: 2222,
|
||||
hostKey,
|
||||
knownHostsPath
|
||||
})
|
||||
const content = await fs.promises.readFile(knownHostsPath, 'utf8')
|
||||
assert.match(content, /^\[127\.0\.0\.1\]:2222 ssh-ed25519 /)
|
||||
const result = await checkKnownHosts({
|
||||
host: '127.0.0.1',
|
||||
port: 2222,
|
||||
hostKey,
|
||||
knownHostsPath
|
||||
})
|
||||
assert.equal(result.status, 'match')
|
||||
assert.equal(result.meta.sha256, meta.sha256)
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('removeKnownHost removes matching entry', async () => {
|
||||
const tempDir = await fs.promises.mkdtemp(join(os.tmpdir(), 'electerm-known-hosts-'))
|
||||
try {
|
||||
const knownHostsPath = join(tempDir, 'known_hosts')
|
||||
const key1 = createHostKey('host1')
|
||||
const key2 = createHostKey('host2')
|
||||
await appendKnownHost({
|
||||
host: 'host1.test',
|
||||
port: 22,
|
||||
hostKey: key1,
|
||||
knownHostsPath
|
||||
})
|
||||
await appendKnownHost({
|
||||
host: 'host2.test',
|
||||
port: 22,
|
||||
hostKey: key2,
|
||||
knownHostsPath
|
||||
})
|
||||
await removeKnownHost({
|
||||
host: 'host1.test',
|
||||
port: 22,
|
||||
keyType: 'ssh-ed25519',
|
||||
knownHostsPath
|
||||
})
|
||||
const result1 = await checkKnownHosts({
|
||||
host: 'host1.test',
|
||||
port: 22,
|
||||
hostKey: key1,
|
||||
knownHostsPath
|
||||
})
|
||||
assert.equal(result1.status, 'not-found')
|
||||
const result2 = await checkKnownHosts({
|
||||
host: 'host2.test',
|
||||
port: 22,
|
||||
hostKey: key2,
|
||||
knownHostsPath
|
||||
})
|
||||
assert.equal(result2.status, 'match')
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('replaceKnownHost updates a changed key', async () => {
|
||||
const tempDir = await fs.promises.mkdtemp(join(os.tmpdir(), 'electerm-known-hosts-'))
|
||||
try {
|
||||
const knownHostsPath = join(tempDir, 'known_hosts')
|
||||
const originalKey = createHostKey('original')
|
||||
const newKey = createHostKey('new')
|
||||
await appendKnownHost({
|
||||
host: 'router.test',
|
||||
port: 22,
|
||||
hostKey: originalKey,
|
||||
knownHostsPath
|
||||
})
|
||||
await replaceKnownHost({
|
||||
host: 'router.test',
|
||||
port: 22,
|
||||
hostKey: newKey,
|
||||
knownHostsPath
|
||||
})
|
||||
const oldResult = await checkKnownHosts({
|
||||
host: 'router.test',
|
||||
port: 22,
|
||||
hostKey: originalKey,
|
||||
knownHostsPath
|
||||
})
|
||||
assert.equal(oldResult.status, 'mismatch')
|
||||
const newResult = await checkKnownHosts({
|
||||
host: 'router.test',
|
||||
port: 22,
|
||||
hostKey: newKey,
|
||||
knownHostsPath
|
||||
})
|
||||
assert.equal(newResult.status, 'match')
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('buildHostMismatchPrompt returns a confirm prompt with warning', () => {
|
||||
const meta = {
|
||||
keyType: 'ssh-ed25519',
|
||||
sha256: 'AAABBBCCC123'
|
||||
}
|
||||
const prompt = buildHostMismatchPrompt({
|
||||
host: 'router.test',
|
||||
port: 22,
|
||||
meta,
|
||||
knownHostsPath: '/home/user/.ssh/known_hosts'
|
||||
})
|
||||
assert.equal(prompt.mode, 'confirm')
|
||||
assert.equal(prompt.submitText, 'Update Key')
|
||||
assert.equal(prompt.cancelText, 'Reject')
|
||||
assert.ok(prompt.instructions.some(i => i.includes('WARNING')))
|
||||
assert.ok(prompt.instructions.some(i => i.includes('router.test')))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Test: when password is provided, password auth should be tried first
|
||||
* before publickey/agent, even if ~/.ssh has keys available.
|
||||
*/
|
||||
|
||||
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 } = require('../../src/app/server/session-ssh')
|
||||
|
||||
const USERNAME = 'tester'
|
||||
const PASSWORD = 'electerm-test-password'
|
||||
|
||||
const HOST_KEY = utils.generateKeyPairSync('ed25519', {
|
||||
comment: 'electerm-test-host'
|
||||
})
|
||||
|
||||
function parseKey (key, passphrase) {
|
||||
let parsed = utils.parseKey(key, passphrase)
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed = parsed[0]
|
||||
}
|
||||
if (parsed instanceof Error) {
|
||||
throw parsed
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function matchesPublicKey (ctx, publicKey) {
|
||||
return Buffer.compare(
|
||||
parseKey(publicKey).getPublicSSH(),
|
||||
ctx.key.data
|
||||
) === 0
|
||||
}
|
||||
|
||||
function makeTmpDir () {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-ssh-test-'))
|
||||
}
|
||||
|
||||
function setEnvVar (name, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
} else {
|
||||
process.env[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
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(' ')} failed: ${result.stderr || result.stdout}`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function generateClientKey ({ dir, name, type, 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 an SSH server that tracks which auth methods are attempted.
|
||||
* @param {string} publicKey - The public key to accept for publickey auth
|
||||
* @returns {{ port: number, authAttempts: string[], close: () => Promise<void> }}
|
||||
*/
|
||||
async function startTrackingServer (publicKey) {
|
||||
const clients = new Set()
|
||||
const authAttempts = []
|
||||
|
||||
const server = new Server({
|
||||
hostKeys: [HOST_KEY.private]
|
||||
}, (client) => {
|
||||
clients.add(client)
|
||||
const cleanup = () => clients.delete(client)
|
||||
|
||||
client.on('close', cleanup)
|
||||
client.on('end', cleanup)
|
||||
client.on('authentication', (ctx) => {
|
||||
authAttempts.push(ctx.method)
|
||||
|
||||
if (ctx.method === 'none') {
|
||||
return ctx.reject(['publickey', 'password'])
|
||||
}
|
||||
|
||||
// Accept password auth
|
||||
if (ctx.method === 'password' && ctx.username === USERNAME && ctx.password === PASSWORD) {
|
||||
return ctx.accept()
|
||||
}
|
||||
|
||||
// Accept publickey auth
|
||||
if (ctx.method === 'publickey' && ctx.username === USERNAME && matchesPublicKey(ctx, publicKey)) {
|
||||
return ctx.accept()
|
||||
}
|
||||
|
||||
ctx.reject(['publickey', 'password'])
|
||||
})
|
||||
client.on('ready', () => {
|
||||
client.on('session', (accept) => {
|
||||
const sshSession = accept()
|
||||
sshSession.on('env', (accept) => accept())
|
||||
sshSession.on('pty', (accept) => accept())
|
||||
sshSession.on('shell', (accept) => {
|
||||
const stream = accept()
|
||||
stream.write('electerm ready\n')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
|
||||
return {
|
||||
port: server.address().port,
|
||||
authAttempts,
|
||||
async close () {
|
||||
for (const client of clients) {
|
||||
client.end()
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptWs () {
|
||||
return {
|
||||
prompts: [],
|
||||
s (payload) {},
|
||||
once (handler) {
|
||||
queueMicrotask(() => {
|
||||
handler({ results: ['trust'] })
|
||||
})
|
||||
},
|
||||
close () {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('session-ssh password-first auth', () => {
|
||||
let tmpDir
|
||||
let oldEnv
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir()
|
||||
oldEnv = {
|
||||
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 })
|
||||
|
||||
setEnvVar('HOME', homeDir)
|
||||
setEnvVar('USERPROFILE', homeDir)
|
||||
setEnvVar('SSH_AUTH_SOCK', undefined)
|
||||
setEnvVar('SSH_AGENT_PID', undefined)
|
||||
setEnvVar('sshKeysPath', sshKeysDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setEnvVar('HOME', oldEnv.HOME)
|
||||
setEnvVar('USERPROFILE', oldEnv.USERPROFILE)
|
||||
setEnvVar('SSH_AUTH_SOCK', oldEnv.SSH_AUTH_SOCK)
|
||||
setEnvVar('SSH_AGENT_PID', oldEnv.SSH_AGENT_PID)
|
||||
setEnvVar('sshKeysPath', oldEnv.sshKeysPath)
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('when password is provided, password auth is tried before publickey from ~/.ssh keys', async () => {
|
||||
// Generate a key pair and put it in the sshKeysPath (simulating ~/.ssh)
|
||||
const keyPair = generateClientKey({
|
||||
dir: tmpDir,
|
||||
name: 'id_ed25519',
|
||||
type: 'ed25519',
|
||||
passphrase: ''
|
||||
})
|
||||
|
||||
// Copy public key to sshKeysDir so getSSHKeys() finds it
|
||||
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
|
||||
fs.copyFileSync(keyPair.keyPath, path.join(sshKeysDir, 'id_ed25519'))
|
||||
fs.copyFileSync(`${keyPair.keyPath}.pub`, path.join(sshKeysDir, 'id_ed25519.pub'))
|
||||
|
||||
// Start server that accepts both password and publickey
|
||||
const server = await startTrackingServer(keyPair.publicKey)
|
||||
const ws = createPromptWs()
|
||||
|
||||
let term
|
||||
try {
|
||||
term = await session({
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
username: USERNAME,
|
||||
password: PASSWORD,
|
||||
useSshAgent: false,
|
||||
enableSsh: false,
|
||||
readyTimeout: 5000
|
||||
}, ws)
|
||||
|
||||
// Verify the connection succeeded
|
||||
assert.ok(term, 'session should be created')
|
||||
|
||||
// The key assertion: password should be the first real auth attempt
|
||||
// (after 'none' which is always first)
|
||||
const authMethods = server.authAttempts
|
||||
const firstRealAuth = authMethods.find(m => m !== 'none')
|
||||
assert.equal(firstRealAuth, 'password',
|
||||
`Expected password to be tried first, but auth attempts were: ${JSON.stringify(authMethods)}`)
|
||||
|
||||
// Password should appear before any publickey attempt
|
||||
const passwordIndex = authMethods.indexOf('password')
|
||||
const publickeyIndex = authMethods.indexOf('publickey')
|
||||
if (publickeyIndex !== -1) {
|
||||
assert.ok(passwordIndex < publickeyIndex,
|
||||
`password (index ${passwordIndex}) should be tried before publickey (index ${publickeyIndex}), auth attempts: ${JSON.stringify(authMethods)}`)
|
||||
}
|
||||
} finally {
|
||||
if (term) {
|
||||
term.kill()
|
||||
}
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('when password is provided with agent available, password is still tried first', async () => {
|
||||
// Generate a key pair and put it in sshKeysPath
|
||||
const keyPair = generateClientKey({
|
||||
dir: tmpDir,
|
||||
name: 'id_rsa',
|
||||
type: 'rsa',
|
||||
bits: 2048,
|
||||
passphrase: ''
|
||||
})
|
||||
|
||||
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
|
||||
fs.copyFileSync(keyPair.keyPath, path.join(sshKeysDir, 'id_rsa'))
|
||||
fs.copyFileSync(`${keyPair.keyPath}.pub`, path.join(sshKeysDir, 'id_rsa.pub'))
|
||||
|
||||
// Start server that accepts both password and publickey
|
||||
const server = await startTrackingServer(keyPair.publicKey)
|
||||
const ws = createPromptWs()
|
||||
|
||||
let term
|
||||
try {
|
||||
term = await session({
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
username: USERNAME,
|
||||
password: PASSWORD,
|
||||
// Don't disable agent - let it be set naturally
|
||||
// useSshAgent defaults to checking SSH_AUTH_SOCK
|
||||
enableSsh: false,
|
||||
readyTimeout: 5000
|
||||
}, ws)
|
||||
|
||||
assert.ok(term, 'session should be created')
|
||||
|
||||
const authMethods = server.authAttempts
|
||||
const firstRealAuth = authMethods.find(m => m !== 'none')
|
||||
assert.equal(firstRealAuth, 'password',
|
||||
`Expected password to be tried first, but auth attempts were: ${JSON.stringify(authMethods)}`)
|
||||
} finally {
|
||||
if (term) {
|
||||
term.kill()
|
||||
}
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('when only password is provided (no keys in ~/.ssh), password auth works', async () => {
|
||||
// No keys in sshKeysDir - only password available
|
||||
const server = await startTrackingServer(null)
|
||||
const ws = createPromptWs()
|
||||
|
||||
let term
|
||||
try {
|
||||
term = await session({
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
username: USERNAME,
|
||||
password: PASSWORD,
|
||||
useSshAgent: false,
|
||||
enableSsh: false,
|
||||
readyTimeout: 5000
|
||||
}, ws)
|
||||
|
||||
assert.ok(term, 'session should be created')
|
||||
|
||||
const authMethods = server.authAttempts
|
||||
assert.ok(authMethods.includes('password'),
|
||||
`password auth should have been attempted, auth attempts: ${JSON.stringify(authMethods)}`)
|
||||
} finally {
|
||||
if (term) {
|
||||
term.kill()
|
||||
}
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('with isMFA and password provided, ~/.ssh keys are NOT loaded (no passphrase dialog)', async () => {
|
||||
// Generate a key with passphrase - simulating ~/.ssh key that would trigger passphrase dialog
|
||||
const keyPair = generateClientKey({
|
||||
dir: tmpDir,
|
||||
name: 'id_ed25519_passphrase',
|
||||
type: 'ed25519',
|
||||
passphrase: 'key-passphrase'
|
||||
})
|
||||
|
||||
// Copy key to sshKeysDir so getSSHKeys() would find it
|
||||
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
|
||||
fs.copyFileSync(keyPair.keyPath, path.join(sshKeysDir, 'id_ed25519_passphrase'))
|
||||
fs.copyFileSync(`${keyPair.keyPath}.pub`, path.join(sshKeysDir, 'id_ed25519_passphrase.pub'))
|
||||
|
||||
// Server only accepts password (simulating bastion host)
|
||||
const authAttempts = []
|
||||
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) => {
|
||||
authAttempts.push(ctx.method)
|
||||
if (ctx.method === 'none') {
|
||||
return ctx.reject(['keyboard-interactive', 'password'])
|
||||
}
|
||||
if (ctx.method === 'password' && ctx.username === USERNAME && ctx.password === PASSWORD) {
|
||||
return ctx.accept()
|
||||
}
|
||||
if (ctx.method === 'keyboard-interactive' && ctx.username === USERNAME) {
|
||||
return ctx.prompt(
|
||||
[{ prompt: 'Password:', echo: false }],
|
||||
'instructions',
|
||||
'lang',
|
||||
(responses) => {
|
||||
if (responses[0] === PASSWORD) {
|
||||
ctx.accept()
|
||||
} else {
|
||||
ctx.reject(['keyboard-interactive', 'password'])
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
ctx.reject(['keyboard-interactive', 'password'])
|
||||
})
|
||||
client.on('ready', () => {
|
||||
client.on('session', (accept) => {
|
||||
const sshSession = accept()
|
||||
sshSession.on('env', (accept) => accept())
|
||||
sshSession.on('pty', (accept) => accept())
|
||||
sshSession.on('shell', (accept) => {
|
||||
const stream = accept()
|
||||
stream.write('electerm ready\n')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
const port = server.address().port
|
||||
|
||||
// Track if any passphrase prompt appears
|
||||
let passphrasePrompted = false
|
||||
const ws = {
|
||||
prompts: [],
|
||||
s (payload) {
|
||||
if (payload && payload.action === 'session-interactive') {
|
||||
this.prompts.push(payload.options)
|
||||
// Check if this is a passphrase prompt
|
||||
if (payload.options?.prompts?.[0]?.prompt?.toLowerCase().includes('passphrase') ||
|
||||
payload.options?.name?.toLowerCase().includes('passphase')) {
|
||||
passphrasePrompted = true
|
||||
}
|
||||
}
|
||||
},
|
||||
once (handler) {
|
||||
queueMicrotask(() => {
|
||||
handler({ results: ['trust'] })
|
||||
})
|
||||
},
|
||||
close () {}
|
||||
}
|
||||
|
||||
let term
|
||||
try {
|
||||
term = await session({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
username: USERNAME,
|
||||
password: PASSWORD,
|
||||
isMFA: true,
|
||||
useSshAgent: false,
|
||||
enableSsh: false,
|
||||
readyTimeout: 5000
|
||||
}, ws)
|
||||
|
||||
assert.ok(term, 'session should be created')
|
||||
assert.equal(passphrasePrompted, false,
|
||||
'Should NOT prompt for key passphrase when password is provided in bookmark')
|
||||
|
||||
// Verify password or keyboard-interactive was used, NOT publickey
|
||||
const hasPublickey = authAttempts.includes('publickey')
|
||||
assert.equal(hasPublickey, false,
|
||||
`publickey auth should NOT be attempted when password is provided, auth attempts: ${JSON.stringify(authAttempts)}`)
|
||||
} finally {
|
||||
if (term) {
|
||||
term.kill()
|
||||
}
|
||||
for (const client of clients) {
|
||||
client.end()
|
||||
}
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,580 @@
|
||||
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 { setTimeout: delay } = require('node:timers/promises')
|
||||
const { Server, utils } = require('@electerm/ssh2')
|
||||
const { session } = require('../../src/app/server/session-ssh')
|
||||
|
||||
const USERNAME = 'tester'
|
||||
const PASSWORD = 'electerm-test'
|
||||
const OTP = '123456'
|
||||
const PASSPHRASE = 'electerm-passphrase'
|
||||
|
||||
const HOST_KEY = utils.generateKeyPairSync('ed25519', {
|
||||
comment: 'electerm-test-host'
|
||||
})
|
||||
|
||||
function parseKey (key, passphrase) {
|
||||
let parsed = utils.parseKey(key, passphrase)
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed = parsed[0]
|
||||
}
|
||||
if (parsed instanceof Error) {
|
||||
throw parsed
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function matchesPublicKey (ctx, publicKey) {
|
||||
return Buffer.compare(
|
||||
parseKey(publicKey).getPublicSSH(),
|
||||
ctx.key.data
|
||||
) === 0
|
||||
}
|
||||
|
||||
function makeTmpDir () {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-ssh-test-'))
|
||||
}
|
||||
|
||||
function setEnvVar (name, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
} else {
|
||||
process.env[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
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(' ')} failed: ${result.stderr || result.stdout}`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function generateClientKey ({ dir, name, type, 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')
|
||||
}
|
||||
}
|
||||
|
||||
async function startServer (authHandler) {
|
||||
const clients = new Set()
|
||||
const server = new Server({
|
||||
hostKeys: [HOST_KEY.private]
|
||||
}, (client) => {
|
||||
clients.add(client)
|
||||
const cleanup = () => clients.delete(client)
|
||||
|
||||
client.on('close', cleanup)
|
||||
client.on('end', cleanup)
|
||||
client.on('authentication', (ctx) => {
|
||||
authHandler(ctx)
|
||||
})
|
||||
client.on('ready', () => {
|
||||
client.on('session', (accept) => {
|
||||
const sshSession = accept()
|
||||
sshSession.on('env', (accept) => accept())
|
||||
sshSession.on('pty', (accept) => accept())
|
||||
sshSession.on('shell', (accept) => {
|
||||
const stream = accept()
|
||||
stream.write('electerm ready\n')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
|
||||
return {
|
||||
port: server.address().port,
|
||||
async close () {
|
||||
for (const client of clients) {
|
||||
client.end()
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptWs (promptResponder, options = {}) {
|
||||
const {
|
||||
includeConfirmPrompts = false,
|
||||
defaultConfirmResponse = ['trust']
|
||||
} = options
|
||||
const prompts = []
|
||||
let pendingOptions
|
||||
return {
|
||||
prompts,
|
||||
s (payload) {
|
||||
if (payload && payload.action === 'session-interactive') {
|
||||
pendingOptions = payload.options
|
||||
if (includeConfirmPrompts || payload.options?.mode !== 'confirm') {
|
||||
prompts.push(payload.options)
|
||||
}
|
||||
}
|
||||
},
|
||||
once (handler) {
|
||||
const currentOptions = pendingOptions
|
||||
queueMicrotask(() => {
|
||||
handler({
|
||||
results: currentOptions?.mode === 'confirm' && !includeConfirmPrompts
|
||||
? defaultConfirmResponse
|
||||
: promptResponder(currentOptions, prompts)
|
||||
})
|
||||
})
|
||||
},
|
||||
close () {}
|
||||
}
|
||||
}
|
||||
|
||||
function publicKeyOnlyAuth (publicKey) {
|
||||
return (ctx) => {
|
||||
if (ctx.method === 'none') {
|
||||
return ctx.reject(['publickey'])
|
||||
}
|
||||
if (ctx.method === 'publickey' && ctx.username === USERNAME && matchesPublicKey(ctx, publicKey)) {
|
||||
return ctx.accept()
|
||||
}
|
||||
return ctx.reject(['publickey'])
|
||||
}
|
||||
}
|
||||
|
||||
function sameRoundKeyboardInteractiveAuth () {
|
||||
return (ctx) => {
|
||||
if (ctx.method === 'none') {
|
||||
return ctx.reject(['keyboard-interactive'])
|
||||
}
|
||||
if (ctx.method === 'keyboard-interactive' && ctx.username === USERNAME) {
|
||||
return ctx.prompt([
|
||||
{ prompt: 'Password:', echo: false },
|
||||
{ prompt: 'Verification code:', echo: false }
|
||||
], 'electerm-test', 'same-round otp', (responses) => {
|
||||
if (responses[0] === PASSWORD && responses[1] === OTP) {
|
||||
ctx.accept()
|
||||
} else {
|
||||
ctx.reject(['keyboard-interactive'])
|
||||
}
|
||||
})
|
||||
}
|
||||
return ctx.reject(['keyboard-interactive'])
|
||||
}
|
||||
}
|
||||
|
||||
function splitRoundKeyboardInteractiveAuth (rounds = []) {
|
||||
return (ctx) => {
|
||||
if (ctx.method === 'none') {
|
||||
return ctx.reject(['keyboard-interactive'])
|
||||
}
|
||||
if (ctx.method === 'keyboard-interactive' && ctx.username === USERNAME) {
|
||||
rounds.push('otp')
|
||||
return ctx.prompt([
|
||||
{ prompt: 'Verification code:', echo: false }
|
||||
], 'electerm-test', 'otp round', (otpResponses) => {
|
||||
if (otpResponses[0] !== OTP) {
|
||||
return ctx.reject(['keyboard-interactive'])
|
||||
}
|
||||
rounds.push('password')
|
||||
ctx.prompt([
|
||||
{ prompt: 'Password:', echo: false }
|
||||
], 'electerm-test', 'password round', (passwordResponses) => {
|
||||
if (passwordResponses[0] === PASSWORD) {
|
||||
ctx.accept()
|
||||
} else {
|
||||
ctx.reject(['keyboard-interactive'])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
return ctx.reject(['keyboard-interactive'])
|
||||
}
|
||||
}
|
||||
|
||||
function startAgent () {
|
||||
const socketPath = path.join(os.tmpdir(), `ea-${process.pid}-${Date.now()}.sock`)
|
||||
const output = runCommand('ssh-agent', ['-a', socketPath, '-s'])
|
||||
const sock = output.match(/SSH_AUTH_SOCK=([^;]+)/)
|
||||
const pid = output.match(/SSH_AGENT_PID=([^;]+)/)
|
||||
|
||||
if (!sock || !pid) {
|
||||
throw new Error(`Unable to parse ssh-agent output: ${output}`)
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
SSH_AUTH_SOCK: sock[1],
|
||||
SSH_AGENT_PID: pid[1]
|
||||
}
|
||||
|
||||
return {
|
||||
env,
|
||||
kill () {
|
||||
runCommand('ssh-agent', ['-k'], { env })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('session-ssh auth flows', () => {
|
||||
let tmpDir
|
||||
let oldEnv
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir()
|
||||
oldEnv = {
|
||||
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 })
|
||||
|
||||
setEnvVar('HOME', homeDir)
|
||||
setEnvVar('USERPROFILE', homeDir)
|
||||
setEnvVar('SSH_AUTH_SOCK', undefined)
|
||||
setEnvVar('SSH_AGENT_PID', undefined)
|
||||
setEnvVar('sshKeysPath', sshKeysDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setEnvVar('HOME', oldEnv.HOME)
|
||||
setEnvVar('USERPROFILE', oldEnv.USERPROFILE)
|
||||
setEnvVar('SSH_AUTH_SOCK', oldEnv.SSH_AUTH_SOCK)
|
||||
setEnvVar('SSH_AGENT_PID', oldEnv.SSH_AGENT_PID)
|
||||
setEnvVar('sshKeysPath', oldEnv.sshKeysPath)
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('connects with an rsa key protected by a passphrase', async () => {
|
||||
const keyPair = generateClientKey({
|
||||
dir: tmpDir,
|
||||
name: 'rsa-passphrase',
|
||||
type: 'rsa',
|
||||
bits: 2048,
|
||||
passphrase: PASSPHRASE
|
||||
})
|
||||
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
|
||||
const ws = createPromptWs(() => {
|
||||
throw new Error('explicit passphrase login should not prompt')
|
||||
})
|
||||
|
||||
let term
|
||||
try {
|
||||
term = await session({
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
username: USERNAME,
|
||||
privateKey: keyPair.privateKey,
|
||||
passphrase: PASSPHRASE,
|
||||
useSshAgent: false,
|
||||
enableSsh: false,
|
||||
readyTimeout: 5000
|
||||
}, ws)
|
||||
|
||||
assert.equal(ws.prompts.length, 0)
|
||||
} finally {
|
||||
term && term.kill()
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('connects with an ed25519 key protected by a passphrase', async () => {
|
||||
const keyPair = generateClientKey({
|
||||
dir: tmpDir,
|
||||
name: 'ed25519-passphrase',
|
||||
type: 'ed25519',
|
||||
passphrase: PASSPHRASE
|
||||
})
|
||||
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
|
||||
const ws = createPromptWs(() => {
|
||||
throw new Error('explicit passphrase login should not prompt')
|
||||
})
|
||||
|
||||
let term
|
||||
try {
|
||||
term = await session({
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
username: USERNAME,
|
||||
privateKey: keyPair.privateKey,
|
||||
passphrase: PASSPHRASE,
|
||||
useSshAgent: false,
|
||||
enableSsh: false,
|
||||
readyTimeout: 5000
|
||||
}, ws)
|
||||
|
||||
assert.equal(ws.prompts.length, 0)
|
||||
} finally {
|
||||
term && term.kill()
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('handles password and otp in the same keyboard-interactive round', async () => {
|
||||
const server = await startServer(sameRoundKeyboardInteractiveAuth())
|
||||
const ws = createPromptWs((options) => {
|
||||
assert.equal(options.prompts.length, 2)
|
||||
return [PASSWORD, OTP]
|
||||
})
|
||||
|
||||
let term
|
||||
try {
|
||||
term = await session({
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
username: USERNAME,
|
||||
password: PASSWORD,
|
||||
useSshAgent: false,
|
||||
enableSsh: false,
|
||||
readyTimeout: 5000
|
||||
}, ws)
|
||||
|
||||
assert.equal(ws.prompts.length, 1)
|
||||
assert.match(ws.prompts[0].prompts[1].prompt, /verification code/i)
|
||||
} finally {
|
||||
term && term.kill()
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('handles otp then password across separate keyboard-interactive rounds', async () => {
|
||||
const rounds = []
|
||||
const server = await startServer(splitRoundKeyboardInteractiveAuth(rounds))
|
||||
const ws = createPromptWs((options, prompts) => {
|
||||
if (prompts.length === 1) {
|
||||
assert.match(options.prompts[0].prompt, /verification code/i)
|
||||
return [OTP]
|
||||
}
|
||||
assert.match(options.prompts[0].prompt, /password/i)
|
||||
return [PASSWORD]
|
||||
})
|
||||
|
||||
let term
|
||||
try {
|
||||
term = await session({
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
username: USERNAME,
|
||||
password: PASSWORD,
|
||||
useSshAgent: false,
|
||||
enableSsh: false,
|
||||
readyTimeout: 5000
|
||||
}, ws)
|
||||
|
||||
assert.deepEqual(rounds, ['otp', 'otp', 'password'])
|
||||
assert.equal(ws.prompts.length, 1)
|
||||
assert.match(ws.prompts[0].prompts[0].prompt, /verification code/i)
|
||||
} finally {
|
||||
term && term.kill()
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('connects with ssh agent only and leaves user keys untouched', async (t) => {
|
||||
let agent
|
||||
try {
|
||||
agent = startAgent()
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
t.skip('ssh-agent is not available in this environment')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const keyPair = generateClientKey({
|
||||
dir: tmpDir,
|
||||
name: 'agent-ed25519',
|
||||
type: 'ed25519',
|
||||
passphrase: ''
|
||||
})
|
||||
|
||||
try {
|
||||
runCommand('ssh-add', [keyPair.keyPath], { env: agent.env })
|
||||
setEnvVar('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
|
||||
setEnvVar('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
|
||||
|
||||
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
|
||||
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: 5000
|
||||
}, createPromptWs(() => {
|
||||
throw new Error('ssh-agent login should not prompt')
|
||||
}))
|
||||
} finally {
|
||||
term && term.kill()
|
||||
await server.close()
|
||||
}
|
||||
} finally {
|
||||
if (agent) {
|
||||
agent.kill()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('prompts for an unknown host key once and records it in known_hosts', async () => {
|
||||
const keyPair = generateClientKey({
|
||||
dir: tmpDir,
|
||||
name: 'host-verify-ed25519',
|
||||
type: 'ed25519',
|
||||
passphrase: ''
|
||||
})
|
||||
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
|
||||
const knownHostsPath = path.join(process.env.HOME, '.ssh', 'known_hosts')
|
||||
|
||||
let term
|
||||
let firstPromptCount = 0
|
||||
try {
|
||||
const ws = createPromptWs((options, prompts) => {
|
||||
firstPromptCount = prompts.length
|
||||
assert.equal(options.mode, 'confirm')
|
||||
assert.match(options.name, /trust ssh host key/i)
|
||||
assert.match(options.instructions.join('\n'), /Fingerprint: SHA256:/)
|
||||
return ['trust']
|
||||
}, {
|
||||
includeConfirmPrompts: true
|
||||
})
|
||||
|
||||
term = await session({
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
username: USERNAME,
|
||||
privateKey: keyPair.privateKey,
|
||||
useSshAgent: false,
|
||||
enableSsh: false,
|
||||
readyTimeout: 5000
|
||||
}, ws)
|
||||
|
||||
assert.equal(firstPromptCount, 1)
|
||||
assert.match(fs.readFileSync(knownHostsPath, 'utf8'), /^\[127\.0\.0\.1\]:\d+ ssh-ed25519 /)
|
||||
term.kill()
|
||||
term = null
|
||||
|
||||
term = await session({
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
username: USERNAME,
|
||||
privateKey: keyPair.privateKey,
|
||||
useSshAgent: false,
|
||||
enableSsh: false,
|
||||
readyTimeout: 5000
|
||||
}, createPromptWs(() => {
|
||||
throw new Error('known_hosts match should not prompt again')
|
||||
}))
|
||||
} finally {
|
||||
term && term.kill()
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('waits for host trust confirmation before resolving the session', async () => {
|
||||
const keyPair = generateClientKey({
|
||||
dir: tmpDir,
|
||||
name: 'host-verify-blocking-ed25519',
|
||||
type: 'ed25519',
|
||||
passphrase: ''
|
||||
})
|
||||
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
|
||||
|
||||
let term
|
||||
let pendingHandler
|
||||
let sessionResolved = false
|
||||
let promptSeenResolve
|
||||
const promptSeen = new Promise(resolve => {
|
||||
promptSeenResolve = resolve
|
||||
})
|
||||
const ws = {
|
||||
prompts: [],
|
||||
s (payload) {
|
||||
if (payload && payload.action === 'session-interactive') {
|
||||
this.prompts.push(payload.options)
|
||||
promptSeenResolve(payload.options)
|
||||
}
|
||||
},
|
||||
once (handler) {
|
||||
pendingHandler = handler
|
||||
},
|
||||
close () {}
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionPromise = session({
|
||||
host: '127.0.0.1',
|
||||
port: server.port,
|
||||
username: USERNAME,
|
||||
privateKey: keyPair.privateKey,
|
||||
useSshAgent: false,
|
||||
enableSsh: false,
|
||||
readyTimeout: 5000
|
||||
}, ws).then(result => {
|
||||
sessionResolved = true
|
||||
return result
|
||||
})
|
||||
|
||||
const promptOptions = await promptSeen
|
||||
assert.equal(promptOptions.mode, 'confirm')
|
||||
await delay(50)
|
||||
assert.equal(sessionResolved, false)
|
||||
assert.equal(typeof pendingHandler, 'function')
|
||||
|
||||
pendingHandler({
|
||||
results: ['trust']
|
||||
})
|
||||
term = await sessionPromise
|
||||
} finally {
|
||||
term && term.kill()
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,381 @@
|
||||
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 net = require('node:net')
|
||||
const { once } = require('node:events')
|
||||
const findFreePort = require('find-free-port')
|
||||
const FtpSrv = require('@electerm/ftp-srv')
|
||||
const serialportModulePath = require.resolve('serialport')
|
||||
const serialportExports = require(serialportModulePath)
|
||||
const { MockBinding } = require('@serialport/binding-mock')
|
||||
|
||||
const globalState = require('../../src/app/server/global-state')
|
||||
const { Ftp } = require('../../src/app/server/session-ftp')
|
||||
const sessionSerial = require('../../src/app/server/session-serial')
|
||||
const sessionTelnet = require('../../src/app/server/session-telnet')
|
||||
|
||||
const FTP_USERNAME = 'test'
|
||||
const FTP_PASSWORD = 'test123'
|
||||
const SERIAL_PATH = '/dev/electerm-test'
|
||||
const TELNET_USERNAME = 'tester'
|
||||
const TELNET_PASSWORD = 'electerm-test'
|
||||
|
||||
function makeTmpDir (prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||
}
|
||||
|
||||
async function getFreePort (start = 30000, end = 39999) {
|
||||
const [port] = await findFreePort(start, end, '127.0.0.1')
|
||||
return port
|
||||
}
|
||||
|
||||
async function startFtpServer () {
|
||||
const root = makeTmpDir('electerm-ftp-test-')
|
||||
const port = await getFreePort(31000, 31999)
|
||||
const server = new FtpSrv({
|
||||
url: `ftp://127.0.0.1:${port}`,
|
||||
anonymous: false,
|
||||
root
|
||||
})
|
||||
|
||||
server.on('login', ({ username, password }, resolve, reject) => {
|
||||
if (username === FTP_USERNAME && password === FTP_PASSWORD) {
|
||||
return resolve({ root })
|
||||
}
|
||||
return reject(new Error('Invalid username or password'))
|
||||
})
|
||||
|
||||
await server.listen()
|
||||
|
||||
return {
|
||||
port,
|
||||
root,
|
||||
async close () {
|
||||
await server.close()
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startTelnetServer () {
|
||||
const port = await getFreePort(32000, 32999)
|
||||
const sockets = new Set()
|
||||
|
||||
function waitForIdle (timeout = 5000) {
|
||||
if (sockets.size === 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for telnet sockets to close'))
|
||||
}, timeout)
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (sockets.size === 0) {
|
||||
clearTimeout(timer)
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
}
|
||||
}, 25)
|
||||
})
|
||||
}
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.setEncoding('utf8')
|
||||
|
||||
let stage = 'username'
|
||||
let buffer = ''
|
||||
|
||||
socket.write('login: ')
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk
|
||||
|
||||
while (buffer.includes('\n')) {
|
||||
const endIndex = buffer.indexOf('\n')
|
||||
const line = buffer.slice(0, endIndex).replace(/\r$/, '')
|
||||
buffer = buffer.slice(endIndex + 1)
|
||||
|
||||
if (stage === 'username') {
|
||||
if (line === TELNET_USERNAME) {
|
||||
stage = 'password'
|
||||
socket.write('Password: ')
|
||||
} else {
|
||||
socket.write('login incorrect\r\nlogin: ')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (stage === 'password') {
|
||||
if (line === TELNET_PASSWORD) {
|
||||
stage = 'shell'
|
||||
socket.write('Welcome to electerm\r\n$ ')
|
||||
} else {
|
||||
socket.write('Login failed\r\n')
|
||||
socket.end()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
socket.write(`echo:${line}\r\n$ `)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('close', () => {
|
||||
sockets.delete(socket)
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(port, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
|
||||
return {
|
||||
port,
|
||||
waitForIdle,
|
||||
async close () {
|
||||
await waitForIdle().catch(() => {})
|
||||
for (const socket of sockets) {
|
||||
socket.destroy()
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitForText (emitter, matcher, timeout = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = ''
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Timed out waiting for text. Received: ${output}`))
|
||||
}, timeout)
|
||||
|
||||
const onData = (chunk) => {
|
||||
output += chunk.toString()
|
||||
if (matcher(output, chunk)) {
|
||||
cleanup()
|
||||
resolve(output)
|
||||
}
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
cleanup()
|
||||
reject(new Error(`Stream closed before matcher succeeded. Received: ${output}`))
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
emitter.off('data', onData)
|
||||
emitter.off('close', onClose)
|
||||
emitter.off('end', onClose)
|
||||
}
|
||||
|
||||
emitter.on('data', onData)
|
||||
emitter.on('close', onClose)
|
||||
emitter.on('end', onClose)
|
||||
})
|
||||
}
|
||||
|
||||
describe('session-ftp transport flows', () => {
|
||||
let ftpServer
|
||||
let ftp
|
||||
|
||||
beforeEach(async () => {
|
||||
ftpServer = await startFtpServer()
|
||||
ftp = new Ftp({
|
||||
uid: 'ftp-session-ci',
|
||||
host: '127.0.0.1',
|
||||
port: ftpServer.port,
|
||||
user: FTP_USERNAME,
|
||||
password: FTP_PASSWORD,
|
||||
readyTimeout: 5000
|
||||
})
|
||||
await ftp.connect(ftp.initOptions)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (ftp) {
|
||||
ftp.kill()
|
||||
ftp = null
|
||||
}
|
||||
if (ftpServer) {
|
||||
await ftpServer.close()
|
||||
ftpServer = null
|
||||
}
|
||||
})
|
||||
|
||||
test('connects and performs core file operations against ftp-srv', async () => {
|
||||
assert.equal(globalState.getSession(ftp.pid), ftp)
|
||||
|
||||
await ftp.mkdir('/docs')
|
||||
await ftp.writeFile('/docs/hello.txt', 'hello ftp')
|
||||
|
||||
assert.equal(await ftp.readFile('/docs/hello.txt'), 'hello ftp')
|
||||
|
||||
const stats = await ftp.stat('/docs/hello.txt')
|
||||
assert.equal(stats.isDirectory, false)
|
||||
assert.equal(stats.size, 'hello ftp'.length)
|
||||
|
||||
await ftp.cp('/docs/hello.txt', '/docs-copy.txt')
|
||||
assert.equal(await ftp.readFile('/docs-copy.txt'), 'hello ftp')
|
||||
|
||||
const list = await ftp.list('/docs')
|
||||
assert.deepEqual(list.map(item => item.name), ['hello.txt'])
|
||||
})
|
||||
|
||||
test('copies directories recursively and removes them recursively', async () => {
|
||||
await ftp.mkdir('/source')
|
||||
await ftp.mkdir('/source/nested')
|
||||
await ftp.writeFile('/source/root.txt', 'root')
|
||||
await ftp.writeFile('/source/nested/child.txt', 'child-data')
|
||||
|
||||
assert.equal(await ftp.cp('/source', '/copied'), 1)
|
||||
assert.equal(await ftp.readFile('/copied/root.txt'), 'root')
|
||||
assert.equal(await ftp.readFile('/copied/nested/child.txt'), 'child-data')
|
||||
|
||||
const size = await ftp.getFolderSize('/copied')
|
||||
assert.deepEqual(size, {
|
||||
size: 'root'.length + 'child-data'.length,
|
||||
count: 2
|
||||
})
|
||||
|
||||
assert.equal(await ftp.rmdir('/source'), 1)
|
||||
assert.equal(await ftp.tryStat('/source'), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-serial transport flows', () => {
|
||||
let term
|
||||
|
||||
beforeEach(() => {
|
||||
require.cache[serialportModulePath].exports = {
|
||||
...serialportExports,
|
||||
SerialPort: serialportExports.SerialPortMock
|
||||
}
|
||||
MockBinding.reset()
|
||||
MockBinding.createPort(SERIAL_PATH, {
|
||||
echo: true,
|
||||
record: true
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (term && term.port) {
|
||||
const port = term.port
|
||||
const closePromise = once(port, 'close').catch(() => {})
|
||||
term.kill()
|
||||
await closePromise
|
||||
term = null
|
||||
} else if (term) {
|
||||
term.kill()
|
||||
term = null
|
||||
}
|
||||
require.cache[serialportModulePath].exports = serialportExports
|
||||
MockBinding.reset()
|
||||
})
|
||||
|
||||
test('creates a serial session and echoes writes through the mock binding', async () => {
|
||||
term = await sessionSerial.session({
|
||||
uid: 'serial-session-ci',
|
||||
path: SERIAL_PATH,
|
||||
baudRate: 9600
|
||||
})
|
||||
|
||||
assert.equal(globalState.getSession(term.pid), term)
|
||||
|
||||
const dataPromise = once(term.port, 'data')
|
||||
term.write('ping')
|
||||
const [data] = await dataPromise
|
||||
|
||||
assert.equal(data.toString(), 'ping')
|
||||
|
||||
const port = term.port
|
||||
const closePromise = once(port, 'close')
|
||||
term.kill()
|
||||
await closePromise
|
||||
term = null
|
||||
|
||||
assert.equal(globalState.getSession('serial-session-ci'), undefined)
|
||||
})
|
||||
|
||||
test('reports serial connectivity through the exported test helper', async () => {
|
||||
assert.equal(await sessionSerial.test({
|
||||
path: SERIAL_PATH,
|
||||
baudRate: 9600
|
||||
}), true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-telnet transport flows', () => {
|
||||
let telnetServer
|
||||
let term
|
||||
|
||||
beforeEach(async () => {
|
||||
telnetServer = await startTelnetServer()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (term) {
|
||||
term.kill()
|
||||
term = null
|
||||
}
|
||||
if (telnetServer) {
|
||||
await telnetServer.close()
|
||||
telnetServer = null
|
||||
}
|
||||
})
|
||||
|
||||
test('connects, authenticates with regex prompts, and exchanges shell data', async () => {
|
||||
term = await sessionTelnet.session({
|
||||
uid: 'telnet-session-ci',
|
||||
host: '127.0.0.1',
|
||||
port: telnetServer.port,
|
||||
username: TELNET_USERNAME,
|
||||
password: TELNET_PASSWORD,
|
||||
loginPrompt: '/login[: ]*$/i',
|
||||
passwordPrompt: '/password[: ]*$/i',
|
||||
readyTimeout: 5000
|
||||
})
|
||||
term.channel.on('error', () => {})
|
||||
term.channel.socket.on('error', () => {})
|
||||
|
||||
assert.equal(globalState.getSession(term.pid), term)
|
||||
|
||||
const banner = await waitForText(term.port, (output) => {
|
||||
return output.includes('Welcome to electerm')
|
||||
})
|
||||
assert.match(banner, /welcome to electerm/i)
|
||||
|
||||
const responsePromise = waitForText(term.port, (output) => {
|
||||
return output.includes('echo:status')
|
||||
})
|
||||
term.write('status\n')
|
||||
const response = await responsePromise
|
||||
|
||||
assert.match(response, /echo:status/)
|
||||
|
||||
term.resize(132, 43)
|
||||
assert.equal(term.channel.options.terminalWidth, 132)
|
||||
assert.equal(term.channel.options.terminalHeight, 43)
|
||||
|
||||
term.kill()
|
||||
await telnetServer.waitForIdle()
|
||||
term = null
|
||||
|
||||
assert.equal(globalState.getSession('telnet-session-ci'), undefined)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
const { describe, test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
describe('terminal OSC color query helpers', () => {
|
||||
test('builds OSC color responses from theme hex colors', async () => {
|
||||
const { buildOscColorResponse } = await import('../../src/client/components/terminal/terminal-color-query.mjs')
|
||||
|
||||
assert.strictEqual(
|
||||
buildOscColorResponse(11, '#20111b'),
|
||||
'\x1b]11;rgb:20/11/1b\x1b\\'
|
||||
)
|
||||
})
|
||||
|
||||
test('falls back when a transparent theme color cannot describe the visible background', async () => {
|
||||
const { buildOscColorResponse } = await import('../../src/client/components/terminal/terminal-color-query.mjs')
|
||||
|
||||
assert.strictEqual(
|
||||
buildOscColorResponse(11, 'rgba(0, 0, 0, 0)', '#121214'),
|
||||
'\x1b]11;rgb:12/12/14\x1b\\'
|
||||
)
|
||||
})
|
||||
|
||||
test('only handles query payloads when registering xterm OSC handlers', async () => {
|
||||
const { handleTerminalColorQuery } = await import('../../src/client/components/terminal/terminal-color-query.mjs')
|
||||
const sent = []
|
||||
const terminal = {
|
||||
input: (data, wasUserInput) => sent.push({ data, wasUserInput })
|
||||
}
|
||||
|
||||
assert.equal(handleTerminalColorQuery(terminal, 11, '#20111b', null, '#20111b'), false)
|
||||
assert.deepEqual(sent, [])
|
||||
|
||||
assert.equal(handleTerminalColorQuery(terminal, 11, '#20111b', null, '?'), true)
|
||||
assert.deepEqual(sent, [
|
||||
{
|
||||
data: '\x1b]11;rgb:20/11/1b\x1b\\',
|
||||
wasUserInput: false
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('keeps xterm transparent except when webgl needs an opaque clear background', async () => {
|
||||
const { createRendererThemeConfig } = await import('../../src/client/components/terminal/terminal-color-query.mjs')
|
||||
const themeConfig = {
|
||||
foreground: '#bbbbbb',
|
||||
background: '#20111b'
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
createRendererThemeConfig(themeConfig, 'canvas', '#121214'),
|
||||
{
|
||||
foreground: '#bbbbbb',
|
||||
background: 'rgba(0,0,0,0)'
|
||||
}
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
createRendererThemeConfig(themeConfig, 'webGL', '#121214'),
|
||||
{
|
||||
foreground: '#bbbbbb',
|
||||
background: '#121214'
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user