Skip to content

Commit d065031

Browse files
ralyodioclaude
andauthored
dns: ask the port what is running, not just our pidfile (#425)
* dns: ask the port what is running, not just our pidfile `moshcode dns status` answered "is the bridge running" from its own pidfile alone. That file only ever describes a bridge this tool started, in this privilege context, so every other way a bridge reaches 5354 read as `bridge not running`: a systemd unit, a hand-started `dns start`, or — the common one — an `enable` that escalated to root and therefore wrote its pidfile under root's HOME instead of the invoking user's runtime dir. That was not a cosmetic lie. Status followed it with "routing is in place but the bridge is not running — Moshpit names will fail" and advised `sudo moshcode dns enable`. Following that advice starts a second bridge bound to 127.0.0.1:5354 while the working one holds 0.0.0.0:5354; the kernel delivers to the more specific socket, so the healthy bridge stops receiving anything and the machine loses DNS. portHolder's comment already describes that outage twice over — this is the path that walks into it by following our own instructions. So the port gets asked. `bridgePresence` puts both questions that matter, a Moshpit name and a clearnet one, because a resolver that stopped answering loses a namespace and one that stopped forwarding takes the box off the internet. A bridge nothing here started is reported as a bridge, with its pid, and the alarm now fires on "nothing answers" rather than "our pidfile is empty". A new warning covers answering-but-not-forwarding, which was previously invisible from the Moshpit side. The remedy also drops its `sudo`: the CLI escalates the one step that needs root, and teaching `sudo moshcode` is how `sudo moshcode update` ends up reinstalling the tool into /root. Two supporting fixes: - pidfilePath honours SUDO_UID, so the escalated run that starts the bridge records it where the unprivileged runs that ask about it will look. Only when that runtime dir actually exists — deriving /run/user/<uid> on a machine without one swaps an unreadable path for a missing one, so macOS falls through unchanged. - status uses the injected fetchTlds, like every other caller. Reaching past it made status the one subcommand untestable without a network. Verified against a box running the bridge as a systemd unit: before, `not running` plus the shadowing advice; after, `answering on 127.0.0.1:5354 (pid 1330, bun) — started by something other than \`dns enable\``. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * dns: inject the routing check so status tests do not read the host The two new status tests passed here and failed on CI, and the tests were right to fail: `status` read `/etc/systemd/resolved.conf.d/moshpit.conf` off the real filesystem, so "is this machine routed" was answered by whichever machine happened to be running the suite. Green on a developer's box with Moshpit enabled, red on a clean runner. So `exists` joins the other injected system calls, and the tests state the routing they are asserting about instead of inheriting it. Adds the case that was missing on both sides: an unrouted machine with no bridge is not an emergency and must not print the alarm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c0a1751 commit d065031

3 files changed

Lines changed: 360 additions & 13 deletions

File tree

src/dns-system.mjs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,24 @@ function defaultRunner(command, args) {
332332
* root to listen on 5354, and requiring it to write a pidfile somewhere
333333
* privileged would make the whole daemon need privileges it otherwise does not.
334334
*/
335-
export function pidfilePath() {
336-
const base = process.env.XDG_RUNTIME_DIR || join(homedir(), ".moshcode") || tmpdir();
335+
export function pidfilePath(env = process.env, exists = existsSync) {
336+
// `dns enable` escalates, so the run that *starts* the bridge is root and the
337+
// runs that later ask about it are not. Under sudo both XDG_RUNTIME_DIR and
338+
// HOME belong to root, so the pidfile went to /root/.moshcode — a path the
339+
// unprivileged `dns status` and `dns disable` never look at and could not
340+
// read if they did. The bridge was reported "not running" for the rest of its
341+
// life, and the fix status advised started a second one on top of it.
342+
//
343+
// So an escalated run records against the invoking user's runtime dir, and
344+
// only when that directory is really there: deriving /run/user/<uid> on a
345+
// machine without one trades an unreadable path for a nonexistent one. macOS
346+
// has no /run/user and falls through unchanged — the escalated paths this
347+
// matters for are the systemd-resolved ones.
348+
const invoker = env.SUDO_UID ? `/run/user/${env.SUDO_UID}` : null;
349+
const base = (invoker && exists(invoker) ? invoker : null)
350+
|| env.XDG_RUNTIME_DIR
351+
|| join(homedir(), ".moshcode")
352+
|| tmpdir();
337353
return join(base, "moshpit-dns.pid");
338354
}
339355

src/dns.mjs

Lines changed: 119 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1742,7 +1742,7 @@ export function parseUdpListeners(text) {
17421742
return out;
17431743
}
17441744

1745-
const defaultUdpListeners = async () => {
1745+
export const defaultUdpListeners = async () => {
17461746
const { execFile } = await import("node:child_process");
17471747
const text = await new Promise((resolve) => {
17481748
execFile("ss", ["-lnup"], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : String(stdout)));
@@ -1783,6 +1783,89 @@ export function portHolder(listeners, { host = DEFAULT_HOST, port = DEFAULT_PORT
17831783
return null;
17841784
}
17851785

1786+
/**
1787+
* What is actually on the bridge's port, rather than what our pidfile claims.
1788+
*
1789+
* `status` used to answer this from the pidfile alone, and that file only ever
1790+
* describes a bridge *this tool* started, in *this* privilege context. Every
1791+
* other way a bridge reaches 5354 read as "not running": a systemd unit, a
1792+
* hand-started `dns start`, or — the common one — an `enable` that escalated to
1793+
* root and therefore wrote its pidfile under root's HOME instead of the
1794+
* invoking user's runtime dir.
1795+
*
1796+
* That is not a cosmetic lie. Status followed it with "routing is in place but
1797+
* the bridge is not running", and the fix it advised starts a second bridge on
1798+
* 127.0.0.1 while the working one holds 0.0.0.0. The kernel delivers to the
1799+
* more specific socket, so the advice shadows the bridge it was meant to
1800+
* rescue and the machine stops resolving — the outage `portHolder` above
1801+
* already describes, arrived at this time by following our own instructions.
1802+
*
1803+
* So the port gets asked. A bridge nothing here started is still a bridge.
1804+
* Both questions are put because they fail differently: a resolver that has
1805+
* stopped answering Moshpit names loses a namespace, and one that has stopped
1806+
* forwarding takes the machine off the internet.
1807+
*/
1808+
export async function bridgePresence({
1809+
host = DEFAULT_HOST,
1810+
port = DEFAULT_PORT,
1811+
recorded = { running: false, pid: null, stale: false },
1812+
listeners = defaultUdpListeners,
1813+
answers = probeResolver,
1814+
forwards = probeForwarding,
1815+
} = {}) {
1816+
const [moshpit, clearnet] = await Promise.all([
1817+
answers({ host, port }).catch(() => false),
1818+
forwards({ host, port, name: CLEARNET_PROBE }).catch(() => false),
1819+
]);
1820+
const answering = Boolean(moshpit || clearnet);
1821+
1822+
// Ours and alive is the ordinary case, and the probe still runs first: a
1823+
// recorded pid that no longer answers is worth saying out loud rather than
1824+
// reporting as a healthy bridge on the strength of the file alone.
1825+
if (recorded.running) {
1826+
return { kind: "ours", pid: recorded.pid, answering, forwards: clearnet, moshpit };
1827+
}
1828+
1829+
if (!answering) {
1830+
return recorded.stale
1831+
? { kind: "stale", pid: recorded.pid, answering: false, forwards: false, moshpit: false }
1832+
: { kind: "none", pid: null, answering: false, forwards: false, moshpit: false };
1833+
}
1834+
1835+
// Only asked once something is known to be there, because `ss` is the
1836+
// expensive half and an unattributable owner is not a reason to call a
1837+
// demonstrably answering bridge absent.
1838+
const holder = portHolder(await listeners().catch(() => []), { host, port });
1839+
return {
1840+
kind: "foreign",
1841+
pid: holder?.pid ?? null,
1842+
process: holder?.process ?? null,
1843+
answering: true,
1844+
forwards: clearnet,
1845+
moshpit,
1846+
};
1847+
}
1848+
1849+
/** One line for `status`, kept next to the states it names. */
1850+
export function describeBridge(presence, { host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
1851+
switch (presence.kind) {
1852+
case "ours":
1853+
return presence.answering
1854+
? `running (pid ${presence.pid})`
1855+
: `running (pid ${presence.pid}) — but not answering on ${host}:${port}`;
1856+
case "foreign": {
1857+
const who = presence.pid
1858+
? `pid ${presence.pid}${presence.process ? `, ${presence.process}` : ""}`
1859+
: "owner not visible";
1860+
return `answering on ${host}:${port} (${who}) — started by something other than \`dns enable\``;
1861+
}
1862+
case "stale":
1863+
return `NOT running — stale pidfile for ${presence.pid}`;
1864+
default:
1865+
return "not running";
1866+
}
1867+
}
1868+
17861869
/**
17871870
* Everything that has to be true of the machine before the routing is written.
17881871
*
@@ -2151,7 +2234,7 @@ import { existsSync } from "node:fs";
21512234
import { fileURLToPath } from "node:url";
21522235
import {
21532236
applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan,
2154-
requiredPort, startDaemon, stopDaemon,
2237+
probeResolver, requiredPort, startDaemon, stopDaemon,
21552238
} from "./dns-system.mjs";
21562239
import { escalateSelf } from "./escalate.mjs";
21572240

@@ -2230,6 +2313,8 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
22302313
applyWith = applyWithRollback,
22312314
verify = verifyResolution,
22322315
bridgeStatus = daemonStatus,
2316+
presenceImpl = bridgePresence,
2317+
exists = existsSync,
22332318
startBridge = startDaemon,
22342319
proxyReachableImpl = proxyReachable,
22352320
findLocalProxyImpl = findLocalProxy,
@@ -3009,25 +3094,48 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
30093094

30103095
if (sub === "status") {
30113096
const platform = detectPlatform();
3012-
const daemon = await daemonStatus();
3097+
const daemon = await bridgeStatus();
3098+
const statusPort = requiredPort(platform, port);
3099+
const presence = await presenceImpl({ port: statusPort, recorded: daemon });
30133100
out(`platform ${platform || process.platform}`);
3014-
out(`bridge ${daemon.running ? `running (pid ${daemon.pid})` : daemon.stale ? `NOT running — stale pidfile for ${daemon.pid}` : "not running"}`);
3101+
out(`bridge ${describeBridge(presence, { port: statusPort })}`);
30153102

30163103
// Routing is read off the filesystem rather than remembered, so a config
30173104
// someone edited or removed by hand is reported as it actually is.
30183105
const marker = platform === "macos" ? "/etc/resolver" : MOSHPIT_DROPIN;
3019-
const routed = platform === "linux" ? existsSync(marker) : platform === "macos" ? existsSync(marker) : null;
3106+
// Injected like every other system call this command makes. Read straight
3107+
// off the filesystem, "is this machine routed" made the status tests depend
3108+
// on whether the machine running them happened to have Moshpit enabled —
3109+
// green on a developer's box, red on a clean runner.
3110+
const routed = platform === "linux" || platform === "macos" ? exists(marker) : null;
30203111
out(`routing ${routed === null ? "(check NRPT: Get-DnsClientNrptRule)" : routed ? `configured (${marker})` : "not configured"}`);
30213112

3022-
// The state worth shouting about: names are pointed at a bridge that is not
3023-
// there, so every Moshpit name fails instead of falling through.
3024-
if (routed && !daemon.running) {
3113+
// The state worth shouting about, and the condition is "nothing answers"
3114+
// rather than "our pidfile is empty". Those are not the same machine, and
3115+
// shouting on the second one sent people to start a bridge that shadowed
3116+
// the working one they already had.
3117+
//
3118+
// The advice drops its `sudo` too: the CLI escalates the one step that
3119+
// needs root, and teaching `sudo moshcode` is how `sudo moshcode update`
3120+
// ends up reinstalling the whole tool into /root.
3121+
if (routed && !presence.answering) {
3122+
out("");
3123+
out(`! routing is in place but nothing answers on ${DEFAULT_HOST}:${statusPort} — Moshpit names will fail.`);
3124+
out(" fix with: moshcode dns enable undo with: moshcode dns disable");
3125+
}
3126+
3127+
// Answering but not forwarding is the dangerous half, and it is invisible
3128+
// from the Moshpit side: names resolve, and everything else on the machine
3129+
// stops. Catch-all routing is what makes it total.
3130+
if (routed && presence.answering && !presence.forwards) {
30253131
out("");
3026-
out("! routing is in place but the bridge is not running — Moshpit names will fail.");
3027-
out(" fix with: sudo moshcode dns enable undo with: sudo moshcode dns disable");
3132+
out(`! the bridge on ${DEFAULT_HOST}:${statusPort} answers Moshpit names but is not forwarding`);
3133+
out(" clearnet lookups routed through it will fail. Restart it, or `moshcode dns disable`.");
30283134
}
30293135

3030-
const known = await fetchTlds({ registryBase }).catch(() => null);
3136+
// The injected one, like every other caller. Reaching past it here made
3137+
// `status` the one subcommand that could not be tested without a network.
3138+
const known = await fetchTldsImpl({ registryBase }).catch(() => null);
30313139
const probe = known
30323140
? await resolveName(`probe.${known[0] || "moshpit"}`, { registryBase }).catch(() => null)
30333141
: null;

0 commit comments

Comments
 (0)