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.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user