The ticket
A platform team runs a tailnet of about 200 devices with MagicDNS enabled. Internal services live under corp.example.com, served by an internal resolver at 10.0.5.53 that sits behind a subnet router (node-b, advertising an approved 10.0.0.0/16). At 10:05 UTC the Customer opens a high urgency ticket: engineers cannot reach GitLab or the artifact registry, and browsers are throwing certificate warnings because they are landing on the wrong server entirely.
“Since about 09:30 UTC every laptop resolves gitlab.corp.example.com to a public address and hits a certificate warning. Our DNS server has not changed. This all worked yesterday.”
Evidence provided
The first responder collected a query from an affected macOS client, node-a:
$ dig gitlab.corp.example.com A
; <<>> DiG 9.18.24 <<>> gitlab.corp.example.com A
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 23817
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; ANSWER SECTION:
gitlab.corp.example.com. 300 IN A 203.0.113.20
;; Query time: 24 msec
;; SERVER: 100.100.100.100#53(100.100.100.100)
;; WHEN: Mon Aug 10 10:02:41 UTC 2026
The expected answer is 10.0.20.14. Two facts in this one capture are worth more than everything else in the ticket: the answer came from 100.100.100.100, and it is a clean NOERROR carrying a public address.
Hypothesis tree
Classify the wrongness before touching anything. “Right name, wrong answer” and “no answer” are different diseases: a dead resolver produces SERVFAIL, REFUSED, or a timeout; it can never produce a confident public A record. That single distinction prunes half the tree before you run a command.
Investigation
-
Confirm the query path. The dig output already shows
SERVER: 100.100.100.100#53. The OS handed the query to Tailscale’s local resolver, exactly as designed. This rules out the bypass branch: resolv.conf drift, another VPN fighting for DNS, a hardcoded resolver in the app. -
Classify the wrong.
status: NOERRORwith a public A record. A dead or unreachable internal resolver cannot manufacture that answer; it fails loudly. This demotes “resolver down” from primary suspect to impossible-as-sole-cause. Someone answered this query honestly, from the wrong view of the zone. -
Ask the internal resolver directly. From the same client, through the still-approved
10.0.0.0/16subnet route:$ dig @10.0.5.53 gitlab.corp.example.com A +short 10.0.20.14Correct answer, 30 ms. The resolver is healthy, the subnet route works, ACLs permit the traffic. “Nothing changed on the DNS server” is now verified fact, not Customer assertion.
-
Read the DNS config the control plane pushed. On the client (the
dnscommand is available in Tailscale v1.74.0 and later (kb-cli); output abridged):$ tailscale dns status === 'Use Tailscale DNS' status === Tailscale DNS: enabled. === MagicDNS configuration === MagicDNS: enabled tailnet-wide (suffix = velo-cirrus.ts.net) Resolvers (in preference order): - 1.1.1.1 Split DNS Routes: (no routes configured: split DNS disabled)Yesterday this listed a route sending
corp.example.comto10.0.5.53. Today the table is empty. The client is faithfully executing a configuration that no longer contains the rule. That rules out client caching, per-device weirdness, and platform quirks: every device got the same push. -
Check the admin console DNS page. Under Nameservers, the global resolvers are present, but the restricted nameserver row (
10.0.5.53, restricted tocorp.example.com) is gone. Change history and the admin who made it line up: at 09:26 UTC someone swapped global nameservers and deleted the restricted row in the same edit session. Onset “about 09:30” matches. -
Explain the public answer.
dig @9.9.9.9 gitlab.corp.example.comfrom outside the tailnet returns the same203.0.113.20: a years-old public wildcard for*.corp.example.compointing at the company’s web gateway. The public record is legitimate, ruling out the hijack branch.
Root cause
Split DNS in Tailscale is a suffix-to-resolver routing table, stored in the admin console DNS page and pushed to every client by the control plane (Module 02). A restricted nameserver entry said: queries matching corp.example.com go only to 10.0.5.53. During a routine nameserver edit, an admin deleted that entry. Per the DNS KB, a restricted nameserver only applies to queries matching a specific search domain, while a global nameserver handles queries for any domain, so with the restricted row gone the internal names stopped matching anything special and became ordinary queries for the global resolver. The quad-100 resolver on each client (Module 06) kept doing its job perfectly; the table it was given simply had one less row.
The reason this presented as wrong answers rather than failures is split-horizon DNS: the same names exist in the public zone with different records. If the public wildcard had not existed, every lookup would have returned NXDOMAIN, the ticket would have said “names stopped resolving,” and the missing-route diagnosis would have been nearly instant. This is the signature to memorize (Module 11): no answer points at a server or path; the right name with a wrong answer points at query routing, and on a tailnet, query routing is the split DNS table.
Fix and prevention
Immediate. In the admin console DNS page, add the nameserver back: Add nameserver, Custom, 10.0.5.53, then restrict it to the search domain corp.example.com so it becomes a restricted nameserver again. The control plane pushes the change without any client restart. Verify on an affected client: dig gitlab.corp.example.com now returns 10.0.20.14 from 100.100.100.100, and tailscale dns status lists the route again. Total client-side action required: none, which is also your proof of the mechanism.
Durable.
- Treat the DNS page as production configuration. Split DNS lives in the admin console, not in the policy file, so it does not ride through your ACL review flow. Give it an equivalent: a documented change process and a second person on any nameserver edit.
- Add a canary: a scheduled job on a tailnet node runs
dig canary.corp.example.comand alerts if the answer falls outside10.0.0.0/16. This converts the silent failure mode into a paged one, and it would have caught this at 09:27 instead of 10:05. - If the public wildcard is not load-bearing, remove it. A failure mode of NXDOMAIN is a gift: loud, obvious, and impossible to mistake for an application bug.
The handoff package
Summary: All tailnet clients resolve corp.example.com names to public IPs; split DNS restricted nameserver entry absent from tailnet DNS config after an 09:26 UTC admin console edit.
Repro: On any client with MagicDNS enabled, dig gitlab.corp.example.com returns 203.0.113.20 (public wildcard) from 100.100.100.100; expected 10.0.20.14.
Log evidence: 10:02:41 UTC, node-a: NOERROR public answer via quad-100 (dig capture attached). 09:26 UTC: DNS page nameserver edit removing restricted entry 10.0.5.53 for corp.example.com. 10:41 UTC, node-a: dig @10.0.5.53 returns correct internal record.
Version matrix: Clients v1.84.0 (macOS, Linux); subnet router node-b v1.84.0 (Linux); MagicDNS enabled tailnet-wide.
Impact scope: ~200 devices, every name under corp.example.com, 09:26 to 11:10 UTC.
Ruled out: internal resolver health, subnet route to 10.0.0.0/16, ACLs, client DNS bypass, public zone tampering, client caching.
Proposed owning area: none in product; admin configuration change. If escalated at all: control plane DNS configuration UX (deleting a restricted nameserver warns no differently than deleting a global one).
The trap
The weak version of this investigation hears “DNS is broken” and starts restarting things: the internal resolver, tailscaled, the laptops. Cache flushes everywhere. Each restart takes long enough that someone believes it worked, then the next lookup disproves it, and two hours vanish. The evidence that shortcuts all of it was in the very first dig: the SERVER field says who answered, and the status field says what kind of wrong you have. A clean NOERROR with a public address through quad-100 can only mean the query was routed somewhere that answers from the public view, and on a tailnet exactly one table decides that routing.