Skip to content

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

Open
HuggeK wants to merge 12 commits into
srcfl:masterfrom
HuggeK:worktree-mdns-forward-resolve
Open

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

Conversation

@HuggeK

@HuggeK HuggeK commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Note

There is no longer an alternative route. #746 offered one (Alpine base,
dependency-free Go resolver) and is now closed, because #731 merged.

#731 is already merged. This PR is based on master at
980b247edb20f6b5b48fd217899efa3289d873e4. Since the previous review, the
base gained only #789's notifications-test synchronization; the four #728
commits are unchanged. The current head is
0f5635b2032daec33d6e2e2562a63c89e34a6548. Review the full PR diff.

Important

#714 remains separate. It changes the setup wizard's device binding;
this PR supplies the resolver and transport policy needed for configured
.local names. #714 is not changed here.

What & why

FTW builds with CGO_ENABLED=0, so the normal Go resolver cannot use the host's
mDNS NSS module. A device configured as zap.local therefore needs an explicit
mDNS path before FTW can connect to it.

Resolver and transport

The resolver asks Avahi first and uses a validated direct multicast query as a
fallback. Modbus TCP, MQTT (driver and Home Assistant bridge), HTTP, WebSocket,
and raw TCP use the shared resolver. Parser checks validate the DNS response,
source, answer name/type/class/family, and Avahi's interface and address fields.
IPv4/IPv6, interface, and IPv6 zone handling fail closed.

mDNS does not prove server identity. capabilities.allow_unverified_local
defaults to false and must be enabled per driver before a .local transport
can be used. The Home Assistant bridge has its own equivalent setting. A name
allow-list does not count as server identity.

HTTP and WebSocket transports check the original destination before selecting an
HTTP proxy, covering GET, POST, PATCH, credentials, and command payloads. A
TLS certificate pin does not bypass the .local opt-in: all mDNS transports
use the same default-deny rule. Literal IPs and ordinary DNS names keep their
existing proxy behavior.

Compose

docker-compose.yml carries the Avahi socket mount commented out.
docs/operations.md describes when to enable it. Direct multicast is suitable
for host-networked Linux and Home Assistant add-on deployments; bridged macOS
deployments should use an IP address.

Verification

  • go test -race ./internal/mdnsresolve ./internal/drivers ./internal/config ./internal/configreload ./internal/modbus ./internal/mqtt ./internal/ha
  • make verify-all
  • fake-proxy red-green tests for HTTP and WebSocket default deny, explicit
    opt-in, and ordinary-host proxy behavior
  • fresh GitHub CI for 0f5635b2032daec33d6e2e2562a63c89e34a6548 passed:
    test,
    changeset,
    repo hygiene,
    brand cleanup, and
    RPi installer

The fixed Home Link uplink endpoint also uses an environment proxy, but it is a
fixed ordinary-DNS endpoint and does not resolve driver .local names; it is
outside this policy change.

Not in scope

internal/scanner's reverse mDNS lookup remains unchanged. It serves discovery
rather than driver dialing and has its own tests.

@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.

claude and others added 4 commits August 4, 2026 14:52
@
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>
@
@frahlg
frahlg force-pushed the worktree-mdns-forward-resolve branch from 9c0ce14 to 0f5635b Compare August 4, 2026 12:54
@frahlg
frahlg self-requested a review August 4, 2026 12:54
@frahlg
frahlg marked this pull request as ready for review August 4, 2026 12:57

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0f5635b203

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/internal/mdnsresolve/multicast.go Outdated
Two fixes, both from evidence rather than reasoning.

A pilot Home Assistant install resolves ".local" today, on stock
v1.10.0-beta.1, with none of this code: Supervisor points every container at
its own CoreDNS, which carries an mdns plugin backed by systemd-resolved.
The failure text names it outright —

    lookup ftw-no-such-device-xyz.local on 172.30.32.3:53: no such host

— and a probe against the real device returned its serial over that path
while the configured IP gave "no route to host". Returning the mDNS error
instead of trying the system resolver would therefore take an install that
works and break it. The dialer now falls through, and reports both causes so
neither hides the other.

Second, a truncated record after a valid one no longer keeps the addresses
read before it. `break parse` left them in place and handed them to
finishAnswer, so a malformed UDP response could populate the cache — the
opposite of what the comment above the loop claimed. Reported by Codex on
this PR.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@HuggeK

