Saturn v2 mDNS Technical Specification
Date: 2026-03-25
Status: Draft
Scope: mDNS/DNS-SD subsystem redesign
Based on: docs/mdns-os-research.md, bite analysis in chomp/bites/mdns-rfc-gap-analysis/
Table of Contents
- Problem Statement
- Architecture: Multi-Backend mDNS Layer
- Backend 1: Avahi (Linux)
- Backend 2: Bonjour / mDNSResponder (macOS)
- Backend 3: Windows DNS-SD API
- Backend 4: Userspace Fallback
- TXT Record Schema v2
- DNS-SD Subtype Registration
- Conflict Resolution and Stable Identity
- Settle Detection
- Migration Path
- Performance Targets
- Security Hardening
1. Problem Statement
Saturn's current mDNS layer has three concrete failure modes:
Failure 1: Silent non-compliance on macOS.
mDNSResponder holds port 5353 exclusively. python-zeroconf's workaround sends mDNS
responses from ephemeral source ports. RFC 6762 §11 requires source port 5353. Compliant
implementations discard or deprioritize these responses. Every Saturn developer on a Mac
runs a non-compliant stack without any visible error. Discovery appears to work (same-process
loopback works fine) but cross-host discovery on macOS is degraded.
Failure 2: Identity lost on name conflict.
Saturn's instance name is its only identity (myservice-8080._saturn._tcp.local.). RFC §8
probing (handled by python-zeroconf) will rename conflicting services to myservice-8080 (2),
etc. Saturn has no conflict callback, no stable UUID, and no rename-and-re-probe cycle.
Any code that tracks services by name breaks silently after a conflict rename.
Failure 3: Startup race condition in discovery.
discover() uses time_since_last >= settle_time (default 1.0s). This is a heuristic with
no protocol basis. On congested or asymmetric networks it either returns too early (missing
peers) or waits longer than necessary (slow startup). The tests encode this fragility as
time.sleep(2.5) walls.
2. Architecture: Multi-Backend mDNS Layer
2.1 Abstract Interface
Define a MdnsBackend protocol in Python that all backends implement:
# saturn/mdns/backend.py
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Callable, List, Optional, Protocol, runtime_checkable
@dataclass
class ServiceRecord:
name: str # instance name, may change on conflict
node_id: str # stable UUID from id= TXT key
host: str # resolved IP address
port: int
txt: dict[str, str]
@dataclass
class AdvertiseSpec:
name: str
port: int
txt: dict[str, str] # must include id=<uuid>
subtypes: list[str] = field(default_factory=list)
ServiceEvent = tuple[str, ServiceRecord] # ("added"|"removed"|"updated", record)
@runtime_checkable
class MdnsBackend(Protocol):
def advertise(self, spec: AdvertiseSpec) -> None: ...
def withdraw(self) -> None: ...
def browse(self, callback: Callable[[ServiceEvent], None]) -> None: ...
def stop_browse(self) -> None: ...
def close(self) -> None: ...
2.2 Backend Selection
# saturn/mdns/detect.py
import sys
import platform
def select_backend() -> str:
if sys.platform == "darwin":
return "bonjour"
if sys.platform == "win32":
build = int(platform.version().split(".")[-1])
return "windows" if build >= 17763 else "userspace" # Win10 1809+ for stable DNS-SD
# Linux
try:
import dbus
dbus.SystemBus().get_object("org.freedesktop.Avahi", "/")
return "avahi"
except Exception:
pass
return "userspace"
def make_backend(name: str | None = None) -> MdnsBackend:
backend = name or select_backend()
if backend == "bonjour":
from saturn.mdns.bonjour import BonjourBackend
return BonjourBackend()
if backend == "avahi":
from saturn.mdns.avahi import AvahiBackend
return AvahiBackend()
if backend == "windows":
from saturn.mdns.windows import WindowsBackend
return WindowsBackend()
from saturn.mdns.userspace import UserspaceBackend
return UserspaceBackend()
3. Backend 1: Avahi (Linux)
3.1 Dependencies
At runtime, avahi-daemon must be running. Detect via D-Bus:
3.2 Registration
# saturn/mdns/avahi.py
import avahi
import dbus
import dbus.mainloop.glib
from gi.repository import GLib
class AvahiBackend:
SERVICE_TYPE = "_saturn._tcp"
def __init__(self):
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
self._bus = dbus.SystemBus()
self._server = dbus.Interface(
self._bus.get_object(avahi.DBUS_NAME, avahi.DBUS_PATH_SERVER),
avahi.DBUS_INTERFACE_SERVER
)
self._group: dbus.Interface | None = None
self._loop = GLib.MainLoop()
import threading
threading.Thread(target=self._loop.run, daemon=True).start()
def advertise(self, spec: AdvertiseSpec) -> None:
if self._group:
self._group.Reset()
else:
path = self._server.EntryGroupNew()
self._group = dbus.Interface(
self._bus.get_object(avahi.DBUS_NAME, path),
avahi.DBUS_INTERFACE_ENTRY_GROUP
)
self._group.connect_to_signal("StateChanged", self._on_group_state)
txt = avahi.string_array_to_txt_array(
[f"{k}={v}" for k, v in spec.txt.items()]
)
self._group.AddService(
avahi.IF_UNSPEC,
avahi.PROTO_UNSPEC,
dbus.UInt32(0),
spec.name,
self.SERVICE_TYPE,
"local",
"", # use local hostname
dbus.UInt16(spec.port),
txt
)
# Register DNS-SD subtypes
for subtype in spec.subtypes:
self._group.AddServiceSubtype(
avahi.IF_UNSPEC, avahi.PROTO_UNSPEC, dbus.UInt32(0),
spec.name, self.SERVICE_TYPE, "local",
f"{subtype}._sub.{self.SERVICE_TYPE}"
)
self._group.Commit()
def _on_group_state(self, state, error):
import avahi
if state == avahi.ENTRY_GROUP_COLLISION:
# RFC §8: name collision — choose alternative and re-probe
self._handle_collision()
elif state == avahi.ENTRY_GROUP_FAILURE:
raise RuntimeError(f"Avahi registration failed: {error}")
def _handle_collision(self):
if self._current_spec is None:
return
new_name = self._server.GetAlternativeServiceName(self._current_spec.name)
from .conflict import update_instance_name
update_instance_name(new_name) # persists new name, notifies caller
self._current_spec.name = new_name
self.advertise(self._current_spec) # re-probe with new name
def withdraw(self) -> None:
if self._group:
self._group.Reset() # sends goodbye packets for all records
def browse(self, callback):
self._browser = self._server.ServiceBrowserNew(
avahi.IF_UNSPEC, avahi.PROTO_UNSPEC,
self.SERVICE_TYPE, "local", dbus.UInt32(0)
)
browser_iface = dbus.Interface(
self._bus.get_object(avahi.DBUS_NAME, self._browser),
avahi.DBUS_INTERFACE_SERVICE_BROWSER
)
browser_iface.connect_to_signal("ItemNew", lambda *a: self._on_new(callback, *a))
browser_iface.connect_to_signal("ItemRemove", lambda *a: self._on_remove(callback, *a))
# AllForNow fires when initial cache sweep is complete
browser_iface.connect_to_signal("AllForNow", self._on_all_for_now)
def _on_all_for_now(self):
self._settle_event.set() # unblocks discover() immediately
Key property: avahi_entry_group_reset() sends goodbye packets for all records in the
group atomically, including PTR, SRV, TXT, A. No partial goodbye window.
3.3 Privilege Model
Avahi registration requires no elevated privileges. The daemon (avahi-daemon) is the
privileged component. The D-Bus connection works from any process, including containers
that have access to the system D-Bus socket (/run/dbus/system_bus_socket).
3.4 Debian 13+ Consideration
On Debian Trixie (13+), systemd-resolved's mDNS is disabled by default and Avahi is
canonical. Detection via D-Bus handles this correctly — if org.freedesktop.Avahi is on
the bus, use Avahi; otherwise fall back to userspace.
4. Backend 2: Bonjour / mDNSResponder (macOS)
4.1 Strategy
macOS's mDNSResponder holds port 5353 exclusively. The only correct approach is to
delegate all mDNS operations to it via the dns_sd.h API or the Python pybonjour binding.
Alternatively, use ctypes to call libdns_sd.dylib directly — zero new dependencies.
4.2 Registration via ctypes
# saturn/mdns/bonjour.py
import ctypes, ctypes.util, socket, threading, struct
_lib = ctypes.cdll.LoadLibrary(ctypes.util.find_library("dns_sd"))
DNSServiceRef = ctypes.c_void_p
DNSServiceFlags = ctypes.c_uint32
DNSServiceErrorType = ctypes.c_int32
class BonjourBackend:
SERVICE_TYPE = "_saturn._tcp"
def __init__(self):
self._sd_ref: ctypes.c_void_p | None = None
self._thread: threading.Thread | None = None
self._running = threading.Event()
def advertise(self, spec: AdvertiseSpec) -> None:
if self._sd_ref:
_lib.DNSServiceRefDeallocate(self._sd_ref)
# Build TXT record in DNS wire format
txt_bytes = b""
for k, v in spec.txt.items():
entry = f"{k}={v}".encode()
txt_bytes += bytes([len(entry)]) + entry
sd_ref = DNSServiceRef()
err = _lib.DNSServiceRegister(
ctypes.byref(sd_ref),
ctypes.c_uint32(0), # flags
ctypes.c_uint32(0), # all interfaces
spec.name.encode(),
f"{self.SERVICE_TYPE}.".encode(),
None, # domain: "local."
None, # host: local hostname
socket.htons(spec.port),
ctypes.c_uint16(len(txt_bytes)),
ctypes.c_char_p(txt_bytes),
self._register_reply,
None
)
if err != 0:
raise RuntimeError(f"DNSServiceRegister failed: {err}")
self._sd_ref = sd_ref
self._start_event_loop()
@staticmethod
def _register_reply(sd_ref, flags, err, name, regtype, domain, ctx):
# name may differ from requested name after conflict rename
# (kDNSServiceFlagsAdd will be set; err = kDNSServiceErr_NameConflict on failure)
if err == -65548: # kDNSServiceErr_NameConflict
from .conflict import handle_conflict
handle_conflict()
elif err != 0:
import logging
logging.getLogger("saturn.mdns.bonjour").error(
f"Registration error {err}"
)
def _start_event_loop(self):
import select
if self._thread and self._thread.is_alive():
return
self._running.set()
def loop():
fd = _lib.DNSServiceRefSockFD(self._sd_ref)
while self._running.is_set():
r, _, _ = select.select([fd], [], [], 0.5)
if r:
_lib.DNSServiceProcessResult(self._sd_ref)
self._thread = threading.Thread(target=loop, daemon=True)
self._thread.start()
def withdraw(self) -> None:
if self._sd_ref:
# mDNSResponder sends goodbyes on dealloc
_lib.DNSServiceRefDeallocate(self._sd_ref)
self._sd_ref = None
def browse(self, callback):
# DNSServiceBrowse + DNSServiceResolve on ItemNew
# kDNSServiceFlagsMoreComing absence = settle signal
...
Key property: DNSServiceRefDeallocate instructs mDNSResponder to send goodbye
packets via the daemon's port 5353 socket. No RFC violation. Works in App Sandbox.
4.3 Conflict Handling on macOS
The _register_reply callback receives the actual registered name (may differ from
requested if mDNSResponder renamed it due to a conflict). If err == kDNSServiceErr_NameConflict
(-65548), the registration was rejected outright; call the conflict handler to choose a new
name and re-register.
4.4 Crash Recovery
If mDNSResponder crashes (rare but possible), DNSServiceRefSockFD returns an
error-state fd and DNSServiceProcessResult returns kDNSServiceErr_BadReference.
The event loop must detect this and re-establish the DNSServiceRef:
result = _lib.DNSServiceProcessResult(self._sd_ref)
if result == -65563: # kDNSServiceErr_BadReference
self._sd_ref = None
self.advertise(self._current_spec) # reconnect to restarted daemon
5. Backend 3: Windows DNS-SD API
5.1 Requirements
- Windows 10 1903 (build 18362) SDK for full registration
windns.h/dnsapi.dll
5.2 ctypes Implementation Sketch
# saturn/mdns/windows.py
import ctypes, ctypes.wintypes
_dnsapi = ctypes.windll.LoadLibrary("dnsapi.dll")
class DNS_SERVICE_INSTANCE(ctypes.Structure):
_fields_ = [
("pszInstanceName", ctypes.c_wchar_p),
("pszHostName", ctypes.c_wchar_p),
("ip4Address", ctypes.c_void_p),
("ip6Address", ctypes.c_void_p),
("wPort", ctypes.c_uint16),
("wPriority", ctypes.c_uint16),
("wWeight", ctypes.c_uint16),
("dwPropertyCount", ctypes.c_uint32),
("keys", ctypes.c_void_p), # PWSTR* array
("values", ctypes.c_void_p), # PWSTR* array
("dwInterfaceIndex",ctypes.c_uint32),
]
class WindowsBackend:
SERVICE_TYPE = "_saturn._tcp.local"
def advertise(self, spec: AdvertiseSpec) -> None:
keys = (ctypes.c_wchar_p * len(spec.txt))(*spec.txt.keys())
vals = (ctypes.c_wchar_p * len(spec.txt))(*spec.txt.values())
inst = DNS_SERVICE_INSTANCE(
pszInstanceName = f"{spec.name}.{self.SERVICE_TYPE}",
pszHostName = None,
wPort = spec.port,
dwPropertyCount = len(spec.txt),
keys = ctypes.cast(keys, ctypes.c_void_p),
values = ctypes.cast(vals, ctypes.c_void_p),
)
# DNS_SERVICE_REGISTER_REQUEST struct + DnsServiceRegister() call
...
def withdraw(self) -> None:
# DnsServiceDeRegister — process exit also auto-deregisters (Windows guarantee)
...
Key Windows caveat: The browse callback fires only for services appearing — there is no push notification for service removal. Implement tombstoning:
# windows.py — tombstone tracker
class TombstoneTracker:
TTL = 120 # seconds, matching SRV host_ttl
def __init__(self, callback):
self._seen: dict[str, float] = {}
self._callback = callback
threading.Thread(target=self._reap_loop, daemon=True).start()
def on_browse_result(self, name: str):
now = time.time()
if name not in self._seen:
self._callback(("added", name))
self._seen[name] = now
def _reap_loop(self):
while True:
time.sleep(30)
cutoff = time.time() - self.TTL
gone = [n for n, t in self._seen.items() if t < cutoff]
for n in gone:
del self._seen[n]
self._callback(("removed", n))
6. Backend 4: Userspace Fallback
The existing python-zeroconf implementation, renamed and wrapped to implement MdnsBackend.
# saturn/mdns/userspace.py
from zeroconf import Zeroconf, ServiceBrowser, ServiceInfo, NonUniqueNameException
class UserspaceBackend:
def __init__(self):
self._zc = Zeroconf()
def advertise(self, spec: AdvertiseSpec) -> None:
info = ServiceInfo(
type_="_saturn._tcp.local.",
name=f"{spec.name}._saturn._tcp.local.",
port=spec.port,
addresses=[...],
properties=spec.txt,
)
try:
self._zc.register_service(info)
except NonUniqueNameException:
from .conflict import handle_conflict_userspace
handle_conflict_userspace(spec, self)
macOS note: When UserspaceBackend is selected on macOS (i.e., never in normal
operation — only in fallback mode), log a WARNING explaining the RFC §11 violation
and that responses may be ignored by compliant implementations.
7. TXT Record Schema v2
7.1 Required Keys
| Key | Max len | Description |
|---|---|---|
v |
3 | Protocol version — always "2" in v2 |
id |
36 | Stable node UUID (RFC 4122, hex with hyphens) |
dep |
7 | "network" or "cloud" |
api |
10 | "openai" or "ollama" |
pri |
5 | Priority integer as string |
7.2 Optional Keys
| Key | Max len | Description |
|---|---|---|
base |
80 | Base URL for cloud deployments |
feat |
30 | Feature flags: "ephemeral_auth", "network_proxy" |
caps |
40 | Comma-separated capabilities: "chat,code,vision" |
ctx |
7 | Max context window as string |
cost |
7 | "free", "paid", or "unknown" |
models |
200 | Comma-separated model IDs (truncated, full list at /v1/models) |
mtrunc |
1 | "1" if models list was truncated |
7.3 Node Identity
The id= key contains a UUID v4 generated once at first service start and persisted:
# saturn/mdns/identity.py
import uuid
from pathlib import Path
_ID_FILE = Path.home() / ".saturn" / "node_id"
def get_node_id() -> str:
if _ID_FILE.exists():
return _ID_FILE.read_text().strip()
nid = str(uuid.uuid4())
_ID_FILE.parent.mkdir(parents=True, exist_ok=True)
_ID_FILE.write_text(nid)
return nid
The UUID persists across service restarts, name conflict renames, and port changes. Clients
should key their internal state on id, not on instance name.
7.4 Total Size Budget
v=2 4 bytes
id=<uuid36> 39 bytes
dep=network 11 bytes
api=openai 10 bytes
pri=100 7 bytes
feat=network_proxy 18 bytes
caps=chat,code 14 bytes
ctx=8192 8 bytes
cost=free 9 bytes
models=... 200 bytes (budget)
--------------------------
TOTAL 320 bytes (< 400-byte practical limit)
8. DNS-SD Subtype Registration
Register subtypes alongside the primary PTR record so role-specific browsing is possible without downloading TXT records:
Primary: _saturn._tcp.local. PTR "node._saturn._tcp.local."
Coordinator: _coordinator._sub._saturn._tcp.local. PTR "node._saturn._tcp.local."
Worker: _worker._sub._saturn._tcp.local. PTR "node._saturn._tcp.local."
Saturn roles:
# saturn/mdns/subtypes.py
ROLE_SUBTYPES = {
"coordinator": "_coordinator",
"worker": "_worker",
"cloud": "_cloud",
"beacon": "_beacon",
}
def subtypes_for_role(role: str) -> list[str]:
return [ROLE_SUBTYPES[r] for r in [role] if r in ROLE_SUBTYPES]
A client browsing only coordinators queries _coordinator._sub._saturn._tcp.local. and
receives only coordinator PTR records, avoiding unnecessary SRV/TXT resolution for workers.
9. Conflict Resolution and Stable Identity
9.1 Conflict Handler
# saturn/mdns/conflict.py
import logging, re
from pathlib import Path
_NAME_FILE = Path.home() / ".saturn" / "instance_name"
logger = logging.getLogger("saturn.mdns.conflict")
def get_instance_name(base: str) -> str:
if _NAME_FILE.exists():
return _NAME_FILE.read_text().strip()
return base
def update_instance_name(new_name: str) -> None:
_NAME_FILE.write_text(new_name)
logger.warning(
f"mDNS name conflict — renamed to '{new_name}'. "
f"Stable node identity (id=) is unchanged."
)
def next_name(name: str) -> str:
m = re.match(r"^(.*)\s+\((\d+)\)$", name)
if m:
return f"{m.group(1)} ({int(m.group(2)) + 1})"
return f"{name} (2)"
9.2 Conflict Detection During Operation
For the userspace backend, attach a NameChanged signal handler on the Avahi entry group
(or equivalent) and call update_instance_name + re-advertise:
# In AvahiBackend._on_group_state:
elif state == avahi.ENTRY_GROUP_ESTABLISHED:
# The actual registered name may have been renamed by Avahi
actual_name = self._group.GetServiceName()
if actual_name != self._current_spec.name:
update_instance_name(actual_name)
10. Settle Detection
10.1 Avahi: AVAHI_BROWSER_ALL_FOR_NOW
# AvahiBackend.browse():
self._settle = threading.Event()
browser_iface.connect_to_signal("AllForNow", self._settle.set)
def wait_for_settle(self, timeout: float = 5.0) -> None:
self._settle.wait(timeout=timeout)
AllForNow fires when Avahi has flushed its local cache and sent the first PTR query and
received all cached responses. Subsequent events are pushed in real-time.
10.2 Bonjour: kDNSServiceFlagsMoreComing
# BonjourBackend._browse_reply:
def _browse_reply(self, sd_ref, flags, ifindex, err, name, regtype, domain, ctx):
more_coming = bool(flags & 0x1) # kDNSServiceFlagsMoreComing
self._on_service_event(flags, name)
if not more_coming:
self._settle.set()
kDNSServiceFlagsMoreComing absent means the daemon has dispatched its current batch.
The first time a browse callback fires without this flag, settle is complete.
10.3 Userspace Fallback
python-zeroconf does not expose a batch-completion signal. Use query-schedule-based settle:
# After ServiceBrowser construction, the browser sends its first PTR query.
# RFC 6762 §5.2: no response arrives within 1s → exponential backoff begins.
# If services are present, their responses arrive within 300ms (RFC §6 timeout).
# Set settle after first query interval with a minimum floor:
def _settle_after_first_query(browser, settle_event):
# Schedule settle 500ms after browser construction
# (first query sent at t=0, responses arrive by t=300ms per RFC §6)
import threading
threading.Timer(0.5, settle_event.set).start()
10.4 discover() Replacement
# saturn/mdns/discover.py
def discover(timeout: float = 5.0, backend: str | None = None) -> list[SaturnService]:
mdns = make_backend(backend)
services: dict[str, SaturnService] = {}
settle = threading.Event()
def on_event(event: ServiceEvent) -> None:
action, record = event
if action == "added":
services[record.node_id] = build_service(record) # keyed on stable UUID
elif action == "removed":
services.pop(record.node_id, None)
mdns.browse(callback=on_event)
mdns.wait_for_settle(timeout=timeout)
mdns.stop_browse()
mdns.close()
return sorted(services.values(), key=lambda s: s.priority)
Services are keyed on node_id (the stable UUID), not instance name. This makes
discover() immune to rename events during the browse window.
11. Migration Path
11.1 Phase 1: Schema v2 with backward compat (non-breaking)
Effort: Small (discovery.py only) Risk: Low
- Keep existing
SaturnAdvertiserbut addid=get_node_id()to_properties() - Keep
version=1.0for old-schema compatibility; addv=2as a new key - Add
mtrunc=1to_properties()when models list is truncated - Existing v1 receivers ignore unknown TXT keys — safe
Code change in _properties():
return {
'v': '2',
'version': '1.0', # backward compat
'id': get_node_id(),
'dep': self.deployment, # renamed from 'deployment'
'deployment': self.deployment, # backward compat
...
'mtrunc': '1' if models_truncated else '',
}
11.2 Phase 2: Backend abstraction (non-breaking)
Effort: Medium (new files, refactor discovery.py) Risk: Low (additive, existing path remains as userspace backend)
- Create
saturn/mdns/package withbackend.py,detect.py,identity.py - Move existing
SaturnDiscovery/SaturnAdvertisertosaturn/mdns/userspace.py - Add
AvahiBackendandBonjourBackendas new files - Update
SaturnDiscovery/SaturnAdvertiserindiscovery.pyto delegate to backend
11.3 Phase 3: Conflict handling
Effort: Small Risk: Low
- Create
saturn/mdns/conflict.pywithget_instance_name,update_instance_name - Catch
NonUniqueNameExceptioninUserspaceBackend.advertise()and trigger rename loop - Wire
AVAHI_ENTRY_GROUP_COLLISIONhandler inAvahiBackend - Wire
kDNSServiceErr_NameConflicthandler inBonjourBackend
11.4 Phase 4: Settle detection cleanup
Effort: Small Risk: Low
- Add
wait_for_settle()method to each backend - Replace
time_since_last >= settle_timeloop indiscover()withwait_for_settle() - Remove
settle_timeparameter fromdiscover()(keep as ignored kwarg for 1 release) - Remove
time.sleep()from tests — replace withwait_for_settle()
11.5 Phase 5: DNS-SD subtypes
Effort: Small Risk: Low
Add subtypes field to SaturnAdvertiser.__init__() and pass to backend. Consumers
that browse a specific subtype get it for free. Non-subtype consumers are unaffected.
12. Performance Targets
| Metric | Current | Target | Method |
|---|---|---|---|
| Service registration time (Linux) | ~2.5s (priority scan) | <500ms | Remove _find_available_priority() pre-scan; handle conflict after probe fails |
| Service registration time (macOS) | ~2.5s | <500ms | Same; Bonjour backend skips userspace socket setup |
discover() first result latency |
300ms–1s | <300ms | wait_for_settle() returns on first AllForNow / kDNSServiceFlagsMoreComing absent |
discover() total time (no peers) |
8s (hard timeout) | 2s | Settle detection + 2s fallback timeout (RFC: no response in 1s = no peers) |
| Test suite discovery time | 2.5s per test | <1s | Protocol-based settle, not sleep |
| TXT record parse round-trip | Opaque (library) | Same | No regression expected |
12.1 Startup time improvement
The current _find_available_priority() adds a 2-second mDNS scan on every service
start. Removing it and handling priority at the TXT level (priority is a soft preference,
not a uniqueness constraint — duplicates are fine) reduces startup time by 2 seconds
unconditionally.
13. Security Hardening
13.1 Avahi CVEs (2025–2026)
Three CVEs fixed in Avahi 0.9-rc3 (released 2026-01-27):
- CVE-2025-68276: Remote/local DoS via reachable assertion in browse path
- CVE-2025-68468: Remote DoS via malformed record in announce path
- CVE-2025-68471: Local DoS via entry group state machine assertion
Mitigation for Saturn:
- Saturn does not expose Avahi's D-Bus API to untrusted code
- Saturn's Avahi client runs as an unprivileged D-Bus client — it cannot trigger
daemon-side assertions directly
- Ensure deployment environments run Avahi 0.9-rc3+ (add to deployment documentation)
- On Avahi 0.8 deployments: treat any AvahiClientState.AVAHI_CLIENT_FAILURE as a
signal to fall back to userspace backend rather than crashing
def _on_client_state(self, client, state, userdata):
if state == avahi.CLIENT_FAILURE:
logger.warning("Avahi daemon failure, falling back to userspace backend")
self._fallback_to_userspace()
13.2 Beacon TXT Record — Ephemeral Key Size
BeaconAdvertiser._properties() (runner.py:143-156) emits the ephemeral API key in
the TXT record. TXT records are multicast to the entire link segment — anyone on the
network who can receive mDNS can read the key. This is by design (the key is ephemeral
and scoped to the calling process), but:
- Log a warning if key length > 240 bytes (already done at
runner.py:146-147) - Consider a TTL on the mDNS advertisement equal to the credential expiry time so caches
don't serve expired keys. Currently this is not implemented — TXT records use the
default 4500s TTL regardless of key expiry. Set
other_ttlon theServiceInfo:
# In BeaconAdvertiser.register():
self._info = ServiceInfo(
...
other_ttl=min(self.credential_manager.expiration_interval, 4500)
)
13.3 TXT Record Injection
Saturn builds TXT values from user-provided model names fetched from the upstream API
(_fetch_models() in runner.py:286-306). If a hostile upstream returns model names
containing =, newlines, or null bytes, these could corrupt TXT parsing on receivers.
Sanitize model names before inserting into TXT:
def _sanitize_txt_value(v: str) -> str:
return v.replace('=', '_').replace('\x00', '').replace('\n', '')[:63]
13.4 mDNS Packet Validation (Userspace Backend)
When running in userspace mode, Saturn receives raw mDNS packets. python-zeroconf handles
DNS wire-format parsing. Ensure the version in use is not vulnerable to known parsing CVEs.
As of zeroconf 0.148.0 no known CVEs apply, but pin the minimum version in requirements.txt:
13.5 RFC 6762 §11 TTL=255 Check
python-zeroconf does not verify that incoming mDNS packets have TTL=255 in the IP header (RFC 6762 §11: packets with TTL < 255 SHOULD be discarded). This means a remote attacker on a different subnet who can forge UDP source addresses could potentially inject mDNS responses. This is partially mitigated by the link-local scope of multicast routing (routers don't forward TTL=1 multicast), but defense in depth would add the TTL check.
This is a python-zeroconf limitation. File an upstream issue if this is a concern; the
fix requires IP_RECVTTL + ancillary data handling.