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

93 lines
4.9 KiB
Markdown

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