android: DNS queries forwarded to a tailnet-IP resolver intermittently dropped in-app; triggers "DNS unavailable" and ~5s stalls
What is the issue?
With split DNS pointing at a resolver on a tailnet IP 100.100.6.79, Android intermittently shows the "DNS unavailable" health warning and app DNS lookups stall ~5-20s, while the resolver itself is healthy.
While the warning was showing:
- From Termux, all resolvers answer fast: quad100,
100.100.6.79, global nameserver. All <100ms curlto a split-domain host stalls 5s on DNS. tcpdump on the resolver's host shows getaddrinfo's first query never arrives. then the 5s retry is answered in 0.2ms.- So the netd -> quad100 -> forwarder path fail
logcat during a failure, right after a burst of bindSocketToActiveNetwork rebinds:
gojni: dns udp query: waiting for response or error from [100.100.6.79]: context deadline exceededThat timeout sets dnsForwarderFailing the warning message.
Fable 5 analysis, unverifiedon Android
dialer.UseNetstackForIPis never set (onlycmd/tailscaledandtsnetset it), soforwarder.dialUDPto a tailnet-IP upstream falls through tostdNetPacketListener- a plain kernel socket with no protect/bind control, which only reaches the resolver by looping back through the app's own VPN TUN. A VPN reconfigure/rebind silently drops the query; direct queries to the same resolver (a normal TUN flow) work throughout
next session
Did some digging on this. I think the root cause is that on Android,
dialer.UseNetstackForIPis never set (it's wired up incmd/tailscaled/tailscaled.gobut not in tailscale-android'slibtailscale/backend.go). So when the in-app forwarder sends a UDP query to a resolver at a tailnet IP,forwarder.dialUDP(net/dns/resolver/forwarder.go) falls through tostdNetPacketListener- a plain kernel socket whose only route to a tailnet IP is looping back through the app's own VPN TUN. Any time the VPN reconfigures/rebinds (network changes, doze wakeups), those in-flight queries are silently dropped, which matches the intermittent failures and ~5s stalls here.
The netstack UDP path to make this work already exists since #20786 - it looks like the fix is just setting
UseNetstackForIPin the Android backend the same way tailscaled does, so forwarded DNS to tailnet IPs goes via netstack instead of the kernel socket.
battery session
A side note on this setup in general: with the resolver on a tailnet IP, every background DNS query from every app rides the tunnel, so the radio and tunnel never sleep. batterystats had the app at 58.9% of mobile-radio-active time (1517 radio wakeups, 1.3M packets, ~35% of total battery blame). After moving DNS off the tailnet IP (public DoH upstream instead, plus the
silent-disconodeAttr), that dropped ~40x to a 7.2% radio-active share and a handful of wakeups. Worth keeping in mind for anyone running this kind of setup even once the forwarder bug is fixed.
possibly related: #19649, #13314
Steps to reproduce
- In Admin console, add a split DNS entry pointing a domain at a resolver on a tailnet IP
- Use an Android device normally
- Intermittently get "DNS unavailable" warning, and system resolver lookups in the split domain stall ~5s. direct
dig @<resolver>works fine
Are there any recent changes that introduced the issue?
No response
OS
Android
OS version
Android 17 (Pixel 10a, Graphene OS)
Tailscale version
1.102.2
Other software
no other VPN, Private DNS off
Bug report
BUG-77ea843794da3961009912a4f1dc6c3379a791520f58f853375cad4ef0f1d31f-20260825164135Z-455d1f48004e6f6c
Source: tailscale/tailscale