Commit a20b0ea
fix(dns): make
`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>dns enable a switch that can be undone, not a one-way write (#259)1 parent 06d3a9a commit a20b0ea
2 files changed
Lines changed: 892 additions & 25 deletions
0 commit comments