Skip to content

feat(web): install devices by mDNS name, warn to reserve raw IPs - #714

Open
HuggeK wants to merge 8 commits into
srcfl:masterfrom
HuggeK:mdns-first-device-install
Open

feat(web): install devices by mDNS name, warn to reserve raw IPs#714
HuggeK wants to merge 8 commits into
srcfl:masterfrom
HuggeK:mdns-first-device-install

Conversation

@HuggeK

@HuggeK HuggeK commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Important

This is one half of the feature. It cannot ship alone, and neither can the other half.

"Install a device by name so the connection survives a DHCP move" needs two
things merged:

  1. Something that can resolve .localfeat(net): resolve device .local names, asking avahi first #728. This used to be a choice
    between two routes; it no longer is. build: run one base across the whole stack (debian:trixie-slim) #731 merged, putting the whole stack on
    one debian:trixie-slim-derived base, and the Alpine alternative build: one alpine base for core and updater, with .local resolved in Go #746 is
    closed. feat(net): resolve device .local names, asking avahi first #728 asks the host avahi-daemon over its Unix socket and falls
    back to its own multicast query where no such socket exists — the Home
    Assistant app being the case that only ever has the fallback.
  2. This PR — the wizard change that actually writes the .local name.

Neither half delivers the feature on its own. This PR is web-only
(web/setup.html, web/setup.js, web/settings/tabs/devices.js), so until it
lands the resolver is in place but the UI keeps writing raw IPs and a normal
install sees no change.

The order is not symmetric, which is why this one is the blocked one:

  • A resolver without this PR is safe. It changes nothing about the install
    flow, and it already 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.
  • This PR without a resolver is not safe everywhere, which is the part
    that matters. Measured since: under Home Assistant a .local name already
    resolves without feat(net): resolve device .local names, asking avahi first #728, because Supervisor points every app at its own DNS
    service and that service answers mDNS names — verified on a pilot install,
    where a device that had moved off its configured IP was still reached by
    name. On a Docker Compose or Raspberry Pi install there is no such resolver:
    resolv.conf points at the router, which does not answer .local. So this
    PR alone would work on one supported platform and break device installs on
    the others.

GitHub cannot gate any of this — the dependency branches live on a fork, and a
PR's base branch has to exist in srcfl/ftw — so the ordering is declared here
rather than enforced.

On the review point about nss-mdns: it is worth having and #731 ships it, but
it cannot serve this process. glibc reaches NSS modules through dlopen, and a
CGO_ENABLED=0 binary never does. Verified on a live host with
docker exec ftw ldd /app/ftwnot a dynamic executable. Under #746 the
point is moot in the other direction: musl has no NSS plugin mechanism at all.


What & why

When you install a discovered device, the setup wizard now binds to the device's
self-broadcast mDNS (.local) name — the same name already shown in the scan
results — instead of its raw IPv4 address, whenever the device advertises one.

A raw IP is only stable if it's reserved in the router's DHCP pool. If it isn't,
a new DHCP lease can move the device and silently break the connection. An mDNS
.local name follows the device across lease changes, so binding to it is the
safer default.

When only a raw IP is available (no .local broadcast), the wizard and the
device settings now tell the operator to reserve that IP for the device in
their router's DHCP settings
so a future lease can't move it.

Changes

  • web/setup.js — the scan already resolved and displayed the mDNS
    hostname, but the install flow dropped it and bound the raw IP. Now:
    • useScanDevice / useManualDevice carry a hostname through to the
      selected device.
    • prefillDriverConfig prefills the host field with the .local name when
      the device broadcasts one (isMDNSName), otherwise the IP.
    • a new updateHostHint() shows a green note for an mDNS host (survives DHCP
      changes, with the discovered IP for reference) or a yellow DHCP-reservation
      warning for a raw-IP host; it also tracks manual edits of the field.
    • host validation and capability binding (modbus / mqtt / http) accept
      an IP or a hostname.
  • web/setup.html — host labels read "IP address or hostname"; adds the
    .host-hint / .host-hint.mdns / .host-hint.dhcp styles and the hint
    element under the host field.
  • web/settings/tabs/devices.js — the Settings device host fields get the
    same guidance: prefer a .local name, and reserve a raw IP in the router.
  • web/setup.test.mjs — structural tests lock in the hostname
    carry-through, the .local preference, and the DHCP-reservation warning.

The Go backend already accepts hostnames for Modbus/MQTT/HTTP hosts and for
fingerprint scanning, so this is a front-end-only change.

Why only .local names are preferred, not every DNS name

Only RFC 6762 .local names are self-broadcast by the device itself over mDNS,
so they resolve without depending on the router's DNS. A generic reverse-DNS
name depends on router configuration that may not be present, so the wizard
keeps the IP as the bound host in that case (and warns to reserve it).

