Skip to content

fix(dns): move MagicDNS off the range other overlays drop - #15

Closed
wighawag wants to merge 9 commits into
tunnetio:mainfrom
wighawag:fix/magicdns-off-cgnat
Closed

fix(dns): move MagicDNS off the range other overlays drop#15
wighawag wants to merge 9 commits into
tunnetio:mainfrom
wighawag:fix/magicdns-off-cgnat

Conversation

@wighawag

@wighawag wighawag commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Tailscale claims 100.64.0.0/10 and installs a SOURCE-matched anti-spoof rule:

-A ts-input -s 100.64.0.0/10 ! -i tailscale0 -j DROP

Because it matches on source rather than destination, it also drops the host's own traffic to any of our addresses in that range, including over loopback. MagicDNS at 100.100.100.53 was inside it, so on any machine running both products the resolver stayed listening and never received a query. DNS failed with no error and no log line on either side.

Measured on an affected host: 50 of 50 packets to 100.100.100.53 and 50 of 50 to the node's mesh address were dropped, while a 127.0.0.1 control was untouched. Tailscale's own resolver answered normally on the same box, because Tailscale exempts its own address on lo and Tunnet had no such exemption.

Move the default to 169.254.0.53. RFC 3927 reserves 169.254.0.0/24 and excludes it from link-local autoconfiguration, so nothing on the link can take it; link-local is never routed or policed by an overlay filter, and it avoids the LAN RFC1918 and Managed 10.x ranges that CGNAT was originally chosen to dodge.

Changing the default alone would only help new installs, because magic_ip is serialized into the config file. Relocate a stored contested address on load as well, with a warning naming the override. This is safe to do locally: nothing derives the resolver address and no peer needs to agree on it, unlike the mesh addresses.

Note this does NOT fix synthetic peer IPs, which synthetic_ip_for hardcodes to 100.100.0.0/16 and which both ends derive independently, so they cannot be relocated without coordination.

UNVERIFIED: the OS DNS integration has not been exercised with a link-local resolver address on a live host. systemd-resolved and the other osdns backends accept it in principle, but that needs confirming before merge.

orielhaim and others added 9 commits September 4, 2026 02:11
…er ownership

Linux TUN writer staged [32B frame headroom][12B virtio][IP] but passed
offset 12, so the kernel received zeros instead of IPv4 and dropped every
inbound packet silently. Writer now owns dedicated [12B][IP] buffers.

Also: unify BatchSlot AsRef/AsMut on the receive area, drop dead
detach/recycle APIs, fix v3 net prefix in reject fallback, single reader
ownership (adopt/hook), ingress generation guard, dataplane health states
(up/degraded/restarting/down) with restart counters, git-hash + ALPN in
status with CLI/daemon mismatch warning, privileged TUN round-trip test.
…ch honesty, A/B knobs

Scheduler reports every drop: EnqueueOutcome (Accepted/AcceptedEvicted/
Rejected) with victim lengths for exact gauge reconcile; drain_drops()
surfaces CoDel/emergency deltas exactly once; pump + agent sites report
all of flow/peer/codel/emergency/transport-full to telemetry.

Diagnostic A/B knobs (env): TUNNET_FLOW_PACKET_CAP, TUNNET_TUN_OFFLOAD,
TUNNET_QUIC_DATAGRAM_BUFFER_KB, TUNNET_PUMP_BACKOFF_MAX_US.

Eager preconnect of routed peers on keep-alive bring-up (kills first-
ping-timeout). bench.ps1/sh: exit codes + stderr captured everywhere,
no invented 50 Mbps fallback (TCP failure stops), UDP sender/receiver
split with sum_received delivered, size x direction matrix, valid flags.

Audit section 18.
Tailscale claims 100.64.0.0/10 and installs a SOURCE-matched anti-spoof
rule:

    -A ts-input -s 100.64.0.0/10 ! -i tailscale0 -j DROP

Because it matches on source rather than destination, it also drops the
host's own traffic to any of our addresses in that range, including over
loopback. MagicDNS at 100.100.100.53 was inside it, so on any machine
running both products the resolver stayed listening and never received a
query. DNS failed with no error and no log line on either side.

Measured on an affected host: 50 of 50 packets to 100.100.100.53 and 50 of
50 to the node's mesh address were dropped, while a 127.0.0.1 control was
untouched. Tailscale's own resolver answered normally on the same box,
because Tailscale exempts its own address on lo and Tunnet had no such
exemption.