HuggeK commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Tested on a real Home Assistant install, and it changed two things here

A pilot HA green (192.168.1.10, core v1.10.0-beta.1, add-on container
9a185f3a-ftw) turned out to answer the question this PR could only reason
about. Three probes through POST /api/drivers/test, same driver, only the host
differs:

Host Result
192.168.1.92 (what the site is configured with) dial tcp 192.168.1.92:80: connect: no route to host
zap.local Zap: gateway identity zap-000064963cd51edc, discovered 2 device(s); meter=20222956, v2x=1
ftw-no-such-device-xyz.local dial tcp: lookup ftw-no-such-device-xyz.local on 172.30.32.3:53: no such host

Two conclusions, both load-bearing for this PR.

1. .local already resolves under Home Assistant, without any of this code.
172.30.32.3 is Supervisor's CoreDNS. It ships an
mdns plugin
backed by systemd-resolved over D-Bus, whose README states the purpose exactly:
"providing mDNS records to non-mDNS-aware applications by making them
accessible through a standard DNS server."
Supervisor sets that resolver on
every app container (supervisor/docker/manager.py:442) and — now confirmed
empirically — it applies under host_network: true too.

So the earlier claim in this PR that the add-on has no .local path was wrong.
It has one; it just isn't avahi. There is no avahi on HA OS at all — the
multicast plugin is
mdns-repeater, a packet
relay with no query API.

2. As written, this PR would have broken that install. Dialer.DialContext
returned the mDNS error rather than letting the system resolver try. Fixed in
cf9cd8d: on lookup failure it falls through to the stdlib dial and reports both
causes, so no such host alone can't send an operator hunting the wrong
problem. New test asserts the system resolver is actually consulted.

Also in that commit: the Codex P2 above. A truncated record after a valid one
was left in addrs and passed to finishAnswer; it now voids the packet, which
is what the comment over the loop always claimed. Test added.

Third probe as a control — why the middle row is conclusive

The serial zap-000064963cd51edc can only come from the device answering. The
IP probe and the nonexistent-name probe both returned identity: {"make": "Sourceful"} with no serial; only zap.local returned one, and the log shows a
full device discovery behind it. The device has moved off 192.168.1.92 — which
is the exact DHCP failure #714 exists to prevent, happening right now on a live
site.

One thing left, and it needs a decision rather than a patch

CheckLocalDestination denies every .local endpoint unless
capabilities.allow_unverified_local is set. On that pilot, host: zap.local
works today with no flag at all. After this PR it would be refused before
resolution is even attempted — a silent breaking change for any HA install
already using a .local name.

I have deliberately not touched that gate, because it was added on purpose
and also guards proxy selection (guardMDNSProxy), where relaxing it has real
consequences. The narrow fix, if you want it, is to let a denied name fall
through to the system resolver exactly as a failed lookup now does: the flag
would then gate FTW's own mDNS resolution — which is what its doc comment says
it gates — and not the OS's, which resolves these names today anyway and with
identical trust properties. That needs the same treatment in guardMDNSProxy to
be coherent.

Your call; I'd rather flag it than quietly widen a security gate.

HuggeK added a commit to HuggeK/home-assistant-addons that referenced this pull request Aug 5, 2026
Tested on a pilot HA green instead of reasoning about it, and the earlier
text was wrong in the way that matters. `.local` does not need anything
from FTW here: Supervisor points every app at its own CoreDNS, which
carries an mdns plugin backed by systemd-resolved, and that answers over
ordinary unicast DNS. A lookup failure names it — "on 172.30.32.3:53".

The avahi paragraph stands but for a further reason: HA OS runs no avahi
at all. Its multicast plugin is mdns-repeater, a packet relay with no
query API, so there is no socket to bind even in principle.

Evidence: srcfl/ftw#728 (comment).

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
HuggeK and others added 2 commits August 5, 2026 10:59
allow_unverified_local refused the dial outright. That broke two things
that matter more than the risk it was guarding.

srcfl#714 makes the setup wizard write `host: zap.local`, and nothing in web/
sets this flag — so every device installed by the wizard would have been
refused on its first poll, usually the site meter, which stops dispatch.
And a pilot Home Assistant install resolves .local today through
Supervisor's DNS service, on stock v1.10.0-beta.1, with none of this
code: refusing would take a working site and break it.

