Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .changeset/resolve-local-device-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
"ftw": minor
---

Resolve device `.local` names, so devices can be configured by name instead of a DHCP-assigned IP.

Go never resolves `.local` itself: it hands those names to libc only when cgo is
available, and FTW builds with `CGO_ENABLED=0`, so a configured `zap.local`
became a unicast DNS query to the site router and failed. That is true on every
base image and every libc — shipping `libnss-mdns` changes what `getent` and
`curl` resolve inside the container, not what this process resolves.

FTW now asks the host's own mDNS responder, `avahi-daemon`, over its
simple-protocol socket — the same daemon and the same socket
`libnss_mdns4_minimal.so.2` uses, so a name resolves identically whether FTW
dials it or an operator checks it from a shell in the container. Where that
socket cannot be reached, FTW queries the LAN directly instead. The socket has
to be bind-mounted, and under the Home Assistant Supervisor an add-on cannot
mount arbitrary host paths at all, so the direct path is what makes the feature
work there; it is also the default in Compose, where mounting a host runtime
directory is left to the operator. A successful lookup logs which one answered.

Every driver transport uses it — Modbus TCP, MQTT (driver and Home Assistant
bridge), HTTP including TLS-pinned clients, WebSocket and raw TCP.

Resolution happens per dial rather than once at startup, so a device that moves
to a new DHCP lease is found again on the next reconnect without a config edit.
Answers are cached (30–120 s, following the record TTL where there is one) so
reconnect loops do not flood the LAN, and failures are cached briefly so a
device that is still booting is retried soon. A failed resolution logs
`mDNS resolution failed` and names the mechanism, instead of surfacing as a
generic dial error.

Only `.local` names take this path; literal IPs and ordinary DNS names dial
exactly as before. Multicast still has to reach the LAN, which the Linux
Compose topology has via `network_mode: host`. Under `docker-compose.macos.yml`
the container is bridged, so configure devices by IP there.

Direct queries use each active multicast interface. IPv4 and IPv6 are
supported; link-local IPv6 addresses carry their interface zone and unscoped
answers are rejected. The resolver also rejects non-response DNS packets,
wrong answer classes or families, invalid sources, and Avahi replies whose
interface, name, address family or address does not match the request. mDNS is
unauthenticated, so `capabilities.allow_unverified_local: true` for a driver —
or `homeassistant.allow_unverified_local: true` for the Home Assistant bridge —
gates whether FTW acts on an answer it obtained itself. It gates the resolution
path, not the connection: without it a `.local` name still goes to the system
resolver exactly as it does today, which is what keeps working installs working
under Home Assistant, where Supervisor answers `.local` through its own DNS
service. Host allowlists do not prove server identity, and a TLS pin does not
bypass the gate yet. Literal IP and ordinary DNS endpoints are unchanged.

