Document caching, negative responses, and add CLAUDE.md

README was missing the continuous-caching and RFC 2308 negative
response behavior added in recent commits. CLAUDE.md is new,
providing architecture guidance for future Claude Code sessions.
This commit is contained in:
2026-08-06 15:02:32 +02:00
parent e45560f763
commit 943a1c6647
2 changed files with 94 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A Discovery Proxy for Multicast DNS-Based Service Discovery (RFC 8766), implemented in pure
Python as a single file: `proxy.py`. It uses `zeroconf` to gather mDNS advertisements from the
local network and `Twisted` (`twisted.names`) to re-publish them as a unicast DNS service. It
does not strictly follow RFC 8766 — it works with real-world clients (macOS, iOS) but violations
of the spec should be treated as bugs to fix.
## Running
```bash
python3 proxy.py <domain> <port>
# e.g.
python3 proxy.py home.arpa 35353
```
Dependencies: `pip install -r requirements.txt` (`zeroconf`, `twisted`). There is no build step,
test suite, or linter configured in this repo.
Manual verification against a running instance:
```bash
dig yourcomputer.home.arpa
dig yourcomputer.home.arpa aaaa
dig _ssh._tcp.home.arpa ptr
dig yourcomputer._ssh._tcp.home.arpa srv
```
It is deployed as a systemd service; see `dns-sd-proxy.service` for the unit file shape (runs as
an unprivileged user, `MemoryMax=256M`).
## Architecture
Everything lives in `proxy.py` and revolves around one class, `DynamicResolver`, which acts as a
Twisted `IResolver` client plumbed into a `twisted.names.server.DNSServerFactory`. Domain, port,
and tuning constants (`ttl`, `timeout`, `negative_ttl`, `refresh_interval`, `refresh_pool_size`)
are module-level globals read from `sys.argv` at import time.
**Query flow**: `DynamicResolver.query()` accepts a DNS query only if its name ends with the
configured `domain`; anything else fails with `error.DomainError()`. Matching queries go to
`_doDynamicResponse`, which strips the domain suffix and re-appends `.local.` to get the mDNS
name, then dispatches by record type to one of four inner handlers — `browse` (PTR), `txt` (TXT),
`srv` (SRV, also synthesizes A/AAAA additional records for the target host), `host` (A/AAAA). Each
handler runs on a Twisted worker thread via `threads.deferToThread` since `zeroconf` is
synchronous/blocking. SOA queries get a synthetic negative-response SOA record
(`soa_record()`, RFC 2308) instead of hitting mDNS at all; any record type not handled returns SOA
too, standing in for a negative response.
**Caching and warm-up** (added to avoid slow mDNS round-trips on every unicast query):
- `_ensure_browser` keeps a single `ServiceBrowser` alive across *all* service types ever queried
(`self.browser_types`), recreated with the enlarged type set when a new type appears, so thread
count doesn't grow with the number of distinct types.
- `_keep_warm` registers a periodic re-query job (per cache key) exactly once, so the zeroconf
cache for that name/type stays populated between DNS lookups. Jobs live in a min-heap
(`refresh_heap`) keyed by next-due time, drained by a single scheduler thread
(`_refresh_scheduler`) into `refresh_queue`, and executed by a fixed pool of
`refresh_pool_size` worker threads (`_refresh_worker`). This bounds thread usage regardless of
how many names/types are being tracked.
- Answers are read from `self.zeroconf.cache` with TTLs derived from the cached record's
*remaining* TTL (`get_remaining_ttl`), not a fixed value, except where a fallback `ttl` constant
is used because there's no cached TTL to compare against (freshly-fetched `SRV`/additional
A/AAAA records).
**IPv6 handling**: only IPv6 Unique Local Addresses (`fc00::/7`, module constant
`ipv6_ula_network`) are ever published in `AAAA` records — link-local addresses are unusable by
unicast DNS clients (no zone index in DNS), and global addresses are deliberately filtered out.
This filter is applied both in the `host()` handler and when synthesizing additional records in
`srv()`.
**TXT record handling**: `zeroconf`'s `ServiceInfo.properties` is a dict and doesn't preserve key
order or exact key-only (no `=`) entries, so `txt()` manually walks the raw `info.text` bytes to
recover original key order and reconstruct valueless keys (present in `properties` with value
`None`) as bare keys rather than `key=`.
**Message truncation**: `TruncatingDNSDatagramProtocol` overrides `writeMessage` to enforce the
512-byte UDP DNS message limit — first by dropping additional records, then by dropping answers
and setting the truncation bit if still too large — before falling back to standard Twisted
truncation behavior.
## Working in this file
- Keep new record-type handlers following the existing pattern: an inner function taking
`localname` (and `qtype` where relevant) returning `(answers, authority, additional)`, run via
`threads.deferToThread` since `zeroconf` calls block.
- Any addition that queries mDNS repeatedly for the same key should go through `_keep_warm` rather
than opening new ad-hoc polling loops, to keep thread usage bounded.
- Locking: `self.lock` guards `browser_types`, `browser`, `interests`, and `refresh_heap` — hold it
for the shortest span needed, as done in the existing methods.
+2
View File
@@ -2,6 +2,8 @@ This is a **Discovery Proxy for Multicast DNS-Based Service Discovery**, written
Both IPv4 and IPv6 are supported on the mDNS side. For IPv6, only Unique Local Addresses (ULA, `fc00::/7`) are published in `AAAA` records: link-local addresses aren't usable by unicast DNS clients (DNS has no way to express the required zone index), and globally routable addresses are filtered out so that only internal ULA addresses are advertised.
Rather than issuing a one-shot mDNS query and blocking for a timeout on every lookup, the proxy keeps a live mDNS cache warm in the background: browsing and previously-seen names/services are re-queried periodically, so most unicast DNS queries are answered straight from cache with a TTL that reflects the underlying record's real remaining lifetime. Negative responses (e.g. a name or service that doesn't exist) are returned per [RFC 2308](https://tools.ietf.org/html/rfc2308), with a synthesized SOA record in the authority section so resolvers can negative-cache them too.
This kind of proxy is specified by [RFC 8766](https://tools.ietf.org/html/rfc8766). I did not actually follow the standard in implementing it, which means that it doesn't abide by many of the finer details. Nevertheless, it works fine with clients like macOS 10.15 and iOS 13. Any violations of the standard are to be considered bugs and should be fixed over time.
Other implementations of discovery proxies are [ohybridproxy](https://github.com/sbyx/ohybridproxy) and [included with mDNSResponder](https://opensource.apple.com/source/mDNSResponder/mDNSResponder-1096.60.2/ServiceRegistration/dnssd-proxy.c.auto.html). Since the former only runs on OpenWRT and mDNSResponder is not readily available on Linux, I decided to put together my own implementation in Python. Its dependencies are pure Python, so it should be possible to run it pretty much anywhere.