From 943a1c66479cfe582a596dbfd2a36f39c114b401 Mon Sep 17 00:00:00 2001 From: Franck Zoccolo Date: Thu, 6 Aug 2026 15:02:32 +0200 Subject: [PATCH] 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. --- CLAUDE.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 94 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b811ff0 --- /dev/null +++ b/CLAUDE.md @@ -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 +# 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. diff --git a/README.md b/README.md index c8e1687..cb2f998 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ This is a **Discovery Proxy for Multicast DNS-Based Service Discovery**, written Both IPv4 and IPv6 are supported on the mDNS side. For IPv6, only Unique Local Addresses (ULA, `fc00::/7`) are published in `AAAA` records: link-local addresses aren't usable by unicast DNS clients (DNS has no way to express the required zone index), and globally routable addresses are filtered out so that only internal ULA addresses are advertised. +Rather than issuing a one-shot mDNS query and blocking for a timeout on every lookup, the proxy keeps a live mDNS cache warm in the background: browsing and previously-seen names/services are re-queried periodically, so most unicast DNS queries are answered straight from cache with a TTL that reflects the underlying record's real remaining lifetime. Negative responses (e.g. a name or service that doesn't exist) are returned per [RFC 2308](https://tools.ietf.org/html/rfc2308), with a synthesized SOA record in the authority section so resolvers can negative-cache them too. + This kind of proxy is specified by [RFC 8766](https://tools.ietf.org/html/rfc8766). I did not actually follow the standard in implementing it, which means that it doesn't abide by many of the finer details. Nevertheless, it works fine with clients like macOS 10.15 and iOS 13. Any violations of the standard are to be considered bugs and should be fixed over time. Other implementations of discovery proxies are [ohybridproxy](https://github.com/sbyx/ohybridproxy) and [included with mDNSResponder](https://opensource.apple.com/source/mDNSResponder/mDNSResponder-1096.60.2/ServiceRegistration/dnssd-proxy.c.auto.html). Since the former only runs on OpenWRT and mDNSResponder is not readily available on Linux, I decided to put together my own implementation in Python. Its dependencies are pure Python, so it should be possible to run it pretty much anywhere.