Network scanning now reports the name a device answers for itself. It used to
ask unicast reverse DNS first and only fall back to mDNS, which meant the
router's label for the lease — `zap-000064963cd51edc.localdomain` on a UniFi
network — was what reached the setup wizard, and the wizard fell back to the
raw IP because that is not a `.local` name. Both queries now run together and a
`.local` answer wins. Reverse mDNS alone is not enough either: RFC 6762 leaves
`in-addr.arpa` mapping optional and many responders publish a forward `A`
record without one, so where no reverse record exists FTW re-asks the label
forward as `<label>.local` and keeps it only when it resolves back to the same
address. Unverified names are still shown, but only as display text.
10 changes: 9 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ drivers:
# - name: ambibox
# lua: drivers/ambibox_v2x.lua
# capabilities:
# # Lets FTW resolve the name itself over mDNS. Without it the name is
# # left to the system resolver, which answers on some platforms only.
# allow_unverified_local: true
# mqtt:
# host: sid-os.local
# port: 1883
Expand All @@ -90,8 +93,12 @@ drivers:
# is_site_meter: true
# battery_telemetry_only: true
# capabilities:
# # Lets FTW resolve zap.local itself. Without it the name is left to
# # the system resolver — which answers under Home Assistant, but not on
# # a plain Compose or Pi install.
# allow_unverified_local: true
# http:
# allowed_hosts: ["zap.local"] # use the LAN IP if mDNS is unavailable
# allowed_hosts: ["zap.local"] # .local is resolved by FTW over mDNS
# config:
# host: zap.local
# # meter_serial: p1m-... # optional; P1 is auto-selected
Expand Down Expand Up @@ -136,6 +143,7 @@ api:
homeassistant:
enabled: false
broker: 192.168.1.1
# allow_unverified_local: true # let FTW resolve broker.local over mDNS itself
port: 1883
username: homeems
password: homeems
Expand Down
15 changes: 15 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ services:
# here to render update progress in the UI; it never writes to it.
- update-ipc:/run/ftw-update
- optimizer-ipc:/run/ftw-optimizer
# OPTIONAL — avahi-daemon's runtime directory. Host networking shares
# ports, not Unix sockets, so this is the only way in.
#
# With it, FTW asks the host's mDNS responder to resolve `.local` device
# names (and `getent hosts zap.local` works inside the container, via
# libnss-mdns). Without it FTW queries the LAN itself, which needs no
# host software and is why this stays commented out by default.
#
# Uncomment only if the host runs avahi-daemon — the Raspberry Pi image
# does. Mount the DIRECTORY, not the socket inside it: if the path is
# missing Docker creates it, and an empty directory there is harmless,
# whereas a directory created where the *socket* belongs stops
# avahi-daemon from ever starting. Restarting avahi after the container
# detaches the mount, so restart FTW too if you do.
# - /run/avahi-daemon:/run/avahi-daemon:ro

ftw-optimizer:
image: ghcr.io/srcfl/ftw-optimizer:${FTW_OPTIMIZER_IMAGE_TAG:-latest}
Expand Down
101 changes: 85 additions & 16 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,30 +180,99 @@ Verify the broker address from the same network namespace as core, then inspect
broker and driver logs. Device credentials and topic mappings belong to the
driver configuration.

### `.local` names inside the container
### A device `.local` name does not resolve

The image ships `libnss-mdns`, so ordinary glibc tools inside the container —
`getent hosts zap.local` and `wget` — resolve `.local` the way they do on
the host. `apt` wires `mdns4_minimal [NOTFOUND=return]` into
`/etc/nsswitch.conf` when the package is installed; nothing else is needed at
build time.
FTW resolves `.local` device names itself; the OS resolver is not involved,
because a `CGO_ENABLED=0` Go binary never consults NSS. There are two places an
answer can come from, and the log line for a successful lookup says which:

At run time that path talks to `avahi-daemon` over a Unix socket, and a socket
is not shared by host networking the way a port is. Mount it explicitly:
```
resolved host over mDNS host=zap.local addr=192.168.1.42 via=avahi
```

**`via=avahi`** — the host's `avahi-daemon` answered over its socket. This is
preferred when available: avahi already holds a record cache, and it is the
same daemon that `getent hosts zap.local` inside the container goes through, so
FTW and your shell cannot disagree about an address.

**`via=multicast`** — FTW queried the LAN directly. This is what happens when
the avahi socket is not mounted, which is the default, and it needs no host
software at all.

Failures log `mDNS resolution failed`, and the message names both backends when
both were tried.

Either way multicast has to reach the LAN, which the Linux Compose topology
provides through `network_mode: host`. Under `docker-compose.macos.yml` the
container is bridged and multicast does not reach the LAN, so configure devices
by IP there. The direct path sends on every active, non-loopback multicast
interface. It supports IPv4 and IPv6; a link-local IPv6 answer is used only
with its interface zone, and an unscoped link-local answer is discarded.

mDNS has no built-in authentication. Treat a `.local` name as a LAN trust
boundary, reserve names used by control drivers, and use TLS certificate pins
where the driver supports them. Network allowlists still check the configured
host name and port before resolution; they do not prove that an mDNS responder
is the intended device.

