Sol review (org-redact5, changes 6): - org-service: Node filesystem errors become "cannot <syscall> <label> (<CODE>)"; code, syscall, deleteStep and deleteReceipt stay. git failures name the verb and exit code, never git's stderr. - org-console: consoleStop names "another checkout", not the entry path. Exit 3. - config-command: the system config is named by where it came from. Exit 3. - cli org: documents and raw fs errors are named by kind and code. Exit 2. - instances: --vars file named by flag and kind. Exit 4. - hosts: a refused command is named by the basename refusedCommand matched. - role.mjs (T1): the change is back to the readJsonFile label only. Sentinel tests for each; pins in last-values, rename-b-commands, org-recovery.
297 lines
17 KiB
JavaScript
297 lines
17 KiB
JavaScript
// Instance-wide Console process; organization routing is owned by S5.
|
|
import { randomBytes } from 'node:crypto';
|
|
import { processState } from '../../seat/src/process-state.mjs';
|
|
import { spawn } from 'node:child_process';
|
|
import { request as httpRequest } from 'node:http';
|
|
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
|
|
import { basename, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
|
|
import { cmdlineOf, startTimeOf } from './host.mjs';
|
|
import { CliError } from './errors.mjs';
|
|
import { devLanOptions } from './dev-local-owner.mjs';
|
|
|
|
const WEBUI = fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url));
|
|
const forbidden = new Set([7332, 7340, 7341, 33223, 17450, 17443, 44307]);
|
|
const fail = message => { throw new CliError(message, 3); };
|
|
const directory = ctx => join(ctx.dataRoot, 'console');
|
|
const stateFile = ctx => join(directory(ctx), 'console.json');
|
|
function state(ctx) {
|
|
let fd;
|
|
try { fd = openSync(stateFile(ctx), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
catch (e) { if (e.code === 'ENOENT') return null; fail('console state is unreadable'); }
|
|
let data;
|
|
try {
|
|
const st = fstatSync(fd);
|
|
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.nlink !== 1) fail('unsafe console state');
|
|
try { data = JSON.parse(readFileSync(fd, 'utf8')); } catch { fail('console state is unreadable'); }
|
|
} finally { closeSync(fd); }
|
|
if (!Number.isSafeInteger(data?.pid) || data.pid <= 0 || typeof data.startTime !== 'string' || !data.startTime || typeof data.url !== 'string') fail('console state is invalid');
|
|
// org-lan-bind: a LAN-exposed Console records where it listens and the names it answers.
|
|
if (data.bind !== undefined || data.allowHosts !== undefined) {
|
|
try { if (!Array.isArray(data.allowHosts) || data.allowHosts.some(name => typeof name !== 'string' || name.includes(',')) || !devLanOptions({ devOwner: true, bind: data.bind, names: data.allowHosts })) throw new Error(); }
|
|
catch { fail('console state is invalid'); }
|
|
}
|
|
return data;
|
|
}
|
|
const lanWarning = (bind, port) => `WARNING: dev owner Console listens on ${bind}:${port} with no sign-in; anyone who can reach it acts as the owner.\n`;
|
|
function checkDir(ctx) {
|
|
mkdirSync(directory(ctx), { recursive: true, mode: 0o700 });
|
|
const st = lstatSync(directory(ctx));
|
|
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o700) fail('unsafe console directory');
|
|
}
|
|
function liveConsole(s) {
|
|
let p;
|
|
try { p = processState(s.pid); } catch { fail('console process identity is uncertain'); }
|
|
return p.alive && p.startTime === s.startTime;
|
|
}
|
|
function removeStale(ctx, s, warn) {
|
|
if (liveConsole(s)) return false;
|
|
const file = stateFile(ctx), st = lstatSync(file);
|
|
const current = state(ctx);
|
|
if (!current || current.pid !== s.pid || current.startTime !== s.startTime || current.url !== s.url) fail('console state changed during recovery');
|
|
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('unsafe console state');
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const aside = `${file}.stale-${stamp}-${randomBytes(4).toString('hex')}`;
|
|
const currentStat = lstatSync(file);
|
|
if (currentStat.dev !== st.dev || currentStat.ino !== st.ino) fail('console state changed during recovery');
|
|
renameSync(file, aside);
|
|
warn(`mosaic: stale console state moved to ${aside}\n`);
|
|
return true;
|
|
}
|
|
function takeLock(ctx) {
|
|
checkDir(ctx);
|
|
const path = join(directory(ctx), 'start.lock');
|
|
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
|
|
return () => { closeSync(fd); rmSync(path, { force: true }); };
|
|
}
|
|
// The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's
|
|
// own restart route (POST /api/console/restart, a human action) exits with it.
|
|
export const RESTART_EXIT = 75;
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
let lastStamp = 0;
|
|
// One Console process: spawn it, wait for the URL it prints, check the address and port.
|
|
async function launch(ctx, args, lan, detach) {
|
|
lastStamp = Math.max(Date.now(), lastStamp + 1);
|
|
const log = join(directory(ctx), `console-${process.pid}-${lastStamp}.log`);
|
|
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
let child;
|
|
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
|
|
finally { closeSync(fd); }
|
|
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
|
|
let exited = false; done.then(() => { exited = true; });
|
|
try {
|
|
const deadline = Date.now() + 30000;
|
|
while (Date.now() < deadline && !exited) {
|
|
const text = readFileSync(log, 'utf8');
|
|
// The URL is loopback, or the specific address a LAN Console binds.
|
|
const match = /Mosaic Console: (http:\/\/([0-9.]+):(\d+)\/)/.exec(text);
|
|
if (match) {
|
|
if (match[2] !== '127.0.0.1' && match[2] !== lan?.bind) fail('console printed an unexpected address');
|
|
if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port');
|
|
const startTime = startTimeOf(child.pid);
|
|
if (!startTime) fail('console exited during startup');
|
|
return { child, done, url: match[1], port: match[3], startTime };
|
|
}
|
|
await sleep(50);
|
|
}
|
|
// The log is named under the data root, never by host path (PR #1916).
|
|
fail(`console failed to start; see console/${basename(log)} under the data root`);
|
|
} catch (e) {
|
|
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
|
|
throw e;
|
|
}
|
|
}
|
|
const record = (run, lan) => JSON.stringify({ pid: run.child.pid, startTime: run.startTime, url: run.url, ...(lan ?? {}) }) + '\n';
|
|
// F3: a relaunch keeps the arguments and the address. The state file must still name the process
|
|
// that asked for the restart; a stop that ran in between wins, and nothing is relaunched.
|
|
async function relaunch(ctx, old, args, lan) {
|
|
const next = args.map((a, i) => (args[i - 1] === '--port' ? old.port : a));
|
|
let release;
|
|
for (let n = 0; !release; n++) {
|
|
try { release = takeLock(ctx); } catch (e) { if (n >= 50) throw e; await sleep(100); }
|
|
}
|
|
try {
|
|
const s = state(ctx);
|
|
if (!s || s.pid !== old.child.pid || s.startTime !== old.startTime || s.url !== old.url) fail('console state changed during restart');
|
|
let fresh;
|
|
// The old listener has closed; a refused bind is retried briefly.
|
|
for (let n = 0; !fresh; n++) {
|
|
try { fresh = await launch(ctx, next, lan, false); } catch (e) { if (n >= 4) throw e; await sleep(500); }
|
|
}
|
|
try {
|
|
if (fresh.url !== old.url) fail('console restarted on another address');
|
|
const tmp = `${stateFile(ctx)}.tmp-${randomBytes(4).toString('hex')}`;
|
|
writeFileSync(tmp, record(fresh, lan), { mode: 0o600, flag: 'wx' });
|
|
renameSync(tmp, stateFile(ctx));
|
|
} catch (e) { fresh.child.kill('SIGTERM'); await fresh.done; throw e; }
|
|
return fresh;
|
|
} finally { release(); }
|
|
}
|
|
// Foreground supervision: relaunch on RESTART_EXIT, end on any other exit. `stop` forwards a
|
|
// stop signal (the unit's SIGTERM, a terminal's Ctrl-C) to the Console; a stop is a clean end.
|
|
function supervise(ctx, first, args, lan, warn) {
|
|
let current = first, stopping = false;
|
|
const stop = () => {
|
|
if (stopping) return;
|
|
stopping = true;
|
|
const child = current.child;
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
child.kill('SIGTERM');
|
|
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 15000).unref();
|
|
};
|
|
const done = (async () => {
|
|
for (;;) {
|
|
const code = await current.done;
|
|
if (stopping) return 0;
|
|
if (code !== RESTART_EXIT) return code;
|
|
warn('mosaic: console asked for a restart; relaunching on the same arguments\n');
|
|
try { current = await relaunch(ctx, current, args, lan); }
|
|
catch (e) { warn(`mosaic: console restart failed: ${e.message}\n`); return 1; }
|
|
if (stopping) { stopping = false; stop(); await current.done; return 0; }
|
|
}
|
|
})();
|
|
return { done, stop };
|
|
}
|
|
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
|
|
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
|
|
// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and
|
|
// this process relaunches it in place (F3). A detached Console has no supervisor.
|
|
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
|
|
const value = port === 'auto' ? 0 : Number(port);
|
|
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
|
|
let lan;
|
|
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
|
|
const release = takeLock(ctx);
|
|
let run, args;
|
|
try {
|
|
const prior = state(ctx);
|
|
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
|
|
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
|
|
if (org) await resolveOrg(ctx, org.id);
|
|
// The pre-S5 Console still accepts a business preselection.
|
|
args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
|
|
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : []), ...(detach ? [] : ['--supervised'])];
|
|
run = await launch(ctx, args, lan, detach);
|
|
try { writeFileSync(stateFile(ctx), record(run, lan), { mode: 0o600, flag: 'wx' }); }
|
|
catch (e) { run.child.kill('SIGTERM'); await run.done; throw e; }
|
|
} finally { release(); }
|
|
if (detach) run.child.unref();
|
|
// The child's stderr goes to the log; the person who started it sees the warning here.
|
|
if (lan) warn(lanWarning(lan.bind, run.port));
|
|
const result = { url: run.url, pid: run.child.pid, ...(lan ?? {}) };
|
|
const supervision = detach ? { done: run.done, stop: () => run.child.kill('SIGTERM') } : supervise(ctx, run, args, lan, warn);
|
|
Object.defineProperty(result, 'done', { value: supervision.done });
|
|
Object.defineProperty(result, 'stop', { value: supervision.stop });
|
|
return result;
|
|
}
|
|
export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } = {}) {
|
|
const release = takeLock(ctx);
|
|
try {
|
|
const s = state(ctx);
|
|
if (!s || removeStale(ctx, s, warn)) return { stopped: false };
|
|
if (!liveConsole(s)) fail('console process changed before stop');
|
|
const argv = cmdlineOf(s.pid);
|
|
if (!argv || !argv.length) fail('console process identity is uncertain');
|
|
const i = argv.indexOf(WEBUI);
|
|
// The other entry script is a host path, so the refusal never names it (PR #1916).
|
|
if (i < 0 || argv[i + 1] !== 'serve') fail('console was started from another checkout; stop it there');
|
|
process.kill(s.pid, 'SIGTERM');
|
|
const deadline = Date.now() + 20000;
|
|
while (Date.now() < deadline) {
|
|
let p; try { p = processState(s.pid); } catch { fail('console process identity is uncertain'); }
|
|
if (!p.alive || p.startTime !== s.startTime) { rmSync(stateFile(ctx)); return { stopped: true, pid: s.pid }; }
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
fail('console did not stop');
|
|
} finally { release(); }
|
|
}
|
|
// Read-only: what the state file says and whether that process still runs. It never moves a
|
|
// stale state file (start and stop do that under the start lock).
|
|
export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {}) {
|
|
const s = state(ctx);
|
|
if (!s) return { running: false };
|
|
const running = liveConsole(s);
|
|
const bind = s.bind ?? '127.0.0.1';
|
|
const port = /:(\d+)\/$/.exec(s.url)?.[1] ?? 'unknown';
|
|
// The same warning start printed, for as long as a Console started with the LAN flags runs.
|
|
if (running && s.bind) warn(lanWarning(bind, port));
|
|
return { running, ...(running ? {} : { stale: true }), pid: s.pid, url: s.url, bind, ...(s.allowHosts ? { allowHosts: s.allowHosts } : {}) };
|
|
}
|
|
|
|
// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a
|
|
// terminal. Each one is a request to the running Console named by console.json, sent as the page
|
|
// sends it: the Console's own Host and Origin, Sec-Fetch-Site: same-origin and a JSON `{}` body.
|
|
// The Console's route applies its owner, --send and origin checks and writes the action trail;
|
|
// nothing here writes the trail, signals a process or touches a bus. The CLI verbs refuse inside
|
|
// an agent run before they get here (transport.mjs refuseInsideAgent).
|
|
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
function consoleTarget(ctx) {
|
|
const s = state(ctx);
|
|
if (!s) fail('no Console runs for this instance; start one with mosaic console start');
|
|
if (!liveConsole(s)) fail('the Console in console state is not running; see mosaic console status');
|
|
const url = /^http:\/\/([0-9.]+):(\d+)\/$/.exec(s.url);
|
|
if (!url) fail('console state is invalid');
|
|
const port = Number(url[2]);
|
|
if (forbidden.has(port)) fail('console state names a forbidden port');
|
|
// A LAN Console on 0.0.0.0 answers on loopback too; one bound to an address answers only there.
|
|
const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1';
|
|
return { address, port, host: `${address}:${port}` };
|
|
}
|
|
function consoleCall(target, { method = 'GET', path, timeoutMs }) {
|
|
return new Promise((resolve, reject) => {
|
|
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } : {}) };
|
|
let sent = false;
|
|
const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => {
|
|
let text = '';
|
|
res.setEncoding('utf8');
|
|
res.on('data', chunk => { text += chunk; if (text.length > 65536) req.destroy(new Error('the Console answer is too large')); });
|
|
res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body }); });
|
|
res.on('error', e => reject(Object.assign(e, { sent: true })));
|
|
});
|
|
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' })));
|
|
req.on('finish', () => { sent = true; });
|
|
req.on('error', e => reject(Object.assign(e, { sent })));
|
|
req.end(method === 'POST' ? '{}' : undefined);
|
|
});
|
|
}
|
|
// A refused connection never reached the Console. Anything else after the request left may have
|
|
// changed something, so it is outcome-unknown and nothing is retried.
|
|
async function consolePost(target, path, { timeoutMs, unknown }) {
|
|
let answer;
|
|
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs }); }
|
|
catch (e) {
|
|
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
|
|
throw new CliError(`outcome unknown: ${unknown}`, 1);
|
|
}
|
|
return answer;
|
|
}
|
|
function refusal(answer, what) {
|
|
const code = typeof answer.body?.error === 'string' && /^[a-z-]{1,64}$/.test(answer.body.error) ? answer.body.error : `http ${answer.status}`;
|
|
const message = typeof answer.body?.message === 'string' ? `: ${answer.body.message.slice(0, 500)}` : '';
|
|
return new CliError(`${what} refused (${code})${message}`, answer.status >= 500 ? 1 : 3);
|
|
}
|
|
// POST /api/console/restart, then wait for the relaunched Console (a new boot id on /api/config).
|
|
export async function consoleRestart(ctx, { timeoutMs = 10000, waitMs = 30000 } = {}) {
|
|
const target = consoleTarget(ctx);
|
|
const answer = await consolePost(target, '/api/console/restart', { timeoutMs, unknown: 'the Console may be restarting; check mosaic console status and the action trail' });
|
|
if (answer.status !== 202 || answer.body?.outcome !== 'restarting' || typeof answer.body.boot !== 'string') throw refusal(answer, 'console restart');
|
|
const from = answer.body.boot, deadline = Date.now() + waitMs;
|
|
while (Date.now() < deadline) {
|
|
await sleep(250);
|
|
try {
|
|
const config = await consoleCall(target, { path: '/api/config', timeoutMs: 2000 });
|
|
if (config.status === 200 && typeof config.body?.boot === 'string' && config.body.boot !== from) return { outcome: 'restarted', from, boot: config.body.boot };
|
|
} catch { /* closed while it restarts */ }
|
|
}
|
|
throw new CliError('the Console accepted the restart but has not answered since; check mosaic console status', 1);
|
|
}
|
|
// POST /api/orgs/ID/restart/bus: a running bus restarts, a stopped one starts (org-admin.mjs restartBus).
|
|
export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) {
|
|
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
|
|
const target = consoleTarget(ctx);
|
|
const answer = await consolePost(target, `/api/orgs/${id}/restart/bus`, { timeoutMs, unknown: `the bus may be restarting; check mosaic bus status ${id} and the action trail` });
|
|
if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload');
|
|
return answer.body;
|
|
}
|