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); +});