Skip to content

Commit a20b0ea

Browse files
ralyodioclaude
andauthored
fix(dns): make dns enable a switch that can be undone, not a one-way write (#259)
`dns enable` repoints every lookup on the machine and had no way back. It wrote the drop-in, restarted systemd-resolved, started the bridge afterwards, printed which steps landed, and stopped. If the result did not resolve — for any reason, including reasons visible before the first byte was written — the machine was left pointed at a resolver that does not answer, and the person sitting at it now has no DNS with which to look up how to fix it. On a desktop that is the whole internet, gone, from a command whose failure mode was a paragraph of text. Four phases now, and each one is a specific outage. Preflight — refuse rather than guess. A second drop-in setting DNS= is not a competing server, it is an appended one: systemd-resolved merges every drop-in into one global list, rotates away from a server that fails, and never rotates back. So a single bridge restart moves every query on the machine to the other server permanently. That server answers NXDOMAIN for Moshpit names, which is the worst available shape of failure — DNS reads as completely healthy while the entire namespace this command exists to serve is dead, and nothing anywhere reports an error. A DigitalOcean.conf from the cloud image did this three times in one afternoon and was diagnosed as a bridge bug each time. Which file wins is not this command's call to make, so it names the file and stops. A stranger on 127.0.0.1:5354 is the same story from the other end. Bound to the address ours wants it takes the port outright, and `startDaemon` spawns detached with stdio ignored, so that bind failure is invisible and enable still prints "bridge started". Bound to loopback while ours holds 0.0.0.0 the kernel delivers to the more specific socket, so ours is up, healthy and receiving nothing. Either way the routing points every lookup at a process we did not start, which answers NOERROR with zero answers and eats the query. That took DNS down twice in one day. Both refusals are overridable with --force, and both print what they found even when forced. The check is on behaviour, not identity. A holder that answers the clearnet probe is a working bridge someone started by hand, and refusing on the missing pidfile alone would make --force the normal way to run this command, which is how a safety check stops being one. It is reported and used as-is. Likewise a drop-in naming only the bridge we are about to write is a duplicate, not a conflict — there is nothing to rotate to — so it gets a note saying `dns disable` will not remove it, rather than a refusal. Without those two carve-outs this refuses to run on the machine it was developed on. Apply — with the previous file kept first. Whatever the plan overwrites is read and held before anything is written, and copied next to the original under a `.moshcode-backup` suffix. The suffix is load-bearing: systemd-resolved globs `*.conf`, so a backup named `moshpit.bak.conf` would be the exact conflict the preflight refuses to run into. In-memory is what the rollback uses; the on-disk copy is what a person finds if this process is killed between the write and the restart, which is why a failed copy is one warning line and not a refusal. The bridge now starts before the routing points at it, rather than after. The old order left a window where catch-all routing sent every lookup on the machine to a port with nothing behind it, and it left nothing to verify against — there is no answer to ask for until the bridge exists. Verify — two names, and the second one is the point. A Moshpit name proves the bridge is reachable through the routing just written. A clearnet name proves it is forwarding rather than swallowing, and that is what "you broke my DNS" actually means. A check that only asked about Moshpit names would call a box that cannot reach the internet a success. Resolution goes through the machine's own resolver, not straight at the bridge on 5354: the claim being made is that a normal lookup works, not that a socket answers. An empty answer counts as failure — NOERROR with no records is precisely what a resolver that has swallowed the query returns, and reading it as success is how the silent version of this outage stayed silent. Retried, because systemd-resolved is not ready the instant it restarts and a rollback over that race would undo a switch that was fine. Roll back — on any failure, including a failed apply. The held contents go back byte-for-byte, or the file is removed when this run created it. Removed, not emptied: an empty moshpit.conf is a file systemd-resolved still reads and `dns status` still reports as configured, so "back as it was" has to mean gone. Restoring is then not enough on its own — the resolver read the file at start — so the plan's own `run` steps replay afterwards. Windows writes no files and undoes NRPT rules with different commands entirely, so that path hands in the disable plan's steps instead. A bridge this run started is stopped again, since leaving it is a process on 5354 that the next run's preflight would refuse to proceed past. A failed apply rolls back for the same reason a failed verification does. Half-written routing is the state this exists to make impossible, and "some steps failed, good luck" was the previous answer to it. When the rollback itself fails, the backup path is printed, because at that point it is the only remaining record of what the machine used to look like. --dry-run prints all four phases and touches nothing, including the rollback it would perform — deciding whether to hand a command root means seeing the part that undoes it, not just the part that does it. The preflight still runs, since it only reads, and still reports a refusal it would make: that is most of why someone runs a dry run on a machine in this state. `Domains=~.` stays exactly as it was and is asserted in the dry-run test. Without it the per-link servers DHCP hands out carry Default Route: yes, beat the global scope, and every query goes around the bridge; `resolvectl query seo.rank` answering with `link: eth1` is what that looks like on a box whose config is otherwise perfect. Two things in the brief that the code did not match, left alone deliberately: enable already ran the restart itself rather than printing it — the printed "now run: systemctl restart systemd-resolved" belongs to `dns install --write`, a different verb — and `Domains=~.` is conditional on `catchAllSafety`, which falls back to per-ending routing when the bridge cannot forward. That fallback is the existing guard against this same class of outage and is not something to remove on the way to adding another one. Nineteen tests, all on the decision rather than the system call: filesystem, exec, listener and probe are injected the way serveCommand's already are, and the rollback tests run the real apply loop against a temp directory so the assertion is on the bytes in the file. 762 tests, 0 fail. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 06d3a9a commit a20b0ea

2 files changed

Lines changed: 892 additions & 25 deletions

File tree

0 commit comments

Comments
 (0)