Files
jason.woltje 75f6d5533a fix(proxy): review fixes for the Console proxy setup page (#1873)
Security review:
- Release verification cache keys on ctime as well as mtime, in
  nanoseconds. A same-size rewrite with mtime set back now re-hashes and
  reports proxy-install-invalid.

Build review:
- The setup page hides the account panels on any failed accounts read, not
  only proxy-down, and brings them back once the proxy answers.
- The organizations table shows a dash, not a made-up 0, when a bus does
  not answer or sends no model list (orgCatalogue in proxy-admin.mjs).
- A fence clear the bus did not answer keeps the confirmed restart for a
  retry.
- A fence with no readable time names `mosaic proxy fence clear
  --proxy-restarted` on the page, in the refusal table and in the README.
- CLI `init --generate-key` goes through the setup controller and writes
  the same init receipt as the page.
- Status carries the last ten receipts, so the page shows them after a
  reload. An initialized proxy keeps the init form folded.
- README: proxy-admin-busy is per process; the cross-process locks that
  serialize the writes are named.
2026-11-11 16:32:31 -06:00

196 lines
13 KiB
JavaScript

// #1873 proxy setup host helpers: restart proof from a fake /proc, the stale init-lock rules,
// unit control over a fake systemctl, and receipts that carry no path or key.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { PIN } from '../src/common.mjs';
import { installPaths } from '../src/settings.mjs';
import {
processTree, pidListensOn, processStartMs, verifyRestart, initLockStatus, clearInitLock, initFiles, initState,
fenceState, createUnitControl, releaseState, receiptFile, unitName,
} from '../src/admin.mjs';
const code = name => error => error.code === name;
const scratch = t => { const dir = fs.mkdtempSync(join(tmpdir(), 'pa-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); return dir; };
// A fake /proc: runner pid 100 (start tick 500), its child 101 listens on `port` via socket inode 5555.
function fakeProc(t, { port = 8080, listener = 101, start = 500 } = {}) {
const proc = join(scratch(t), 'proc');
const stat = (pid, ppid, ticks) => `${pid} (node) S ${ppid} 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 ${ticks} 0 0\n`;
fs.mkdirSync(join(proc, 'net'), { recursive: true });
fs.writeFileSync(join(proc, 'stat'), 'cpu 0\nbtime 1000\n');
for (const [pid, ppid, ticks] of [[1, 0, 1], [100, 1, start], [101, 100, start + 1], [200, 1, 1]]) {
fs.mkdirSync(join(proc, String(pid), 'fd'), { recursive: true });
fs.writeFileSync(join(proc, String(pid), 'stat'), stat(pid, ppid, ticks));
}
fs.symlinkSync('socket:[5555]', join(proc, String(listener), 'fd', '3'));
fs.symlinkSync('/dev/null', join(proc, '100', 'fd', '0'));
const hex = port.toString(16).toUpperCase().padStart(4, '0');
fs.writeFileSync(join(proc, 'net', 'tcp'), ' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n'
+ ` 0: 0100007F:${hex} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5555 1 0 100 0 0 10 0\n`
+ ` 1: 0100007F:${hex} 0100007F:9999 01 00000000:00000000 00:00000000 00000000 1000 0 7777 1 0 100 0 0 10 0\n`);
return proc;
}
test('processTree follows parent links; pidListensOn finds a listener in a child of the unit main pid', t => {
const proc = fakeProc(t);
assert.deepEqual(processTree(100, { proc }).sort(), [100, 101]);
assert.equal(pidListensOn(100, 8080, { proc }), true);
assert.equal(pidListensOn(200, 8080, { proc }), false, 'an unrelated process does not count');
assert.equal(pidListensOn(100, 8081, { proc }), false, 'another port does not count');
assert.equal(processStartMs(100, { proc }), 1000 * 1000 + 5000);
assert.equal(processStartMs(999, { proc }), null);
});
const evidence = (over = {}) => ({
unit: 'mosaic-proxy.service', confirmed: true,
before: { pid: 50, since: 10, active: 'active' }, after: { pid: 100, since: 20, active: 'active', listening: true },
observedAt: new Date(2000).toISOString(), ...over,
});
const proof = { fence: { time: new Date(1000).toISOString() }, port: 8080, startMs: () => 1500, listens: (pid, port) => pid === 100 && port === 8080, now: () => 2000 };
test('verifyRestart accepts a confirmed restart to a new live listener started after the fence', () => {
const result = verifyRestart(evidence(), proof);
assert.deepEqual(result, { unit: 'mosaic-proxy.service', before: { pid: 50 }, after: { pid: 100, startedAt: new Date(1500).toISOString() }, observedAt: new Date(2000).toISOString(), listeningPort: 8080 });
});
test('verifyRestart refuses every unproved restart with proxy-restart-unconfirmed', () => {
const cases = [
['not confirmed', evidence({ confirmed: false }), proof],
['same pid', evidence({ before: { pid: 100, since: 10, active: 'active' } }), proof],
['extra field', { ...evidence(), forged: true }, proof],
['bad unit name', evidence({ unit: '../x.service' }), proof],
['stale observation', evidence({ observedAt: new Date(2000).toISOString() }), { ...proof, now: () => 2000 + 11 * 60 * 1000 }],
['process gone', evidence(), { ...proof, startMs: () => null }],
['started before the fence', evidence(), { ...proof, startMs: () => 900 }],
['fence without time', evidence(), { ...proof, fence: { invalid: true } }],
['not listening', evidence(), { ...proof, listens: () => false }],
['no port', evidence(), { ...proof, port: null }],
['not an object', 'restarted', proof],
];
for (const [name, value, options] of cases) assert.throws(() => verifyRestart(value, options), code('proxy-restart-unconfirmed'), name);
});
function dirs(t) {
const root = scratch(t);
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); fs.mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
return { root, configDir, dataRoot, files: initFiles(configDir, dataRoot) };
}
test('init lock: absent refuses, held names each README blocker, clear needs every condition and writes a receipt', t => {
const { root, configDir, dataRoot, files } = dirs(t);
const stopped = { stopped: true, running: [] };
assert.deepEqual(initLockStatus({ configDir, dataRoot, buses: stopped }), { present: false, clearable: false, blockers: [] });
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: stopped }), code('proxy-init-lock-absent'));
fs.writeFileSync(files.lock, '', { mode: 0o600 });
fs.mkdirSync(files.proxyDir, { mode: 0o700 });
const held = initLockStatus({ configDir, dataRoot, buses: { stopped: false, running: ['organization acme'] } });
assert.equal(held.clearable, false);
assert.deepEqual(held.blockers, ['bus running: organization acme', 'the proxy state directory exists']);
assert.equal(JSON.stringify(held).includes(root), false, 'blockers name labels, never paths');
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: { stopped: false, running: ['the instance bus'] } }), code('proxy-init-lock-held'));
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: stopped }), code('proxy-init-lock-held'));
assert.equal(fs.existsSync(files.lock), true);
fs.rmdirSync(files.proxyDir);
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: undefined }), code('proxy-init-lock-held'), 'no bus proof counts as running');
const receipt = clearInitLock({ configDir, dataRoot, buses: stopped, now: () => new Date(0) });
assert.equal(fs.existsSync(files.lock), false);
assert.deepEqual(receipt, { op: 'init-lock.clear', time: new Date(0).toISOString(), conditions: ['bus stopped', 'no proxy.json', 'no management key file', 'no proxy state directory'] });
assert.deepEqual(fs.readFileSync(receiptFile(dataRoot), 'utf8').trim().split('\n').map(JSON.parse), [receipt]);
assert.equal((fs.statSync(receiptFile(dataRoot)).mode & 0o777), 0o600);
});
test('initState reports partial state from file presence and fenceState reads the fence without writing', t => {
const { configDir, dataRoot, files } = dirs(t);
assert.deepEqual(initState(configDir, dataRoot), { initialized: false, partial: false, problem: null, settings: null, files: { settings: false, key: false, proxyDir: false, lock: false }, lock: false });
fs.mkdirSync(files.proxyDir, { mode: 0o700 });
assert.equal(initState(configDir, dataRoot).partial, true);
assert.equal(fenceState(dataRoot), null);
fs.writeFileSync(join(files.proxyDir, 'mosaic-keys.uncertain'), JSON.stringify({ org: 'acme', op: 'apikey.add', time: '2026-11-11T00:00:00.000Z', pid: 7 }), { mode: 0o600 });
assert.deepEqual(fenceState(dataRoot), { org: 'acme', op: 'apikey.add', time: '2026-11-11T00:00:00.000Z', pid: 7 });
fs.chmodSync(join(files.proxyDir, 'mosaic-keys.uncertain'), 0o644);
assert.deepEqual(fenceState(dataRoot), { invalid: true });
assert.deepEqual(fs.readdirSync(files.proxyDir), ['mosaic-keys.uncertain']);
});
test('releaseState: nothing fetched names the next step without a path', t => {
const { root, dataRoot } = dirs(t);
const state = releaseState(dataRoot);
assert.equal(state.fetched, false); assert.equal(state.verified, false); assert.equal(state.problem, 'proxy-install-missing');
assert.equal(JSON.stringify(state).includes(root), false);
});
test('releaseState re-hashes a release file rewritten in place with its mtime set back', async t => {
const { dataRoot } = dirs(t);
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
const binary = Buffer.from('made-up proxy binary 0001'), panel = Buffer.from('<html>made-up panel</html>');
const pin = { ...PIN, version: '0.0.0-test', binary: { ...PIN.binary, sha256: sha(binary) }, panel: { ...PIN.panel, sha256: sha(panel) } };
const paths = installPaths(dataRoot, pin);
fs.mkdirSync(join(paths.dir, 'static'), { recursive: true });
fs.writeFileSync(paths.binary, binary, { mode: 0o755 }); fs.writeFileSync(paths.panel, panel);
const when = 1700000000;
for (const file of [paths.binary, paths.panel]) fs.utimesSync(file, when, when);
assert.equal(releaseState(dataRoot, pin).verified, true);
await new Promise(resolve => setTimeout(resolve, 50));
const before = fs.statSync(paths.binary, { bigint: true });
fs.writeFileSync(paths.binary, Buffer.from('made-up proxy binary 0002'), { flag: 'r+' });
fs.utimesSync(paths.binary, when, when);
const after = fs.statSync(paths.binary, { bigint: true });
assert.deepEqual([after.ino, after.size, after.mtimeNs, after.mode], [before.ino, before.size, before.mtimeNs, before.mode], 'only ctime tells the files apart');
const state = releaseState(dataRoot, pin);
assert.equal(state.verified, false); assert.equal(state.problem, 'proxy-install-invalid');
});
test('unit names follow the instance and refuse a bad instance', () => {
assert.equal(unitName(null), 'mosaic-proxy.service');
assert.equal(unitName('dev'), 'mosaic-proxy.service');
assert.equal(unitName('uiorg'), 'mosaic-proxy-uiorg.service');
assert.throws(() => unitName('../x'), code('proxy-unit-usage'));
});
// A fake systemctl / proxy-service.sh. `states` is the sequence `systemctl show` reports.
function fakeUnit(states, { install = { code: 0, stdout: '', stderr: '' }, verbs = [] } = {}) {
let i = 0;
const run = async (command, args) => {
if (command === 'systemctl' && args[1] === 'show') {
const s = states[Math.min(i++, states.length - 1)];
return { code: 0, stdout: `LoadState=${s.load ?? 'loaded'}\nActiveState=${s.active}\nSubState=running\nMainPID=${s.pid ?? 0}\nExecMainStartTimestampMonotonic=${s.since ?? 0}\nNRestarts=0\n`, stderr: '' };
}
if (command === 'systemctl') { verbs.push(args[1]); return { code: 0, stdout: '', stderr: '' }; }
verbs.push(args.filter(a => !a.endsWith('proxy-service.sh')).join(' '));
return install;
};
return createUnitControl({ instance: 'zz', port: () => 18432, run, probe: async () => true, waitMs: 0, pollMs: 0, now: () => new Date(0) });
}
test('unit restart is confirmed only when a new main process is active and listening', async () => {
const verbs = [];
const control = fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'active', pid: 11, since: 6 }], { verbs });
const { evidence: e } = await control.restart();
assert.equal(e.confirmed, true);
assert.deepEqual(e.before, { pid: 10, since: 5, active: 'active' });
assert.deepEqual(e.after, { pid: 11, since: 6, active: 'active', listening: true });
assert.equal(e.unit, 'mosaic-proxy-zz.service');
assert.deepEqual(verbs, ['restart']);
const same = await fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'active', pid: 10, since: 5 }]).restart();
assert.equal(same.evidence.confirmed, false, 'the same process is not a restart');
const down = await fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'failed', pid: 0, since: 0 }]).restart();
assert.equal(down.evidence.confirmed, false, 'a failed unit is not a restart');
});
test('unit actions map proxy-service refusals by code and refuse a missing unit', async () => {
await assert.rejects(fakeUnit([{ load: 'not-found', active: 'inactive' }]).start(), code('proxy-unit-missing'));
await assert.rejects(fakeUnit([{ load: 'not-found', active: 'inactive' }]).restart(), code('proxy-unit-missing'));
await assert.rejects(fakeUnit([{ active: 'inactive' }], { install: { code: 3, stdout: '', stderr: 'proxy-service: proxy-unit-exists\n' } }).install(), code('proxy-unit-exists'));
await assert.rejects(fakeUnit([{ active: 'inactive' }], { install: { code: 3, stdout: '', stderr: 'proxy-service: /home/x secret detail\n' } }).install(), code('proxy-unit-failed'));
await assert.rejects(fakeUnit([{ active: 'active', pid: 0 }, { active: 'failed', pid: 0 }]).start(), code('proxy-unit-unhealthy'));
const verbs = [];
const started = await fakeUnit([{ active: 'inactive' }, { active: 'active', pid: 9, since: 1 }], { verbs }).start();
assert.equal(started.healthy, true); assert.deepEqual(verbs, ['start']);
assert.throws(() => createUnitControl({ unitDir: 'relative' }), code('proxy-unit-path'));
});