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

Move the whole container stack to one base: Debian 13 "trixie" slim.

Core was `alpine:3.22`, the updater sidecar was `docker:27-cli` (also alpine),
and the optimizer was `python:3.12-slim-bookworm` — two libcs, three unrelated
base images and three security streams in one deployment. Core, updater and
optimizer now all sit on `debian:trixie-slim`, verified to share a single base
layer, so a host pulls that rootfs once and there is one suite to track. It also
matches the Raspberry Pi OS release the SD image is built from.

What the move buys: glibc, so the image can run ordinary prebuilt vendor
binaries, which musl cannot; a full userland, which makes `docker exec`
debugging on a live site practical; and `libnss-mdns` wired into
`/etc/nsswitch.conf`, so `.local` names resolve for glibc tools inside the
container — `getent hosts zap.local`, `curl`, `wget` — once an avahi socket is
mounted. Alpine has no NSS plugin mechanism at all, so none of that was
available before.

That covers tools in the image, not the FTW process itself: the binary is built
`CGO_ENABLED=0` and so never consults NSS. It stays fully static and still
cross-compiles on the build platform.

`wget` is now installed explicitly and asserted by the container boundary test.
It is contractual rather than incidental: `ftw-updater` `docker exec`s it inside
the core image to decide whether an update commits, and updaters already
deployed in the field will keep doing so. The zoneinfo database is also embedded
in the binary as a fallback, so a base image without `tzdata` can never silently
push `time.Local` to UTC and mis-time price and plan windows.

Size: the core image grows from about 53 MB to about 133 MB uncompressed. The
updater is unchanged at about 203 MB — `docker:27-cli` was never a small image —
and now shares its base with the other two, so the per-host download is well
below the nominal sum.

Data ownership is unchanged: the process still runs as the bare numeric uid 100
/ gid 101, and gid 101 is still what grants access to the optimizer's socket.

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

The base is pinned to the `trixie` codename rather than a `stable` alias so a
major-version jump can never arrive silently on a rebuild. A new scheduled
`debian base currency` workflow watches for a newer Debian stable and opens a
tracking issue when one appears.
37 changes: 37 additions & 0 deletions .changeset/resolve-local-device-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"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.
53 changes: 46 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ RUN cd go && go mod download

COPY go/ ./go/

# Cross-compile by mapping TARGETARCH → GOARCH. CGO is off so the
# binary is fully static and runs on alpine without glibc.
# Cross-compile by mapping TARGETARCH → GOARCH. CGO stays off: the binary is
# fully static, so it is the runtime's *userland* we are choosing below, not a
# libc the binary depends on. Keeping CGO off is what lets the toolchain run
# natively on the build platform instead of under emulation.
ARG TARGETOS=linux
ARG TARGETARCH
ARG VERSION=dev
Expand All @@ -36,11 +38,42 @@ RUN cd go && \
go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" \
-o /out/ftw-backup ./cmd/ftw-backup
# --- Runtime ---------------------------------------------------------------
FROM alpine:3.22
# Debian trixie-slim — current Debian stable (13), and the same suite as
# Dockerfile.updater and Dockerfile.optimizer's python:3.12-slim-trixie. One
# rootfs blob is pulled once and shared by all three images, so the extra bytes
# over alpine are paid a single time per host rather than per image, and there
# is one libc and one security stream to track. It also matches the Raspberry Pi
# OS release the SD image is built from (deploy/pi-gen/config: RELEASE=trixie).
#
# Pinned to the codename, not `stable-slim`: a suite alias would silently jump
# major versions on some future rebuild. The `debian base currency` workflow
# watches for a new stable and files an issue, so the bump stays deliberate.
#
# glibc also means the image can run ordinary prebuilt vendor binaries, which
# musl cannot, and ships a full userland for on-site debugging.
FROM debian:trixie-slim

