chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:46:20 +08:00
commit eb21b65815
830 changed files with 113645 additions and 0 deletions
+114
View File
@@ -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)
})
})
+504
View File
@@ -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))
}
})
})
+580
View File
@@ -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()
}
})
})
+381
View File
@@ -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)
})
})
+65
View File
@@ -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'
}
)
})
})