Skip to content

feat(net): resolve device .local names, asking avahi first - #728

Draft
HuggeK wants to merge 7 commits into
srcfl:masterfrom
HuggeK:worktree-mdns-forward-resolve
Draft

feat(net): resolve device .local names, asking avahi first#728
HuggeK wants to merge 7 commits into
srcfl:masterfrom
HuggeK:worktree-mdns-forward-resolve

Conversation

@HuggeK

@HuggeK HuggeK commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Warning

#746 is an alternative to this PR + #731 — merge one route, never both.
It solves the same .local problem with a pure-Go multicast query and no
avahi 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.local inside the container return the same
answer 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 rather
than gated. This branch already contains #731's commits, so review
980c3eb alone
, or
the diff against
#731
.

Important

#714 is the other half of the feature. This PR makes .local names
resolvable; it does not change what the setup wizard writes. #714 is web-only
and is what actually binds a discovered device to its .local name, so until
both 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 .local configs, including the zap.local and sid-os.local
examples documented in config.example.yaml, which cannot resolve on master
today. Merging #714 without a resolver is the combination that breaks device
installs.

What & why

A device configured as zap.local never connected. Go hands a .local name to
libc only when cgo is available, and FTW builds CGO_ENABLED=0, so the pure Go
resolver is always chosen: it reads /etc/resolv.conf and sends a unicast
DNS 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, which
is worth having — but it changes what getent and curl resolve inside the
container
, not what this process resolves. NSS is reached through dlopen, and
a CGO_ENABLED=0 binary never goes near it.

config.example.yaml already ships zap.local as a default, and #714 makes the
setup wizard bind every discovered device by its .local name, so this is
the change that makes that safe.

Where the answer comes from

Ask the machine that already knows. avahi-daemon — running on the Pi image
and on most Linux hosts — answers on its simple-protocol socket. One line out,
one line back:

RESOLVE-HOSTNAME-IPV4 zap.local   →   + 2 0 zap.local 192.168.1.42

No DNS wire format is decoded on that path. It is also not a private side door:
libnss_mdns4_minimal.so.2 has /run/avahi-daemon/socket compiled into it and
speaks the same protocol, so FTW and an operator running getent hosts zap.local in the container physically cannot disagree about an address. avahi
holds 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-daemon cannot be provided there at all. A resolver that required
that socket would not work in the add-on.

The add-on does set host_network: true, which is exactly what the direct query
needs. 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:

resolved host over mDNS host=zap.local addr=192.168.1.42 ttl=30s via=avahi

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 .local names take either path. A literal 192.168.1.5 never triggers a
lookup, 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
.local name 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.yml carries the avahi mount commented out, and
docs/operations.md explains 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.sh
copies 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 is
harmless and is what avahi creates anyway, whereas a directory created where the
socket belongs stops avahi-daemon from ever starting.

Since the direct query needs no host software, off-by-default costs nothing.

Verification

  • go build ./..., go vet clean on every touched package.
  • go test ./internal/mdnsresolve/... — passes, and stable over -count=3.
    (-race needs cgo, which is off by design.)
  • internal/modbus, internal/mqtt, internal/ha, internal/scanner pass.
  • docker compose -f docker-compose.yml config --quiet passes.
  • Avahi's protocol was confirmed against a real avahi-daemon, not against
    documentation — the transcript above is copied from that run, including
    RESOLVE-ADDRESS 127.0.0.1 → + 1 0 <host>.local.
  • The socket path was confirmed by reading it out of
    libnss_mdns4_minimal.so.2 on debian:trixie-slim.

internal/drivers fails locally only because drivers/*.lua are not fetched —
make drivers is broken on master (the pin from #722 names zap and
zuidwijk_p1, which do not exist in device-drivers at 76c968bd). Unrelated to
this branch, and pre-existing.

Not in scope

internal/scanner's reverse mDNS lookup is left where it is. It is merged code
with 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.

claude and others added 2 commits July 30, 2026 16:19
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>
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>
claude and others added 2 commits July 31, 2026 14:06
@
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>
@
@HuggeK HuggeK changed the title feat(net): resolve device .local names over mDNS feat(net): resolve device .local names, asking avahi first Jul 31, 2026
@HuggeK

HuggeK commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

For the record: #746 is the alternative to this PR — the same .local problem solved with a pure-Go multicast query and no avahi dependency, paired with an Alpine consolidation instead of the Debian one.

The trade-off between them is not really the resolver, it is reach vs. agreement: this PR's avahi path makes getent hosts zap.local inside the container return the same answer FTW dials, which musl cannot offer at all; #746 needs no host daemon and no bind-mounted socket anywhere.

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