Skip to content

feat(net): resolve device .local names over mDNS - #728

Draft
HuggeK wants to merge 1 commit into
srcfl:masterfrom
HuggeK:worktree-mdns-forward-resolve
Draft

feat(net): resolve device .local names over mDNS#728
HuggeK wants to merge 1 commit into
srcfl:masterfrom
HuggeK:worktree-mdns-forward-resolve

Conversation

@HuggeK

@HuggeK HuggeK commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What & why

A device configured as zap.local never connects. The cause is not the
container's distro — it is that Go never resolves .local itself.

net/conf.go:395-409 routes a .local lookup to libc only inside
if canUseCgo { … }, and every FTW build sets CGO_ENABLED=0, so
mustUseGoResolver short-circuits and that branch is dead code. The name falls
through to hostLookupFilesDNS/etc/hosts, then a unicast DNS query to
whatever resolv.conf points at, which on a normal site is the router. Routers
don't answer .local.

This holds on every base image and every libc, musl and glibc alike. It also
holds on our own Pi image, which runs NetworkManager + avahi-daemon with no
systemd-resolved: Avahi serves glibc host programs through NSS, it does not
answer DNS queries.

This PR makes FTW resolve those names itself.

Why not switch the container to a glibc base and install nss-mdns

It cannot work without giving up much more than it buys. glibc's NSS loads
modules with dlopen, so it needs a dynamically linked binary — that means
CGO_ENABLED=1, losing the static binary, breaking the $BUILDPLATFORM
cross-compile in Dockerfile:30-37 and make verify-all. And Debian's
libnss-mdns has no standalone resolver; it talks to avahi-daemon over
/run/avahi-daemon/socket, which is not in the container, does not exist on a
generic Docker host, and cannot exist under Docker Desktop.

Resolving in Go costs none of that and works on every host and every base.

Changes

  • go/internal/mdnsresolve/ (new) — forward .local resolution over
    multicast DNS, plus a Dialer that uses it. Reuses the socket pattern the
    scanner already proved: an unconnected ListenUDP with the RFC 6762 QU bit,
    so we never bind 5353 (Avahi owns it on the host).
    • answers cached for the record TTL, clamped to 30–120 s — the floor stops
      a short-TTL device turning every Modbus reconnect into a multicast storm,
      the ceiling stops a DHCP move taking effect arbitrarily late;
    • failures cached 5 s, so a still-booting device is retried soon;
    • a failed resolution logs mDNS resolution failed and names the
      mechanism
      , instead of surfacing as a generic dial error.
  • Six dial sites wiredmodbus/tcp_client.go, mqtt/client.go,
    ha/bridge.go, drivers/lua.go (both the plain HTTP client and the
    TLS-pinned clone), drivers/ws_cap.go, drivers/tcp_cap.go.
  • scanner/mdns.go moves into the new package so there is one mDNS
    implementation rather than two. Pure move; its test moves with it.
  • Docs: config.example.yaml, docs/sourceful-zap.md.

Only .local names take the new path — a literal IP or an ordinary DNS name
never triggers a query, and there is a test asserting exactly that.

Resolution runs per dial, not once at startup. That is what makes binding by
name actually survive a DHCP change: modbus/client.go:107-109 rebuilds from the
original address on reconnect, so the device is found at its new IP with no
config edit.

Testing

  • New go/internal/mdnsresolve suite: packet parse/round-trip, TTL clamping at
    both bounds, a loopback fake responder exercising the real send/receive
    path, positive and negative cache behaviour, cache expiry, and two bypass
    tests proving a plain IP never issues a query and that a resolution failure is
    reported as one.
  • go build ./..., go vet ./... clean.
  • scripts/test-container-boundaries.sh and scripts/test-modular-compose.sh
    both pass — untouched, which is the point: this needs no container change.
  • Cross-compiles clean with CGO_ENABLED=0 for linux/arm64, linux/amd64,
    windows/amd64, so the static binary is intact.
  • Full go test ./...: identical 21 pre-existing failures before and after
    (all Windows-only — unix sockets, C:\ SQLite URIs). Zero regressions.
Limitation worth knowing

mDNS needs multicast reachability. The Linux Compose topology has it via
network_mode: host. Under docker-compose.macos.yml the container is bridged,
so configure devices by IP there — same constraint the existing discovery scan
already has, now written down in docs/sourceful-zap.md.

Relationship to #714

This is the prerequisite for #714. That PR makes the wizard bind every
discovered device to its .local name; without forward resolution, it ships the
exact silent failure its review warned about. Note this is already a live bug —
drivers/zap.lua:52,59,66 default to zap.local today.

web/settings/tabs/devices.js is deliberately not touched here: #714
rewrites that host-hint line, and editing it would only create a conflict.

🤖 Generated with Claude Code

Go never resolves ".local" itself. net/conf.go routes those names to libc
only when cgo is available, and every FTW build sets CGO_ENABLED=0, so the
pure Go resolver is always selected: a configured "zap.local" became a
unicast DNS query to the site router and failed. That holds on every base
image and every libc, musl and glibc alike, so the container's distro was
never the variable here.

Add internal/mdnsresolve, which answers those names over multicast DNS, and
route every driver transport through it: Modbus TCP, MQTT (driver and Home
Assistant bridge), HTTP including the TLS-pinned client, WebSocket and raw
TCP. Only ".local" names take the new path; literal IPs and ordinary DNS
names dial exactly as before.

Resolution runs per dial rather than once at startup, so a device that moves
to a new DHCP lease is found again on the next reconnect with no config edit.
Answers are cached for the record TTL clamped to 30-120s so reconnect loops
cannot flood the LAN, and failures are cached for 5s so a still-booting
device is retried soon. Failures log "mDNS resolution failed" and name the
mechanism rather than surfacing as a generic dial error.

The scanner's reverse PTR lookup moves into the same package so there is one
mDNS implementation instead of two.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants