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.
4.9 KiB
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
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:
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_browserkeeps a singleServiceBrowseralive 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_warmregisters 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) intorefresh_queue, and executed by a fixed pool ofrefresh_pool_sizeworker threads (_refresh_worker). This bounds thread usage regardless of how many names/types are being tracked.- Answers are read from
self.zeroconf.cachewith TTLs derived from the cached record's remaining TTL (get_remaining_ttl), not a fixed value, except where a fallbackttlconstant is used because there's no cached TTL to compare against (freshly-fetchedSRV/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(andqtypewhere relevant) returning(answers, authority, additional), run viathreads.deferToThreadsincezeroconfcalls block. - Any addition that queries mDNS repeatedly for the same key should go through
_keep_warmrather than opening new ad-hoc polling loops, to keep thread usage bounded. - Locking:
self.lockguardsbrowser_types,browser,interests, andrefresh_heap— hold it for the shortest span needed, as done in the existing methods.