Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/dns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1937,6 +1937,7 @@ import {
applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan,
requiredPort, startDaemon, stopDaemon,
} from "./dns-system.mjs";
import { escalateSelf } from "./escalate.mjs";

/** The parking host's address — an A record has to carry an IP, not a name. */
export async function parkingAddress(host = DEFAULT_PARKING_HOST, lookup = dnsPromises.resolve4) {
Expand Down Expand Up @@ -2012,6 +2013,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
manifestFile = manifestPath(),
readManifest = async (path) => parseManifest(await defaultReadMaybe(path)),
uid = typeof process.getuid === "function" ? process.getuid() : 0,
escalate = escalateSelf,
} = deps;
const [sub, ...rest] = args;
const flag = (name, fallback) => {
Expand Down Expand Up @@ -2400,7 +2402,23 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {

// Checked before doing half of it: every step here needs root, and a
// partial apply is worse than a clean refusal with the command to retry.
//
// Escalate this one command rather than telling the operator to re-run the
// whole CLI. `sudo moshcode …` is a habit with a sharp edge — `moshcode
// update` re-runs the installer, whose paths all come from $HOME, so an
// escalated update installs into /root. The DNS state this writes lives in
// /etc and /var/lib, never the operator's home, so raising just this
// command loses nothing.
if (plan.elevated && uid !== 0) {
const escalated = escalate({
args: ["dns", sub, ...rest],
what: `dns ${sub}`,
out,
});
if (escalated.ran) return escalated.code;

// No tty, no sudo, or already escalated and still not root: fall back to
// the advice, and say why it could not just do it.
out(`dns ${sub} edits system DNS and needs root.`);
out(` sudo moshcode dns ${sub}${rest.length ? " " + rest.join(" ") : ""}`);
out("");
Expand Down
85 changes: 85 additions & 0 deletions src/escalate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Escalating one command, instead of asking the operator to escalate the CLI.
//
// `dns enable` genuinely needs root: it writes /etc/resolver/<tld>, an
// /etc/systemd/resolved.conf.d drop-in, or /etc/dnsmasq.d, and binds :53. The
// old advice was to re-run the whole CLI — `sudo moshcode dns enable`. That
// works, but it teaches a habit that has a sharp edge elsewhere in this same
// CLI: `moshcode update` self-updates by re-running the installer, and every
// path the installer uses comes from $HOME. Under sudo that is /root, so
// `sudo moshcode update` reinstalls moshcode into root's home and leaves the
// operator with a `moshcode` on PATH they cannot execute.
//
// So: never ask for a privileged CLI. Ask for a privileged *step*, and let
// sudo do what it is for — prompt for a password, raise one command.
//
// Every input is injectable because the interesting cases (no tty, no sudo,
// user cancels at the prompt) are ones you cannot reach from a test suite
// otherwise.

import { spawnSync } from "node:child_process";

/** Set on the re-executed child so a misconfigured escalator cannot loop. */
export const ESCALATION_MARKER = "MOSHCODE_ESCALATED";

const CANDIDATES = ["sudo", "doas"];

function defaultProbe(tool) {
return spawnSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" }).status === 0;
}

/**
* Which escalation helper this machine has, honouring an explicit override.
* Returns null when there is none — a container running as a non-root user
* with no sudo is a normal place to end up, and it should get advice rather
* than a crash.
*/
export function findEscalator({ env = process.env, probe = defaultProbe } = {}) {
const override = env.MOSHCODE_ESCALATOR;
if (override) return probe(override) ? override : null;
for (const tool of CANDIDATES) {
if (probe(tool)) return tool;
}
return null;
}

/**
* Re-run this CLI's own argv under the escalation helper.
*
* Returns `{ ran: false, reason }` when escalation is not possible, so the
* caller can fall back to printing the manual command. It never throws: a
* failure to escalate has to degrade into advice, not a stack trace.
*
* `{ ran: true, code }` means the privileged child ran to completion — code 1
* covers the operator cancelling at the password prompt, which is a refusal,
* not an error to retry.
*/
export function escalateSelf({
args,
what = args.join(" "),
env = process.env,
argv = process.argv,
isTTY = Boolean(process.stdin?.isTTY && process.stdout?.isTTY),
spawn = spawnSync,
probe = defaultProbe,
out = console.log,
} = {}) {
if (env[ESCALATION_MARKER]) return { ran: false, reason: "already-escalated" };
// Without a terminal there is nowhere to type a password. sudo would either
// fail or, worse, sit waiting in a CI log until the job times out.
if (!isTTY) return { ran: false, reason: "no-tty" };

const tool = findEscalator({ env, probe });
if (!tool) return { ran: false, reason: "no-escalator" };

const [runtime, script] = argv;
if (!runtime || !script) return { ran: false, reason: "no-argv" };

out(`· ${what} needs root — re-running it with ${tool}. You may be prompted for your password.`);
const result = spawn(tool, [runtime, script, ...args], {
stdio: "inherit",
env: { ...env, [ESCALATION_MARKER]: "1" },
});

if (result?.error) return { ran: false, reason: "spawn-failed" };
return { ran: true, code: typeof result?.status === "number" ? result.status : 1 };
}
27 changes: 27 additions & 0 deletions src/upgrade.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,33 @@ export function planUpgrade(targets = []) {
export async function runUpgrade(targets = [], io = {}) {
const log = io.log || ((s) => console.log(s));
const rule = io.rule || (() => console.log("─".repeat(48)));

// `sudo moshcode update` is the one escalation this CLI must never accept.
// selfSpec re-runs the installer, and every path the installer uses comes
// from $HOME — which sudo has set to /root. The update "succeeds", moshcode
// is reinstalled into root's home, and the operator is left with a binary on
// PATH they cannot execute. Engine and tool installers have the same shape:
// they write into $HOME too.
//
// A bare root shell has no SUDO_USER and is a legitimate place to run this,
// so only the escalated-from-a-real-user case is refused.
const uid = io.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
const env = io.env || process.env;
if (uid === 0 && env.SUDO_USER && !env.MOSHCODE_ALLOW_ROOT) {
log(`✗ don't run moshcode update with sudo.`);
log("");
log(` It reinstalls moshcode, and the installer puts everything under $HOME —`);
log(` which sudo has set to ${env.HOME || "/root"}. That would install moshcode for`);
log(` root and leave ${env.SUDO_USER} with a moshcode on PATH it cannot execute.`);
log("");
log(" Run it as yourself instead:");
log(" moshcode update");
log("");
log(" Commands that genuinely need root, like `dns enable`, now ask for it");
log(" themselves — you do not need to escalate the whole CLI for those.");
return [{ name: "moshcode", ok: false, code: 1, signal: null }];
}

const { self, items, unknown } = planUpgrade(targets);

for (const u of unknown) log(`? skipping unknown upgrade target "${u}"`);
Expand Down
133 changes: 133 additions & 0 deletions test/escalate.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Privileged steps should raise themselves, not ask the operator to raise the
// whole CLI. The old advice — `sudo moshcode dns enable` — is correct on its
// own, but it teaches a habit that breaks `moshcode update`, which re-runs the
// installer with $HOME set to /root.
//
// The cases worth pinning are the ones that cannot happen in a test process:
// no tty, no sudo on the box, the operator cancelling at the password prompt.
// So every input is injected.
import assert from "node:assert/strict";
import test from "node:test";

import { ESCALATION_MARKER, escalateSelf, findEscalator } from "../src/escalate.mjs";

const has = (...names) => (tool) => names.includes(tool);
const collect = () => {
const lines = [];
return { lines, out: (s) => lines.push(String(s)) };
};

test("prefers sudo, falls back to doas, and reports neither", () => {
assert.equal(findEscalator({ env: {}, probe: has("sudo", "doas") }), "sudo");
assert.equal(findEscalator({ env: {}, probe: has("doas") }), "doas");
assert.equal(findEscalator({ env: {}, probe: has() }), null);
});

test("an explicit MOSHCODE_ESCALATOR wins, but only if it exists", () => {
assert.equal(findEscalator({ env: { MOSHCODE_ESCALATOR: "doas" }, probe: has("sudo", "doas") }), "doas");
// Naming a helper that is not installed must not silently fall back to sudo:
// the operator asked for something specific.
assert.equal(findEscalator({ env: { MOSHCODE_ESCALATOR: "please" }, probe: has("sudo") }), null);
});

test("re-runs this CLI's own argv under the escalator", () => {
const calls = [];
const { lines, out } = collect();
const result = escalateSelf({
args: ["dns", "enable"],
env: {},
argv: ["/usr/bin/node", "/opt/moshcode/bin/moshcode.mjs", "dns", "enable"],
isTTY: true,
probe: has("sudo"),
spawn: (cmd, argv, opts) => {
calls.push({ cmd, argv, opts });
return { status: 0 };
},
out,
});

assert.deepEqual(result, { ran: true, code: 0 });
assert.equal(calls.length, 1);
assert.equal(calls[0].cmd, "sudo");
assert.deepEqual(calls[0].argv, ["/usr/bin/node", "/opt/moshcode/bin/moshcode.mjs", "dns", "enable"]);
// The password prompt has to reach the terminal.
assert.equal(calls[0].opts.stdio, "inherit");
assert.equal(calls[0].opts.env[ESCALATION_MARKER], "1");
assert.match(lines.join("\n"), /needs root/);
});

test("the child's exit code is the command's exit code", () => {
// 1 here is the operator cancelling at the password prompt. That is a
// refusal, and it must surface as a failure rather than a silent success.
const result = escalateSelf({
args: ["dns", "enable"],
env: {},
argv: ["node", "moshcode.mjs"],
isTTY: true,
probe: has("sudo"),
spawn: () => ({ status: 1 }),
out: () => {},
});

assert.deepEqual(result, { ran: true, code: 1 });
});

test("does not escalate without a terminal", () => {
// In CI there is nowhere to type a password: sudo would fail, or hang until
// the job times out. Fall back to advice instead.
const result = escalateSelf({
args: ["dns", "enable"],
env: {},
argv: ["node", "moshcode.mjs"],
isTTY: false,
probe: has("sudo"),
spawn: () => assert.fail("must not spawn without a tty"),
out: () => {},
});

assert.deepEqual(result, { ran: false, reason: "no-tty" });
});

test("does not escalate when the box has no escalator", () => {
const result = escalateSelf({
args: ["dns", "enable"],
env: {},
argv: ["node", "moshcode.mjs"],
isTTY: true,
probe: has(),
spawn: () => assert.fail("must not spawn without an escalator"),
out: () => {},
});

assert.deepEqual(result, { ran: false, reason: "no-escalator" });
});

test("refuses to escalate twice", () => {
// If the child is somehow still unprivileged, it must print advice rather
// than spawn another escalation and stack password prompts forever.
const result = escalateSelf({
args: ["dns", "enable"],
env: { [ESCALATION_MARKER]: "1" },
argv: ["node", "moshcode.mjs"],
isTTY: true,
probe: has("sudo"),
spawn: () => assert.fail("must not escalate a second time"),
out: () => {},
});

assert.deepEqual(result, { ran: false, reason: "already-escalated" });
});

test("a failed spawn degrades to advice rather than throwing", () => {
const result = escalateSelf({
args: ["dns", "enable"],
env: {},
argv: ["node", "moshcode.mjs"],
isTTY: true,
probe: has("sudo"),
spawn: () => ({ error: new Error("ENOENT") }),
out: () => {},
});

assert.deepEqual(result, { ran: false, reason: "spawn-failed" });
});
86 changes: 86 additions & 0 deletions test/upgrade-root-refusal.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// `sudo moshcode update` is the one escalation this CLI must never accept.
// runUpgrade's self spec re-runs the installer, and every path the installer
// uses comes from $HOME — which sudo has set to /root. The update reports
// success, moshcode is reinstalled into root's home, and the operator is left
// with a `moshcode` on PATH that they cannot execute:
//
// $ moshcode install secrets
// zsh: permission denied: moshcode
//
// This is not theoretical; it happened on a real machine, and the path there
// was `sudo moshcode update` typed because `dns enable` asks for root.
import assert from "node:assert/strict";
import test from "node:test";

import { runUpgrade } from "../src/upgrade.mjs";

const collect = () => {
const lines = [];
return { lines, io: { log: (s) => lines.push(String(s)), rule: () => {} } };
};

const shouldNotRun = () => assert.fail("nothing should be executed after a refusal");

test("refuses an escalated update, and runs nothing", async () => {
const { lines, io } = collect();
const results = await runUpgrade([], {
...io,
uid: 0,
env: { SUDO_USER: "anthony", HOME: "/root" },
runCmd: shouldNotRun,
});

const output = lines.join("\n");
assert.match(output, /don't run moshcode update with sudo/);
assert.match(output, /anthony/, "it should name the user who would be locked out");
assert.match(output, /\/root/, "it should show the HOME that sudo substituted");

// The caller turns a non-ok result into a non-zero exit, so a refusal must
// not look like "nothing to do".
assert.equal(results.length, 1);
assert.equal(results[0].ok, false);
});

test("the refusal points at the escalation that does work", async () => {
const { lines, io } = collect();
await runUpgrade([], { ...io, uid: 0, env: { SUDO_USER: "anthony" }, runCmd: shouldNotRun });

// Someone here got to `sudo moshcode update` from `dns enable` asking for
// root, so the way out has to mention that dns now asks for itself.
assert.match(lines.join("\n"), /dns enable/);
});

test("a bare root shell still upgrades", async () => {
// Containers and root-only boxes have no SUDO_USER. Refusing there would
// break a legitimate upgrade.
const { lines, io } = collect();
await runUpgrade([], { ...io, uid: 0, env: {}, runCmd: async () => ({ code: 0 }) });

assert.doesNotMatch(lines.join("\n"), /don't run moshcode update with sudo/);
});

test("MOSHCODE_ALLOW_ROOT overrides the refusal", async () => {
const { lines, io } = collect();
await runUpgrade([], {
...io,
uid: 0,
env: { SUDO_USER: "anthony", MOSHCODE_ALLOW_ROOT: "1" },
runCmd: async () => ({ code: 0 }),
});

assert.doesNotMatch(lines.join("\n"), /don't run moshcode update with sudo/);
});

test("an ordinary user is unaffected even with SUDO_USER set", async () => {
// A normal shell inherits SUDO_USER after any earlier sudo call, so the uid
// has to be what decides.
const { lines, io } = collect();
await runUpgrade([], {
...io,
uid: 1000,
env: { SUDO_USER: "anthony" },
runCmd: async () => ({ code: 0 }),
});

assert.doesNotMatch(lines.join("\n"), /don't run moshcode update with sudo/);
});
Loading