The risk it guarded is also not one-sided. A raw IP is no more an
identity on a LAN than a name — it can be claimed by ARP, and DHCP can
hand it to another device with nobody attacking anything. Only the name
was gated, and the fallback the gate pushed operators towards is the
option with the extra failure mode.

So the flag now gates what its own doc comment always said: whether FTW
may use *its own* mDNS answer. Without it the name goes to the system
resolver, exactly as before this package existed. With it, avahi and the
LAN are queried too. Both causes are wrapped so errors.Is finds either.

guardMDNSProxy goes with it. Where a proxy is configured FTW never
resolves the destination — the proxy does — so there was nothing for it
to gate, and its tests asserted a contract that no longer holds.

The check that would actually help is on identity after connect: record
make+serial or MAC on first success, fault when it changes. FTW already
collects all three (state.ResolveDeviceID) and never compares them.
Filed separately rather than smuggled in here.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@HuggeK

HuggeK commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Fixed: the gate now covers the resolver, not the connection

Following the mismatch flagged on #714 and the LAN-risk point that came with it, allow_unverified_local no longer refuses a .local dial. It decides whether FTW may use its own mDNS answer; without it the name goes to the system resolver, exactly as on every shipped version. guardMDNSProxy is gone — where a proxy is configured FTW never resolves the destination anyway, so it had nothing to gate.

Why, in one line each:

  • feat(web): install devices by mDNS name, warn to reserve raw IPs #714 would have shipped broken. The wizard writes host: zap.local and nothing in web/ sets the flag, so every wizard-installed device would have been refused on first poll — usually the site meter, which stops dispatch.
  • Home Assistant already resolves .local through Supervisor's DNS, on stock v1.10.0-beta.1. Refusing would break a working site.
  • The risk was one-sided. A raw IP is no more an identity on a LAN than a name — ARP claims it, and DHCP can hand it to another device with nobody attacking anything. Only the name was gated, and the fallback it pushed operators toward carries the extra failure mode.

Verified against a real Sourceful Zap:

Config Result
zap.local, flag unset reached the device via the system resolver — gateway identity zap-000064963cd51edc
zap.local, flag set resolved host over mDNS host=zap.local addr=192.168.1.141 ttl=2m0s via=multicast

On that host nslookup zap.local returns NXDOMAIN from the router, so a plain Go resolver gets nothing — which is the case this package exists for on Compose and Pi installs.

Tests changed with the contract: the deny test becomes "without the opt-in our own answer is ignored but the name still resolves", plus its opposite, and both proxy tests now assert a .local request takes the same path as any other host. Both causes are wrapped with %w so errors.Is still finds ErrUnverifiedLocal.

Not included, and it is the check that would actually help: verifying device identity after connect. state.ResolveDeviceID already ranks make+serial > MAC > endpoint, drivers report make and serial as soon as they know them, and RegisterDevice writes them — but nothing ever compares a reported identity to the one seen before. A grep for a mismatch fault across go/internal returns nothing. That belongs in its own PR.

HuggeK and others added 2 commits August 5, 2026 12:28
Reverse lookups asked unicast DNS first and mDNS only as a fallback, so on
any network whose router answers PTR the scan returned the router's label for
the lease and never looked for a .local name. The setup wizard then fell back
to the raw IP, which is the outcome it exists to avoid.

Both queries now run together and a .local answer wins. That alone is still not
enough: RFC 6762 leaves in-addr.arpa mapping optional and plenty of responders
publish a forward A record without one. A Sourceful Zap answers zap.local all
day and returns nothing for 141.1.168.192.in-addr.arpa. So where no reverse
record exists, the label is re-asked forward as <label>.local and kept only
when it resolves back to the same address -- the check is what keeps the
router's naming from deciding what FTW dials.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@HuggeK

HuggeK commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Verified on a real device: the wizard was not choosing the mDNS name

Asked to confirm this PR does what it says, I checked against a live Sourceful
Zap rather than by reading the code. It did not — and the cause was upstream of
this PR, in the scanner.

resolveHostnames asked unicast reverse DNS first and only fell back to
reverse mDNS when that returned nothing. On any network whose router answers
PTR — UniFi here, and dnsmasq/OpenWrt, Fritz!Box and most ISP routers do the
same — the router answers, so the mDNS path never ran. The wizard then received
a name ending in .localdomain, isMDNSName() correctly rejected it, and it
saved the raw IP: exactly the outcome this PR exists to prevent.

