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
194 changes: 194 additions & 0 deletions src/dns-service.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
// is guessed and nothing depends on PATH.
import { spawn } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";

Expand Down Expand Up @@ -171,3 +172,196 @@ export async function removeService({ system = false, home = homedir(), exec = r
steps.push({ step: `${cmd} ${[...flags, "daemon-reload"].join(" ")}`, ok: reload.ok, error: reload.error });
return { ok: true, path, scope, steps };
}

/* --------------------------------------------------- the pinned-TLS proxy */

/**
* The other half of a machine that can actually reach Moshpit names.
*
* The bridge makes names resolve. It cannot make them verifiable: no CA will
* ever sign for `.eggs`, so without a proxy every name answers its origin's own
* self-signed leaf and a stock client refuses it. moshpit-proxy terminates TLS
* with a local root instead — one root for every ending, rather than trusting
* certificates one name at a time.
*
* moshpit-proxy ships no unit of its own, so nothing ever started it. It was
* installed, trusted, and idle, which reads exactly like "not installed" from
* every direction: nothing on 443, no certificate, and `dns enable` correctly
* reporting no proxy on a machine that had one.
*/
export const PROXY_UNIT_NAME = "moshpit-proxy.service";

/**
* A system unit, unlike the bridge's.
*
* 443 is privileged and the proxy must have it: DNS carries an address and has
* nowhere to put a port, so a browser sent to a Moshpit name goes to 443 or
* nowhere. A user unit cannot bind it. So this is a system unit that drops to
* the operator's account and is granted the one capability it needs — rather
* than running as root, which it has no other use for.
*/
export function proxyServicePaths() {
return { path: join("/etc/systemd/system", PROXY_UNIT_NAME), systemctl: ["systemctl"], scope: "system" };
}

/**
* The unit text, pinned to this install.
*
* `ExecStart` runs moshpit-proxy's own wrapper rather than reaching past it to
* an entry script, so a change to that project's layout does not silently break
* this. The wrapper execs `node`, which systemd's PATH does not have on a mise,
* nvm or asdf box — so PATH is set from the interpreter running this code,
* which is by definition one that works. That mistake has now been made three
* times in this codebase; it is made here on purpose and only once.
*/
export function proxyServiceUnit({
wrapper,
nodeDir,
home = homedir(),
user = process.env.SUDO_USER || process.env.USER || process.env.LOGNAME,
port = 443,
tlds = [],
} = {}) {
if (!wrapper) throw new Error("proxyServiceUnit needs the moshpit-proxy wrapper path");
if (!user) throw new Error("proxyServiceUnit needs the account the proxy runs as");

const path = [nodeDir, "/usr/local/bin", "/usr/bin", "/bin"].filter(Boolean).join(":");
// `.moshpit` under the operator's home is where the local root already lives,
// put there by moshpit-proxy's own installer. Naming it explicitly keeps the
// service off /root/.moshpit, which is where a system unit would otherwise
// look and where there is nothing.
const dir = join(home, ".moshpit");

const lines = [
"# Generated by `moshcode dns enable`. Regenerate rather than editing:",
"# the paths below are this install's, and a node or proxy that moves leaves",
"# a unit that fails at 203/EXEC with nothing else to say.",
"[Unit]",
"Description=Moshpit pinned-TLS proxy",
"Documentation=https://github.com/profullstack/moshpit-proxy",
"After=network-online.target",
"Wants=network-online.target",
"",
"[Service]",
"Type=simple",
`User=${user}`,
`Environment=PATH=${path}`,
`Environment=MOSHPIT_PROXY_PORT=${port}`,
`Environment=MOSHPIT_PROXY_DIR=${dir}`,
];
// Only the endings it is asked to serve. Left unset it defaults to `.moshpit`
// alone, which is why a proxy can be running, healthy, and unable to present
// a certificate for the name someone is actually trying to reach.
if (tlds.length) lines.push(`Environment=MOSHPIT_PROXY_TLDS=${tlds.join(",")}`);

lines.push(
`ExecStart=${wrapper}`,
"Restart=always",
"RestartSec=2",
// The whole reason this is a system unit. Granted rather than inherited:
// the proxy runs as the operator and needs exactly one privilege.
"AmbientCapabilities=CAP_NET_BIND_SERVICE",
"CapabilityBoundingSet=CAP_NET_BIND_SERVICE",
"NoNewPrivileges=yes",
"PrivateTmp=yes",
"",
"[Install]",
"WantedBy=multi-user.target",
"",
);
return lines.join("\n");
}

/** Where moshpit-proxy's installer puts its wrapper, if it ran. */
export function proxyWrapperPath({ home = homedir(), exists = existsSync } = {}) {
const candidate = join(home, ".local/bin/moshpit-proxy");
return exists(candidate) ? candidate : null;
}

/**
* Put the proxy under supervision, and wait for it to actually hold 443.
*
* Returns a plain report rather than throwing, and every caller treats a
* failure as "no proxy" rather than as a failed run. A machine without a
* working proxy resolves Moshpit names and cannot verify them, which is worse
* than it sounds but is still enormously better than a machine whose DNS was
* refused because an optional component would not start.
*
* `listening` is asked rather than assumed: `Type=simple` reports active the
* moment it forks, so "started" and "serving" are different questions and this
* has to answer the second one. The proxy fetches a registry pin before it can
* answer, so the wait is generous.
*/
export async function ensureProxyService({
home = homedir(),
user = process.env.SUDO_USER || process.env.USER || process.env.LOGNAME,
nodeDir = dirname(process.execPath),
tlds = [],
port = 443,
exec = run,
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 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 = [];
try {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, unit);
steps.push({ step: `wrote ${path}`, ok: true });
} catch (error) {
return { ok: false, reason: "write-failed", error: error.message, before, steps };
}

const [cmd, ...flags] = systemctl;
for (const args of [[...flags, "daemon-reload"], [...flags, "enable", "--now", 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 };
}

const held = await listening(port, waitMs);
steps.push({ step: `proxy holds 127.0.0.1:${port}`, ok: held });
return { ok: held, reason: held ? null : "not-listening", before, steps, path, unit };
}

/** Poll rather than sleep once: a proxy that comes up in 2s should not cost 30. */
async function defaultPortHeld(port, waitMs) {
const { connect } = await import("node:net");
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
const open = await new Promise((resolve) => {
const socket = connect({ host: "127.0.0.1", port });
const done = (v) => { try { socket.destroy(); } catch { /* gone */ } resolve(v); };
socket.once("connect", () => done(true));
socket.once("error", () => done(false));
setTimeout(() => done(false), 1000);
});
if (open) return true;
await new Promise((r) => setTimeout(r, 1000));
}
return false;
}

/** Take the proxy service away. Missing is not a failure. */
export async function removeProxyService({ exec = run } = {}) {
const { path, systemctl } = proxyServicePaths();
const [cmd, ...flags] = systemctl;
const steps = [];
const off = await exec(cmd, [...flags, "disable", "--now", PROXY_UNIT_NAME]);
steps.push({ step: `${cmd} ${[...flags, "disable", "--now", PROXY_UNIT_NAME].join(" ")}`, ok: off.ok, error: off.error });
await rm(path, { force: true });
steps.push({ step: `removed ${path}`, ok: true });
const reload = await exec(cmd, [...flags, "daemon-reload"]);
steps.push({ step: `${cmd} ${[...flags, "daemon-reload"].join(" ")}`, ok: reload.ok, error: reload.error });
return { ok: true, path, steps };
}
67 changes: 66 additions & 1 deletion src/dns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2261,12 +2261,21 @@ export async function captureRestorePoint({
dropins = readDropins,
read = defaultReadMaybe,
now = () => new Date().toISOString(),
// Files this run creates that are not part of the resolver plan — the two
// service units, and the local root in the system trust store. Recorded the
// same way as everything else: prior content, or null for "was not here", so
// `disable` replays rather than guesses. A unit removed because it happened
// to exist is the failure this shape prevents.
extraPaths = [],
} = {}) {
const files = new Map();
for (const file of await dropins({ dir }).catch(() => [])) {
if (!dropinNameservers(file.content).length && !dropinDomains(file.content).length) continue;
files.set(`${dir}/${file.name}`, file.content);
}
for (const path of extraPaths) {
if (path && !files.has(path)) files.set(path, await read(path));
}
for (const step of plan?.steps || []) {
if (step.kind !== "write" && step.kind !== "remove") continue;
if (!files.has(step.path)) files.set(step.path, await read(step.path));
Expand Down Expand Up @@ -2368,7 +2377,7 @@ import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { isRealTld } from "./iana-tlds.mjs";
import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME } from "./dns-service.mjs";
import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME, ensureProxyService, removeProxyService, proxyServicePaths, proxyWrapperPath } from "./dns-service.mjs";
import {
applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan,
probeResolver, requiredPort, startDaemon, stopDaemon,
Expand Down Expand Up @@ -2473,6 +2482,12 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
findLocalProxyImpl = findLocalProxy,
autoTrustImpl = createAutoTrust,
stopBridge = stopDaemon,
// The two proxy-service calls, injected for the same reason as every
// other system call here: a test must be able to exercise the branch
// without shelling out to systemctl or writing to /etc.
ensureProxy = ensureProxyService,
removeProxy = removeProxyService,
proxyWrapper = proxyWrapperPath,
dropins = readDropins,
manifestFile = manifestPath(),
readManifest = async (path) => parseManifest(await defaultReadMaybe(path)),
Expand Down Expand Up @@ -3181,6 +3196,30 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {

const stopped = await stopBridge();
out(stopped.stopped ? " ok bridge stopped" : ` ok bridge was not running${stopped.reason ? ` (${stopped.reason})` : ""}`);

// The proxy service `enable` installed. Taken away here, because a
// supervised proxy left holding 443 after Moshpit is turned off is a
// service the operator never asked to keep and would not think to look
// for. Safe from an escalated run: it is a system unit, so root is
// exactly the right context to stop it in.
//
// The manifest still carries whatever was at that path beforehand, so a
// machine that already had a unit of its own gets it back rather than
// losing it to a cleanup it never asked for.
// Only when there is one. A unit that was never installed needs no
// systemctl call to not exist, and reaching for /etc on a machine that
// never had a proxy is a side effect nobody asked this command for.
// Gated on the restore point, not on what happens to be in /etc. `disable`
// undoes what `enable` did; a proxy unit this tool never installed is
// somebody else's, and stopping it because it shares a filename is
// exactly the guessing the manifest exists to prevent.
const proxyWasOurs = (restore?.files || []).some((f) => f.path === proxyServicePaths().path);
if (platform === "linux" && proxyWasOurs) {
const px = await removeProxy();
for (const step of px.steps || []) {
if (step.ok) out(` ok ${step.step}`);
}
}
// Consumed. Leaving it would let a later `disable` restore a machine to a
// state that is two changes old.
if (restore) {
Expand Down Expand Up @@ -3215,12 +3254,38 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
// run killed halfway leaves behind the one thing needed to undo it. The
// per-file backup covers the file this run overwrites; this covers the
// machine, which is a different question and the one `disable` has to ask.
// Supervised before anything goes looking for it. moshpit-proxy ships no
// unit of its own, so on every machine that installed it, it sat there
// installed, trusted and never started — which is indistinguishable from
// absent: nothing on 443, no certificate, and this command correctly
// reporting no proxy on a box that had one.
//
// Non-fatal in every direction. A machine without a working proxy resolves
// Moshpit names and cannot verify them, which is bad; a machine whose DNS
// was refused because an optional component would not start is worse.
let proxyUnitPath = null;
if (!rest.includes("--no-proxy") && platform === "linux") {
if (!proxyWrapper()) {
out(" -- no pinned-TLS proxy installed — https:// on a name will not verify");
out(" moshcode update installs it, or:");
out(" curl -fsSL https://raw.githubusercontent.com/profullstack/moshpit-proxy/main/install.sh | sh");
} else {
proxyUnitPath = proxyServicePaths().path;
const ensured = await ensureProxy({ tlds });
for (const step of ensured.steps || []) {
out(` ${step.ok ? "ok " : "-- "} ${step.step}${step.error ? ` — ${step.error}` : ""}`);
}
if (!ensured.ok) out(` -- the proxy is not serving (${ensured.reason}) — https:// will not verify`);
}
}

const point = await captureRestorePoint({
plan,
platform,
backend: platform === "linux" ? linuxBackend : platform,
bridge: `${DEFAULT_HOST}:${wanted}`,
dropins,
extraPaths: [proxyUnitPath, servicePaths({ system: false }).path].filter(Boolean),
});
const recorded2 = await applyPlan({
steps: [{ kind: "write", path: manifestFile, content: `${JSON.stringify(point, null, 2)}\n`, why: "so disable can put this machine back" }],
Expand Down
Loading
Loading