Testing

  • node --test over the web test files — 48 pass, 0 fail.

🤖 Generated with Claude Code

The setup wizard showed a discovered device's mDNS hostname in the scan
table but always bound the raw IP into the driver config. Prefer the
self-broadcast .local name when the device advertises one, so the
connection survives DHCP lease changes; other reverse-DNS names stay
display-only since resolving them depends on the router.

When the configured host is an IP literal, a live hint under the host
field now tells the operator to reserve that IP for the device in the
router's DHCP settings. The Settings device help texts carry the same
advice.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@HuggeK
HuggeK marked this pull request as ready for review July 29, 2026 13:45
@HuggeK
HuggeK requested a review from erikarenhill as a code owner July 29, 2026 13:45

@frahlg frahlg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Read the whole diff. The reasoning is right and the implementation is careful — restricting the preference to .local via isMDNSName() and leaving every other reverse-DNS name display-only is exactly the correct line, since anything else depends on the router's DNS. The live hint on input is a nice touch, and routing saveDriver through one host variable keeps Modbus, MQTT and HTTP consistent.

One concern before this becomes the default, and I could not settle it from here.

Can the official container resolve a .local name at all?

  • The runtime image is alpine:3.22 with ca-certificates and tzdata (Dockerfile:39,43). No mDNS resolver, and musl has no NSS plugin mechanism, so the usual nss-mdns fix is not available the way it is on glibc.
  • Go's resolver does unicast DNS against the nameservers in resolv.conf. mDNS is multicast to 224.0.0.251:5353. The repo's own mDNS code (go/internal/scanner/mdns.go) is a hand-rolled multicast query used for discovery; it does not feed the resolver that modbus.Dial and the HTTP client use.
  • network_mode: host means the container can reach a host-local stub resolver, so this may work where the host runs systemd-resolved with MulticastDNS enabled — and fail where it runs dhcpcd + avahi, which is the common Raspberry Pi OS setup.

So the outcome likely depends on the host, and the PR removes the one sentence that warned about it: "mDNS names work when your OS resolver supports them; otherwise use the LAN IP". config.example.yaml:94 still carries the same caveat — allowed_hosts: ["zap.local"] # use the LAN IP if mDNS is unavailable.

The failure mode is bad: the wizard silently writes inverter.local, the driver never connects, and the operator has no way to tell that name resolution is the reason.

What would settle it: install a discovered device by its .local name on the official image on a Pi and confirm the driver comes online. If it works, this is ready. If it depends on the host resolver, the safer shape is to probe the name before saving and fall back to the IP when it does not resolve — the wizard already knows both.

Not blocking on my side; @erikarenhill owns web/ and this is his call.

@HuggeK

HuggeK commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Branch updated with master (clean merge, no conflicts) — it was ~142 commits behind. web/setup.test.mjs still passes 20/20 against the new base.

On the open review question

Can the official container resolve a .local name at all?

No, it could not — the concern was correct. But the reason is not musl, and that matters, because it means no container change fixes it.

