Document the per-session DNS resolution feature

Records the design decision (one dnsmasq instance per session, not per
network -- avoids the NXDOMAIN-fallthrough problem a per-network design
would have hit for multi-network containers) and the three real bugs
found while building it (dnsmasq's --pid-file needing daemonize mode,
its default privilege drop breaking $XDG_STATE_HOME access, and REFUSED
AAAA answers breaking getaddrinfo()-based tools), matching the level of
detail already recorded for the other networking features in this
document and in CLAUDE.md's own file-by-file reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-09-03 17:36:55 +00:00
parent a70634f1d7
commit 0745d30c97
2 changed files with 230 additions and 1 deletions
+102
View File
@@ -106,6 +106,108 @@ optional — when omitted, resolves to whichever single extern network the
container joined; an error (not a silent guess) if the container joined more
than one extern network and didn't disambiguate.
## DNS resolution
Containers on a shared `-n <network>` can resolve each other by the name
given via `--hostname` on any network they share, plus
`host.containers.internal` (podman's own convention) resolving to the first
`extern` network's own gateway, if any. Implemented via `dnsmasq` — no
resolver of any kind was referenced anywhere in this project before this,
and `dnsmasq` isn't in `README.md`'s runtime-dependency list, so this is a
genuinely new, but deliberately optional (best-effort, gated on
`is_dnsmasq_available()`), dependency: a missing `dnsmasq` degrades with a
warning rather than failing `-r/--run`.
**One `dnsmasq` instance per session, not per network.** The "obvious"
design — one persistent instance per network, mirroring how the uplink
provisions one process per network — was considered and rejected before
being built, once a real correctness problem was worked through: a
container joined to two networks would list two `nameserver` lines in
`/etc/resolv.conf`, and standard stub resolvers (glibc, musl, busybox
included) don't fall through to the next nameserver on NXDOMAIN, only on
timeout — a name that exists only on the container's *second* network would
silently fail to resolve, since the first nameserver's authoritative "no
such name" ends the lookup right there. Running one instance per session
instead sidesteps the problem entirely: it's entered into the *container's
own* network namespace and bound to `127.0.0.1:53` there, so
`/etc/resolv.conf` is always just `nameserver 127.0.0.1` regardless of how
many networks were joined, and that one instance is configured (via
dnsmasq's own repeatable `--hostsdir=<dir>`, one per joined network,
inotify-watched so no reload signal is ever needed) to already know about
every network the specific container asking could possibly mean — there's
never a second nameserver to fall through to in the first place.
Each network gets one shared, host-global hosts-directory
(`$XDG_STATE_HOME/slocker-lite/dns-hosts/<network>`); each session that
joined it and was given a `--hostname` writes one record file into that
directory (plain `<ip> <hostname>` syntax) so sibling sessions' own
resolvers, watching the same directory, discover it — and since a session's
own record lands in the exact directory its own resolver also watches,
self-resolution falls out for free, no special-casing needed.
`host.containers.internal` is generated per session instead (a small,
private `--addn-hosts=<file>`, not shared) pointing at the first `extern`
network's own gateway address, omitted entirely for an intern-only session.
### Three real bugs found while building this
Each confirmed by direct testing — isolating the change, reproducing the
symptom, then confirming the fix — not assumed:
1. **dnsmasq only writes `--pid-file` while actually daemonizing.**
`-d`/`--no-daemon` was tried first (a simpler model: the forked/exec'd
pid stays the real pid throughout, matching how `nsenter`'s own in-place
`execve()` already lets other parts of this project treat a pid as
stable across `exec()`). It suppresses `--pid-file` entirely — confirmed
directly: dnsmasq started and successfully read the hosts file (visible
in its own, still-attached stdout at the time) but the pid-file this
project polls for (the same bounded-poll shape `network_join.cpp`'s own
`wait_for_isolated_net_namespace()` already uses) never appeared, so
every attempt timed out. Fixed by letting dnsmasq daemonize normally
instead: the forked/exec'd process is then only the *intermediate* one
(reaped immediately, not tracked as the resolver's own pid), and the
real, final daemon pid is read back from the pid-file itself once it
appears — which also *is* the crash-orphan record `--clean-processes`
needs, no separate write step required.
2. **dnsmasq drops root privileges to an unprivileged user by default.**
Broke reading anything under `$XDG_STATE_HOME` at all (typically
`/root/.local/state/slocker-lite/...`, mode `0700` — a non-root user
can't even traverse into `/root`), traced directly to dnsmasq's own log:
`bad dynamic directory .../dns-hosts/<network>: Permission denied`,
followed by every query coming back `REFUSED`. Fixed with an explicit
`--user=root --group=root`, matching this project's already-root-only
networking model throughout (bridges, iptables, the uplink, all already
assume root). **Deliberately not the final answer** — tracked as a
security follow-up in this repo's own `TODO.md`: run dnsmasq as a real
low-privilege user instead, with the state it needs relocated somewhere
that user can reach, rather than keeping a resolver process root for its
entire lifetime purely to work around a directory permissions mismatch.
3. **An AAAA query for a name with only an A record came back `REFUSED`.**
Every record here is IPv4-only (matching `JoinedNetwork::container_ip`'s
own existing scope) — but without `--filter-AAAA`, an AAAA query for a
name dnsmasq otherwise knows perfectly well (an A record, just resolved
correctly moments earlier via `nslookup`) also came back `REFUSED`, not
a clean "no data" answer. This broke `ping <name>` outright: `ping`, like
most `getaddrinfo()`-based tools, queries both A and AAAA together and
treats `REFUSED` on *either* as a hard failure for the whole lookup, not
merely "no IPv6 available" — confirmed by testing both with and without
`--filter-AAAA` against the identical hosts record. `host.containers.internal`
needed a second, related fix on top: serving it via dnsmasq's own
`--address=/name/ip` option kept returning `REFUSED` for AAAA even with
`--filter-AAAA` given — confirmed `--address` records aren't treated the
same internally as ordinary hosts-file entries are — so it's instead
served through the exact same mechanism as everything else (a plain,
session-private `--addn-hosts=<file>`), which resolved it immediately.
**Verified end-to-end**, both on this dev machine and on the real Android
target device (a fresh reboot, invoked consistently from `$HOME` — see this
repo's own `TODO.md` for a separate, unresolved issue found along the way
where invoking from a different working directory produced a completely
separate config/state tree): two containers on a shared network resolve
each other by name (including self-resolution) and can `ping` by name; a
container joined to both an `intern` and an `extern` network resolves both
its `intern` peer and `host.containers.internal` simultaneously — the
specific scenario the per-network-instance design would have broken.
## Subnet / IP allocation
- IPv4 auto-allocates a `/24` starting at `10.168.0.0/24`, incrementing per