Files
franck 943a1c6647 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.
2026-08-06 15:02:32 +02:00

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_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.