Skip to content
Draft
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
24 changes: 24 additions & 0 deletions .changeset/resolve-local-device-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"ftw": minor
---

Resolve device `.local` names over mDNS 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 was true on every
base image and every libc. FTW now answers those names itself over multicast
DNS, and 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 for the record's TTL (clamped to 30–120 s) 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 now 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. mDNS needs multicast reachability, 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.
2 changes: 1 addition & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ drivers:
# battery_telemetry_only: true
# capabilities:
# 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
6 changes: 6 additions & 0 deletions docs/sourceful-zap.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ 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 over multicast
DNS 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`;
- 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.
17 changes: 15 additions & 2 deletions go/internal/drivers/lua.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import (
"time"

lua "github.com/yuin/gopher-lua"

"github.com/srcfl/ftw/go/internal/mdnsresolve"
)

// LuaDriver wraps a running Lua VM bound to a HostEnv.
Expand Down Expand Up @@ -654,8 +656,16 @@ func registerHost(L *lua.LState, env *HostEnv) {
return false, fmt.Sprintf("host %q (port %s) not in allowed_hosts", host, port)
}

// Drivers routinely address a device by its ".local" name, which the
// stdlib resolver cannot answer. Clone the default transport so proxying,
// HTTP/2 and connection pooling are all unchanged — only the dial step
// differs, and only for ".local" hosts.
transport := net_http.DefaultTransport.(*net_http.Transport).Clone()
transport.DialContext = mdnsresolve.DialContext

httpClient := &net_http.Client{
Timeout: 15 * time.Second,
Timeout: 15 * time.Second,
Transport: transport,
CheckRedirect: func(req *net_http.Request, via []*net_http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
Expand All @@ -677,7 +687,10 @@ func registerHost(L *lua.LState, env *HostEnv) {
// CA. Drivers WITHOUT a pin keep Go's default transport untouched, so
// nothing about existing HTTP drivers changes.
if pin := tlsPin; pin != "" {
tr := net_http.DefaultTransport.(*net_http.Transport).Clone()
// Clone the transport built above so the pinned client keeps the same
// mDNS-aware dialer — a pinned device is usually a local appliance
// addressed by its ".local" name, which is exactly the case that needs it.
tr := transport.Clone()
tr.TLSClientConfig = &tls.Config{
// We replace chain/hostname verification with our own exact
// fingerprint check below, so the stdlib check must be off.
Expand Down
4 changes: 3 additions & 1 deletion go/internal/drivers/tcp_cap.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"strings"
"sync"
"time"

"github.com/srcfl/ftw/go/internal/mdnsresolve"
)

// TCPCap is the host's raw TCP socket capability. One driver = one upstream
Expand Down Expand Up @@ -93,7 +95,7 @@ func (n *netTCP) Open(addr string) error {
return fmt.Errorf("tcp: %s", reason)
}

conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
conn, err := mdnsresolve.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("tcp dial: %w", err)
}
Expand Down
4 changes: 4 additions & 0 deletions go/internal/drivers/ws_cap.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"time"

"github.com/gorilla/websocket"

"github.com/srcfl/ftw/go/internal/mdnsresolve"
)

// gorillaWS is the production WSCap implementation. One per driver.
Expand Down Expand Up @@ -73,6 +75,8 @@ func (g *gorillaWS) Open(url string, headers map[string]string) error {
}
dialer := *websocket.DefaultDialer
dialer.HandshakeTimeout = 15 * time.Second
// ".local" hosts need mDNS; everything else falls through to a plain dial.
dialer.NetDialContext = mdnsresolve.DialContext
if len(subprotocols) > 0 {
dialer.Subprotocols = subprotocols
}
Expand Down
10 changes: 10 additions & 0 deletions go/internal/ha/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net"
"net/url"
"sort"
"strconv"
"strings"
Expand All @@ -22,6 +24,7 @@ import (

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/mdnsresolve"
"github.com/srcfl/ftw/go/internal/telemetry"
)

Expand Down Expand Up @@ -249,6 +252,13 @@ func (b *Bridge) connectAndStart(cfg *config.HomeAssistant, driverNames []string

opts := paho.NewClientOptions().
AddBroker(fmt.Sprintf("tcp://%s:%d", cfg.Broker, cfg.Port)).
// A Home Assistant broker is very often reached as homeassistant.local,
// which the stdlib resolver cannot answer. See internal/mqtt for why a
// TCP-only replacement is complete here.
SetCustomOpenConnectionFn(func(uri *url.URL, o paho.ClientOptions) (net.Conn, error) {
d := mdnsresolve.Dialer{Dialer: net.Dialer{Timeout: o.ConnectTimeout}}
return d.Dial("tcp", uri.Host)
}).
SetClientID("forty-two-watts-ha").
SetAutoReconnect(true).
SetConnectRetry(true).
Expand Down
Loading