Skip to content

fix(dns): prove the bridge is there before routing the machine at it - #361

Merged
ralyodio merged 1 commit into
mainfrom
worktree-dns-daemon-verify
Aug 10, 2026
Merged

fix(dns): prove the bridge is there before routing the machine at it#361
ralyodio merged 1 commit into
mainfrom
worktree-dns-daemon-verify

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What broke

moshcode dns enable reported ok bridge started on 127.0.0.1:5354 (pid 22900) for a bridge that was already dead, then installed catch-all routing — Domains=~. — pointing every lookup on the machine at that dead port. The box lost DNS entirely, and there was no way to find out why: the daemon wrote its reason to stderr, and stderr went to /dev/null.

startDaemon spawned with stdio: "ignore", wrote the pidfile from child.pid, and returned started: true in the same tick — before the child had done anything, including exist.

const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
child.unref();
await writeFile(path, `${child.pid}\n`);
return { started: true, pid: child.pid, alreadyRunning: false };

Found on a Kubuntu desktop whose node comes from mise: under the privilege escalation enable performs, the interpreter was not where the daemon needed it. That specific cause matters less than the class — a port it cannot bind, a half-written install, a missing interpreter all arrived as the same confident success line.

What changed

  • The daemon's output is kept. stdout and stderr go to moshpit-dns.log beside the pidfile, truncated per run so a previous crash can't be mistaken for this one's.
  • An early exit is a failed start. The spawn races exit/error against a readiness probe; either one means started: false, carrying the daemon's own words back to the caller.
  • No pidfile for a process that isn't there. Writing one anyway is what made the next enable believe a bridge was already running and skip starting one.
  • "Started" means it answered. A real UDP query on the port is the proof. Any reply counts, NXDOMAIN and SERVFAIL included — a bridge whose upstreams are unreachable is still a bridge that started.
  • Alive but silent is reported, not punished. A slow registry fetch looks exactly like a bridge that will never serve, so that case returns started: true, verified: false and says so. Killing a bridge that was merely waking up is the worse mistake.
  • enable refuses to route at a bridge that is down, before writing the drop-in, and takes back the restore point it recorded.

READY_TIMEOUT_MS is 8s. It costs nothing on a real failure — a daemon that dies resolves the race immediately — and only bounds the unproven case. The bridge binds after fetching the ending list, measured at ~2.9s against the live registry with 5674 endings.

Before / after

  ok   bridge started on 127.0.0.1:5354 (pid 22900)
  ok   write  /etc/systemd/resolved.conf.d/moshpit.conf     ← machine's DNS now points at nothing
  FAIL bridge did not start on 127.0.0.1:5354 — exited 127 before it could serve

       moshcode: node not on PATH — re-run installer

Refusing to route this machine's DNS at a bridge that is not running.
Nothing has been changed.
  the daemon's output is at /run/user/1000/moshpit-dns.log
  to watch it start in the foreground:  moshcode dns start --port 5354

Tests

test/dns-daemon-verify.test.mjs, 11 cases: startup death, spawn that never ran, no stale pidfile, log capture and persistence, a bridge that really answers over UDP, alive-but-silent, the already-running short-circuit, and the four enable decisions — refuse, take back the restore point, flag the unproven, and don't accuse a starter that simply doesn't report verification.

Full suite: 1545 tests, 0 failures.

🤖 Generated with Claude Code

`startDaemon` spawned with `stdio: "ignore"`, wrote the pidfile from
`child.pid`, and returned `started: true` in the same tick — before the
child had done anything, including exist. On a machine where the daemon
dies on startup that printed `ok bridge started (pid 22900)` for a
process that was already gone, and `enable` went on to install catch-all
routing (`Domains=~.`) pointing every lookup on the box at a dead port.
The machine lost DNS entirely and the reason was unrecoverable: the
daemon had written it to stderr, which was routed to /dev/null.

Found on a Kubuntu desktop whose node comes from mise — under the
privilege escalation `enable` performs, the interpreter was not where the
daemon needed it. The specific cause matters less than the class: every
startup failure arrived as the same confident success line.

- stdout and stderr go to moshpit-dns.log next to the pidfile, truncated
  per run, so a startup failure has somewhere to have happened
- an early exit (or a spawn that never ran) is a failed start carrying
  the daemon's own output; no pidfile is left behind for a dead process,
  which is what made the next run believe a bridge was already up
- "started" now means it answered a real query on the port; alive but
  silent is reported as unproven rather than rounded up or killed, since
  a slow registry fetch looks exactly like that
- `enable` refuses to write the drop-in at all when the bridge is down,
  and takes back the restore point it recorded

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

93 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 43 | LOW: 48

Severity Rule Location
HIGH manifest-typosquat apps/pwa/package.json:19
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
MEDIUM tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:61
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:75
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:101
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:265
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:269
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:314
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:499
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:675
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:677
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:736
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:782
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:852
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:955
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1063
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1199
MEDIUM js-unescaped-html-sink apps/pwa/src/routes/moshpit.mjs:1419
MEDIUM js-dynamic-code-execution apps/pwa/test/apikey-mask.test.mjs:129
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUM sql-template-interpolation apps/pwa/test/moshpit-terms.test.mjs:192
MEDIUM sql-string-concatenation src/cli-schema.mjs:127
MEDIUM tls-verification-disabled src/dns.mjs:741
MEDIUM sql-template-interpolation src/dns.mjs:2549
MEDIUM sql-template-interpolation src/selfupdate.mjs:166
MEDIUM sql-template-interpolation src/selfupdate.mjs:170
MEDIUM sql-template-interpolation src/selfupdate.mjs:208
MEDIUM sql-template-interpolation src/selfupdate.mjs:209
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:93
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:310
MEDIUM insecure-temp-file test/plugins.test.mjs:152
MEDIUM insecure-temp-file test/pty.test.mjs:28
MEDIUM insecure-temp-file test/pty.test.mjs:31
MEDIUM insecure-temp-file test/pty.test.mjs:40
MEDIUM insecure-temp-file test/pty.test.mjs:42
MEDIUM insecure-temp-file test/pty.test.mjs:47
MEDIUM insecure-temp-file test/pty.test.mjs:48
MEDIUM insecure-temp-file test/pty.test.mjs:49
MEDIUM insecure-temp-file test/tabs.test.mjs:8
MEDIUM insecure-temp-file test/tabs.test.mjs:13
MEDIUM insecure-temp-file test/tabs.test.mjs:14
MEDIUM insecure-temp-file test/tabs.test.mjs:22
MEDIUM insecure-temp-file test/trust.test.mjs:240
LOW secret-generic-credential apps/pwa/test/apikey-bearer-scheme.test.mjs:30
LOW secret-generic-credential apps/pwa/test/apikey-mask.test.mjs:38
LOW secret-generic-credential apps/pwa/test/apikey-reveal.test.mjs:35
LOW secret-generic-credential apps/pwa/test/approvals-context.test.mjs:28
LOW secret-generic-credential apps/pwa/test/approvals-credits.test.mjs:28

…and 43 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 26d1bc6 into main Aug 10, 2026
4 checks passed
@ralyodio ralyodio mentioned this pull request Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant