Analysis · system-level build
macOS 26.6 Python 3 launchd DNS :53

MacAdBlock — a DNS sinkhole that survives its own controls

A system-wide ad blocker for macOS with one hard constraint: no terminal commands, ever. Holding that line forced the whole architecture — a root daemon on port 53, a launchd job that restarts it, a PyObjC panel with Install/Uninstall buttons — and it turned out the hard part was never the DNS server. It was three caches I didn't own sitting between my answer and the browser.

Code
1,142 lines
Blocked domains
143,596
Blocklist size
718 KB
Per entry
5 bytes
HTTP endpoints
11
Real bugs fixed
5
The constraint, in his words

“can you just make the adblocker no need to do any terminal stuff and just work”
“i dont want it to quit chrome everytime i add new block domain”

The shape of it

Every DNS query the Mac makes is answered by a Python server listening on 127.0.0.1:53 as root. It checks an allowlist, then a 143,596-entry hashed blocklist, and either answers 0.0.0.0 (the sinkhole) or forwards upstream (Quad9 → Cloudflare → Google). A second thread serves an HTTP panel on :8053 that reads and writes the same state.

  app / browser / any process
        │  getaddrinfo("ads.example.com")
        ▼
  mDNSResponder  ── system DNS = 127.0.0.1 ──►  127.0.0.1:53   (root, launchd)
                                                     │
                                        ┌────────────┴────────────┐
                                        ▼                         ▼
                                  allowlist hit?            hash lookup
                                  → forward upstream        (sorted 40-bit hashes)
                                        │                         │
                                        ▼                    ┌────┴────┐
                                  9.9.9.9 / 1.1.1.1          hit      miss
                                                              │         │
                                                        0.0.0.0    forward + cache

Version ladder — what each pass cut

1

V1 — hosts file

A generated /etc/hosts. Zero code, zero daemon, and one fatal property: it needs sudo after every list change, which is exactly the terminal workflow that was banned.

2

V2 — DNS server on :8054 + pfctl redirect

A real Python sinkhole. It worked, and it needed a pf rule to redirect 53 → 8054 on top of a networksetup call — two root operations that both fail silently if you get them wrong. This is the version that shipped bugs: pausing the blocker closed the DNS socket entirely, so the whole machine lost DNS instead of merely unblocking.

3

V3 — bind :53 directly as a launchd daemon shipped

If the process runs as root from boot, it can own port 53 itself and the pf rule disappears. One launchctl load, one DNS pointer, no packet filter. The GUI gained Install / Uninstall buttons so the install is a click, not a script.

The five bugs that actually mattered

1 · Pause killed DNS for the whole Mac

Toggling off closed the listening socket, so clients got connection refused instead of “nothing is filtered right now”. Fix: the socket stays open forever; a paused server answers by forwarding everything. A pause should be invisible, not fatal.

2 · Toggle race → two workers, double answers

Rapid on/off calls spawned overlapping worker threads on the same socket. Fix: a threading.Lock() guards the worker lifecycle and the new worker waits for the old one to exit before binding.

3 · Unblock didn't unblock

Removing a domain from the custom list left it blocked, because the same domain also lives in the 143k main list. Fix: unblock also writes an allowlist entry, and the allowlist is consulted before any blocklist. Removal is now a real bypass, not a list edit.

4 · Adding a block quit Chrome

The first “make it instant” attempt flushed browser caches with pkill -HUP — which restarts Chrome under the user. Fix: only the macOS resolver cache is flushed (dscacheutil + mDNSResponder HUP). No user process is ever signalled.

5 · GUI died on launch, twice

rumps / NSStatusItem is broken on macOS 26, so the menu-bar app was rewritten in raw PyObjC. Then a Python method used as a main-thread callback had the wrong arity — PyObjC requires Objective-C selector shape, so it became updateInstallStatus_(self, installed).

+ Install/Uninstall that lied

The panel showed “Install Daemon” while the daemon was already loaded: the check only ran on the failure path. Fix: detect the plist on launch and set the button title from the real state, both ways.

Anatomy 1 — 143,596 domains in 718 KB

Storing domain strings would cost ~3 MB and a string compare per query. Instead the build step hashes every domain with FNV-1a truncated to 40 bits and writes the sorted hashes as 5 bytes each: 143,596 × 5 = 717,980 bytes. A lookup is a binary search over a bytes object — no dict, no allocation, ~17 comparisons instead of a linear scan.

PropertyValueWhy it matters
HashFNV-1a & 0xFFFFFFFFFFCheap, no dependency, stable across rebuilds.
Entry size5 bytes718 KB fits in L2; whole list loads at boot in one read.
Lookupbisect over bytes~17 steps for 143k entries; no Python dict churn.
Subdomain ruleblock parent → childrenBlocking toddleapp.com catches web., api., everything.
Custom listset() of stringsUser domains are few; exact match is cheaper than hashing.

Anatomy 2 — the three DNS caches I don't own