Move the default to 169.254.0.53. RFC 3927 reserves 169.254.0.0/24 and
excludes it from link-local autoconfiguration, so nothing on the link can
take it; link-local is never routed or policed by an overlay filter, and
it avoids the LAN RFC1918 and Managed 10.x ranges that CGNAT was
originally chosen to dodge.

Changing the default alone would only help new installs, because magic_ip
is serialized into the config file. Relocate a stored contested address on
load as well, with a warning naming the override. This is safe to do
locally: nothing derives the resolver address and no peer needs to agree
on it, unlike the mesh addresses.

Note this does NOT fix synthetic peer IPs, which synthetic_ip_for hardcodes
to 100.100.0.0/16 and which both ends derive independently, so they cannot
be relocated without coordination.

UNVERIFIED: the OS DNS integration has not been exercised with a
link-local resolver address on a live host. systemd-resolved and the other
osdns backends accept it in principle, but that needs confirming before
merge.
@wighawag

wighawag commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Verified on a live host, with Tailscale up throughout. This resolves the UNVERIFIED caveat in the commit message, though it narrows rather than removes it.

What was confirmed

Relocation of a stored contested address fired as intended:

WARN tunnet_core::agent_config: MagicDNS address sits inside 100.64.0.0/10, which
CGNAT-based overlays such as Tailscale drop by source address even over loopback;
relocating so DNS does not fail silently. Set dns.magic_ip explicitly to override.
configured=100.100.100.53 replacement=169.254.0.53
INFO tunnetd::magic_dns: PeerDNS magic IP on TUN magic_ip=169.254.0.53 ifname="tunnet0"

The address is assigned and the resolver binds to it:

tunnet0   UNKNOWN   100.95.248.22/10   169.254.0.53/32
UNCONN 0 0 169.254.0.53:53  users:(("tunnetd",pid=...,fd=35))

And it answers, which is the point of the change:

dig @169.254.0.53 example.com A   ->  status: NOERROR, ANSWER SECTION present
dig @100.100.100.53 example.com A ->  no servers could be reached

Before this change the same query to 100.100.100.53 timed out with Tailscale up, because its anti-spoof rule matches on source address and dropped the host's own loopback traffic. So a link-local resolver address does work, which was the specific risk in choosing 169.254.0.53.

What is still not verified

The systemd-resolved integration path was not exercised. tunnet dns status reported PeerDNS inactive on the test host, and resolvectl status tunnet0 showed no DNS scope, so the OS never had Tunnet's resolver configured either before or after. Nothing in this PR touches that path (dataplane.rs only applies OS DNS when cfg.dns is Some), but it means "the OS actually uses the new address" remains untested. Worth someone confirming on a host where PeerDNS is active.

Two small things noticed while testing

  • The relocation warning is logged twice per start, because to_dns_config is called more than once. Harmless, but it would read better logged once.
  • tunnet dns status still shows synthetic 100.100.0.1, inside the contested range. Consistent with the commit message already saying synthetic IPs are out of scope, and a reminder that the mesh CIDR itself is untouched.

Refs #18

@wighawag

wighawag commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #24. Closing rather than rebasing.

This PR relocated the resolver from 100.100.100.53 to 169.254.0.53 to get it out of the range Tailscale's source-matched anti-spoof rule drops. #24 removes the address from that range entirely instead: magic_ip and 100.100.100.53 no longer appear anywhere in the source on fix/direct-addressing-authority, PeerDNS binds a loopback address, and it now hard-fails if handed anything non-loopback:

if bind.ip() != Ipv4Addr::LOCALHOST && !bind.ip().is_loopback() {
    anyhow::bail!("PeerDNS must bind a loopback address, got {bind}");
}

That is a better fix than mine. Loopback cannot be claimed by another overlay at all, whereas 169.254.0.53 was merely unlikely to be. It also makes the config-relocation-on-load logic unnecessary.

It additionally resolves the one thing this PR explicitly could not: synthetic_ip_for and its hardcoded 100.100.0.0/16 are gone from #24, so the derived peer addresses both ends computed independently are no longer in the contested range either.

Worth keeping from the investigation: on an affected host, 50 of 50 packets to 100.100.100.53 and 50 of 50 to the node's mesh address were dropped, with a 127.0.0.1 control untouched. The failure is silent on both sides, since the resolver stays listening and simply never receives a query. That silence is the reason this took a packet capture to find, and is worth a regression test on whatever address PeerDNS ends up using.

The branch is untouched if #24 stalls and this needs reviving.

@wighawag wighawag closed this Sep 6, 2026
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.

2 participants