From 7bb94b79efdff0292c07e345c115ed77e82b2a01 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 13:01:28 +0000 Subject: [PATCH] feat(dns): implement `dns service`, the half a reboot takes away `dns service` has been listed in DNS_VERBS since the verb table was written, so it appeared in `dns help` and in shell completion. It had no handler: `moshcode dns service` printed "unknown: dns service" and the usage block. That mattered more than a missing verb usually does, because it is the half of `dns enable` that does not survive a restart. The resolved drop-in is a file and comes back on its own; the bridge process does not. After a reboot the routing points at a port with nothing behind it and every Moshpit name stops resolving with no obvious cause. It is the whole reason Moshpit DNS holds on servers, which got a unit installed by hand, and falls over on desktops, which never did. A unit file did exist, in examples/templates/*/deploy/moshcode-dns.service, and it could not start on an ordinary install: ExecStart=/usr/bin/env moshcode dns start --port 5354 DynamicUser=yes ProtectHome=yes Three independent reasons, each fatal on its own. `moshcode` is not on systemd's PATH - it installs to ~/.local/bin. The `node` its wrapper execs is not on it either, because on a mise, nvm or asdf box that is another shim under $HOME. And ProtectHome hides the install from the service even if both had been found. It starts only where moshcode and node are both installed system-wide, which is the server case that was already working, so the unit meant to fix desktops was the one thing guaranteed not to. No static unit can be right here, so this one is generated. `process.execPath` is an interpreter demonstrably able to run this code, because it is running it, and the entry is the script the command was invoked from. Nothing is guessed and nothing depends on PATH. moshcode dns service print it, change nothing moshcode dns service --write install and start it, as this user moshcode dns service --system a system unit instead moshcode dns service --remove stop it and take it away The user scope is the default because it matches how moshcode is installed: it runs as the user, so $HOME and the shims are reachable, and systemd sets XDG_RUNTIME_DIR so the pidfile lands exactly where an unprivileged `dns status` looks. Under a system unit those are two different paths. Installing a service stays a separate verb rather than folding into `enable` - nothing here starts a service that was not asked for, and `--remove` touches the service only, never the routing. Also fixed, both in the path of this change: `docs/hosting-a-moshpit-name.md` and the `dns` examples in cli-schema told people to type `sudo moshcode dns enable`. The CLI escalates the one step that needs root by itself, and `sudo moshcode` takes every path from the wrong $HOME. Verified on a live systemd user session: the generated unit starts, binds, forwards clearnet, answers Moshpit names, and comes back after SIGKILL under Restart=always. Install and removal were exercised end to end on a spare port so the box's real bridge was never shadowed. Suite: 2707 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ThnQwoieWt8VR6N7gtgnhp --- docs/hosting-a-moshpit-name.md | 48 +++++- .../deploy/moshcode-dns.service | 29 +++- .../caddy-proxy/deploy/moshcode-dns.service | 29 +++- .../caddy-static/deploy/moshcode-dns.service | 29 +++- src/cli-schema.mjs | 13 +- src/dns-service.mjs | 157 ++++++++++++++++++ src/dns.mjs | 52 ++++++ test/dns-service.test.mjs | 101 +++++++++++ 8 files changed, 425 insertions(+), 33 deletions(-) create mode 100644 src/dns-service.mjs create mode 100644 test/dns-service.test.mjs diff --git a/docs/hosting-a-moshpit-name.md b/docs/hosting-a-moshpit-name.md index d6bbacce..a60b7b25 100644 --- a/docs/hosting-a-moshpit-name.md +++ b/docs/hosting-a-moshpit-name.md @@ -98,21 +98,61 @@ anyone scanning the box the app with the name stripped off the front. On every machine that should see the name: ```sh -sudo moshcode dns enable +moshcode dns enable ``` +Do not prefix it with `sudo`. The command escalates the one step that needs +root and re-runs only that; `sudo moshcode` takes every path from the wrong +`$HOME`. + That does two things: writes a `systemd-resolved` drop-in routing Moshpit endings at the bridge, and starts the bridge. The drop-in is a file and survives a reboot. **The bridge process does not** — so after a restart the routing points at a port with nothing behind it, and every Moshpit name stops resolving -with no obvious cause. The bundled `deploy/moshcode-dns.service` is the missing -half: +with no obvious cause. This is the whole reason Moshpit DNS holds on servers, +which got a unit installed by hand, and quietly falls over on desktops, which +never did. + +`dns service` is the missing half: + +```sh +moshcode dns service # print the unit, change nothing +moshcode dns service --write # install and start it, no root needed +``` + +That installs a **user** service, which is the right scope for a per-user +install: it runs as you, so `$HOME` and the mise/nvm shims are reachable, and +the pidfile lands where an unprivileged `dns status` looks for it. A user +service stops at logout unless you allow it to linger: + +```sh +loginctl enable-linger "$USER" +``` + +For a box with moshcode installed system-wide, a system unit is available and +does need root to place: ```sh -sudo cp deploy/moshcode-dns.service /etc/systemd/system/ +moshcode dns service --system | sudo tee /etc/systemd/system/moshcode-dns.service sudo systemctl enable --now moshcode-dns ``` +Either way the unit is **generated from the running process**, so `ExecStart` +names the interpreter that is demonstrably working and the install that is +really there. Do not hand-write one or copy another machine's: moshcode lives +under `$HOME` and its wrapper execs whatever `node` is first on `PATH`, and +systemd has neither on its own `PATH`. A unit that guesses fails at `203/EXEC` +with nothing useful in the journal. Regenerate it after moving or reinstalling +either program. + +Removing it again: + +```sh +moshcode dns service --remove +``` + +That takes away the service only. The routing is `dns disable`. + ## Verifying, one layer at a time Every layer fails identically in a browser, so do not debug from one. diff --git a/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service b/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service index 30ba56ec..e6d9eaea 100644 --- a/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service +++ b/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service @@ -7,8 +7,22 @@ # and every Moshpit name stops resolving with no obvious cause. This unit is # the missing half. # -# sudo cp deploy/moshcode-dns.service /etc/systemd/system/ -# sudo systemctl enable --now moshcode-dns +# GENERATE IT, DO NOT COPY THIS ONE: +# +# moshcode dns service --write # this user's own service, needs no root +# moshcode dns service --system | sudo tee /etc/systemd/system/moshcode-dns.service +# +# `dns service` writes ExecStart from the running process, so it names the node +# that demonstrably works and the install that is actually on the box. A static +# file cannot: moshcode installs under $HOME, and its wrapper execs whatever +# `node` is first on PATH — which on any mise, nvm or asdf machine is another +# shim under $HOME. systemd has neither on its PATH and, with ProtectHome or +# DynamicUser set, could not read them if it did. A unit that gets this wrong +# fails at 203/EXEC with nothing useful in the journal. +# +# What is left below therefore assumes the one case it can safely assume: both +# moshcode and node installed system-wide, service running as root. Check +# `systemctl status moshcode-dns` before believing it. # # Install this on machines that need to REACH Moshpit names. A box that only # serves one does not need it — Caddy answers whatever Host header arrives and @@ -22,18 +36,15 @@ Before=systemd-resolved.service [Service] Type=simple -# Port 5354 is unprivileged, so this does not need root. The trade-off is that -# the parking responder cannot take port 80 and falls back to the public -# parking address — which only affects names that point nowhere yet. -ExecStart=/usr/bin/env moshcode dns start --port 5354 +# Port 5354 is unprivileged, so this does not need root for the DNS side. The +# trade-off is that the parking responder cannot take port 80 and falls back to +# the public parking address — which only affects names that point nowhere yet. +ExecStart=/usr/local/bin/moshcode dns start --port 5354 Restart=always RestartSec=2 -DynamicUser=yes NoNewPrivileges=yes PrivateTmp=yes -ProtectSystem=strict -ProtectHome=yes [Install] WantedBy=multi-user.target diff --git a/examples/templates/caddy-proxy/deploy/moshcode-dns.service b/examples/templates/caddy-proxy/deploy/moshcode-dns.service index 30ba56ec..e6d9eaea 100644 --- a/examples/templates/caddy-proxy/deploy/moshcode-dns.service +++ b/examples/templates/caddy-proxy/deploy/moshcode-dns.service @@ -7,8 +7,22 @@ # and every Moshpit name stops resolving with no obvious cause. This unit is # the missing half. # -# sudo cp deploy/moshcode-dns.service /etc/systemd/system/ -# sudo systemctl enable --now moshcode-dns +# GENERATE IT, DO NOT COPY THIS ONE: +# +# moshcode dns service --write # this user's own service, needs no root +# moshcode dns service --system | sudo tee /etc/systemd/system/moshcode-dns.service +# +# `dns service` writes ExecStart from the running process, so it names the node +# that demonstrably works and the install that is actually on the box. A static +# file cannot: moshcode installs under $HOME, and its wrapper execs whatever +# `node` is first on PATH — which on any mise, nvm or asdf machine is another +# shim under $HOME. systemd has neither on its PATH and, with ProtectHome or +# DynamicUser set, could not read them if it did. A unit that gets this wrong +# fails at 203/EXEC with nothing useful in the journal. +# +# What is left below therefore assumes the one case it can safely assume: both +# moshcode and node installed system-wide, service running as root. Check +# `systemctl status moshcode-dns` before believing it. # # Install this on machines that need to REACH Moshpit names. A box that only # serves one does not need it — Caddy answers whatever Host header arrives and @@ -22,18 +36,15 @@ Before=systemd-resolved.service [Service] Type=simple -# Port 5354 is unprivileged, so this does not need root. The trade-off is that -# the parking responder cannot take port 80 and falls back to the public -# parking address — which only affects names that point nowhere yet. -ExecStart=/usr/bin/env moshcode dns start --port 5354 +# Port 5354 is unprivileged, so this does not need root for the DNS side. The +# trade-off is that the parking responder cannot take port 80 and falls back to +# the public parking address — which only affects names that point nowhere yet. +ExecStart=/usr/local/bin/moshcode dns start --port 5354 Restart=always RestartSec=2 -DynamicUser=yes NoNewPrivileges=yes PrivateTmp=yes -ProtectSystem=strict -ProtectHome=yes [Install] WantedBy=multi-user.target diff --git a/examples/templates/caddy-static/deploy/moshcode-dns.service b/examples/templates/caddy-static/deploy/moshcode-dns.service index 30ba56ec..e6d9eaea 100644 --- a/examples/templates/caddy-static/deploy/moshcode-dns.service +++ b/examples/templates/caddy-static/deploy/moshcode-dns.service @@ -7,8 +7,22 @@ # and every Moshpit name stops resolving with no obvious cause. This unit is # the missing half. # -# sudo cp deploy/moshcode-dns.service /etc/systemd/system/ -# sudo systemctl enable --now moshcode-dns +# GENERATE IT, DO NOT COPY THIS ONE: +# +# moshcode dns service --write # this user's own service, needs no root +# moshcode dns service --system | sudo tee /etc/systemd/system/moshcode-dns.service +# +# `dns service` writes ExecStart from the running process, so it names the node +# that demonstrably works and the install that is actually on the box. A static +# file cannot: moshcode installs under $HOME, and its wrapper execs whatever +# `node` is first on PATH — which on any mise, nvm or asdf machine is another +# shim under $HOME. systemd has neither on its PATH and, with ProtectHome or +# DynamicUser set, could not read them if it did. A unit that gets this wrong +# fails at 203/EXEC with nothing useful in the journal. +# +# What is left below therefore assumes the one case it can safely assume: both +# moshcode and node installed system-wide, service running as root. Check +# `systemctl status moshcode-dns` before believing it. # # Install this on machines that need to REACH Moshpit names. A box that only # serves one does not need it — Caddy answers whatever Host header arrives and @@ -22,18 +36,15 @@ Before=systemd-resolved.service [Service] Type=simple -# Port 5354 is unprivileged, so this does not need root. The trade-off is that -# the parking responder cannot take port 80 and falls back to the public -# parking address — which only affects names that point nowhere yet. -ExecStart=/usr/bin/env moshcode dns start --port 5354 +# Port 5354 is unprivileged, so this does not need root for the DNS side. The +# trade-off is that the parking responder cannot take port 80 and falls back to +# the public parking address — which only affects names that point nowhere yet. +ExecStart=/usr/local/bin/moshcode dns start --port 5354 Restart=always RestartSec=2 -DynamicUser=yes NoNewPrivileges=yes PrivateTmp=yes -ProtectSystem=strict -ProtectHome=yes [Install] WantedBy=multi-user.target diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 58cb2af8..4cc8fb6c 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -390,7 +390,7 @@ export const CORE_CLI_COMMANDS = [ ["--no-proxy", "with enable: answer origins rather than the local proxy", ""], ], examples: [ - ["sudo moshcode dns enable", "route Moshpit endings here"], + ["moshcode dns enable", "route Moshpit endings here (it escalates itself)"], ["moshcode dns resolve blue.eggs", "what a machine actually gets"], ], seeAlso: ["doh", "site"], @@ -1009,7 +1009,16 @@ export const DNS_VERBS = [ { name: "refresh", description: "re-apply routing for endings claimed since" }, { name: "start", description: "run the bridge in the foreground" }, { name: "install", description: "print the resolver config without applying it" }, - { name: "service", description: "install or remove the background service" }, + { + name: "service", + description: "keep the bridge running across reboots — the half `enable` does not survive", + synopsis: [ + ["moshcode dns service", "print the unit for this install, change nothing"], + ["moshcode dns service --write", "install and start it as this user; needs no root"], + ["moshcode dns service --system", "a system unit instead — place it with sudo tee"], + ["moshcode dns service --remove", "stop it and take the unit away"], + ], + }, { name: "tlds", description: "list the endings claimed in the Pit" }, { name: "resolve", description: "what a name resolves to, and why" }, { name: "trust", description: "trust one name's certificate, after checking it against the registry pin" }, diff --git a/src/dns-service.mjs b/src/dns-service.mjs new file mode 100644 index 00000000..1cab6ff0 --- /dev/null +++ b/src/dns-service.mjs @@ -0,0 +1,157 @@ +// Keeping the bridge running across reboots. +// +// `dns enable` sets up two halves with different lifetimes: a systemd-resolved +// drop-in, which is a file and survives a reboot on its own, and the bridge +// process, which does not. After a restart the routing still points at a port +// with nothing behind it and every Moshpit name stops resolving with no +// obvious cause. This is the missing half, and it is why Moshpit DNS has held +// on servers — which got a unit installed by hand — and quietly fallen over on +// desktops, which never did. +// +// The unit is *generated* rather than shipped as a file, because no static +// unit can be correct for this tool. moshcode installs under the invoking +// user's $HOME, and its wrapper execs whatever `node` is first on PATH — which +// on any mise, nvm or asdf box is another shim under $HOME. The unit that +// shipped in examples/templates said: +// +// ExecStart=/usr/bin/env moshcode dns start --port 5354 +// DynamicUser=yes +// ProtectHome=yes +// +// and could not start on an ordinary install three times over: `moshcode` is +// not on systemd's PATH, the `node` its wrapper needs is not on it either, and +// ProtectHome hides the install from the service even if both had been found. +// It starts only where moshcode and node are both installed system-wide, which +// is the server case — the one that was already working. +// +// So the unit is written from the running process instead. `process.execPath` +// is an interpreter demonstrably able to run this code, because it is running +// it, and the entry is the script this very command was invoked from. Nothing +// is guessed and nothing depends on PATH. +import { spawn } from "node:child_process"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export const UNIT_NAME = "moshcode-dns.service"; + +/** + * Where the unit goes, and which systemctl reaches it. + * + * The user scope is the default because it is the one that fits how moshcode + * is installed: a per-user tool, run by the user who owns the install, with + * $HOME and the mise shims reachable exactly as they are in a shell. It also + * puts the pidfile where the unprivileged `dns status` looks — systemd sets + * XDG_RUNTIME_DIR for user units, so `pidfilePath()` resolves to + * /run/user//moshpit-dns.pid for the daemon and for the person asking + * after it. Under a system unit those are two different paths. + */ +export function servicePaths({ system = false, home = homedir() } = {}) { + return system + ? { path: join("/etc/systemd/system", UNIT_NAME), systemctl: ["systemctl"], scope: "system" } + : { path: join(home, ".config/systemd/user", UNIT_NAME), systemctl: ["systemctl", "--user"], scope: "user" }; +} + +/** + * The unit text, pinned to this install. + * + * `ProtectHome` is deliberately absent rather than set to a weaker value: the + * whole program lives under $HOME, so there is no setting of it that both + * protects anything and lets the service start. The hardening that survives is + * the hardening that does not contradict where the code is. + */ +export function serviceUnit({ + system = false, + execPath = process.execPath, + entry, + port, + registryBase = null, + user = process.env.USER || process.env.LOGNAME, +} = {}) { + if (!entry) throw new Error("serviceUnit needs the entry script to run"); + + const args = [entry, "dns", "start", "--port", String(port)]; + if (registryBase) args.push("--registry", registryBase); + const exec = [execPath, ...args].map((part) => (/\s/.test(part) ? JSON.stringify(part) : part)).join(" "); + + const lines = [ + "# Generated by `moshcode dns service`. Regenerate it rather than editing:", + "# the paths below are this install's, and a moshcode or node that moves", + "# leaves a unit that fails at 203/EXEC with nothing else to say.", + "[Unit]", + "Description=Moshpit DNS bridge", + "Documentation=https://github.com/moshcoder/moshcode", + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=simple", + `ExecStart=${exec}`, + "Restart=always", + "RestartSec=2", + // 5354 is unprivileged, so nothing here needs root or a capability. + "NoNewPrivileges=yes", + "PrivateTmp=yes", + ]; + + if (system) { + // A system unit has no user of its own, so it has to be told which install + // to run — and it is the only scope where that question has a wrong answer. + lines.push(`User=${user}`); + } + + lines.push( + "", + "[Install]", + system ? "WantedBy=multi-user.target" : "WantedBy=default.target", + "", + ); + return lines.join("\n"); +} + +function run(command, args) { + return new Promise((resolve) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let err = ""; + 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() })); + }); +} + +/** Write the unit and start it. Returns the steps taken, in order, for printing. */ +export async function installService(unit, { system = false, home = homedir(), exec = run } = {}) { + const { path, systemctl, scope } = servicePaths({ system, home }); + 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, path, scope, steps: [{ step: `could not write ${path}: ${error.message}`, ok: false }] }; + } + + const [cmd, ...flags] = systemctl; + for (const args of [[...flags, "daemon-reload"], [...flags, "enable", "--now", 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, path, scope, steps }; + } + return { ok: true, path, scope, steps }; +} + +/** Stop it and take the unit away. Missing is not a failure — removal is idempotent. */ +export async function removeService({ system = false, home = homedir(), exec = run } = {}) { + const { path, systemctl, scope } = servicePaths({ system, home }); + const [cmd, ...flags] = systemctl; + const steps = []; + for (const args of [[...flags, "disable", "--now", UNIT_NAME]]) { + const result = await exec(cmd, args); + steps.push({ step: `${cmd} ${args.join(" ")}`, ok: result.ok, error: result.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, scope, steps }; +} diff --git a/src/dns.mjs b/src/dns.mjs index f195e79c..368851c4 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -2278,6 +2278,7 @@ import { applyTrust, applyUntrust, createAutoTrust, trustName, verifyStockTls } import { readFile, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME } from "./dns-service.mjs"; import { applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan, probeResolver, requiredPort, startDaemon, stopDaemon, @@ -2310,6 +2311,10 @@ const USAGE = `moshcode dns — resolve Moshpit names on this machine lands on the Pit; --parking-port N, --no-parking-http --no-filter runs it with blocklists off moshcode dns install [--write] print the resolver config without applying it + moshcode dns service print a systemd unit that keeps the bridge + running across reboots; --write installs and + starts it, --system for a system unit rather + than this user's, --remove takes it away moshcode dns filter block ads, trackers, malware and phishing at the resolver — \`moshcode dns filter help\` for the verbs @@ -2684,6 +2689,53 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { return 0; } + // The half of `enable` that a reboot takes away. Kept a separate verb rather + // than folded into `enable`, because installing a service is exactly the kind + // of thing that must stay something a person asked for. + if (sub === "service") { + const system = rest.includes("--system"); + const { path: unitPath, systemctl, scope } = servicePaths({ system }); + + const report = (steps) => { + for (const s of steps) out(`${s.ok ? "ok" : "!!"} ${s.step}${s.error ? ` — ${s.error}` : ""}`); + }; + + if (rest.includes("--remove")) { + report((await removeService({ system })).steps); + out(""); + out("the routing is untouched — `moshcode dns disable` is what removes that"); + return 0; + } + + const unit = serviceUnit({ system, entry: cliEntry(), port, registryBase }); + + if (rest.includes("--write")) { + const result = await installService(unit, { system }); + report(result.steps); + if (!result.ok) { + out(""); + out(system + ? `(writing ${unitPath} needs root — pipe it instead: moshcode dns service --system | sudo tee ${unitPath})` + : "(the user scope needs no privileges — this failure is something else)"); + return 1; + } + out(""); + out(`the bridge now starts with the machine (${scope} scope)`); + if (!system) { + const who = process.env.USER || process.env.LOGNAME || "$USER"; + out(` a user service stops at logout — to survive one: loginctl enable-linger ${who}`); + } + out(" check it: moshcode dns status"); + return 0; + } + + out(`# ${unitPath}`); + out(unit); + out(`# install it with: moshcode dns service --write${system ? " --system" : ""}`); + out(`# then: ${systemctl.join(" ")} status ${UNIT_NAME}`); + return 0; + } + if (sub === "enable" || sub === "disable") { const platform = platformImpl(); if (!platform) { diff --git a/test/dns-service.test.mjs b/test/dns-service.test.mjs new file mode 100644 index 00000000..199e1b78 --- /dev/null +++ b/test/dns-service.test.mjs @@ -0,0 +1,101 @@ +// What the generated unit is not allowed to be. +// +// A unit file shipped in examples/templates said this, and could not start on +// an ordinary install: +// +// ExecStart=/usr/bin/env moshcode dns start --port 5354 +// DynamicUser=yes +// ProtectHome=yes +// +// Three independent reasons, and each one alone is fatal. `moshcode` is not on +// systemd's PATH — it installs to ~/.local/bin. The `node` its wrapper execs is +// not on it either, because on a mise, nvm or asdf box that is a shim under +// $HOME. And ProtectHome hides the install from the service even if both had +// somehow been found. It starts only where moshcode and node are both installed +// system-wide, which is the server case that was already working — so the unit +// that existed to fix desktops was the one thing guaranteed not to. +// +// These tests are mostly negative for that reason: the failure mode is a unit +// 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 { 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"; + +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 }); + +test("ExecStart names an interpreter by absolute path, never a PATH lookup", () => { + const text = unit(); + assert.match(text, /^ExecStart=\/opt\/node\/bin\/node \/opt\/moshcode\/bin\/moshcode\.mjs dns start --port 5354$/m); + assert.doesNotMatch(text, /\/usr\/bin\/env/, "env re-introduces the PATH lookup that could not find moshcode or node"); +}); + +test("the three settings that made the shipped unit unstartable are absent", () => { + const text = unit(); + assert.doesNotMatch(text, /DynamicUser/, "a transient user has no $HOME to find the install in"); + assert.doesNotMatch(text, /ProtectHome/, "the whole program lives under $HOME"); +}); + +test("a user unit does not pin a user, and installs into the user's own tree", () => { + const text = unit({ system: false }); + assert.doesNotMatch(text, /^User=/m, "a user unit already runs as the user"); + assert.match(text, /^WantedBy=default\.target$/m); + + const { path, systemctl, scope } = servicePaths({ system: false, home: "/home/someone" }); + assert.equal(path, `/home/someone/.config/systemd/user/${UNIT_NAME}`); + assert.deepEqual(systemctl, ["systemctl", "--user"]); + assert.equal(scope, "user"); +}); + +test("a system unit has to be told whose install to run", () => { + const text = unit({ system: true, user: "ettinger" }); + assert.match(text, /^User=ettinger$/m, "without this it runs as root and looks for the install in /root"); + assert.match(text, /^WantedBy=multi-user\.target$/m); + assert.equal(servicePaths({ system: true }).path, `/etc/systemd/system/${UNIT_NAME}`); +}); + +test("the port and registry the bridge was asked for reach the unit", () => { + const text = unit({ port: 5355, registryBase: "https://pit.example.test" }); + assert.match(text, /--port 5355/); + assert.match(text, /--registry https:\/\/pit\.example\.test/); +}); + +test("a path with a space survives being written into ExecStart", () => { + // "Application Support" and "Program Files" both contain one, and systemd + // splits ExecStart on whitespace. + const text = unit({ execPath: "/opt/my node/bin/node" }); + assert.match(text, /ExecStart="\/opt\/my node\/bin\/node"/); +}); + +test("a unit without an entry is refused rather than written half-formed", () => { + assert.throws(() => serviceUnit({ port: 5354 }), /entry/); +}); + +test("install writes the unit, then reloads and enables — and stops at the first failure", async () => { + const home = await scratch(); + const calls = []; + const exec = async (cmd, args) => { + calls.push(`${cmd} ${args.join(" ")}`); + return { ok: !args.includes("enable"), error: args.includes("enable") ? "Failed to enable" : "" }; + }; + + const result = await installService(unit(), { home, exec }); + assert.equal(result.ok, false); + assert.deepEqual(calls, ["systemctl --user daemon-reload", `systemctl --user enable --now ${UNIT_NAME}`]); + assert.equal(existsSync(join(home, ".config/systemd/user", UNIT_NAME)), true, "the file is written before systemctl is asked about it"); + assert.equal(result.steps.at(-1).error, "Failed to enable", "the reason systemctl gave is carried back, not swallowed"); +}); + +test("remove takes the unit away even when it was never enabled", async () => { + const home = await scratch(); + // `disable` on a unit systemd has never heard of is an error, and removal + // still has to work — otherwise a half-installed service cannot be undone. + const result = await removeService({ home, exec: async () => ({ ok: false, error: "does not exist" }) }); + assert.equal(result.ok, true); + assert.equal(existsSync(join(home, ".config/systemd/user", UNIT_NAME)), false); +});