Skip to content

IPv6: dual-stack works, but IPv6 literals break outbound paths and discovery is IPv4-only #725

Description

@HuggeK

Summary

FTW is reachable over IPv6 and dials IPv6 fine when a device is addressed by
hostname. But a bare IPv6 literal in config breaks several outbound paths,
and all device discovery is IPv4-only. A genuinely IPv6-only deployment does not
work today.

Nothing in docs/ states a position either way, so this issue is partly a
request to decide one: is dual-stack-only the supported posture, or is IPv6-only
a goal?

Line references verified against master at the time of filing.

What already works

  • The API binds fmt.Sprintf(":%d", cfg.API.Port) (go/cmd/ftw/main.go:335).
    An empty host is Go's dual-stack wildcard, so IPv6 clients reach the UI.
  • docker-compose.yml uses network_mode: host, so the container inherits the
    host's IPv6 exactly.
  • go/internal/modbus/client.go:49 and the Lua tcp capability use
    net.JoinHostPort / raw dial correctly, and splitHostPortLower already
    handles [::1]:8080 and bare fe80::1 (it has a passing test).
  • Any device or cloud API addressed by hostname resolves AAAA and dials v6
    through the stdlib with no special handling.
  • /api/sysinfo deliberately reports IPv6 including link-local, and the settings
    UI has a "Show IPv6" toggle.

Bug 1 — %s:%d instead of net.JoinHostPort

fmt.Sprintf("%s:%d", host, port) with host = "fd00::5" produces
fd00::5:1883, which is not a parseable address. Each of these should be
net.JoinHostPort (or u.Host built from it):

File What breaks
go/internal/mqtt/client.go:53 MQTT broker addressed by IPv6 literal
go/internal/ha/bridge.go:154,251 Home Assistant broker
go/internal/evcloud/ctek.go:149 CTEK Modbus URL
go/internal/nova/publisher.go:91,109 Nova MQTT broker
go/internal/drivers/registry.go:842 feeds a capability allowlist — can reject a legitimate connection, not just mislabel it
go/internal/drivers/registry.go:196,211 endpoint label — also becomes part of ep: device identity
go/internal/api/api_drivers_fingerprint.go:187,197 endpoint label; the http one yields a malformed base URL
go/internal/api/api.go:1613, go/cmd/ftw/main.go:2292 display/log only
Why registry.go:842 is the sharp one

The others produce a wrong string that is mostly cosmetic or fails loudly at
dial time. registry.go:842 builds entries for a driver's allowed-hosts list, so
a malformed entry silently fails to match the real endpoint and the driver is
denied access to its own device — which presents as a driver fault, not as an
address-formatting bug.

Bug 2 — device identity is lost over IPv6

arp.Lookup is IPv4-only by construction (go/internal/arp/arp.go), so a device
reached over IPv6 gets no MAC and falls back to ep:<endpoint> identity in
state.ResolveDeviceID.

Because SLAAC privacy addresses rotate by design, that fallback identity is
actively unstable: persistent per-device state (battery models, RLS twin,
calibration history) is orphaned on renumbering. This collides with the
AGENTS.md invariant "Persistent device state is keyed by stable hardware
identity, not a YAML name."

Possible direction

IPv6 has no ARP; the equivalent is NDP, readable on Linux via
ip -6 neigh or /proc/net/ipv6_route-adjacent interfaces. A neighbour-table
lookup would restore an L2-stable identity for v6 devices. Worth confirming this
is wanted before building it — see the posture question at the top.

Bug 3 — discovery is IPv4-only

  • Network scanlocalSubnets() skips any address without To4() and
    enumerates IPv4 /24../30 hosts (go/internal/scanner/scanner.go:169-262).
    Sweeping a v6 /64 is infeasible, so this one is defensible as-is; noted for
    completeness.
  • mDNSnet.ListenUDP("udp4", ...) on 224.0.0.251 only
    (go/internal/scanner/mdns.go:13,34), no ff02::fb. This is a real gap:
    mDNS discovery and reverse-hostname lookup work fine over IPv6 and would need
    only a second listener.
  • Tesla proxy verify hard-rejects v6 with "IPv6 not supported" and requires
    RFC1918 (go/internal/api/api_tesla_verify.go:137).

Bug 4 — no bind-address setting

There is no API bind-address config at all, only api.port. You cannot bind
v6-only, or to one specific address. (CalDAV does have a listen field and can
be set to [::1]:5232, so the two subsystems are inconsistent.)

Suggested order

  1. The JoinHostPort sweep — mechanical, low risk, testable with a table test.
    registry.go:842 first, since it is the only one that can deny a connection.
  2. Decide the posture question, then mDNS over ff02::fb and the NDP identity
    lookup if IPv6-only is in scope.
  3. api.listen (or api.host) to match caldav.listen.
Origin

Surfaced while answering "does FTW work with IPv6?" and auditing how device
identity is keyed. The HTTP-driver half of the identity problem is fixed
separately in #724; this issue covers what that PR deliberately left alone.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions