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
32 changes: 32 additions & 0 deletions .changeset/one-alpine-base.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"ftw": minor
---

Run core and the updater sidecar on one shared base layer, and move the optimizer off oldstable.

The updater sidecar was `docker:27-cli`. That image is alpine-based too, but it
is a *different* alpine — its own base layer on its own cadence — so a host
pulled two rootfs blobs and tracked two upgrade streams for what looked like one
distribution. The sidecar now sits on the same `alpine:3.22` tag as core, with
the `docker` CLI and the compose plugin copied straight out of the official CLI
image. That is the same upstream artifact, with the version pinned explicitly
and no package repository added; both are statically linked Go binaries, so
neither depends on the base's libc.

The third image cannot join them. CVXPY publishes no musllinux wheels — the
newest that exists is 0.4.10 — and neither do clarabel, osqp or ecos, so the
optimizer cannot be built on alpine without compiling a Rust solver and two C++
solvers from source on every release, arm64 under emulation included. It stays
on Debian slim, but moves from bookworm to trixie, because bookworm is oldstable
and part of the deployment was already off full security support. The container
boundary test now asserts all of this instead of grepping one file for a fixed
string with no error message.

The optimizer is versioned independently and moves to 1.4.0: its image is a
materially different artifact once the base changes, and its release workflow
verifies that a published image's revision label matches the commit it claims,
so the new base could not ship under the old version number.

The zoneinfo database is also embedded in the core binary as a fallback. It was
already installed in the image; the point is that a base without it can no
longer silently push `time.Local` to UTC and mis-time price and plan windows.
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.
14 changes: 14 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,24 @@ RUN cd go && \
go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" \
-o /out/ftw-backup ./cmd/ftw-backup
# --- Runtime ---------------------------------------------------------------
# alpine:3.22, and Dockerfile.updater now uses the same tag, so the two images
# a host runs side by side share one base layer instead of pulling two.
#
# The binary is CGO_ENABLED=0 and fully static, so this picks the image's
# *userland*, not a libc the process depends on. That is why the choice is
# reversible and why it has no bearing on .local resolution — see
# go/internal/mdnsresolve.
#
# Dockerfile.optimizer cannot join this base: CVXPY publishes no musllinux
# wheels at all, so see the note there before trying to unify the third image.
FROM alpine:3.22

# HTTPS integrations and timezone-aware price/plan windows need these at
# runtime. BusyBox wget provides the health check without adding Python/curl.
#
# tzdata is belt-and-braces now: the binary also embeds the zoneinfo database
# (see the time/tzdata import in cmd/ftw). Without either, time.Local silently
# degrades to UTC and mis-times price and plan windows with no error at all.
RUN apk add --no-cache ca-certificates tzdata

# Image layout:
Expand Down
21 changes: 19 additions & 2 deletions Dockerfile.optimizer
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
# Independently releasable FTW mathematical optimizer.
FROM python:3.12-slim-bookworm AS build
#
# This image does NOT share a base with core and the updater, and cannot.
# Measured, not assumed, on python:3.12-alpine:
#
# pip install --only-binary=:all: cvxpy==1.9.2
# → "Could not find a version that satisfies the requirement
# cvxpy==1.9.2 (from versions: 0.4.10)"
#
# CVXPY publishes no musllinux wheels — 0.4.10 is the newest that exists — and
# clarabel, osqp and ecos have none either. numpy, scipy and highspy do. Falling
# back to source builds means compiling a Rust solver and two C++ solvers on
# every release, for arm64 under emulation as well; attempted, and it fails in
# sparsediffpy's build backend before it even reaches them.
#
# So the stack runs two bases by necessity. What is fixed here is the suite:
# bookworm is oldstable, so this moves to trixie to match core's security
# cadence even though the layers stay separate.
FROM python:3.12-slim-trixie AS build

COPY optimizer/ /src/optimizer/
RUN python -m venv /opt/venv && \
/opt/venv/bin/pip install --no-cache-dir /src/optimizer

FROM python:3.12-slim-bookworm
FROM python:3.12-slim-trixie

ARG VERSION=dev
ARG BUILD_SHA=""
Expand Down
27 changes: 22 additions & 5 deletions Dockerfile.updater
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,29 @@ RUN cd go && \
-o /out/ftw-updater ./cmd/ftw-updater

# --- Runtime ---------------------------------------------------------------
# docker:27-cli bundles the `docker` binary + `docker compose` plugin +
# ca-certificates + tzdata, which is exactly what we need to execute
# compose pulls against the host daemon.
FROM docker:27-cli
# The same alpine:3.22 rootfs as Dockerfile, so core and the sidecar share one
# base layer: a host pulls it once instead of twice, and there is one suite to
# track for both.
#
# This was docker:27-cli. That image is alpine-based too, but it is a
# *different* alpine — its own base layer, its own upgrade cadence — so it
# shared nothing with core in practice.
#
# The docker CLI and the compose plugin are copied straight out of the official
# CLI image instead: same upstream artifact, version pinned explicitly, and no
# package repository added. Both are statically linked Go binaries (verified:
# musl's loader reports "Not a valid dynamic program", and both run correctly
# once copied), so nothing here depends on which libc the base ships.
FROM alpine:3.22

# docker:27-cli bundled these; on a bare base they have to be asked for.
# Compose pull hits GHCR over TLS.
RUN apk add --no-cache ca-certificates tzdata

COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker:27-cli /usr/local/libexec/docker/cli-plugins/docker-compose \
/usr/local/libexec/docker/cli-plugins/docker-compose

# Compose pull hits GHCR over TLS; docker:cli already bundles ca-certs.
COPY --from=builder /out/ftw-updater /usr/local/bin/ftw-updater
COPY LICENSE NOTICE /usr/share/doc/ftw/

Expand Down
2 changes: 1 addition & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,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.
7 changes: 7 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ import (
"sync"
"syscall"
"time"
// Embed the zoneinfo database in the binary. Production code uses
// time.Local, which silently falls back to UTC when the runtime has no
// zoneinfo tree — no error, no log line, just price windows and plan
// boundaries an hour or two out. The system database still wins when it is
// present; this only removes the silent-failure mode if a base image ever
// stops shipping tzdata.
_ "time/tzdata"

"github.com/srcfl/ftw/go/internal/api"
"github.com/srcfl/ftw/go/internal/arp"
Expand Down
17 changes: 15 additions & 2 deletions go/internal/drivers/lua.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,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 @@ -1150,8 +1152,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 Down Expand Up @@ -1180,7 +1190,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