That risk is not unique to names, which is why the opt-in below gates the
resolver rather than the connection. A raw IP address is no more an identity on
a LAN than a name is — it can be claimed by ARP, and DHCP can hand it to a
different device with no attacker involved at all. The durable check is the
identity a device reports once connected: make and serial, or its MAC.

So by default FTW does not use *its own* mDNS answer for a `.local` name: the
name goes to the system resolver, exactly as it did before this package
existed, and on platforms that answer `.local` themselves — Home Assistant does,
through Supervisor's DNS service — it simply works. Opt in per driver to let FTW
resolve the name itself, over Avahi or the LAN:

```yaml
capabilities:
allow_unverified_local: true
modbus:
host: inverter.local
port: 502
```

For the Home Assistant bridge, set `homeassistant.allow_unverified_local: true`
instead. It applies to every transport — HTTP, WebSocket, MQTT, Modbus and raw
TCP — and is per driver, so a name allowlist never becomes a server identity for
another driver. Literal IP addresses and ordinary DNS names are untouched.

Without it, a `.local` dial is not refused; it is handed to the system resolver,
and the log records why FTW's own answer was not used. That matters on a host
where nothing else resolves `.local` — a plain Compose or Raspberry Pi install,
whose `resolv.conf` points at the router — because there the name will simply
not resolve until you opt in.

Note the limit of the gate: where an HTTP proxy is configured, FTW never
resolves the destination at all, so the flag has nothing to say about that path.
A TLS pin does not bypass the gate yet.

#### Letting FTW use avahi

Host networking shares ports, not Unix sockets, so avahi has to be bind-mounted
in. `docker-compose.yml` carries the line commented out:

```yaml
volumes:
- /run/avahi-daemon/socket:/run/avahi-daemon/socket:ro
- /run/avahi-daemon:/run/avahi-daemon:ro
```

Only add this on a host that actually runs `avahi-daemon` — the Raspberry Pi
image does. Without the daemon Docker creates a *directory* at that path, which
resolves nothing and is harmless but confusing; `ls -l` there is the quickest
way to tell the two apart.
Mount the *directory*, not the socket file inside it. If the host path is
missing Docker creates it, and an empty directory is harmless — whereas a
directory created where the socket belongs stops `avahi-daemon` from ever
starting. Restarting avahi detaches the mount, so restart FTW after you do.

This is an optimisation, not a requirement: device connectivity is unchanged
without it. It lets `getent hosts zap.local`, `curl` and `wget` check the name
from inside the container.

This makes the container's own tooling agree with the host. Whether the FTW
process itself resolves a device's `.local` name is a separate question,
answered by `internal/mdnsresolve`.
Under the Home Assistant add-on none of this applies: Supervisor mounts only a
fixed set of named paths, so the socket cannot be provided and FTW always
queries the LAN directly. The add-on runs with `host_network: true`, which is
what makes that work.

### Configuration rejected

Expand Down
9 changes: 9 additions & 0 deletions docs/sourceful-zap.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ go test ./internal/drivers -run 'Zap|zap'