This is the part that ate the session. Proof first: with toddleapp.com in the blocklist, dig @127.0.0.1 toddleapp.com returned 0.0.0.0 — and Chrome loaded the page anyway. Three independent caches sit between the answer and the pixels, and each one fails differently:

LayerLifetimeHow it was beaten
macOS resolverseconds → minutesFlushed on every write: dscacheutil -flushcache + killall -HUP mDNSResponder.
Chrome internal resolverholds an IP for the life of the processCannot be flushed from outside. Cleared via chrome://net-internals/#dns → “Clear host cache”, or by restarting Chrome.
DNS-over-HTTPSper requestChrome’s “Use secure DNS” bypasses :53 entirely. Must be off — then Chrome uses the system resolver again.
The honest ceiling: a locally-installed blocker can make new lookups fail instantly, but it cannot reach into a running browser’s private cache. “Block it” is therefore two different promises: the resolver stops answering immediately; an already-cached hostname only dies when the browser drops it. Pretending otherwise was the bug behind three rounds of “it still loads”.

Contract — what the daemon exposes

EndpointEffectWhy it exists
GET /api/statusrunning yes/noWhat the panel polls; smallest possible health check.
GET /api/statscounters, clients, query log, listsOne call paints the entire dashboard — no per-widget round trips.
GET /api/togglepause / resumeLock-protected; socket stays open (bug 1).
GET /api/addblockblock + flush resolver cacheBlocking must be true when the call returns, not “eventually”.
GET /api/unblockremove custom + allowlist + flushMakes removal beat the 143k main list (bug 3).
GET /api/allowbypass the main listFalse positives are real (h-adashx.ut.taobao.com is genuine Taobao telemetry).
GET /api/disallowdrop the bypassReversible allowlist, with its own cache flush.
GET /api/banban a client IPPer-device kill switch for the LAN clients on :53.
GET /api/reloadre-read blocklist.binRebuild the list without restarting the daemon.
GET /api/resetzero the countersMeasurement hygiene after a test run.
GET /api/clearlogclear the ring buffer1,000-query buffer; clears without touching state.

Failure modes, and what they actually were

SymptomCauseHandling
“I blocked X but it still loads”Browser internal DNS cache (and DoH before that)Resolver flushed on write; DoH off; documented chrome://net-internals/#dns
“I removed X but it's still blocked”X is also in the 143k main listUnblock writes an allowlist entry that is checked first
“Blocking a domain quits Chrome”pkill -HUP broadcast to browsersRemoved; only the system resolver is signalled
“toddleapp won't block”Blocked web.toddleapp.com; the app used the apexBlock the parent; subdomains inherit
Paused → every site times outWorker dropped queries while pausedAccepted as correct: a paused filter must not answer for others. Socket stays open.
Daemon “running” but nothing resolvesThe plist path had become an empty directory; launchd crash-looped with can't open fileKeepAlive + verify by pgrep and a real dig, never by launchd status alone

Evidence

ArtifactBytesSHA-256 (first 16)
src/macadblock.py26,357c47128c7d9370a14
src/macadblock_gui.py15,1599712dcc41f3e1bde
macadblock.sh3,313e461d6566fb6ccba
blocklist/blocklist.bin717,98024face9053e46270
demos/macadblock-mirror/index.html6,6928f5f93e571a6274f
demos/macadblock.html canned9,683ff681c6a0ed98ce2

The mirror is the dashboard exactly as the daemon served it — same bytes, same hash. It has no data of its own: opened from the portfolio it will sit empty, because it fetches /api/stats from a daemon that isn't running in your browser. That is what the mirror is for (proof of the real artifact). To actually see the panel, open the canned dashboard, which is the same HTML with window.fetch stubbed to a recorded stats response.

Honest limitations

1 · Install is one password, not zero clicks. Binding :53 and pointing system DNS at 127.0.0.1 are root operations. The panel asks through the normal macOS authorisation dialog — no terminal, but not magic.

2 · DNS-level blocking has a floor. Apps with hardcoded IPs, a VPN, or their own DoH endpoint ignore this entirely. It filters names, not packets.

3 · 40-bit hashes can collide. With 143,596 entries the birthday probability of at least one collision is ≈0.9%. A collision over-blocks one unrelated domain (fail-closed); it never lets a blocked one through. Widening to 64 bits is a rebuild, not a redesign.

4 · The list is borrowed, not audited. StevenBlack + Hagezi, 143,596 entries, rebuilt by tools/build_blocklist.py. False positives are expected; the allowlist is the answer, and h-adashx.ut.taobao.com is a live example of a genuine tracking domain that a user may want.

What's next

Ship the .pkg

The panel and daemon work from source; a signed installer with the plist pre-installed would make the first run two clicks instead of one password prompt plus a python path.

Per-client rules

The daemon already counts per client IP (and can ban one). The obvious next step is per-device blocklists — the kid's iPad and my laptop shouldn't share a policy.

Open the canned dashboard Byte-identical mirror ← Portfolio