334 lines
16 KiB
Diff
334 lines
16 KiB
Diff
diff --git a/docs/plans/DEFERRED.md b/docs/plans/DEFERRED.md
|
|
index 197a29ecb..7945a6829 100644
|
|
--- a/docs/plans/DEFERRED.md
|
|
+++ b/docs/plans/DEFERRED.md
|
|
@@ -319,6 +319,10 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
|
waits 30 s before it calls a lone `sleep 30` a leak. The row 197
|
|
lanes scan only at lane end, about 15 minutes after pkg-bus, so they
|
|
can't see it (mos-1169). (2026-10-21)
|
|
+ Closing note, row 280 (2026-10-22): both scratch sshd fixtures install a
|
|
+ bounded 30/60-second sleep stub that exits when its test directory is gone;
|
|
+ other sleep arguments exec /usr/bin/sleep unchanged. Evidence is recorded
|
|
+ in agents/rocko/work/queue-280/BUILD.md.
|
|
|
|
- **Row 66 live checks: the unslop notice on a live managed session, both harnesses.**
|
|
(mos-1186, Vikunja #454.) Row 66's gate asked for one landing session per
|
|
diff --git a/packages/bus/tests/grant-executor.test.mjs b/packages/bus/tests/grant-executor.test.mjs
|
|
index 1992f1525..b27d91dc4 100644
|
|
--- a/packages/bus/tests/grant-executor.test.mjs
|
|
+++ b/packages/bus/tests/grant-executor.test.mjs
|
|
@@ -6,9 +6,9 @@
|
|
// reaches another host.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
-import { execFileSync, spawn } from 'node:child_process';
|
|
+import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
-import { connect } from 'node:net';
|
|
+import { createServer } from 'node:net';
|
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir, userInfo } from 'node:os';
|
|
import { join } from 'node:path';
|
|
@@ -19,6 +19,8 @@ import { hostExecutor, runHost } from '../src/host-run.mjs';
|
|
import { Store } from '../src/store.mjs';
|
|
import { grantDecision, hostRecord } from './host-fixtures.mjs';
|
|
|
|
+import { ephemeralRange, installSleepStub, pickPort, startScratchSshd } from './scratch-sshd.mjs';
|
|
+
|
|
const SSHD = '/usr/bin/sshd';
|
|
const USER = userInfo().username;
|
|
const AT = '2026-10-21T00:00:00.000Z';
|
|
@@ -35,12 +37,7 @@ const DEFINITIONS = {
|
|
pm: { roleVersion: 2, credentials: [] },
|
|
ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
|
|
};
|
|
-const listening = (port) =>
|
|
- new Promise((resolve) => {
|
|
- const s = connect(port, '127.0.0.1');
|
|
- s.on('connect', () => (s.destroy(), resolve(true)));
|
|
- s.on('error', () => resolve(false));
|
|
- });
|
|
+
|
|
async function until(fn, ms = 10000) {
|
|
const end = Date.now() + ms;
|
|
while (!fn()) {
|
|
@@ -54,12 +51,13 @@ async function until(fn, ms = 10000) {
|
|
// Both also stop when the test dir is gone, since a held stub on the sshd side outlives its test (#1750).
|
|
// wrap.sh logs every request. The sudo stub logs its arguments to sudo.log and runs the command as the
|
|
// test's user.
|
|
-async function sshd(t, dir) {
|
|
+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, 'installer'));
|
|
writeFileSync(join(dir, 'authorized_keys'), readFileSync(join(dir, 'installer.pub')), { mode: 0o600 });
|
|
mkdirSync(join(dir, 'fake'));
|
|
+ installSleepStub(dir);
|
|
const stub = (name, body) => writeFileSync(join(dir, 'fake', name), `#!/bin/sh\nD='${dir}'\n${body}\n`, { mode: 0o755 });
|
|
stub('list', '[ -e "$D/fail-list" ] && exit 1\n[ -e "$D/granted" ] && grep " mosaic-[0-9a-f]*$" "$D/granted"\nexit 0');
|
|
stub(
|
|
@@ -76,26 +74,42 @@ async function sshd(t, dir) {
|
|
`#!/bin/sh\nprintf '%s\\n' "$SSH_ORIGINAL_COMMAND" >> '${dir}/requests.log'\nPATH=${dir}/fake:/usr/bin:/bin; export PATH\nexec /bin/sh -c "$SSH_ORIGINAL_COMMAND"\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
- for (let attempt = 0; attempt < 5; attempt++) {
|
|
- const port = 20000 + Math.floor(Math.random() * 20000);
|
|
- writeFileSync(
|
|
- join(dir, 'sshd_config'),
|
|
- [`Port ${port}`, 'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`, `AuthorizedKeysFile ${dir}/authorized_keys ${dir}/granted`,
|
|
- `PidFile ${dir}/sshd.pid`, 'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no',
|
|
- `ForceCommand ${dir}/wrap.sh`, ''].join('\n'),
|
|
- );
|
|
- const daemon = spawn(SSHD, ['-D', '-f', join(dir, 'sshd_config'), '-E', join(dir, 'sshd.log')], { stdio: 'ignore' });
|
|
- t.after(() => daemon.kill('SIGKILL'));
|
|
- const deadline = Date.now() + 3000;
|
|
- while (Date.now() < deadline && daemon.exitCode === null) {
|
|
- if (await listening(port)) return port;
|
|
- await sleep(30);
|
|
- }
|
|
- daemon.kill('SIGKILL');
|
|
- }
|
|
- throw new Error('scratch sshd did not start');
|
|
+ return startScratchSshd(t, dir, [
|
|
+ 'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`,
|
|
+ `AuthorizedKeysFile ${dir}/authorized_keys ${dir}/granted`, `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}`);
|
|
+ }
|
|
+});
|
|
+
|
|
async function setup(t, opts = {}) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'g235x-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
diff --git a/packages/bus/tests/host-run.test.mjs b/packages/bus/tests/host-run.test.mjs
|
|
index 6f5949b5c..f7eebd45e 100644
|
|
--- a/packages/bus/tests/host-run.test.mjs
|
|
+++ b/packages/bus/tests/host-run.test.mjs
|
|
@@ -4,7 +4,7 @@
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
-import { connect, createServer } from 'node:net';
|
|
+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';
|
|
@@ -19,6 +19,8 @@ import { hostGrant } from '../src/host-grant.mjs';
|
|
import { startBroker } from '../src/runtime.mjs';
|
|
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/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;
|
|
@@ -50,22 +52,18 @@ async function until(fn, ms = 5000) {
|
|
}
|
|
return true;
|
|
}
|
|
-const listening = (port) =>
|
|
- new Promise((resolve) => {
|
|
- const s = connect(port, '127.0.0.1');
|
|
- s.on('connect', () => (s.destroy(), resolve(true)));
|
|
- s.on('error', () => resolve(false));
|
|
- });
|
|
+
|
|
|
|
// 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) {
|
|
+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`,
|
|
@@ -75,26 +73,42 @@ async function sshd(t, dir) {
|
|
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);
|
|
- for (let attempt = 0; attempt < 5; attempt++) {
|
|
- const port = 20000 + Math.floor(Math.random() * 20000);
|
|
- writeFileSync(
|
|
- join(dir, 'sshd_config'),
|
|
- [`Port ${port}`, '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`, ''].join('\n'),
|
|
- );
|
|
- const daemon = spawn(SSHD, ['-D', '-f', join(dir, 'sshd_config'), '-E', join(dir, 'sshd.log')], { stdio: 'ignore' });
|
|
- t.after(() => daemon.kill('SIGKILL'));
|
|
- const deadline = Date.now() + 3000;
|
|
- while (Date.now() < deadline && daemon.exitCode === null) {
|
|
- if (await listening(port)) return port;
|
|
- await sleep(30);
|
|
- }
|
|
- daemon.kill('SIGKILL');
|
|
- }
|
|
- throw new Error('scratch sshd did not start');
|
|
+ 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-'));
|
|
diff --git a/packages/bus/tests/scratch-sshd.mjs b/packages/bus/tests/scratch-sshd.mjs
|
|
new file mode 100644
|
|
index 000000000..2dde26de3
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/scratch-sshd.mjs
|
|
@@ -0,0 +1,88 @@
|
|
+// Row 280: fixture-only sshd startup. A TCP connect alone does not prove ownership.
|
|
+import { spawn } from 'node:child_process';
|
|
+import { once } from 'node:events';
|
|
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
+import { connect } from 'node:net';
|
|
+import { basename, join } from 'node:path';
|
|
+
|
|
+export const ephemeralRange = () => {
|
|
+ const text = readFileSync('/proc/sys/net/ipv4/ip_local_port_range', 'utf8').trim();
|
|
+ if (!/^\d+\s+\d+$/.test(text)) throw Error('invalid ephemeral port range');
|
|
+ const range = text.split(/\s+/).map(Number);
|
|
+ if (range[0] < 1 || range[1] > 65535 || range[0] > range[1]) throw Error('invalid ephemeral port range');
|
|
+ return range;
|
|
+};
|
|
+
|
|
+export function pickPort(rand = Math.random) {
|
|
+ const [lo, hi] = ephemeralRange();
|
|
+ const below = Math.max(0, Math.min(lo, 65536) - 1024);
|
|
+ const above = Math.max(0, 65535 - Math.max(hi, 1023));
|
|
+ const sample = rand();
|
|
+ if (!below && !above) throw Error('no scratch sshd port available');
|
|
+ if (!(sample >= 0 && sample < 1)) throw Error('invalid random sample');
|
|
+ const offset = Math.floor(sample * (below + above));
|
|
+ return offset < below ? 1024 + offset : Math.max(hi + 1, 1024) + offset - below;
|
|
+}
|
|
+
|
|
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
+const listening = port => new Promise(resolve => {
|
|
+ const socket = connect(port, '127.0.0.1');
|
|
+ const done = result => { socket.destroy(); resolve(result); };
|
|
+ socket.setTimeout(200, () => done(false));
|
|
+ socket.on('connect', () => done(true));
|
|
+ socket.on('error', () => done(false));
|
|
+});
|
|
+const stop = async daemon => {
|
|
+ if (daemon.pid === undefined || daemon.exitCode !== null || daemon.signalCode !== null) return;
|
|
+ const exited = once(daemon, 'exit');
|
|
+ daemon.kill('SIGKILL');
|
|
+ await exited;
|
|
+};
|
|
+
|
|
+export async function startScratchSshd(t, dir, config, { firstPort } = {}) {
|
|
+ let lastLog;
|
|
+ for (let attempt = 0; attempt < 5; attempt++) {
|
|
+ const port = attempt === 0 && firstPort !== undefined ? firstPort : pickPort();
|
|
+ const configFile = join(dir, `sshd-${attempt}.conf`);
|
|
+ lastLog = join(dir, `sshd-${attempt}.log`);
|
|
+ writeFileSync(configFile, [`Port ${port}`, ...config, ''].join('\n'));
|
|
+ writeFileSync(lastLog, '');
|
|
+ const daemon = spawn('/usr/bin/sshd', ['-D', '-f', configFile, '-E', lastLog], { stdio: 'ignore' });
|
|
+ t.after(() => stop(daemon));
|
|
+ const saveLog = () => {
|
|
+ if (!process.env.MOSAIC_SSHD_EVIDENCE) return;
|
|
+ const evidence = join(process.env.MOSAIC_SSHD_EVIDENCE, basename(dir));
|
|
+ mkdirSync(evidence, { recursive: true });
|
|
+ writeFileSync(join(evidence, basename(lastLog)), readFileSync(lastLog));
|
|
+ writeFileSync(join(evidence, basename(configFile)), readFileSync(configFile));
|
|
+ };
|
|
+ let spawnError;
|
|
+ daemon.on('error', error => { spawnError = error; });
|
|
+ const deadline = Date.now() + 3000;
|
|
+ while (Date.now() < deadline && !spawnError && daemon.exitCode === null && daemon.signalCode === null) {
|
|
+ if (await listening(port) && daemon.exitCode === null && daemon.signalCode === null &&
|
|
+ readFileSync(lastLog, 'utf8').split('\n').some(line =>
|
|
+ (line === `Server listening on 127.0.0.1 port ${port}.` ||
|
|
+ line === `Server listening on 127.0.0.1 port ${port}.\r`))) { saveLog(); return port; }
|
|
+ await sleep(30);
|
|
+ }
|
|
+ await stop(daemon);
|
|
+ saveLog();
|
|
+ }
|
|
+ throw Error(`scratch sshd did not start: ${lastLog}`);
|
|
+}
|
|
+
|
|
+// Long remote sleeps remain in flight for their original duration, but cannot outlive the fixture.
|
|
+export function installSleepStub(dir) {
|
|
+ const script = `const fs = require("node:fs");
|
|
+const end = Date.now() + Number(process.argv[2]) * 1000;
|
|
+const timer = setInterval(() => {
|
|
+ if (!fs.existsSync(process.argv[1]) || Date.now() >= end) { clearInterval(timer); }
|
|
+}, 50);`;
|
|
+ writeFileSync(join(dir, 'fake', 'sleep'), `#!/bin/sh
|
|
+case "$#:$1" in
|
|
+ 1:30|1:60) exec '${process.execPath}' -e '${script}' '${dir}' "$1" ;;
|
|
+ *) exec /usr/bin/sleep "$@" ;;
|
|
+esac
|
|
+`, { mode: 0o755 });
|
|
+}
|