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 scan —
localSubnets() 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.
- mDNS —
net.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
- 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.
- Decide the posture question, then mDNS over
ff02::fb and the NDP identity
lookup if IPv6-only is in scope.
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.
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 arequest to decide one: is dual-stack-only the supported posture, or is IPv6-only
a goal?
Line references verified against
masterat the time of filing.What already works
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.ymlusesnetwork_mode: host, so the container inherits thehost's IPv6 exactly.
go/internal/modbus/client.go:49and the Luatcpcapability usenet.JoinHostPort/ raw dial correctly, andsplitHostPortLoweralreadyhandles
[::1]:8080and barefe80::1(it has a passing test).through the stdlib with no special handling.
/api/sysinfodeliberately reports IPv6 including link-local, and the settingsUI has a "Show IPv6" toggle.
Bug 1 —
%s:%dinstead ofnet.JoinHostPortfmt.Sprintf("%s:%d", host, port)withhost = "fd00::5"producesfd00::5:1883, which is not a parseable address. Each of these should benet.JoinHostPort(oru.Hostbuilt from it):go/internal/mqtt/client.go:53go/internal/ha/bridge.go:154,251go/internal/evcloud/ctek.go:149go/internal/nova/publisher.go:91,109go/internal/drivers/registry.go:842go/internal/drivers/registry.go:196,211ep:device identitygo/internal/api/api_drivers_fingerprint.go:187,197httpone yields a malformed base URLgo/internal/api/api.go:1613,go/cmd/ftw/main.go:2292Why 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:842builds entries for a driver's allowed-hosts list, soa 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.Lookupis IPv4-only by construction (go/internal/arp/arp.go), so a devicereached over IPv6 gets no MAC and falls back to
ep:<endpoint>identity instate.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.mdinvariant "Persistent device state is keyed by stable hardwareidentity, not a YAML name."
Possible direction
IPv6 has no ARP; the equivalent is NDP, readable on Linux via
ip -6 neighor/proc/net/ipv6_route-adjacent interfaces. A neighbour-tablelookup 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
localSubnets()skips any address withoutTo4()andenumerates 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.
net.ListenUDP("udp4", ...)on224.0.0.251only(
go/internal/scanner/mdns.go:13,34), noff02::fb. This is a real gap:mDNS discovery and reverse-hostname lookup work fine over IPv6 and would need
only a second listener.
"IPv6 not supported"and requiresRFC1918 (
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 bindv6-only, or to one specific address. (
CalDAVdoes have alistenfield and canbe set to
[::1]:5232, so the two subsystems are inconsistent.)Suggested order
JoinHostPortsweep — mechanical, low risk, testable with a table test.registry.go:842first, since it is the only one that can deny a connection.ff02::fband the NDP identitylookup if IPv6-only is in scope.
api.listen(orapi.host) to matchcaldav.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.