Skip to content

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

Draft
HuggeK wants to merge 2 commits into
srcfl:masterfrom
HuggeK:mdns-first-device-install
Draft

feat(web): install devices by mDNS name, warn to reserve raw IPs#714
HuggeK wants to merge 2 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 .local — either build: run one base across the whole stack (debian:trixie-slim) #731 + feat(net): resolve device .local names, asking avahi first #728 (whole
    stack on debian:trixie-slim, resolution asking the host avahi-daemon
    first) or build: one alpine base for core and updater, with .local resolved in Go #746 (core + updater on one alpine:3.22 base, resolution
    natively in Go). Two PRs or one — they are alternative routes, so pick one
    and never merge both.
  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. It would bind every discovered
    device to a name nothing can resolve, so device installs would break.

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.

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