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.
93 lines
4.9 KiB
Markdown
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.
|