Go routes .local to libc only when cgo is available. net/conf.go:395 opens if canUseCgo {, and the mDNS case sits inside it at 408-409:

case hostname != "" && stringslite.HasPrefix(src.source, "mdns"):
    if stringsHasSuffixFold(hostname, ".local") {
        return hostLookupCgo, dnsConf
    }

mustUseGoResolver begins if !cgoAvailable { return true }. Every FTW build sets CGO_ENABLED=0, so canUseCgo is false and that branch is dead code. The name falls through to hostLookupFilesDNS/etc/hosts, then a unicast query to resolv.conf.

So nss-mdns on a glibc base would not have helped either: a CGO_ENABLED=0 binary never calls getaddrinfo and never loads an NSS module, on any libc. The base image was never the variable.

The host-resolver guess doesn't rescue it either. Our Pi image runs NetworkManager (dns=default, router nameservers straight into resolv.conf) plus avahi-daemon, with dhcpcd masked and no systemd-resolved. Avahi answers NSS, not DNS. With network_mode: host the container inherits that resolv.conf, so zap.local becomes a unicast query to a router that will not answer.

What unblocks this

#728 resolves .local in Go — go/internal/mdnsresolve, wired into all six dial sites (Modbus, MQTT, HA bridge, HTTP incl. the TLS-pinned client, WebSocket, raw TCP). Works on any base and any libc, no container change.

Two details that matter for this PR specifically:

  • Resolution happens per dial, not once at startup. That is what actually makes this PR's promise true — "it survives DHCP lease changes" only holds if the name is re-resolved on reconnect, which modbus/client.go does by rebuilding from the original address.
  • A failed resolution now logs mDNS resolution failed and names the mechanism, which closes the specific gap the review flagged: "the operator has no way to tell that name resolution is the reason."

Suggested merge order

#728 first, then this. Merged alone, this PR would write inverter.local into every discovered device's config while nothing can resolve it — precisely the silent failure the review described. With #728 in, the hint text here becomes accurate rather than aspirational.

I deliberately left web/settings/tabs/devices.js untouched in #728 so it would not conflict with this PR — that file is yours, and the two branches have zero file overlap.

How to confirm the diagnosis on a real Pi
docker exec ftw ldd /app/ftw

Prints not a dynamic executable. A statically linked binary cannot dlopen an NSS module, which is how glibc resolves nss-mdns — so this is a conclusive, offline proof that no base-image change could have altered the outcome.

Contrast on the same host: avahi-resolve -n zap.local succeeds, while docker exec ftw wget -qO- http://zap.local/... fails.

(An earlier revision of this comment suggested GODEBUG=netdns=2 with --help. That does not work — netdns=2 only reports when a lookup actually happens, and --help performs none. As a runtime check it would need GODEBUG=netdns=2 set in the compose environment and the logs read once a driver polls. The ldd check settles the same question without that.)

Wider write-up of the audit this came out of: #730.

@HuggeK
HuggeK marked this pull request as ready for review August 5, 2026 08:59
Resolving the merge conflict rewrote the file through a text-mode write on Windows, which turned all 176 line endings into CRLF and made a 23-line addition read as a whole-file rewrite. The repo has no .gitattributes and core.autocrlf is false, so the bytes are stored as written.

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

HuggeK commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Merging this together with #728 does not yet work end to end — one flag is missing between them.

This PR makes the wizard write host: zap.local. #728 adds CheckLocalDestination, which refuses any .local endpoint unless the driver carries capabilities.allow_unverified_local: true. Checked just now: nothing in web/ ever sets that field, and config.example.yaml on the #728 branch documents it as something an operator adds by hand.

So with both merged, every device the wizard installs by name would be denied before resolution is attempted — the exact failure this PR exists to prevent, arriving from the other direction.

Two ways to close it, and they are not equivalent:

  1. The wizard sets the flag on drivers it binds by name. Makes the pair work, but silently opts every new install into the weaker trust model the flag was introduced to make explicit.
  2. A denied name falls through to the system resolver in feat(net): resolve device .local names, asking avahi first #728, exactly as a failed lookup already does since cf9cd8d. This is the smaller change than it looks: on every shipped version today, a .local host is handed to the OS resolver, and on Home Assistant that resolves — measured on a pilot install. The gate as written is a narrowing of existing behaviour introduced by feat(net): resolve device .local names, asking avahi first #728 itself, so relaxing it restores the status quo while keeping FTW's own unverified mDNS answers gated. It needs the same treatment in guardMDNSProxy to be coherent.

I have not implemented either — the second touches a security control on an open review, and that is the reviewer's call rather than mine. Flagging it here so the two PRs are not merged in sequence on the assumption that they compose.

HuggeK added a commit to HuggeK/ftw that referenced this pull request Aug 5, 2026
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

Run against a real device, with #728 underneath

The blocker I flagged above is fixed — in #728, not here. allow_unverified_local
now gates the resolver rather than the connection, so a .local host the
wizard writes is no longer refused. This PR needs no change for the two to
compose.

Verified on a real Sourceful Zap on the LAN, through POST /api/drivers/test
against a local build of #728 + this branch:

Config Result
host: zap.local, no allow_unverified_local reached the device — Zap: gateway identity zap-000064963cd51edc, discovered 2 device(s); meter=20222956, v2x=1
host: zap.local + allow_unverified_local: true same, and the log names the path: resolved host over mDNS host=zap.local addr=192.168.1.141 ttl=2m0s via=multicast

Both work, by different routes, which is the point of the change. Worth noting
what the first row means on this host: Windows answers .local itself, so the
system-resolver fallback carried it. nslookup zap.local on the same machine
returns NXDOMAIN from the router — so a plain Go resolver, which is what FTW
uses with CGO_ENABLED=0, gets nothing. That is exactly the gap #728 fills on a
Compose or Raspberry Pi host.

The multicast path also proved its own need for a fallback: the first attempt
timed out and IPv6 failed to bind on this laptop's Wi-Fi adapter, the retry
resolved cleanly.

The visible change

Rendered both versions, same build, same step, setup.html?step=5:

master

before

this PR

after

The field label moves from IP ADDRESS to IP ADDRESS OR HOSTNAME, which is the
whole premise: the field now accepts a name, so it should say so. The manual-entry
placeholder gains or inverter.local on the step behind I know my device IP.

What these shots do not show

The addressing hint (#drv-host-hint) is driven by an input event and by
selecting a scanned device, so it does not appear on a freshly loaded step. The
headless capture used here cannot type into the field. It needs a human at the
wizard, which the reviewer instructions in this repo ask for anyway.

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

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