Add continuous querying and caching

Replace one-shot, blind-sleep mDNS lookups with a warm cache backed by
persistent background queriers:

- PTR (browse): a single ServiceBrowser tracks every service type seen
  so far (recreated with the enlarged type set when a new one shows
  up, rather than one browser per type), so zeroconf continuously
  refreshes it in the background; answers are read straight from the
  mDNS cache instead of blocking timeout seconds on every query.
- TXT/SRV: still resolved via get_service_info() (already cache-first
  internally), but a background job now periodically re-primes the
  cache so it doesn't go cold between queries.
- A/AAAA (bare hostnames): answers are read from the mDNS cache first;
  only a first-ever query for a name blocks waiting for a response,
  with a background job keeping it warm afterwards.

Periodic refresh jobs are scheduled by a single heap-based scheduler
thread and executed by a fixed pool of 4 worker threads, so the
background thread count stays constant no matter how many distinct
names/services get queried over the proxy's lifetime.

All returned TTLs now reflect the real remaining TTL of the underlying
mDNS record instead of a fixed constant, and goodbye packets (TTL=0)
are handled for free since eviction is delegated to zeroconf's own
cache.

Also add .gitignore for the project-local .venv/ and __pycache__/.
This commit is contained in:
2026-08-05 11:32:18 +02:00
parent fc8b0bfffd
commit 8270d2d7ca
2 changed files with 127 additions and 49 deletions
+2
View File
@@ -0,0 +1,2 @@
.venv/
__pycache__/
+125 -49
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange, ServiceInfo, DNSQuestion, DNSOutgoing, RecordUpdateListener, IPVersion from zeroconf import Zeroconf, ServiceBrowser, ServiceInfo, DNSQuestion, DNSOutgoing, IPVersion, current_time_millis
from zeroconf.const import _TYPE_A, _TYPE_AAAA, _CLASS_IN, _FLAGS_QR_QUERY from zeroconf.const import _TYPE_A, _TYPE_AAAA, _TYPE_PTR, _TYPE_SRV, _TYPE_TXT, _CLASS_IN, _FLAGS_QR_QUERY
import sys import sys
import time import time
import threading
import heapq
import queue
from twisted.internet import reactor, defer, threads from twisted.internet import reactor, defer, threads
from twisted.names import client, dns, error, server from twisted.names import client, dns, error, server
from twisted.python import log from twisted.python import log
@@ -15,6 +18,8 @@ port = int(sys.argv[2])
ttl = 120 ttl = 120
timeout = 2 timeout = 2
negative_ttl = 10 negative_ttl = 10
refresh_interval = 90
refresh_pool_size = 4
ipv6_ula_network = ipaddress.ip_network('fc00::/7') ipv6_ula_network = ipaddress.ip_network('fc00::/7')
def soa_record(): def soa_record():
@@ -26,7 +31,73 @@ def soa_record():
class DynamicResolver(object): class DynamicResolver(object):
def __init__(self): def __init__(self):
self.zeroconf = Zeroconf(ip_version=IPVersion.All) self.zeroconf = Zeroconf(ip_version=IPVersion.All)
self.lock = threading.Lock()
self.browser = None
self.browser_types = set()
self.interests = set()
self.refresh_heap = []
self.refresh_queue = queue.Queue()
for _ in range(refresh_pool_size):
threading.Thread(target=self._refresh_worker, daemon=True).start()
threading.Thread(target=self._refresh_scheduler, daemon=True).start()
def _ensure_browser(self, localname):
"""Make sure a persistent ServiceBrowser covers this service type.
A single ServiceBrowser (and thus a single thread) tracks every
type seen so far; when a genuinely new type shows up, it is
recreated with the enlarged type set rather than starting a
second one, so the thread count stays independent of how many
distinct service types get queried. Returns True the first time
this service type is seen."""
with self.lock:
if localname in self.browser_types:
return False
self.browser_types.add(localname)
if self.browser is not None:
self.browser.cancel()
self.browser = ServiceBrowser(self.zeroconf, list(self.browser_types), [lambda *a, **kw: None])
return True
def _keep_warm(self, key, refresh):
"""Register (once) a periodic `refresh()` job for `key`, so the
zeroconf cache stays populated between DNS queries instead of
going cold and forcing a fresh mDNS round trip on every lookup.
Jobs are executed by a small fixed-size worker pool rather than
one thread per key. Returns True the first time this key is
seen."""
with self.lock:
if key in self.interests:
return False
self.interests.add(key)
heapq.heappush(self.refresh_heap, (time.time() + refresh_interval, key, refresh))
return True
def _refresh_scheduler(self):
"""Single background thread: wakes up due refresh jobs and hands
them to the worker pool via self.refresh_queue."""
while True:
job = None
with self.lock:
if self.refresh_heap and self.refresh_heap[0][0] <= time.time():
_, key, refresh = heapq.heappop(self.refresh_heap)
job = (key, refresh)
if job:
self.refresh_queue.put(job)
else:
time.sleep(1)
def _refresh_worker(self):
"""Fixed pool of worker threads (refresh_pool_size of them) that
execute due refresh jobs and reschedule them for their next run."""
while True:
key, refresh = self.refresh_queue.get()
try:
refresh()
except Exception:
pass
with self.lock:
heapq.heappush(self.refresh_heap, (time.time() + refresh_interval, key, refresh))
def _dynamicResponseRequired(self, query): def _dynamicResponseRequired(self, query):
if str(query.name).endswith(domain): if str(query.name).endswith(domain):
return True return True
@@ -40,26 +111,19 @@ class DynamicResolver(object):
localname = str(query.name)[:-len(domain)] + "local." localname = str(query.name)[:-len(domain)] + "local."
def browse(localname): def browse(localname):
services = [] if self._ensure_browser(localname):
def handler(zeroconf, service_type, name, state_change): time.sleep(timeout)
if state_change is ServiceStateChange.Added:
services.append(name) now = current_time_millis()
records = [r for r in self.zeroconf.cache.get_all_by_details(localname, _TYPE_PTR, _CLASS_IN) if not r.is_expired(now)]
sb = ServiceBrowser(self.zeroconf, localname, [handler]) answers = [dns.RRHeader(name=localname[:-6] + domain, ttl=int(r.get_remaining_ttl(now)), type=dns.PTR, payload=dns.Record_PTR(
time.sleep(timeout) name=r.alias[:-6] + domain
sb.cancel() )) for r in records]
return answers, ([] if answers else [soa_record()]), []
answers, additional = [], []
for service in services:
answers.append(dns.RRHeader(name=localname[:-6] + domain, ttl=ttl, type=dns.PTR, payload=dns.Record_PTR(
name=service[:-6] + domain
)))
#txt_ans, _, _ = txt(service)
#srv_ans, _, a_ans = srv(service)
#additional += a_ans + txt_ans + srv_ans
return answers, ([] if answers else [soa_record()]), additional
def txt(localname): def txt(localname):
self._keep_warm(('info', localname), lambda: self.zeroconf.get_service_info(localname, localname, timeout*1000))
if localname.endswith('._device-info._tcp.local.'): if localname.endswith('._device-info._tcp.local.'):
info = ServiceInfo(localname, localname) info = ServiceInfo(localname, localname)
info.request(self.zeroconf, timeout*1000) info.request(self.zeroconf, timeout*1000)
@@ -80,17 +144,26 @@ class DynamicResolver(object):
i += length i += length
data = [b"%s=%s" % (p, info.properties[p]) for p in sorted(info.properties, key=lambda k: order.index(k) if k in order else 1000)] data = [b"%s=%s" % (p, info.properties[p]) for p in sorted(info.properties, key=lambda k: order.index(k) if k in order else 1000)]
answers = [dns.RRHeader(name=localname[:-6] + domain, ttl=ttl, type=dns.TXT, payload=dns.Record_TXT( now = current_time_millis()
cached = self.zeroconf.cache.get_by_details(localname, _TYPE_TXT, _CLASS_IN)
record_ttl = int(cached.get_remaining_ttl(now)) if cached else ttl
answers = [dns.RRHeader(name=localname[:-6] + domain, ttl=record_ttl, type=dns.TXT, payload=dns.Record_TXT(
*data *data
))] ))]
return answers, [], [] return answers, [], []
def srv(localname): def srv(localname):
self._keep_warm(('info', localname), lambda: self.zeroconf.get_service_info(localname, localname, timeout*1000))
info = self.zeroconf.get_service_info(localname, localname, timeout*1000) info = self.zeroconf.get_service_info(localname, localname, timeout*1000)
if info is None: if info is None:
return [], [soa_record()], [] return [], [soa_record()], []
answers = [dns.RRHeader(name=localname[:-6] + domain, ttl=ttl, type=dns.SRV, payload=dns.Record_SRV( now = current_time_millis()
cached = self.zeroconf.cache.get_by_details(localname, _TYPE_SRV, _CLASS_IN)
srv_ttl = int(cached.get_remaining_ttl(now)) if cached else ttl
answers = [dns.RRHeader(name=localname[:-6] + domain, ttl=srv_ttl, type=dns.SRV, payload=dns.Record_SRV(
info.priority, info.weight, info.port, info.server[:-6] + domain info.priority, info.weight, info.port, info.server[:-6] + domain
))] ))]
target = info.server[:-6] + domain target = info.server[:-6] + domain
@@ -104,36 +177,39 @@ class DynamicResolver(object):
def host(localname, qtype): def host(localname, qtype):
if qtype == dns.AAAA: if qtype == dns.AAAA:
mdns_type, addr_len, parse_addr = _TYPE_AAAA, 16, ipaddress.IPv6Address mdns_type, parse_addr = _TYPE_AAAA, ipaddress.IPv6Address
keep_addr = lambda addr: addr in ipv6_ula_network keep_addr = lambda addr: addr in ipv6_ula_network
else: else:
mdns_type, addr_len, parse_addr = _TYPE_A, 4, ipaddress.IPv4Address mdns_type, parse_addr = _TYPE_A, ipaddress.IPv4Address
keep_addr = lambda addr: not addr.is_link_local keep_addr = lambda addr: not addr.is_link_local
class listener(RecordUpdateListener):
def __init__(self):
self.addrs = []
self.time = time.time()
def update_record(self, zc, now, record):
if record.type == mdns_type and len(record.address) == addr_len:
addr = parse_addr(record.address)
if keep_addr(addr):
self.addrs.append(str(addr))
l = listener()
q = DNSQuestion(localname, mdns_type, _CLASS_IN)
self.zeroconf.add_listener(l, q)
out = DNSOutgoing(_FLAGS_QR_QUERY)
out.add_question(q)
self.zeroconf.send(out)
while len(l.addrs) == 0 and time.time() - l.time < timeout:
time.sleep(0.1)
self.zeroconf.remove_listener(l)
record_cls = dns.Record_AAAA if qtype == dns.AAAA else dns.Record_A record_cls = dns.Record_AAAA if qtype == dns.AAAA else dns.Record_A
answers = [dns.RRHeader(name=query.name.name, ttl=ttl, type=qtype, payload=record_cls(
addr def send_question():
)) for addr in l.addrs] q = DNSQuestion(localname, mdns_type, _CLASS_IN)
out = DNSOutgoing(_FLAGS_QR_QUERY)
out.add_question(q)
self.zeroconf.send(out)
def answers_from_cache():
now = current_time_millis()
result = []
for r in self.zeroconf.cache.get_all_by_details(localname, mdns_type, _CLASS_IN):
if r.is_expired(now):
continue
addr = parse_addr(r.address)
if keep_addr(addr):
result.append(dns.RRHeader(name=query.name.name, ttl=int(r.get_remaining_ttl(now)), type=qtype, payload=record_cls(str(addr))))
return result
is_new = self._keep_warm((localname, mdns_type), send_question)
answers = answers_from_cache()
if not answers and is_new:
send_question()
deadline = time.time() + timeout
while not answers and time.time() < deadline:
time.sleep(0.1)
answers = answers_from_cache()
return answers, ([] if answers else [soa_record()]), [] return answers, ([] if answers else [soa_record()]), []