feat(net): resolve device .local names, asking avahi first - #728
Draft
HuggeK wants to merge 7 commits into
Draft
Conversation
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>
Core was alpine:3.22 and the updater sidecar was docker:27-cli (also alpine), while the optimizer was already python:3.12-slim-bookworm. That is two libcs and two security streams in one deployment, and two base rootfs blobs pulled per host. Put all three on debian:bookworm-slim so the layer is pulled once and tracked once. glibc additionally lets the image run ordinary prebuilt vendor binaries, which musl cannot, and a full userland makes on-site docker exec debugging practical. libnss-mdns is installed and wired into nsswitch.conf so .local resolves for glibc tools inside the container when an avahi socket is mounted; the FTW process does not depend on that, because a CGO_ENABLED=0 binary bypasses NSS entirely and resolves .local in Go instead. The binary stays fully static and still cross-compiles on the build platform, so only the small runtime layer is emulated for arm64. Measured: 79s for a full arm64 build, image 148MB (amd64 128MB, up from 53MB). wget is now installed explicitly and asserted by the boundary test. It is contractual rather than incidental: ftw-updater docker-execs it inside the core image to decide whether an update commits, and updaters already in the field will keep doing so, so the core image cannot stop shipping it. The boundary test's `^FROM alpine:` line is replaced by the invariant it was actually standing in for -- core must not build on a Python base -- and given a real error message. Also embed tzdata in the binary as a fallback. Production code reads time.Local, which silently degrades to UTC when zoneinfo is missing, and the tzdata tests skip rather than fail, so that regression would ship green. Verified uid 100 / gid 101 have no passwd entry on this base, and corrected the two comments that attributed them to an alpine adduser. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
bookworm is oldstable. Move all three images to trixie so the deployment tracks the suite that is actually receiving full security support, and so the base matches the Raspberry Pi OS release the SD image is built from (deploy/pi-gen/config: RELEASE=trixie). This also brings the optimizer along. Leaving it on a bookworm-derived python image would have split the shared base layer, which is the whole reason the images were aligned -- verified after the move that core, updater and optimizer resolve to one identical base layer digest. Verified on trixie: CVXPY 1.9.2 solves and highspy imports under Python 3.12.13; the updater's copied docker CLI 27.5.1 and compose plugin v2.33.0 both run; core has wget, the CA bundle, zoneinfo and an nsswitch.conf wired for mdns, and runs as uid 100 / gid 101. Pinned to the codename rather than a stable alias, so a major-version jump can never arrive silently on a rebuild. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
This was referenced Jul 31, 2026
The optimizer releases independently of core, and optimizer-release.yml skips the build entirely when an image already exists for the requested version, then asserts that the published image's org.opencontainers.image.revision label matches the commit being released. Changing Dockerfile.optimizer without moving the version therefore does not merely produce two different artifacts under one number -- it cannot ship at all. Releasing 1.3.2 from this commit would either reuse the bookworm image or fail metadata verification. Minor rather than patch: the Python surface is untouched, but the runtime underneath it moves a Debian major version, so consumers are getting a materially different artifact. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@ The base move and the resolver were stacked in one branch, which made the resolver look like a consequence of choosing Debian. It is not: the two are independent, and the resolver is the one with the device-facing risk. Take the resolver back out so this branch is only the base move, and restate what libnss-mdns actually buys here — tools inside the image, not the FTW process, which is CGO_ENABLED=0 and never consults NSS. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> @
@ Go hands a ".local" name to libc only when cgo is available, and FTW builds CGO_ENABLED=0, so a configured zap.local became a unicast DNS query to the site router and failed. Shipping libnss-mdns does not change that: it changes what getent and curl resolve inside the image, not what this process resolves. Ask the machine that already knows. avahi-daemon answers over its simple-protocol socket -- one line out, one line back, and no DNS wire format decoded on that path. It is the same daemon over the same socket that libnss_mdns4_minimal.so.2 talks to, so FTW and an operator running `getent hosts zap.local` in the container cannot disagree about an address. Keep a direct query for where that socket cannot be reached. It has to be bind-mounted, and under the Home Assistant Supervisor an add-on cannot mount arbitrary host paths at all, so a resolver that required it would simply not work in the add-on FTW ships as. The lookup log says which backend answered. Wire every driver transport through it: Modbus TCP, MQTT for both the driver and the Home Assistant bridge, HTTP including the TLS-pinned client, WebSocket and raw TCP. Resolution runs per dial, which is what makes a name survive a DHCP move -- the reconnect path rebuilds from the configured address. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> @
This was referenced Jul 31, 2026
Contributor
Author
|
For the record: #746 is the alternative to this PR — the same The trade-off between them is not really the resolver, it is reach vs. agreement: this PR's avahi path makes |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Warning
#746 is an alternative to this PR + #731 — merge one route, never both.
It solves the same
.localproblem with a pure-Go multicast query and noavahi dependency, paired with an Alpine consolidation instead of the Debian
one.
The trade-off is not really the resolver, it is reach vs. agreement: this PR's
avahi path makes
getent hosts zap.localinside the container return the sameanswer FTW dials, which musl cannot offer at all; #746 needs no host daemon
and no bind-mounted socket anywhere. Both work on the Home Assistant add-on.
Important
Blocked by #731. Merge the base move first, then this.
GitHub cannot enforce that here — these branches live on a fork, and a PR's
base branch has to exist in
srcfl/ftw— so the ordering is declared ratherthan gated. This branch already contains #731's commits, so review
980c3ebalone, orthe diff against
#731.
Important
#714 is the other half of the feature. This PR makes
.localnamesresolvable; it does not change what the setup wizard writes. #714 is web-only
and is what actually binds a discovered device to its
.localname, so untilboth are in, a normal install through the UI still gets a raw IP.
So the feature needs either this PR + #731, or #746 — plus #714 either way.
Two PRs or one for the resolver, never both routes, and #714 on top.
Merging this without #714 is safe and worth it on its own: it repairs
hand-written
.localconfigs, including thezap.localandsid-os.localexamples documented in
config.example.yaml, which cannot resolve onmastertoday. Merging #714 without a resolver is the combination that breaks device
installs.
What & why
A device configured as
zap.localnever connected. Go hands a.localname tolibc only when cgo is available, and FTW builds
CGO_ENABLED=0, so the pure Goresolver is always chosen: it reads
/etc/resolv.confand sends a unicastDNS query to the site router, which has never heard of
zap.local.That is true on every base image and every libc. #731 ships
libnss-mdns, whichis worth having — but it changes what
getentandcurlresolve inside thecontainer, not what this process resolves. NSS is reached through
dlopen, anda
CGO_ENABLED=0binary never goes near it.config.example.yamlalready shipszap.localas a default, and #714 makes thesetup wizard bind every discovered device by its
.localname, so this isthe change that makes that safe.
Where the answer comes from
Ask the machine that already knows.
avahi-daemon— running on the Pi imageand on most Linux hosts — answers on its simple-protocol socket. One line out,
one line back:
No DNS wire format is decoded on that path. It is also not a private side door:
libnss_mdns4_minimal.so.2has/run/avahi-daemon/socketcompiled into it andspeaks the same protocol, so FTW and an operator running
getent hosts zap.localin the container physically cannot disagree about an address. avahiholds its own record cache, so a repeat lookup usually costs no LAN traffic.
A direct query remains, for where that socket cannot be reached. It has to be
bind-mounted, and that is not always possible:
Why the fallback is not optional — the Home Assistant add-on
FTW ships as a Home Assistant add-on
(srcfl/home-assistant-addons).
Supervisor only mounts a fixed set of named paths (
config,ssl,share,media,backup, …) — an add-on cannot ask for an arbitrary host path, so/run/avahi-daemoncannot be provided there at all. A resolver that requiredthat socket would not work in the add-on.
The add-on does set
host_network: true, which is exactly what the direct queryneeds. Same story on any Docker host that does not run avahi.
A successful lookup logs which backend answered, because the two fail for
completely different reasons:
What is wired
Every driver transport: Modbus TCP, MQTT (driver and the Home Assistant
bridge), HTTP including the TLS-pinned client, WebSocket, and raw TCP.
Resolution happens per dial, not once at startup — that is what makes a name
survive a DHCP move, since the reconnect path rebuilds from the configured
address. Answers are cached 30–120 s (following the record TTL where there is
one) so reconnect loops cannot flood the LAN; failures are cached 5 s so a
booting device is retried soon.
Only
.localnames take either path. A literal192.168.1.5never triggers alookup, and there is a test that fails if it ever does.
Trust property worth stating explicitly
mDNS is unauthenticated, and the driver allow-list matches on the host string
while resolution happens afterwards at the dial layer. So an allow-listed
.localname resolves to whichever LAN host answers first.This path was dead before this PR, so the assumption is new. TLS-pinned drivers
and IP-based allow-list entries are unaffected, and every resolution is logged
with the address it resolved to.
Going through avahi narrows this slightly where it is available, since avahi
applies its own record handling rather than trusting the first packet off the
wire.
Compose
docker-compose.ymlcarries the avahi mount commented out, anddocs/operations.mdexplains when to enable it.Why it is not on by default, and why it mounts the directory
The Pi image ships the repository's root
docker-compose.yml(deploy/pi-gen/build.shcopies it), so there is one file for both the Pi and generic Linux hosts — it
cannot be enabled for one and not the other.
Left on unconditionally on a host without avahi, Docker would create the mount
point itself. That is why the documented line mounts the directory
/run/avahi-daemon, not the socket inside it: an empty directory there isharmless and is what avahi creates anyway, whereas a directory created where the
socket belongs stops
avahi-daemonfrom ever starting.Since the direct query needs no host software, off-by-default costs nothing.
Verification
go build ./...,go vetclean on every touched package.go test ./internal/mdnsresolve/...— passes, and stable over-count=3.(
-raceneeds cgo, which is off by design.)internal/modbus,internal/mqtt,internal/ha,internal/scannerpass.docker compose -f docker-compose.yml config --quietpasses.avahi-daemon, not againstdocumentation — the transcript above is copied from that run, including
RESOLVE-ADDRESS 127.0.0.1 → + 1 0 <host>.local.libnss_mdns4_minimal.so.2ondebian:trixie-slim.internal/driversfails locally only becausedrivers/*.luaare not fetched —make driversis broken onmaster(the pin from #722 nameszapandzuidwijk_p1, which do not exist in device-drivers at76c968bd). Unrelated tothis branch, and pre-existing.
Not in scope
internal/scanner's reverse mDNS lookup is left where it is. It is merged codewith its own tests, it serves discovery rather than dialling, and it must keep
working where avahi is absent. An earlier revision of this branch moved it; that
was churn, not improvement.