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.
“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”
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
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
| Property | Value | Why it matters |
|---|---|---|
| Hash | FNV-1a & 0xFFFFFFFFFF | Cheap, no dependency, stable across rebuilds. |
| Entry size | 5 bytes | 718 KB fits in L2; whole list loads at boot in one read. |
| Lookup | bisect over bytes | ~17 steps for 143k entries; no Python dict churn. |
| Subdomain rule | block parent → children | Blocking toddleapp.com catches web., api., everything. |
| Custom list | set() of strings | User domains are few; exact match is cheaper than hashing. |
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:
| Layer | Lifetime | How it was beaten |
|---|---|---|
| macOS resolver | seconds → minutes | Flushed on every write: dscacheutil -flushcache + killall -HUP mDNSResponder. |
| Chrome internal resolver | holds an IP for the life of the process | Cannot be flushed from outside. Cleared via chrome://net-internals/#dns → “Clear host cache”, or by restarting Chrome. |
| DNS-over-HTTPS | per request | Chrome’s “Use secure DNS” bypasses :53 entirely. Must be off — then Chrome uses the system resolver again. |
| Endpoint | Effect | Why it exists |
|---|---|---|
| GET /api/status | running yes/no | What the panel polls; smallest possible health check. |
| GET /api/stats | counters, clients, query log, lists | One call paints the entire dashboard — no per-widget round trips. |
| GET /api/toggle | pause / resume | Lock-protected; socket stays open (bug 1). |
| GET /api/addblock | block + flush resolver cache | Blocking must be true when the call returns, not “eventually”. |
| GET /api/unblock | remove custom + allowlist + flush | Makes removal beat the 143k main list (bug 3). |
| GET /api/allow | bypass the main list | False positives are real (h-adashx.ut.taobao.com is genuine Taobao telemetry). |
| GET /api/disallow | drop the bypass | Reversible allowlist, with its own cache flush. |
| GET /api/ban | ban a client IP | Per-device kill switch for the LAN clients on :53. |
| GET /api/reload | re-read blocklist.bin | Rebuild the list without restarting the daemon. |
| GET /api/reset | zero the counters | Measurement hygiene after a test run. |
| GET /api/clearlog | clear the ring buffer | 1,000-query buffer; clears without touching state. |
| Symptom | Cause | Handling |
|---|---|---|
| “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 list | Unblock writes an allowlist entry that is checked first |
| “Blocking a domain quits Chrome” | pkill -HUP broadcast to browsers | Removed; only the system resolver is signalled |
“toddleapp won't block” | Blocked web.toddleapp.com; the app used the apex | Block the parent; subdomains inherit |
| Paused → every site times out | Worker dropped queries while paused | Accepted as correct: a paused filter must not answer for others. Socket stays open. |
| Daemon “running” but nothing resolves | The plist path had become an empty directory; launchd crash-looped with can't open file | KeepAlive + verify by pgrep and a real dig, never by launchd status alone |
| Artifact | Bytes | SHA-256 (first 16) |
|---|---|---|
| src/macadblock.py | 26,357 | c47128c7d9370a14 |
| src/macadblock_gui.py | 15,159 | 9712dcc41f3e1bde |
| macadblock.sh | 3,313 | e461d6566fb6ccba |
| blocklist/blocklist.bin | 717,980 | 24face9053e46270 |
| demos/macadblock-mirror/index.html | 6,692 | 8f5f93e571a6274f |
| demos/macadblock.html canned | 9,683 | ff681c6a0ed98ce2 |
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.
127.0.0.1 are root operations. The panel asks through the normal macOS authorisation dialog —
no terminal, but not magic.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.
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.
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.