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