# HTTPS integrations and timezone-aware price/plan windows need these at
# runtime. BusyBox wget provides the health check without adding Python/curl.
RUN apk add --no-cache ca-certificates tzdata
# ca-certificates — HTTPS integrations.
# tzdata — timezone-aware price/plan windows. Without a zoneinfo tree
# time.Local silently degrades to UTC and mis-times plan
# boundaries with no error, so this is load-bearing.
# wget — the HEALTHCHECK below AND ftw-updater's readiness probe,
# which `docker exec`s wget in THIS image to decide whether
# an update commits. Debian slim ships neither wget nor curl,
# so it must be installed explicitly; dropping it would make
# every self-update fail its health gate and roll back.
# libnss-mdns — resolves ".local" for glibc programs in the image (getent,
# curl, wget), so in-container debugging agrees with the
# host. apt wires mdns4_minimal into /etc/nsswitch.conf on
# install. At run time it forwards to avahi-daemon over
# /run/avahi-daemon/socket, which must be bind-mounted; see
# docs/operations.md. It does nothing for the FTW binary
# itself, which is CGO_ENABLED=0 and therefore never consults
# NSS — see the note on the builder stage above.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates tzdata wget libnss-mdns && \
rm -rf /var/lib/apt/lists/*

# Image layout:
# /app/ftw binary (immutable, replaced on upgrade)
Expand Down Expand Up @@ -79,7 +112,13 @@ EXPOSE 8080
# with each release.
#
# UID note: the process runs as uid 100 / gid 101 for compatibility with
# existing bind mounts. Named docker volumes inherit ownership from the image
# existing bind mounts. These are deliberately NUMERIC — no account is created
# and none is needed, which is why ENV HOME above is load-bearing. Verified on
# this base: uid 100 and gid 101 have no passwd/group entry, so ownership simply
# renders numerically. Do not renumber: gid 101 is what grants access to the
# optimizer's 0660 socket, and existing installs (and every flashed SD card)
# already own their data dir as 100:101.
# Named docker volumes inherit ownership from the image
# automatically and just work. For HOST BIND MOUNTS, the host
# directory must be owned by uid 100 (or world-writable) before the
# container starts:
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.optimizer
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Independently releasable FTW mathematical optimizer.
FROM python:3.12-slim-bookworm AS build
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
22 changes: 17 additions & 5 deletions Dockerfile.updater
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,24 @@ 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
# Same debian:trixie-slim rootfs as Dockerfile and Dockerfile.optimizer, so
# the whole stack pulls one base layer instead of three. docker:27-cli is
# alpine-based and was the last thing keeping a second libc in the deployment.
#
# The docker CLI and the compose plugin are copied straight out of the official
# CLI image rather than installed from Docker's apt repository: it is the same
# upstream artifact, it pins the version explicitly, and it keeps apt out of the
# emulated arm64 layer.
FROM debian:trixie-slim

RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates tzdata && \
rm -rf /var/lib/apt/lists/*

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
5 changes: 3 additions & 2 deletions deploy/pi-gen/stage-ftw/01-ftw-setup/00-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ EOF
# records /opt/ftw automatically. COMPOSE_PROJECT_NAME
# stays the literal `ftw`.
#
# data/ is owned 100:101 because the in-container ftw user (alpine
# `adduser -S`) needs to own it before SQLite can create state.db.
# data/ is owned 100:101 because the container process runs as that bare
# numeric uid/gid (no account exists in the image) and needs to own the
# directory before SQLite can create state.db.
# Same UID/GID mapping as scripts/install.sh.
install -d -m 0755 "${ROOTFS_DIR}/opt/ftw"
install -d -m 0755 -o 100 -g 101 "${ROOTFS_DIR}/opt/ftw/data"
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
52 changes: 52 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,58 @@ 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.

### A device `.local` name does not resolve

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:

```
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.

#### 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:/run/avahi-daemon:ro
```

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. What it does add is `libnss-mdns` working for ordinary tools in the
image — `getent hosts zap.local`, `curl`, `wget` — which is the quickest way to
check a name from inside the container.

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

Read the validation error, compare with
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.
8 changes: 8 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ import (
"syscall"
"time"

// Embed the zoneinfo database as a fallback. The image's system tree still
// wins whenever it is present; this exists so time.Local can never silently
// degrade to UTC and mis-time price and plan windows if a base image ships
// without tzdata. The failure it guards against is invisible — production
// code reads time.Local, and the tzdata tests skip rather than fail — so the
// ~450 KB is worth it.
_ "time/tzdata"

"github.com/srcfl/ftw/go/internal/api"
"github.com/srcfl/ftw/go/internal/arp"
"github.com/srcfl/ftw/go/internal/battery"
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
Loading