Skip to content

feat(dns): route every lookup here, instead of naming each claimed ending - #195

Merged
ralyodio merged 1 commit into
mainfrom
dns-catchall
Aug 1, 2026
Merged

feat(dns): route every lookup here, instead of naming each claimed ending#195
ralyodio merged 1 commit into
mainfrom
dns-catchall

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

The bug, found the hard way

moshcode dns resolve chovy.hacker answered. curl chovy.hacker said Could not resolve host. Both on the same machine, seconds apart.

They ask different things — resolve queries the registry over HTTPS, curl asks the OS — and the OS genuinely had never been told about .hacker:

endings written to moshpit.conf 4,586
endings systemd-resolved accepted 1,090
the rest rejected, one journal line each
systemd-resolved: Failed to add search domain '~zombies', ignoring: Argument list too long

Taken alphabetically until the cap, so ~hacker landed in the dropped 76%. moshcode dns status reported "routing configured" throughout. Every ending anyone claims makes the cut deeper.

The fix

Domains=~. — one entry that never grows.

The bill is that the bridge now sees every lookup on the machine, so it has to be a resolver rather than an oracle for a list:

  • Non-Moshpit names are relayed byte for byte. A parse-and-rebuild proxy would mean implementing the whole record space correctly to avoid corrupting answers we only need to pass along — SVCB, SRV, DNSKEY, whatever arrives.
  • isOurs is the gate that makes this safe. google.com has exactly two labels, same as blue.eggs. Parsing alone would have the bridge answer for the clearnet. Only an ending someone has actually claimed is ours.
  • An unknown ending set means "not ours". Failing that way costs a Moshpit name until the registry answers again. Failing the other way costs the whole internet on that machine.
  • Silent upstreams are SERVFAIL, not NXDOMAIN. "I could not find out" is retried elsewhere; "it does not exist" gets cached and the name stays broken after the network returns.
  • Loopback nameservers are dropped when discovering upstreams — once routing points here, 127.0.0.53 is the thing asking us, and forwarding back is a loop.
  • dnsmasq gets no-resolv, so it doesn't inherit upstreams from /etc/resolv.conf that may point back here.

Additive, not a swap

With no upstreams configured the bridge behaves exactly as before, so the per-ending deployment keeps working and resolvedConf/dnsmasqConf are untouched — which also keeps the drift test against @moshcoder/moshpit-dns meaningful.

Tests

99 tests, 98 pass, 1 skipped (dns-drift, package not installed locally).

The safety gate is verified by removing it: 4 tests fail, including the one asserting google.com is never ours. A test that can't fail wouldn't be worth having for this.

Not in this PR

moshcode dns status still reports "routing configured" without checking what the resolver actually loaded — the silence that made this take so long to find. It should compare what it wrote against resolvectl domain and say "wrote 4586, resolver accepted 1090". Worth doing regardless of this change.

@moshcoder/moshpit-dns will need the same change to stay in step.

🤖 Generated with Claude Code

…ding

Routing endings one at a time does not survive the registry growing, and
it fails silently when it stops. `dns install` wrote all 4586 claimed
endings onto one `Domains=` line; systemd-resolved took 1090 of them
alphabetically, rejected the remaining 3496 with "Argument list too long"
one journal line at a time, and reported success. `moshcode dns status`
said "routing configured" and was satisfied.

The result is a failure with no visible cause. `moshcode dns resolve
chovy.hacker` answered, because it asks the registry over HTTPS, while
`curl chovy.hacker` could not resolve, because it asks the OS — which had
never been told about `.hacker`. Nothing in between said why, and every
ending anyone claims makes the cut deeper.

`Domains=~.` is one entry that never grows. The bill is that this bridge
now sees every lookup on the machine, so it has to be a resolver rather
than an oracle for a list:

  - Anything that is not a claimed ending is relayed byte for byte,
    including question types this has no opinion about. Re-encoding them
    would mean implementing the whole record space correctly to avoid
    corrupting answers we only need to pass along.
  - `isOurs` is the gate that makes this safe. `google.com` has exactly
    two labels, the same as `blue.eggs`, so parsing alone would have the
    bridge answer for the clearnet. Only an ending someone has actually
    claimed is ours. An unknown ending set means "not ours": failing that
    way costs a Moshpit name until the registry answers again, failing
    the other way costs the whole internet on that machine.
  - Silent upstreams are SERVFAIL, not NXDOMAIN. "I could not find out"
    is retried elsewhere; "it does not exist" gets cached and the name
    stays broken after the network comes back.
  - Loopback nameservers are dropped when discovering upstreams. Once
    routing points here, 127.0.0.53 is the thing asking us.

Additive: with no upstreams configured the bridge behaves exactly as it
did, so the per-ending deployment keeps working and the existing config
generators are untouched.

The gate is verified by removing it — 4 tests fail, including the one
that asserts google.com is never ours.

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

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit ff78498 into main Aug 1, 2026
3 checks passed
ralyodio added a commit that referenced this pull request Aug 1, 2026
#195 added the catch-all config generators, upstream parsing and the
forwarding path, and wired none of them in. `dns enable` still called
resolvedConf(tlds) and `dns start` never passed upstreams, so v0.16.0
shipped the capability and none of the behaviour: a box that upgraded got
the same 4586-ending list, the same silent truncation at the resolver's
cap, and the same `curl: (6) Could not resolve host`. The release notes
said routing had stopped being a list. It had not.

Wiring it is the easy half. The hard half is that catch-all routing is
only safe when the bridge can forward what is not ours — point every
lookup at a bridge with no upstreams and the machine loses DNS entirely,
which is far worse than a Moshpit name that does not resolve.

So it is conditional by construction rather than by flag:

  - `discoverUpstreams` reads /etc/resolv.conf BEFORE routing is switched,
    because afterwards resolv.conf may point at us and the real servers
    are no longer discoverable from it
  - loopback entries are dropped, so the bridge cannot forward to itself
  - upstreams found  → `Domains=~.` and the bridge forwards
  - none found       → the per-ending list, exactly as before, which can
    only ever break Moshpit names
  - `dns start` passes the same upstreams and the claimed-ending set to
    the server, and says which upstreams it will use

The dnsmasq backend follows the same rule, with no-resolv so it does not
inherit upstreams that point back here.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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