- not found: confirm Zap is on Wi-Fi and reachable at
`http://zap.local/api/system` from the FTW host;
- `.local` name does not resolve: FTW resolves `.local` itself — via the host's
`avahi-daemon` where its socket is mounted, otherwise by querying the LAN —
rather than through the OS resolver, so it needs to be on the same L2 segment
as the device. That is the case with the Linux Compose topology
(`network_mode: host`); under `docker-compose.macos.yml` the container is
bridged and multicast does not reach the LAN, so configure the device by IP
there. The log line naming the failure is `mDNS resolution failed`, and a
successful lookup logs `via=avahi` or `via=multicast`; see
[docs/operations.md](operations.md);
- no meter: inspect Zap's `/api/devices` and pin `meter_serial` when needed;
- duplicate PV/battery: disable the overlapping Zap DER;
- visible battery is not controlled: expected for the telemetry-only driver.
4 changes: 2 additions & 2 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,10 +529,10 @@ func main() {
}
}
reg.MQTTFactory = func(name string, c *config.MQTTConfig) (drivers.MQTTCap, error) {
return mqttcli.Dial(c.Host, c.Port, c.Username, c.Password, "ftw-"+name)
return mqttcli.DialWithOptions(c.Host, c.Port, c.Username, c.Password, "ftw-"+name, c.AllowUnverifiedLocal)
}
reg.ModbusFactory = func(name string, c *config.ModbusConfig) (drivers.ModbusCap, error) {
return modbuscli.Dial(c.Host, c.Port, c.UnitID)
return modbuscli.DialWithOptions(c.Host, c.Port, c.UnitID, c.AllowUnverifiedLocal)
}
reg.SerialFactory = func(name string, c *config.SerialConfig) (drivers.SerialCap, error) {
return drivers.OpenSerial(c)
Expand Down
29 changes: 22 additions & 7 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -885,13 +885,17 @@ type DriverControlOptIn struct {

// Capabilities explicitly scope what host resources a driver can access.
type Capabilities struct {
MQTT *MQTTConfig `yaml:"mqtt,omitempty" json:"mqtt,omitempty"`
Modbus *ModbusConfig `yaml:"modbus,omitempty" json:"modbus,omitempty"`
Serial *SerialConfig `yaml:"serial,omitempty" json:"serial,omitempty"`
HTTP *HTTPCapability `yaml:"http,omitempty" json:"http,omitempty"`
WebSocket *WSCapability `yaml:"websocket,omitempty" json:"websocket,omitempty"`
TCP *TCPCapability `yaml:"tcp,omitempty" json:"tcp,omitempty"`
Standalone bool `yaml:"standalone,omitempty" json:"standalone,omitempty"`
// AllowUnverifiedLocal permits a driver to use an mDNS-resolved .local
// endpoint when the transport has no cryptographic server identity. The
// name allowlist is not identity, so the safe default is false.
AllowUnverifiedLocal bool `yaml:"allow_unverified_local,omitempty" json:"allow_unverified_local,omitempty"`
MQTT *MQTTConfig `yaml:"mqtt,omitempty" json:"mqtt,omitempty"`
Modbus *ModbusConfig `yaml:"modbus,omitempty" json:"modbus,omitempty"`
Serial *SerialConfig `yaml:"serial,omitempty" json:"serial,omitempty"`
HTTP *HTTPCapability `yaml:"http,omitempty" json:"http,omitempty"`
WebSocket *WSCapability `yaml:"websocket,omitempty" json:"websocket,omitempty"`
TCP *TCPCapability `yaml:"tcp,omitempty" json:"tcp,omitempty"`
Standalone bool `yaml:"standalone,omitempty" json:"standalone,omitempty"`
}

// MQTTConfig grants access to one MQTT broker.
Expand All @@ -900,13 +904,21 @@ type MQTTConfig struct {
Port int `yaml:"port,omitempty" json:"port,omitempty"` // default 1883
Username string `yaml:"username,omitempty" json:"username,omitempty"`
Password string `yaml:"password,omitempty" json:"password,omitempty"`
// AllowUnverifiedLocal is copied from capabilities.allow_unverified_local
// by the core before this config reaches the transport factory. It is
// runtime-only and never comes from this nested YAML block.
AllowUnverifiedLocal bool `yaml:"-" json:"-"`
}

// ModbusConfig grants access to one Modbus TCP endpoint.
type ModbusConfig struct {
Host string `yaml:"host" json:"host"`
Port int `yaml:"port,omitempty" json:"port,omitempty"` // default 502
UnitID int `yaml:"unit_id,omitempty" json:"unit_id,omitempty"` // default 1
// AllowUnverifiedLocal is copied from capabilities.allow_unverified_local
// by the core before this config reaches the transport factory. It is
// runtime-only and never comes from this nested YAML block.
AllowUnverifiedLocal bool `yaml:"-" json:"-"`
}

// SerialConfig grants read-only access to one local serial device.
Expand Down Expand Up @@ -986,6 +998,9 @@ type HomeAssistant struct {
Username string `yaml:"username,omitempty" json:"username,omitempty"`
Password string `yaml:"password,omitempty" json:"password,omitempty"`
PublishIntervalS int `yaml:"publish_interval_s,omitempty" json:"publish_interval_s,omitempty"`
// AllowUnverifiedLocal permits the bridge to use an mDNS-resolved broker
// without a verified server identity. The default is fail-closed.
AllowUnverifiedLocal bool `yaml:"allow_unverified_local,omitempty" json:"allow_unverified_local,omitempty"`
}

// StateConf is the persistent state DB config.
Expand Down
44 changes: 44 additions & 0 deletions go/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,50 @@ func TestLoadMinimalYAML(t *testing.T) {
}
}

func TestAllowUnverifiedLocalDefaultsDenyAndParsesExplicitOptIn(t *testing.T) {
cfg, err := Parse([]byte(minimalYAML), "/tmp")
if err != nil {
t.Fatal(err)
}
if cfg.Drivers[0].Capabilities.AllowUnverifiedLocal {
t.Fatal("allow_unverified_local must default to false")
}

withOptIn := strings.Replace(minimalYAML,
"capabilities:\n mqtt:",
"capabilities:\n allow_unverified_local: true\n mqtt:", 1)
optedIn, err := Parse([]byte(withOptIn), "/tmp")
if err != nil {
t.Fatal(err)
}
if !optedIn.Drivers[0].Capabilities.AllowUnverifiedLocal {
t.Fatal("explicit allow_unverified_local=true was not retained")
}
}

func TestHomeAssistantAllowUnverifiedLocalDefaultsDeny(t *testing.T) {
base := minimalYAML + `
homeassistant:
enabled: true
broker: broker.local
`
cfg, err := Parse([]byte(base), "/tmp")
if err != nil {
t.Fatal(err)
}
if cfg.HomeAssistant.AllowUnverifiedLocal {
t.Fatal("homeassistant allow_unverified_local must default to false")
}

optedIn, err := Parse([]byte(base+" allow_unverified_local: true\n"), "/tmp")
if err != nil {
t.Fatal(err)
}
if !optedIn.HomeAssistant.AllowUnverifiedLocal {
t.Fatal("homeassistant explicit local opt-in was not retained")
}
}

func TestParseIgnoresRetiredRemoteAccessKeys(t *testing.T) {
legacy := minimalYAML + `
remote_access:
Expand Down
10 changes: 10 additions & 0 deletions go/internal/drivers/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ type HostEnv struct {
// backward compat with existing drivers that didn't declare a list.
// Populated from driver config `capabilities.http.allowed_hosts`.
HTTPAllowedHosts []string
// AllowUnverifiedLocal permits all this driver's mDNS-resolved .local
// transports. The name allowlist is not server identity; core keeps this
// false unless the operator explicitly opts in.
AllowUnverifiedLocal bool
// HTTPTLSPinSHA256, when non-empty, pins the HTTPS leaf certificate to
// this SHA-256 fingerprint (hex; colons/whitespace ignored, case-
// insensitive — same value as `openssl x509 -fingerprint -sha256`).
Expand Down Expand Up @@ -492,6 +496,12 @@ func (h *HostEnv) WithHTTPAllowedHosts(hosts []string) *HostEnv {
return h
}

// WithAllowUnverifiedLocal permits this driver's raw .local transports.
func (h *HostEnv) WithAllowUnverifiedLocal() *HostEnv {
h.AllowUnverifiedLocal = true
return h
}

// WithHTTPTLSPin pins the HTTPS leaf certificate this driver's http_*
// calls will accept, by SHA-256 fingerprint. Empty string = no pin
// (standard system-root verification). See HostEnv.HTTPTLSPinSHA256.
Expand Down
Loading