Bypass DNS under systemd-resolved #58

Merged
mysticalsoap merged 2 commits from mysticalsoap/fix/bypass-dns-resolved into trunk 2026-08-19 12:24:13 -04:00 AGit
Owner

Problem

Two halves of #31:

  • cgroup_vpn calls set_dns with no tunnel interface, which under resolved logs an error and does nothing. That call is the resolv.conf-era complement of per-link DNS: on resolv.conf systems it is still needed, under resolved it is redundant by design — the tunnel link already carries its DNS from tunnel-up.
  • The bypass resolver (dnsmasq on 127.0.0.1:5354, fed by the cgroup port-53 REDIRECT) sends its upstream queries from the service's own cgroup, so they follow the default route — through the tunnel. Bypass DNS never actually bypassed, on any init system: geo answers come from the VPN region and split-horizon LAN names resolve to their public addresses.

For reference, Mullvad documents this exact limitation and leaves it ("it will still use the DNS server on the Mullvad server that you are connected to"); Proton's daemon has no per-process DNS handling at all. The redirect+dnsmasq mechanism already exceeds both — this PR makes it point the right way.

Fix

Commit 1 — the no-tun set_dns calls are now explicitly resolv.conf-only (dns_manager.resolved_active(), also used internally by set_dns). No more error-log noise; the design is stated at the call sites.

Commit 2 — the bypass dnsmasq joins the bypass cgroup, so its upstream queries carry the fwmark and leave through the physical link like the apps it resolves for:

  • dnsmasq daemonizes on its own, so _join_bypass_cgroup polls the pid file (stale files unlinked pre-spawn) and writes the pid into cgroup.procs; on timeout it logs and degrades to the old behavior
  • loop guard: RETURN rules ahead of the port-53 REDIRECTs, matching the uid dnsmasq drops to (now pinned with --user=nobody) — without them its upstream queries would redirect back into itself
  • cgroup_vpn reordered so create_cgroup runs before the dnsmasq spawn (the join raced the cgroup teardown/recreate)
  • loopback delivery to 127.0.0.1:5354 is unaffected by the fwmark: the local routing rule sits at priority 0, ahead of the fwmark rule

Known boundary (documented, not fixable from here): apps resolving through nss-resolve/varlink never touch port 53, so they still get resolved's answer via the tunnel. Arch's default nsswitch puts resolve first, so plain glibc apps fall in that bucket; browsers and Electron apps use their own resolvers against the stub and are covered.

Verification

  • tests/test_dns_manager.py: cgroup join from pid file, poll-until-appears, timeout degradation, --user=nobody pinned, join only when a uid is passed
  • CI runs pytest/ruff
  • Live check on an installed build, bypass enabled + VPN up: iptables-legacy -t nat -L OUTPUT -n shows the two RETURNs above the two REDIRECTs; cat /proc/$(cat /var/run/dnsmasq_aqomui_bypass.pid)/cgroup ends in bypass_aqomui; a DNS query from a bypassed app shows up in tcpdump -i <physical> port 53 instead of the tun

Closes #31

Assisted-by: claude-fable-5

## Problem Two halves of #31: - `cgroup_vpn` calls `set_dns` with no tunnel interface, which under resolved logs an error and does nothing. That call is the resolv.conf-era complement of per-link DNS: on resolv.conf systems it is still needed, under resolved it is redundant by design — the tunnel link already carries its DNS from tunnel-up. - The bypass resolver (dnsmasq on 127.0.0.1:5354, fed by the cgroup port-53 REDIRECT) sends its **upstream** queries from the service's own cgroup, so they follow the default route — through the tunnel. Bypass DNS never actually bypassed, on any init system: geo answers come from the VPN region and split-horizon LAN names resolve to their public addresses. For reference, Mullvad documents this exact limitation and leaves it ("it will still use the DNS server on the Mullvad server that you are connected to"); Proton's daemon has no per-process DNS handling at all. The redirect+dnsmasq mechanism already exceeds both — this PR makes it point the right way. ## Fix Commit 1 — the no-tun `set_dns` calls are now explicitly resolv.conf-only (`dns_manager.resolved_active()`, also used internally by `set_dns`). No more error-log noise; the design is stated at the call sites. Commit 2 — the bypass dnsmasq joins the bypass cgroup, so its upstream queries carry the fwmark and leave through the physical link like the apps it resolves for: - dnsmasq daemonizes on its own, so `_join_bypass_cgroup` polls the pid file (stale files unlinked pre-spawn) and writes the pid into `cgroup.procs`; on timeout it logs and degrades to the old behavior - loop guard: RETURN rules ahead of the port-53 REDIRECTs, matching the uid dnsmasq drops to (now pinned with `--user=nobody`) — without them its upstream queries would redirect back into itself - `cgroup_vpn` reordered so `create_cgroup` runs before the dnsmasq spawn (the join raced the cgroup teardown/recreate) - loopback delivery to 127.0.0.1:5354 is unaffected by the fwmark: the local routing rule sits at priority 0, ahead of the fwmark rule Known boundary (documented, not fixable from here): apps resolving through nss-resolve/varlink never touch port 53, so they still get resolved's answer via the tunnel. Arch's default nsswitch puts `resolve` first, so plain glibc apps fall in that bucket; browsers and Electron apps use their own resolvers against the stub and are covered. ## Verification - `tests/test_dns_manager.py`: cgroup join from pid file, poll-until-appears, timeout degradation, `--user=nobody` pinned, join only when a uid is passed - CI runs pytest/ruff - Live check on an installed build, bypass enabled + VPN up: `iptables-legacy -t nat -L OUTPUT -n` shows the two RETURNs above the two REDIRECTs; `cat /proc/$(cat /var/run/dnsmasq_aqomui_bypass.pid)/cgroup` ends in `bypass_aqomui`; a DNS query from a bypassed app shows up in `tcpdump -i <physical> port 53` instead of the tun Closes #31 Assisted-by: claude-fable-5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix: bypass DNS resolves over the physical link, not the tunnel
All checks were successful
ci / test (pull_request) Successful in 28s
ci / test (push) Successful in 28s
758e84c0bd
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.