// Row 210 (#1707, design section 8): host.run over a scratch sshd on 127.0.0.1 that this test starts, // with keys this test makes and a fake sudo that records its argv and runs the command as the test // user. Nothing runs as root and nothing reaches another host. import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, spawn } from 'node:child_process'; import { createServer } from 'node:net'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir, userInfo } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Credentials, parseHostKey } from '../src/credentials.mjs'; import { addKey, hostExecutor, runHost, sshArgs, startTime, sweepRunDir, quote, remoteCommand } from '../src/host-run.mjs'; import { fixture } from './spec-fixtures.mjs'; import { HUMAN_YES, hostGrantRecord, hostRecord } from './host-fixtures.mjs'; import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs'; import { Store } from '../src/store.mjs'; import { hostGrant } from '../src/host-grant.mjs'; import { startBroker } from '../src/runtime.mjs'; import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs'; import { ephemeralRange, installSleepStub, pickPort, startScratchSshd } from './scratch-sshd.mjs'; const SSHD = '/usr/bin/sshd'; const REPO = fileURLToPath(new URL('../../../', import.meta.url)); const USER = userInfo().username; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const keygen = (file, ...extra) => execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', ...extra, '-f', file]); const pubLine = (file) => readFileSync(file + '.pub', 'utf8').trim().split(' ').slice(0, 2).join(' '); // Every pid whose argv names `text`, with its argv and environment. ssh-agent makes itself // non-dumpable, so its environ reads as null. Its environment is the PATH runHost passes. const readOrNull = (file) => { try { return readFileSync(file, 'utf8'); } catch { return null; } }; function procsNaming(text) { const out = []; for (const pid of readdirSync('/proc').filter((p) => /^\d+$/.test(p))) { const cmdline = readOrNull(`/proc/${pid}/cmdline`); if (cmdline?.includes(text)) out.push({ pid: Number(pid), cmdline, environ: readOrNull(`/proc/${pid}/environ`) }); } return out; } async function until(fn, ms = 5000) { const end = Date.now() + ms; while (!fn()) { if (Date.now() > end) return false; await sleep(20); } return true; } // A scratch sshd. ForceCommand puts the fake sudo first on PATH and hands the command to /bin/sh, so // the remote side parses the command line the way a real host's POSIX shell does. async function sshd(t, dir, options) { if (!existsSync(SSHD)) throw new Error(`${SSHD} is missing: the executor tests need it and fail, never skip`); keygen(join(dir, 'hostkey')); keygen(join(dir, 'otherhost')); keygen(join(dir, 'user')); writeFileSync(join(dir, 'authorized_keys'), readFileSync(join(dir, 'user.pub')), { mode: 0o600 }); mkdirSync(join(dir, 'fake')); installSleepStub(dir); writeFileSync( join(dir, 'fake', 'sudo'), `#!/bin/sh\n'${process.execPath}' -e 'require("fs").appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + "\\n")' '${dir}/argv.json' "$@"\nshift 2\nexec "$@"\n`, ); chmodSync(join(dir, 'fake', 'sudo'), 0o755); // A remote command that prints ssh's pin refusal text and exits 255 (N3). writeFileSync(join(dir, 'fake', 'fail255'), '#!/bin/sh\necho "Host key verification failed." >&2\nexit 255\n', { mode: 0o755 }); writeFileSync(join(dir, 'wrap.sh'), `#!/bin/sh\nPATH=${dir}/fake:/usr/bin:/bin; export PATH\nexec /bin/sh -c "$SSH_ORIGINAL_COMMAND"\n`); chmodSync(join(dir, 'wrap.sh'), 0o755); return startScratchSshd(t, dir, [ 'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`, `AuthorizedKeysFile ${dir}/authorized_keys`, `PidFile ${dir}/sshd.pid`, 'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no', `ForceCommand ${dir}/wrap.sh`, ], options); } test('row 280: an occupied TCP port is attempted, refused, and retried with our sshd', async t => { const dir = mkdtempSync(join(tmpdir(), 'sshd280-')); t.after(() => rmSync(dir, { recursive: true, force: true })); const server = createServer(socket => socket.end()); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); t.after(() => new Promise(resolve => server.close(resolve))); const occupied = server.address().port; const port = await sshd(t, dir, { firstPort: occupied }); assert.match(readFileSync(join(dir, 'sshd-0.conf'), 'utf8'), new RegExp(`^Port ${occupied}\\n`)); assert.equal(readFileSync(join(dir, 'sshd-0.log'), 'utf8').includes(`Server listening on 127.0.0.1 port ${occupied}.`), false); assert.notEqual(port, occupied); assert.ok([1, 2, 3, 4].some(n => existsSync(join(dir, `sshd-${n}.log`)) && readFileSync(join(dir, `sshd-${n}.log`), 'utf8').includes(`Server listening on 127.0.0.1 port ${port}.`))); }); test('row 280: every injected picker sample is unprivileged and outside the ephemeral range', () => { const [lo, hi] = ephemeralRange(); for (const sample of [0, 0.25, 0.5, 0.6384, 0.75, 0.999999]) { const port = pickPort(() => sample); assert.ok(port >= 1024 && port <= 65535); assert.ok(port < lo || port > hi, `sample ${sample}: ${port} inside ${lo}-${hi}`); } }); const GRANT = [['true'], ['false'], ['echo'], ['sleep'], ['head'], ['cat'], ['systemctl', 'status']]; async function hostFixture(t, { commands = GRANT, reviewerHosts, wrongHostKey = false, coderWeb2 = false } = {}) { const dir = mkdtempSync(join(tmpdir(), 'h210-')); t.after(() => rmSync(dir, { recursive: true, force: true })); const port = await sshd(t, dir); const keyFile = join(dir, 'user'); const credentials = new Credentials({ references: { 'demo/coder': { 'host.web1': { file: keyFile, rotateBy: '2099-01-01' } } }, dataRoot: join(dir, 'data'), }); t.after(() => credentials.close()); const root = join(dir, 'run'); const executor = hostExecutor({ credentials, root }); const entry = { address: '127.0.0.1', port, user: USER, hostKeys: [pubLine(join(dir, wrongHostKey ? 'otherhost' : 'hostkey'))] }; const authority = { withinRole: ['message.send', 'record.write'], crossRole: [] }; const f = await fixture(t, { roles: { coder: { definition: 'coder', authority } } }); // Row 234 (#1740, r6 X4): host.run reads store records, so the hosts and grants are fixtures there. for (const host of ['web1', 'web2']) hostRecord(f.store, { host, entry }); hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands }); if (coderWeb2) hostGrantRecord(f.store, { agent: 'coder', host: 'web2', commands }); for (const [host, g] of Object.entries(reviewerHosts ?? {})) hostGrantRecord(f.store, { agent: 'reviewer', host, commands: g.commands }); const approve = async (argv, stdin, host = 'web1') => { const d = await f.raise({ action: 'host.sudo', target: host, argv, ...(stdin !== undefined ? { stdin } : {}) }); f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }); return d.id; }; const run = (args, cap = f.coder) => f.broker.requestHost(cap, { verb: 'host.run', args }, executor); const call = async (argv, { stdin, timeout } = {}) => run({ host: 'web1', argv, ...(stdin !== undefined ? { stdin } : {}), ...(timeout ? { timeout } : {}), decision: await approve(argv, stdin) }); const events = () => f.store.all("SELECT kind, body FROM events WHERE business='demo' ORDER BY seq").map((e) => ({ kind: e.kind, ...JSON.parse(e.body) })); const argvSeen = () => (existsSync(join(dir, 'argv.json')) ? readFileSync(join(dir, 'argv.json'), 'utf8').trim().split('\n').map((l) => JSON.parse(l)) : []); const key = parseHostKey(readFileSync(keyFile, 'utf8')); return { ...f, dir, root, port, entry, keyFile, key, credentials, executor, approve, run, call, events, argvSeen }; } const refusesWith = async (p, code) => assert.rejects(p, (e) => (assert.equal(e.code, code), true)); test('the executor tests need sshd, ssh, ssh-agent, ssh-add, ssh-keygen and setpriv: a missing one fails, never skips', () => { for (const bin of [SSHD, '/usr/bin/ssh', '/usr/bin/ssh-agent', '/usr/bin/ssh-add', '/usr/bin/ssh-keygen', '/usr/bin/setpriv']) assert.ok(existsSync(bin), `${bin} is missing`); }); test('T3: organization.set, limits.set and instances.set refuse hosts, and the writer never runs', (t) => { const root = mkdtempSync(join(tmpdir(), 'h210-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const chiefAuthority = JSON.parse(readFileSync(join(REPO, 'roles', 'chief-of-staff.json'), 'utf8')).authority; const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, definitions: {}, roles: { chief: { definition: 'chief-of-staff', authority: chiefAuthority } }, projects: {}, }, }; const calls = []; const b = new Broker({ store, businesses, organization: (...a) => (calls.push(a), { ok: true }) }); const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }); b.request(chief, { verb: 'role.claim' }); const revision = 'a'.repeat(64); const hosts = { web1: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } }; assert.equal(ORGANIZATION_KEYS.includes('hosts'), false); assert.throws(() => b.request(chief, { verb: 'organization.set', args: { revision, hosts } }), /invalid-request/); assert.throws(() => b.request(human, { verb: 'limits.set', args: { revision, hosts } }), /invalid-request/); assert.throws(() => b.request(human, { verb: 'instances.set', args: { revision, instance: 'chief', op: 'change', hosts } }), /invalid-request/); assert.equal(calls.length, 0); // Controls: the same calls without hosts reach the writer. b.request(chief, { verb: 'organization.set', args: { revision, models: {} } }); b.request(human, { verb: 'limits.set', args: { revision, models: {} } }); b.request(human, { verb: 'instances.set', args: { revision, instance: 'chief', op: 'change', launch: true } }); // organization.set reaches the writer as limits.set (broker.mjs:1379). assert.deepEqual(calls.map((c) => c[1]), ['limits.set', 'limits.set', 'instances.set']); }); test('T4: the value check takes one unencrypted ed25519 key and nothing else', () => { const dir = mkdtempSync(join(tmpdir(), 'h210-')); try { keygen(join(dir, 'good')); execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', 'secret-pass', '-f', join(dir, 'enc')]); execFileSync('ssh-keygen', ['-q', '-t', 'rsa', '-b', '2048', '-N', '', '-f', join(dir, 'rsa')]); const good = readFileSync(join(dir, 'good'), 'utf8'); // Two keys: the same blob with nkeys set to 2. const lines = good.trim().split('\n'); const blob = Buffer.from(lines.slice(1, -1).join(''), 'base64'); // magic (15), cipher "none" (4+4), kdf "none" (4+4), empty kdfoptions (4), then nkeys. blob.writeUInt32BE(2, 15 + 8 + 8 + 4); const pem = (b) => [lines[0], ...b.toString('base64').match(/.{1,70}/g), lines.at(-1)].join('\n') + '\n'; // One check each: a field renamed in place, so every other check still passes. Offsets: magic 15, // cipher name 19, kdf name 27, nkeys 35, public type 47, private section 98, its type 110. const renamed = (at, text) => { const copy = Buffer.from(lines.slice(1, -1).join(''), 'base64'); copy.write(text, at, 'latin1'); return pem(copy); }; const write = (name, text) => (writeFileSync(join(dir, name), text, { mode: 0o600 }), join(dir, name)); const load = (file, service = 'host.web1') => new Credentials({ references: { 'demo/coder': { [service]: { file, rotateBy: '2099-01-01' } } }, dataRoot: join(dir, 'data') }); const ok = load(join(dir, 'good')); assert.match(ok.status()[0].fingerprint, /^SHA256:/); assert.equal(ok.status()[0].publicKey, pubLine(join(dir, 'good'))); ok.close(); for (const file of [ join(dir, 'enc'), join(dir, 'rsa'), write('two', pem(blob)), write('cipher', renamed(19, 'aes1')), write('kdf', renamed(27, 'bcry')), write('public-type', renamed(47, 'ssh-ed25518')), write('private-type', renamed(110, 'ssh-ed25518')), write('crlf', good.replaceAll('\n', '\r\n')), write('token', 'fake_gitea_token_0123456789\n'), ]) assert.throws(() => load(file), /credential-format/, file); assert.throws(() => load(join(dir, 'good'), 'gitea'), /credential-format/); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('T5 to T8: registry, grant, argv shape and allowlist refusals', async (t) => { const f = await hostFixture(t, { reviewerHosts: { web2: { commands: [['true']] } } }); await refusesWith(f.run({ host: 'web9', argv: ['true'] }), 'host-unknown'); await refusesWith(f.run({ host: 'web2', argv: ['true'] }), 'host-not-granted'); for (const argv of [[], ['a\0'], ['a'.repeat(4097)], Array(257).fill('true'), ['bin/true'], [7], 'true']) await refusesWith(f.run({ host: 'web1', argv }), 'invalid-request'); await refusesWith(f.run({ host: 'web1', argv: ['true'], stdin: 'x'.repeat(65537) }), 'invalid-request'); await refusesWith(f.run({ host: 'web1', argv: ['true'], timeout: 901 }), 'invalid-request'); await refusesWith(f.run({ host: 'web1', argv: ['systemctl'] }), 'command-not-granted'); await refusesWith(f.run({ host: 'web1', argv: ['systemctl', 'restart', 'nginx'] }), 'command-not-granted'); await refusesWith(f.run({ host: 'web1', argv: ['ls'] }), 'command-not-granted'); // N1: the raise refuses the same way, so the human is never asked about a call host.run refuses. assert.throws(() => f.raise({ action: 'host.sudo', target: 'web2', argv: ['true'] }), /host-not-granted/); assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['ls'] }), /command-not-granted/); assert.equal(f.store.get("SELECT count(*) AS n FROM decisions WHERE action='host.sudo'").n, 0); // Granted prefixes run, a longer argv too. await f.call(['systemctl', 'status']); assert.equal((await f.call(['echo', 'hi', 'there'])).stdout, 'hi there\n'); assert.deepEqual(f.argvSeen(), [['-n', '--', 'systemctl', 'status'], ['-n', '--', 'echo', 'hi', 'there']]); const refused = f.events().filter((e) => e.kind === 'action.refused' && e.verb === 'host.run').map((e) => e.code); assert.ok(refused.includes('host-unknown') && refused.includes('command-not-granted')); }); test('T9: under 1.7 every call needs a decision bound to its host, argv and stdin, spent once', async (t) => { const f = await hostFixture(t, { coderWeb2: true }); await refusesWith(f.run({ host: 'web1', argv: ['true'] }), 'decision-required'); const other = await f.approve(['echo', 'a']); await refusesWith(f.run({ host: 'web1', argv: ['echo', 'b'], decision: other }), 'decision-mismatch'); const stdin = await f.approve(['cat'], 'one'); await refusesWith(f.run({ host: 'web1', argv: ['cat'], stdin: 'two', decision: stdin }), 'decision-mismatch'); await refusesWith(f.run({ host: 'web1', argv: ['cat'], decision: stdin }), 'decision-mismatch'); const host = await f.approve(['true'], undefined, 'web2'); await refusesWith(f.run({ host: 'web1', argv: ['true'], decision: host }), 'decision-mismatch'); const once = await f.approve(['cat'], 'one'); assert.equal((await f.run({ host: 'web1', argv: ['cat'], stdin: 'one', decision: once })).stdout, 'one'); await refusesWith(f.run({ host: 'web1', argv: ['cat'], stdin: 'one', decision: once }), 'decision-consumed'); // An unresolved decision doesn't run. const open = (await f.raise({ action: 'host.sudo', target: 'web1', argv: ['true'] })).id; await refusesWith(f.run({ host: 'web1', argv: ['true'], decision: open }), 'decision-not-approved'); // The human sees host, user, each argv element and all of stdin, appended by the broker. const q = f.store.get('SELECT question FROM decisions WHERE id=?', once).question; assert.ok(q.includes(`Host web1, ${USER} at 127.0.0.1 port ${f.port}`), q); assert.ok(q.includes('argv: "cat"'), q); assert.ok(q.endsWith('stdin: "one"'), q); assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['cat'], stdin: 'x'.repeat(4096) }), /invalid-request/); assert.throws(() => f.raise({ action: 'host.sudo', target: 'web9', argv: ['cat'] }), /host-unknown/); assert.throws(() => f.raise({ action: 'deploy', argv: ['cat'] }), /invalid-request/); assert.equal(f.store.get("SELECT class FROM decisions WHERE id=?", once).class, 'gated'); }); test('T10, T11: argv reaches sudo unchanged after sudo -n --', async (t) => { const f = await hostFixture(t); const argv = ['true', 'a b', "it's", '"q"', '$(touch x)', '`id`', 'a;b', 'line\nbreak', '*', '?[a]', '$HOME', '\\']; assert.equal((await f.call(argv)).exit, 0); assert.deepEqual(f.argvSeen(), [['-n', '--', ...argv]]); assert.equal(existsSync(join(f.dir, 'x')), false); assert.equal(quote("it's"), "'it'\\''s'"); assert.equal(remoteCommand(['a b']), "sudo -n -- 'a b'"); }); test('T12, T13: exit code passes through, the timeout kills the group, output is capped', async (t) => { const f = await hostFixture(t); assert.equal((await f.call(['false'])).exit, 1); const began = Date.now(); const slow = await f.call(['sleep', '30'], { timeout: 1 }); // D6: the timeout answers signal. ssh exits 255 on SIGTERM, which the result can't tell from a remote 255. assert.equal(slow.signal, 'SIGTERM'); assert.ok(Date.now() - began < 10000); const big = await f.call(['head', '-c', '2097152', '/dev/zero']); assert.equal(Buffer.byteLength(big.stdout), 1024 * 1024); assert.deepEqual(big.truncated, { stdout: true, stderr: false }); }); test('T14: a host key not in the registry refuses and nothing runs', async (t) => { const f = await hostFixture(t, { wrongHostKey: true }); await refusesWith(f.call(['true']), 'host-key-mismatch'); assert.deepEqual(f.argvSeen(), []); }); // Row 234 (#1740, r6 G18, duckworth-0081 B2): hosts.check's `list` goes through runHost with the record's // pinned keys. A stub `list` on the scratch sshd's PATH records each run. test('G18: hosts.check with a wrong pin refuses host-key-mismatch and runs nothing on the host', async (t) => { const f = await hostFixture(t); writeFileSync(join(f.dir, 'fake', 'list'), `#!/bin/sh\necho "$@" >> '${f.dir}/list.log'\n`, { mode: 0o755 }); const configDir = join(f.dir, 'cfg'); const keyDir = join(configDir, 'secrets', 'hosts', 'demo', 'web1'); mkdirSync(keyDir, { recursive: true, mode: 0o700 }); for (const d of [join(configDir, 'secrets'), join(configDir, 'secrets', 'hosts'), join(configDir, 'secrets', 'hosts', 'demo'), keyDir]) chmodSync(d, 0o700); writeFileSync(join(keyDir, 'installer'), readFileSync(f.keyFile), { mode: 0o600 }); const hg = hostGrant({ configDir, runRoot: f.root }); const record = { address: '127.0.0.1', port: f.port, user: 'jarvis', hostKeys: [pubLine(join(f.dir, 'otherhost'))] }; await refusesWith(hg.check('demo', 'web1', record), 'host-key-mismatch'); assert.equal(existsSync(join(f.dir, 'list.log')), false); // Control: with the right pin the same command reaches the stub. const out = await runHost({ root: f.root, hostId: 'web1', entry: f.entry, key: f.key, timeout: 10, command: 'list jarvis' }); assert.equal(out.exit, 0); assert.equal(readFileSync(join(f.dir, 'list.log'), 'utf8'), 'jarvis\n'); }); test('N3: a remote exit 255 that prints the pin refusal text is the command result, and an ssh failure keeps its message', async (t) => { const f = await hostFixture(t, { commands: [['fail255']] }); const out = await f.call(['fail255']); assert.equal(out.exit, 255); assert.match(out.stderr, /Host key verification failed/); assert.deepEqual(f.argvSeen(), [['-n', '--', 'fail255']]); // A closed port: ssh's message reaches the agent from the run dir's log. const closed = await runHost({ root: f.root, hostId: 'web1', entry: { ...f.entry, port: 1 }, key: f.key, argv: ['true'], stdin: '', timeout: 10 }); assert.equal(closed.exit, 255); assert.match(closed.stderr, /port 1: Connection refused/); }); test('T15: ssh reads no config file and no user known_hosts', async (t) => { // ssh finds ~/.ssh from the passwd entry, not $HOME, and the test may not write the real home, so // the options are checked here and the call runs with an alias no user file holds (BUILD.md). const args = sshArgs({ hostId: 'web1', address: '10.0.0.5', port: 22, user: 'admin', dir: '/r' }); assert.deepEqual(args.slice(0, 2), ['-F', '/dev/null']); assert.deepEqual(args.slice(3, 5), ['-E', '/r/ssh.log']); for (const o of ['StrictHostKeyChecking=yes', 'UserKnownHostsFile=/r/known_hosts', 'GlobalKnownHostsFile=/dev/null', 'HostKeyAlias=mosaic-host-web1', 'BatchMode=yes', 'IdentitiesOnly=yes', 'ForwardAgent=no', 'ProxyCommand=none', 'ControlMaster=no']) assert.ok(args.includes(o), o); assert.deepEqual(args.slice(-4), ['-l', 'admin', '--', '10.0.0.5']); const f = await hostFixture(t); assert.equal((await f.call(['true'])).exit, 0); }); test('T16, T17: key material in output or stdin refuses credential-leak, and no event holds it', async (t) => { const f = await hostFixture(t, { commands: [['cat']] }); const line = f.key.text.split('\n')[2]; writeFileSync(join(f.dir, 'one-line'), line + '\n'); writeFileSync(join(f.dir, 'seed-hex'), f.key.seed.toString('hex') + '\n'); for (const file of [f.keyFile, join(f.dir, 'one-line'), join(f.dir, 'seed-hex')]) await refusesWith(f.call(['cat', file]), 'credential-leak'); const ran = f.argvSeen().length; await refusesWith(f.call(['cat'], { stdin: 'x' + line }), 'credential-leak'); assert.equal(f.argvSeen().length, ran, 'nothing ran for a stdin leak'); const text = JSON.stringify(f.events()); for (const needle of [line, f.key.seed.toString('hex')]) assert.equal(text.includes(needle), false); const failed = f.events().filter((e) => e.action === 'host.sudo' && e.phase === 'completed'); assert.ok(failed.every((e) => e.outcome === 'failed' && e.code === 'credential-leak')); }); test('T18, T19, T20: one call at a time, no key in any argv or env, and nothing left after', async (t) => { const f = await hostFixture(t); const first = f.call(['sleep', '3']); assert.ok(await until(() => f.argvSeen().length === 1), 'fake sudo started'); const procs = procsNaming(f.root); assert.ok(procs.length >= 2, 'ssh-agent and ssh name the run dir'); const needles = [...f.key.needles, f.key.text]; assert.ok(procs.some((p) => p.environ !== null), 'ssh environ read'); for (const p of procs) for (const n of needles) assert.equal(p.cmdline.includes(n) || (p.environ ?? '').includes(n), false); // host-busy refuses before the decision is spent, so it still runs after. const held = await f.approve(['true']); await refusesWith(f.run({ host: 'web1', argv: ['true'], decision: held }), 'host-busy'); assert.equal((await first).exit, 0); assert.equal((await f.run({ host: 'web1', argv: ['true'], decision: held })).exit, 0); // Success, a refusal, a timeout and a thrown error each leave no run dir and no process. await refusesWith(f.call(['cat', f.keyFile]), 'credential-leak'); await f.call(['sleep', '30'], { timeout: 1 }); await assert.rejects( runHost({ root: f.root, hostId: 'web1', entry: { ...f.entry, hostKeys: null }, key: f.key, argv: ['true'], stdin: '', timeout: 5 }), ); assert.deepEqual(readdirSync(f.root), []); assert.ok(await until(() => procsNaming(f.root).length === 0, 2000)); }); test('T21: revoke kills the call in flight, and the next call refuses', async (t) => { const f = await hostFixture(t); const slow = f.call(['sleep', '30']); assert.ok(await until(() => f.argvSeen().length === 1)); f.credentials.revoke('demo/coder', 'host.web1'); await refusesWith(slow, 'credential-unavailable'); await refusesWith(f.call(['true']), 'credential-unavailable'); assert.ok(await until(() => procsNaming(f.root).length === 0, 2000)); }); test('T22: events carry host, address, user and argv in clear, and sizes and hashes only', async (t) => { const f = await hostFixture(t); await f.call(['cat'], { stdin: 'secret-ish stdin' }); const [requested, completed] = f.events().filter((e) => e.action === 'host.sudo' && e.phase); assert.deepEqual( { host: requested.host, address: requested.address, user: requested.user, argv: requested.argv, stdinBytes: requested.stdinBytes }, { host: 'web1', address: '127.0.0.1', user: USER, argv: ['cat'], stdinBytes: 16 }, ); assert.match(requested.stdinSha256, /^[0-9a-f]{64}$/); assert.equal(completed.outcome, 'ran'); assert.equal(completed.stdoutBytes, 16); assert.match(completed.stdoutSha256, /^[0-9a-f]{64}$/); assert.equal(JSON.stringify(f.events()).includes('secret-ish stdin'), false); }); test('T24: no literal gitea and vikunja kind list outside vocabulary.mjs, and the credential use sites are known', () => { const files = readdirSync(join(REPO, 'packages')).flatMap((pkg) => { const src = join(REPO, 'packages', pkg, 'src'); return existsSync(src) ? readdirSync(src).filter((n) => n.endsWith('.mjs')).map((n) => `packages/${pkg}/src/${n}`) : []; }); const code = (file) => readFileSync(join(REPO, file), 'utf8').split('\n').filter((l) => !/^\s*(\/\/|\*)/.test(l)).join('\n'); const lists = files.filter((file) => !file.endsWith('organization/src/vocabulary.mjs') && /\[\s*['"]gitea['"]\s*,\s*['"]vikunja['"]\s*\]/.test(code(file))); assert.deepEqual(lists, []); const uses = files.filter((file) => /credentials\.(use|useFor)\(/.test(code(file))).sort(); assert.deepEqual(uses, ['packages/bus/src/host-run.mjs', 'packages/bus/src/runtime.mjs', 'packages/tasks/src/adapter.mjs']); }); test('T25: no role definition lists host.sudo while it is a hard wait', () => { const dir = join(REPO, 'roles'); for (const name of readdirSync(dir).filter((n) => n.endsWith('.json'))) { const role = JSON.parse(readFileSync(join(dir, name), 'utf8')); const authority = role.authority ?? {}; for (const list of [authority.withinRole ?? [], authority.crossRole ?? []]) assert.equal(list.includes('host.sudo'), false, name); } }); test('T27: the bus child dying takes ssh and ssh-agent with it', async (t) => { const f = await hostFixture(t); // A separate bus child runs one call; the test SIGKILLs it while fake sudo sleeps. const script = join(f.dir, 'child.mjs'); const src = new URL('../src/', import.meta.url).href; writeFileSync(script, ` import { Credentials } from '${src}credentials.mjs'; import { hostExecutor } from '${src}host-run.mjs'; const credentials = new Credentials({ references: { 'demo/coder': { 'host.web1': { file: ${JSON.stringify(f.keyFile)}, rotateBy: '2099-01-01' } } }, dataRoot: ${JSON.stringify(join(f.dir, 'data'))} }); const executor = hostExecutor({ credentials, root: ${JSON.stringify(join(f.dir, 'run2'))} }); await executor.run({ business: 'demo', role: 'coder' }, 'web1', ${JSON.stringify(f.entry)}, { argv: ['sleep', '60'], stdin: '', timeout: 120 }); `); const child = spawn(process.execPath, [script], { stdio: 'ignore' }); t.after(() => child.kill('SIGKILL')); assert.ok(await until(() => f.argvSeen().length === 1, 10000), 'fake sudo started'); const [run] = readdirSync(join(f.dir, 'run2')); const pids = readFileSync(join(f.dir, 'run2', run, 'pids'), 'utf8').trim().split('\n').map((l) => Number(l.split(' ')[0])); assert.equal(pids.length, 3); assert.equal(pids[0], child.pid); child.kill('SIGKILL'); assert.ok(await until(() => pids.slice(1).every((p) => !existsSync(`/proc/${p}`)), 2000), 'ssh and ssh-agent are gone'); // The next start sweeps the dir. assert.deepEqual(sweepRunDir(join(f.dir, 'run2')).removed, [run]); assert.deepEqual(readdirSync(join(f.dir, 'run2')), []); }); test('T27: ssh-add -t drops the key from an agent the test keeps alive', async (t) => { const dir = mkdtempSync(join(tmpdir(), 'h210-')); t.after(() => rmSync(dir, { recursive: true, force: true })); keygen(join(dir, 'k')); const sock = join(dir, 'a.sock'); const agent = spawn('ssh-agent', ['-D', '-a', sock], { stdio: 'ignore' }); t.after(() => agent.kill('SIGKILL')); assert.ok(await until(() => existsSync(sock))); // A failed list names its cause: when each step ran on the monotonic clock (hrtime) and on // /proc/uptime (CLOCK_BOOTTIME, which ssh-agent's lifetime counts in whole seconds), and // whether the agent and its socket were still there. const marks = []; const mark = (name) => marks.push({ name, ns: process.hrtime.bigint(), uptime: readFileSync('/proc/uptime', 'utf8').split(' ')[0] }); const timing = () => { const at = (m) => `${m.name} +${(Number(m.ns - marks[0].ns) / 1e6).toFixed(1)} ms (hrtime ${(Number(m.ns % 1_000_000_000n) / 1e9).toFixed(4)} s into its second, uptime ${m.uptime})`; const alive = agent.exitCode === null && agent.signalCode === null; return `${marks.map(at).join('; ')}; agent ${alive ? 'alive' : `gone (${agent.exitCode ?? agent.signalCode})`}, socket ${existsSync(sock) ? 'present' : 'missing'}`; }; // ssh-agent sets a key's death to the whole second of CLOCK_BOOTTIME at the add plus the // lifetime, and drops it once that whole second is reached. So `-t N` keeps a key between N - 1 // and N seconds: `-t 1` added just before a second boundary was gone within milliseconds (#453). // `-t 2` keeps it at least 1 s, and the sleep waits past the 2 s ceiling. mark('addKey start'); await addKey(sock, readFileSync(join(dir, 'k'), 'utf8'), 2); mark('addKey resolved'); const list = (name) => { mark(name); try { return execFileSync('ssh-add', ['-l'], { env: { PATH: '/usr/bin:/bin', SSH_AUTH_SOCK: sock }, encoding: 'utf8' }); } catch (e) { return e.stdout; } }; const first = list('first list'); assert.match(first, /SHA256:/, `first list got ${JSON.stringify(first)}: ${timing()}`); await sleep(3500); const second = list('second list'); assert.match(second, /no identities/, `second list got ${JSON.stringify(second)}: ${timing()}`); }); // The test's own limit makes a hung addKey (M37a, M37b) fail in seconds, not at the batch timeout. test('N2: a hung ssh-add is killed at its limit or on abort, and nothing is left running', { timeout: 10000 }, async (t) => { const dir = mkdtempSync(join(tmpdir(), 'h210-')); t.after(() => rmSync(dir, { recursive: true, force: true })); keygen(join(dir, 'k')); const text = readFileSync(join(dir, 'k'), 'utf8'); // An agent socket that accepts and never answers. const closed = []; const server = createServer((c) => c.resume().on('close', () => closed.push(Date.now()))); const sock = join(dir, 'a.sock'); await new Promise((r) => server.listen(sock, r)); t.after(() => server.close()); const began = Date.now(); await refusesWith(addKey(sock, text, 1, { limitMs: 300 }), 'host-failed'); assert.ok(Date.now() - began < 2000); const controller = new AbortController(); setTimeout(() => controller.abort(), 100); await refusesWith(addKey(sock, text, 1, { limitMs: 60000, signal: controller.signal }), 'credential-unavailable'); // Both ssh-add processes are gone: the socket saw each connection close. assert.ok(await until(() => closed.length === 2), `closed ${closed.length}`); }); test('T27: the start sweep keeps a live bus dir and kills only a matching live process', async (t) => { const root = mkdtempSync(join(tmpdir(), 'h210-run-')); t.after(() => rmSync(root, { recursive: true, force: true })); chmodSync(root, 0o700); const dead = spawn('true'); await new Promise((r) => dead.on('close', r)); const make = (name, lines) => { mkdirSync(join(root, name), { mode: 0o700 }); writeFileSync(join(root, name, 'pids'), lines.map((l) => l.join(' ') + '\n').join('')); return join(root, name); }; // A dir whose bus is gone, with a planted live process that names it. const gone = join(root, 'h-aaaaaa'); const planted = spawn('/bin/sh', ['-c', 'sleep 60; :', gone], { stdio: 'ignore', detached: true }); t.after(() => { try { process.kill(planted.pid, 'SIGKILL'); } catch {} }); await until(() => startTime(planted.pid) !== null); make('h-aaaaaa', [[dead.pid, 'x'], [planted.pid, startTime(planted.pid)]]); // A dir whose bus is alive. make('h-bbbbbb', [[process.pid, startTime(process.pid)]]); // A dir whose bus is gone and whose recorded pid is alive but with another start time. const other = join(root, 'h-cccccc'); const decoy = spawn('/bin/sh', ['-c', 'sleep 60; :', other], { stdio: 'ignore', detached: true }); t.after(() => { try { process.kill(decoy.pid, 'SIGKILL'); } catch {} }); await until(() => startTime(decoy.pid) !== null); make('h-cccccc', [[dead.pid, 'x'], [decoy.pid, '1']]); assert.deepEqual(sweepRunDir(root).removed.sort(), ['h-aaaaaa', 'h-cccccc']); assert.ok(await until(() => planted.exitCode !== null || planted.signalCode !== null, 2000), 'planted process killed'); assert.equal(existsSync(`/proc/${decoy.pid}`), true, 'decoy kept'); assert.deepEqual(readdirSync(root), ['h-bbbbbb']); writeFileSync(join(root, 'stray'), ''); assert.throws(() => sweepRunDir(root), /credential-file/); }); test('T27: the bus start sweeps /secrets/run before it listens, and a stray entry stops the start', async (t) => { const root = mkdtempSync(join(tmpdir(), 'h210-')); t.after(() => rmSync(root, { recursive: true, force: true })); const rolesDir = rolesCopy(root); writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo')); mkdirSync(join(root, 'data'), { mode: 0o700 }); const run = join(root, 'secrets', 'run'); mkdirSync(join(run, 'h-dddddd'), { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700); const dead = spawn('true'); await new Promise((r) => dead.on('close', r)); writeFileSync(join(run, 'h-dddddd', 'pids'), `${dead.pid} x\n`); const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } }, }; const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }); const bus = await start(); try { assert.deepEqual(readdirSync(run), []); } finally { await bus.close(); } writeFileSync(join(run, 'stray'), ''); // A bus that starts anyway is closed, so a failure here ends the file instead of holding it open. await assert.rejects(async () => { const again = await start(); await again.close(); }, /credential-file/); }); // Row 234 (#1740, duckworth-0079 M3, r6 X4 first bullet): host.run takes a grant record only behind the // Approve record check. Each case breaks one part of it. The refusals come before the executor, so none // needs sshd. The control passes the check and stops at host-not-configured. test('M3: host.run refuses host-not-granted unless the grant record has an approved, spent host grant', async (t) => { const f = await fixture(t, { roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } }); const entry = { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] }; for (const host of ['web1', 'web2']) hostRecord(f.store, { host, entry }); const run = (cap = f.coder, host = 'web1') => f.broker.requestHost(cap, { verb: 'host.run', args: { host, argv: ['true'] } }, null); const commands = [['true']]; const cases = { 'no record': null, unresolved: { close: null }, 'resolved no': { close: { ...HUMAN_YES, choice: 'no' } }, withdrawn: { close: { op: 'withdrawn', by: 'jason', via: 'cli' } }, 'an arbiter': { close: { ...HUMAN_YES, by: 'reviewer', via: 'broker' } }, 'the tester': { close: { ...HUMAN_YES, by: 'tester' } }, 'an agent': { close: { ...HUMAN_YES, by: 'coder', via: 'broker' } }, 'not via cli': { close: { ...HUMAN_YES, via: 'web' } }, 'consumed as grant.host-enroll': { spend: 'grant.host-enroll' }, unspent: { spend: null }, 'not a credential.mint': { action: 'deploy' }, 'no grant in the raise': { grant: null }, 'a host-enroll grant': { grant: { method: 'host-enroll' } }, 'a grant for another agent': { grant: { agent: 'reviewer' } }, 'a grant for another host': { grant: { host: 'web2' } }, }; for (const [name, opts] of Object.entries(cases)) { f.store.run('DELETE FROM host_grants'); if (opts) hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands, ...opts }); await refusesWith(run(), 'host-not-granted').catch((e) => assert.fail(`${name}: ${e.message}`)); } f.store.run('DELETE FROM host_grants'); hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands }); await refusesWith(run(), 'host-not-configured'); // The record is per agent: the reviewer holds none on web1, and web9 has no host record. await refusesWith(run(f.reviewer), 'host-not-granted'); await refusesWith(run(f.coder, 'web9'), 'host-unknown'); // The host.sudo raise takes the same check (N1). f.store.run('DELETE FROM host_grants'); hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands, spend: null }); assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['true'] }), /host-not-granted/); }); // Row 234 (#1740, M3, r6 X4 third bullet): at start each host_grants record gives Credentials a store // reference to host., so a restart finds the key the grant executor wrote. test('M3: a host_grants record gives Credentials a store reference at start, and a restart reads its key', async (t) => { const root = mkdtempSync(join(tmpdir(), 'h234-')); t.after(() => rmSync(root, { recursive: true, force: true })); const rolesDir = rolesCopy(root); mkdirSync(join(root, 'data'), { mode: 0o700 }); mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700); const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } }, }; const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }); const hostKeys = (bus) => bus.credentials.status().filter((c) => c.service.startsWith('host.')); let bus = await start(); assert.deepEqual(hostKeys(bus), []); await bus.close(); const store = new Store(join(root, 'data')); hostRecord(store, { host: 'web1', entry: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } }); hostGrantRecord(store, { agent: 'coder', host: 'web1', commands: [['true']], rotateBy: '2099-02-03' }); store.close(); bus = await start(); try { assert.deepEqual(hostKeys(bus).map(({ instance, service, date, state }) => ({ instance, service, date, state })), [ { instance: 'demo/coder', service: 'host.web1', date: '2099-02-03', state: 'not-issued' }, ]); } finally { await bus.close(); } const dir = join(root, 'secrets', 'agents', 'demo', 'coder'); mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets', 'agents', 'demo'), 0o700); keygen(join(dir, 'host.web1')); bus = await start(); try { const [key] = hostKeys(bus); assert.equal(key.publicKey, pubLine(join(dir, 'host.web1'))); assert.notEqual(key.state, 'not-issued'); } finally { await bus.close(); } });