Measured against 192.168.1.141:

probe result
unicast reverse DNS (router) zap-000064963cd51edc.localdomain
reverse mDNS PTR (empty)
forward mDNS zap-000064963cd51edc.local 192.168.1.141
forward mDNS zap.local 192.168.1.141
what /api/scan handed the wizard zap-000064963cd51edc.localdomain → saved the IP

Note rows 2 and 3 together: the device answers to a .local name it never
advertises in reverse. Reordering the two queries would not have been enough.

The fix

In #728, since it needs the forward resolver: 4d746ddd

Both queries now run concurrently and a .local answer wins. Where no reverse
record exists — the common case, since RFC 6762 leaves in-addr.arpa mapping
optional — the label is re-asked forward as <label>.local and kept only if
it resolves back to the same address
. Names that fail that check are still
shown, but only as display text.

After the fix, same address, same code path:

lookupName(192.168.1.141) = "zap-000064963cd51edc.local"
what /api/scan now hands the wizard: hostname="zap-000064963cd51edc.local"
Why the round-trip check is there rather than trusting the label

Without it the router's naming would decide what FTW dials: take whatever
label came back from a PTR answer, append .local, and connect. Requiring the
name to resolve back to the address the scan actually found means the device
itself has confirmed the name is its own, over a different transport than the
one that suggested it. It is a cheap check and it is the difference between a
verified name and a guess.

It also costs nothing when the device does publish a reverse mDNS record — that
answer is authoritative and is used directly, without the second round.

Scan latency

Unchanged in the common case and bounded in the worst. The two reverse queries
now run concurrently instead of in series, so the scan waits for the slower of
the two (900 ms) rather than for both (1.7 s). Only addresses with no reverse
mDNS record pay one further 900 ms round, and every address is still resolved
in parallel with all the others.

Tests are in go/internal/scanner/hostname_test.go, covering the preference
order, the forward-verification path, a verification that fails, and nothing
answering at all. The three probes are swappable, so none of it needs a LAN.

frahlg added a commit to srcfl/home-assistant-addons that referenced this pull request Aug 7, 2026
* build: follow Core and Optimizer onto the trixie base

@
srcfl/ftw#731 moves the whole FTW stack onto Debian 13 "trixie". This repo
inherits that automatically -- the add-on image is built FROM the Optimizer
image -- so the change that matters here is the test fixtures, which stood in
for Core and Optimizer on bookworm and would have gone on testing a base that
no longer ships.

Also say, once, why libnss-mdns is not installed here even though Core has it:
it only forwards to avahi-daemon over a Unix socket, and Supervisor gives an
add-on no way to bind an arbitrary host path. FTW resolves ".local" in-process
instead, which is what host_network is for. Documented in DOCS.md for operators
who hit a name that will not resolve.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@

* docs: correct how `.local` resolves under Supervisor

Tested on a pilot HA green instead of reasoning about it, and the earlier
text was wrong in the way that matters. `.local` does not need anything
from FTW here: Supervisor points every app at its own CoreDNS, which
carries an mdns plugin backed by systemd-resolved, and that answers over
ordinary unicast DNS. A lookup failure names it — "on 172.30.32.3:53".

The avahi paragraph stands but for a further reason: HA OS runs no avahi
at all. Its multicast plugin is mdns-repeater, a packet relay with no
query API, so there is no socket to bind even in principle.

Evidence: srcfl/ftw#728 (comment).

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>

* docs: say what `.local` resolution depends on, and what it does not

Someone reading the previous version could reasonably ask which plugin
they have to install. The answer is none, and that is worth stating
rather than leaving implied.

Supervisor hard-codes five built-in services and starts them itself
(supervisor/plugins/manager.py: cli, dns, audio, observer, multicast).
They are not add-ons — an app cannot install one, and cannot declare a
dependency on one either. A failure there becomes a repairable system
issue Supervisor raises on its own.

The one real host requirement is systemd-resolved, which HA OS always
has and which the Supervised installer's first step sets up. A host
that skipped it resolves no .local names, so that case is called out
with its workaround.

Also documents the resolver address in the failure message, since
seeing 172.30.32.3 is what separates "device is not there" from
"resolution never got that far".

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>

* docs: qualify local-name network failures

Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

---------

Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Fredrik Ahlgren <fredrik@sourceful-labs.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.

3 participants