I run AdGuard Home in Docker on a spare Linux box at home. Every device on my network gets it as DNS through DHCP, so the laptops, the phones, the TV, all of them resolve through one container I control. For a few weeks it looked like the cleanest thing in my homelab. 18 filter lists, 85 user rules papering over the false positives, ads gone everywhere including the TV.
Then iMessage stopped sending and receiving on my Mac.
The symptom was confusing. Restart the Mac, messages arrive fine for about 30 minutes, then everything queues and never delivers. The BlueBubbles API returned status 200, message sent, every single time. chat.db told the truth, error 4, is_delivered 0. IDS logs showed NSURLErrorDomain Code=-1001 and response code 2, so Apple's push layer was timing out, nothing in the messaging app itself.
My first instinct was DNS, because it always is, but I checked the obvious thing anyway. nslookup courier.push.apple.com returned 0.0.0.0. The same lookup against 8.8.8.8 returned 17.57.144.152. My own resolver was answering Apple's push domain with a null route.
The rules doing it were explicit, ||mask.icloud.com^ and ||mask-h2.icloud.com^, sitting in a filter list I had turned on for privacy, and those domains are how iCloud Private Relay masks traffic. Blocking them killed Private Relay on the iPhone, which toggled between available and unavailable all day, and the push hostnames got caught by an aggressive list above them.
Why a reboot fixed it for 30 minutes
IDS keeps a DNS cache. At boot it resolves the push hostnames once, gets real IPs while the cache is cold, connects, and messages work. Thirty minutes later the cache expires, IDS re-resolves through the resolver that answers 0.0.0.0, and every connection after that fails. That works-after-reboot, dies-30-minutes-later shape is the fingerprint of DNS poisoning. Nothing in the messaging stack changed between minute 1 and minute 30.
I killed imagent once to force a re-resolve, which was a mistake. It triggered the same XPC sandbox crash I had seen in July, CoreData failed to create NSXPCConnection, contactsd sandbox restriction, xpc_error 159. imagent uses the system resolver on every connection attempt and caches nothing, so killing it bought me nothing and cost me a reboot.
The same failure one layer down
While I was in there I found a second one. AdGuard had two DoH upstreams, Quad9 and Cloudflare, in load_balance mode. Quad9's endpoint intermittently fails on domains with multi-level CNAME chains, Azure Front Door traffic especially. The log line looked like this.
dnsproxy: exchange failed upstream=https://dns10.quad9.net:443/dns-query
question=";app.evenrealities.com. IN A" duration=3.6s err="unexpected EOF"
An app that worked on cellular and failed on WiFi sent me looking, cellular bypasses my resolver entirely, WiFi goes through it, so the phone was fine until it joined my network. With two upstreams in load_balance, roughly half the queries for an affected domain hit the broken one, which makes the failure look random from the client. Testing one domain and getting an answer proves nothing. It only shows on the CNAME-chain domains, and only on some tries.
The guard
Two fixes, one pattern. First, allowlist rules in user_rules, one per domain, ten for Apple's push and identity stack, eleven more later for X/Twitter when its CDN domains got caught by the same lists.
@@||courier.push.apple.com^$important
@@||init.push.apple.com^$important
@@||mask.icloud.com^$important
The @@ makes it an exception and $important makes it override filter list blocks. Second, I cut the lists themselves from 18 to 1, AdGuard's own DNS filter. The aggressive tiers were catching Apple push, Google login, X, Chegg, and I was writing a rule to unblock each service as it broke, 85 rules to keep 18 lists happy. After the trim, 23.
Then the DoH upstream swap, remove Quad9, add Google DoH next to Cloudflare.
Editing the file without breaking it
One of those edits destroyed the config. I piped a base64 Python script through SSH, something on the way in stripped every control character including the ones YAML needs, and the file came back unreadable. The backup sitting next to it saved me a rebuild. Now the script lands in /tmp as a real file and runs from there, and the edit itself is yaml.safe_load into yaml.dump with sort_keys=False, never sed.
AdGuard reads config at startup, so docker restart on the container, wait about five seconds before testing, the DNS proxy is not ready the instant the process is up. A reload does not re-parse user rules.
Verification is two lookups. Local resolver against a public one, same domain. If local says 0.0.0.0 and 1.1.1.1 says a real address, the block is mine. Then grep the container logs for exchange failures over the last couple of minutes.
What the config looks like today
I re-read the live file this week, one filter list enabled out of the 18 still listed in it, AdGuard's own, 57 user rules up from 23, because Google login, iCloud, gstatic and googletagmanager got allowlisted after they broke the same way. courier.push.apple.com resolves through my resolver again, full CNAME chain, and doubleclick.net still answers 0.0.0.0.
The upstream change is the one that did not stick. Today's config still lists Quad9 and Cloudflare in load_balance, so the intermittent failure is still in there, waiting for a domain with a long CNAME chain and a bad coin flip. The swap is written down and not applied. That is the next five minutes of work on this box.
Top comments (0)