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
65 changes: 62 additions & 3 deletions src/dns-service.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,12 @@ export function serviceUnit({
function run(command, args) {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
child.on("error", (error) => resolve({ ok: false, error: error.message }));
child.on("exit", (code) => resolve({ ok: code === 0, error: err.trim() }));
child.on("exit", (code) => resolve({ ok: code === 0, stdout: out, error: err.trim() }));
});
}

Expand Down Expand Up @@ -277,6 +279,38 @@ export function proxyServiceUnit({
return lines.join("\n");
}

/**
* Is the local root the old shape, minted for a list of endings?
*
* moshpit-proxy used to constrain its root by naming what it could certify.
* That root works only for the endings it happened to name, which is why a
* machine could reach `.2600` over HTTPS and not `.hacker` — and why the list
* could never be right, since the registry keeps selling more.
*
* The root it mints now excludes the real internet instead and covers the whole
* namespace. But a machine that ran the old proxy still has the old root on
* disk, and moshpit-proxy will not replace a root that already exists — so
* without this, upgrading leaves the narrow root in place and `.hacker` keeps
* failing with nothing to explain why.
*
* A permitted DNS subtree is the tell. The new root has none by design.
*/
export async function rootIsNarrow({
home = operatorHome(),
exec = run,
exists = existsSync,
} = {}) {
const file = join(home, ".moshpit", "ca", "ca.crt");
if (!exists(file)) return { narrow: false, reason: "no root yet", file };
const described = await exec("openssl", ["x509", "-noout", "-text", "-in", file]);
// Unreadable is not narrow. Deleting a root because openssl was missing would
// throw away a working setup to fix a problem nobody had.
if (!described.ok) return { narrow: false, reason: "could not read it", file };
return described.stdout && /Permitted:/i.test(described.stdout)
? { narrow: true, reason: "constrained to a list of endings", file }
: { narrow: false, reason: "already covers the namespace", file };
}

/** Where moshpit-proxy's installer puts its wrapper, if it ran. */
export function proxyWrapperPath({ home = operatorHome(), exists = existsSync } = {}) {
const candidate = join(home, ".local/bin/moshpit-proxy");
Expand Down Expand Up @@ -304,21 +338,39 @@ export async function ensureProxyService({
tlds = [],
port = 443,
exec = run,
narrowRoot = rootIsNarrow,
paths = proxyServicePaths,
read = async (f) => (await import("node:fs/promises")).readFile(f, "utf8"),
listening = defaultPortHeld,
waitMs = 30000,
} = {}) {
const wrapper = proxyWrapperPath({ home });
if (!wrapper) return { ok: false, reason: "not-installed", steps: [] };

const { path, systemctl } = proxyServicePaths();
const { path, systemctl } = paths();
const unit = proxyServiceUnit({ wrapper, nodeDir, home, user, port, tlds });
// Read before writing so the manifest can put back whatever was here — which
// is usually nothing, and "nothing" has to be recorded as precisely as
// content would be, or disable leaves a unit nobody asked for.
const before = await read(path).catch(() => null);

const steps = [];

// A root minted by the old proxy names the endings it may certify, and
// moshpit-proxy will not replace a root that already exists. Left alone, an
// upgraded machine keeps the narrow root and `.hacker` keeps failing — so it
// goes, and the proxy mints the current shape on its next start.
//
// Safe to remove: it is a local root, regenerated in seconds, and `dns enable`
// installs the replacement into the trust stores in the same run. Removing it
// without that would be the destructive half on its own, which is why this
// lives here and not in the installer.
const narrow = await narrowRoot({ home, exec });
if (narrow.narrow) {
await rm(join(home, ".moshpit", "ca"), { recursive: true, force: true }).catch(() => {});
steps.push({ step: `reminted the local root — the old one was ${narrow.reason}`, ok: true });
}

try {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, unit);
Expand All @@ -328,7 +380,14 @@ export async function ensureProxyService({
}

const [cmd, ...flags] = systemctl;
for (const args of [[...flags, "daemon-reload"], [...flags, "enable", "--now", PROXY_UNIT_NAME]]) {
// `enable --now` starts a stopped unit and does nothing to a running one, so
// an upgrade would leave the previous process — and the previous root, and
// the previous namespace — in place. Restart is what makes an upgrade take.
for (const args of [
[...flags, "daemon-reload"],
[...flags, "enable", PROXY_UNIT_NAME],
[...flags, "restart", PROXY_UNIT_NAME],
]) {
const result = await exec(cmd, args);
steps.push({ step: `${cmd} ${args.join(" ")}`, ok: result.ok, error: result.error });
if (!result.ok) return { ok: false, reason: "systemctl-failed", before, steps, path, unit };
Expand Down
98 changes: 97 additions & 1 deletion test/dns-service.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@
// that looks plausible and dies at 203/EXEC with nothing useful in the journal.
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME } from "../src/dns-service.mjs";
import { proxyProbeFromArgs, captureRestorePoint } from "../src/dns.mjs";
import { proxyServiceUnit, proxyServicePaths, PROXY_UNIT_NAME } from "../src/dns-service.mjs";
import { rootIsNarrow, ensureProxyService } from "../src/dns-service.mjs";

const scratch = () => mkdtemp(join(tmpdir(), "moshcode-service-"));
const unit = (opts = {}) => serviceUnit({ entry: "/opt/moshcode/bin/moshcode.mjs", port: 5354, execPath: "/opt/node/bin/node", ...opts });
Expand Down Expand Up @@ -251,3 +252,98 @@ test("the restore point records the units, including that they were absent", asy
assert.equal(byPath["/etc/systemd/system/moshpit-proxy.service"], "theirs, from before\n");
assert.equal(byPath["/home/x/.config/systemd/user/moshcode-dns.service"], null);
});

/** A scratch home with moshpit-proxy installed in it, as a real machine has. */
async function homeWithProxy() {
const home = await scratch();
await mkdir(join(home, ".local/bin"), { recursive: true });
await writeFile(join(home, ".local/bin/moshpit-proxy"), "#!/bin/sh\nexec node x\n");
return home;
}

/* ---------------------------------------------- the root that stopped scaling */

// moshpit-proxy used to constrain its root by naming the endings it could
// certify, and it will not replace a root that already exists. So an upgraded
// machine keeps the narrow root, `.hacker` keeps failing, and nothing says why.
// `dns enable` has to notice and remint, or the upgrade is a no-op for anyone
// who ran the old proxy.

const described = (stdout) => async () => ({ ok: true, stdout });

test("a root naming endings is narrow — it only works for what it named", async () => {
const r = await rootIsNarrow({
home: "/anywhere",
exists: () => true,
exec: described("X509v3 Name Constraints: critical\n Permitted:\n DNS:.hacker\n"),
});
assert.equal(r.narrow, true);
});

test("a root excluding the internet is current — it covers everything", async () => {
const r = await rootIsNarrow({
home: "/anywhere",
exists: () => true,
exec: described("X509v3 Name Constraints: critical\n Excluded:\n DNS:.com\n"),
});
assert.equal(r.narrow, false);
assert.match(r.reason, /covers the namespace/);
});

test("no root is not a narrow root", async () => {
const r = await rootIsNarrow({ home: "/anywhere", exists: () => false });
assert.equal(r.narrow, false);
});

test("an unreadable root is left alone rather than deleted", async () => {
// Throwing away a working root because openssl was missing would break a
// machine to fix a problem it did not have.
const r = await rootIsNarrow({
home: "/anywhere",
exists: () => true,
exec: async () => ({ ok: false, error: "openssl: not found" }),
});
assert.equal(r.narrow, false);
assert.match(r.reason, /could not read/);
});

/* ------------------------------------------------- an upgrade that takes */

test("the proxy is restarted, not merely enabled", async () => {
// `enable --now` starts a stopped unit and does nothing to a running one, so
// an upgrade would leave the old process serving the old root from the old
// namespace — and report success.
const home = await homeWithProxy();
const calls = [];
await ensureProxyService({
home,
user: "x",
nodeDir: "/opt/node/bin",
listening: async () => true,
paths: () => ({ path: join(home, "moshpit-proxy.service"), systemctl: ["systemctl"], scope: "system" }),
narrowRoot: async () => ({ narrow: false, reason: "current" }),
exec: async (cmd, args) => { calls.push(args.join(" ")); return { ok: true, error: "" }; },
}).catch(() => {});

assert.ok(calls.some((c) => c.startsWith("restart")), `expected a restart, got ${JSON.stringify(calls)}`);
});

test("a narrow root is reminted as part of bringing the proxy up", async () => {
const home = await homeWithProxy();
let removed = false;
const result = await ensureProxyService({
home,
user: "x",
nodeDir: "/opt/node/bin",
listening: async () => true,
paths: () => ({ path: join(home, "moshpit-proxy.service"), systemctl: ["systemctl"], scope: "system" }),
narrowRoot: async () => { removed = true; return { narrow: true, reason: "constrained to a list of endings" }; },
exec: async () => ({ ok: true, error: "" }),
}).catch(() => null);

assert.ok(removed, "the root's shape is checked before the proxy is started");
assert.ok(
(result?.steps || []).some((s) => /reminted the local root/.test(s.step)),
"and the remint is reported, because it invalidates the trust the machine already has",
);
});
Loading