From 872230853296c55534bc8b6f3e933c9cb93d05c9 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 18 Aug 2026 10:11:28 +0200 Subject: [PATCH 1/2] fix(api): turn the house password on only from the box A LAN peer could set the password and lock Settings. POST /api/config can no longer flip the flag either. --- .changeset/lock-lan-auth-enable.md | 5 +++++ docs/operations.md | 9 ++++++--- go/internal/api/api.go | 3 +++ go/internal/api/api_lan_auth_test.go | 26 +++++++++++++++++++++++--- go/internal/api/lan_auth.go | 13 +++++++++++++ 5 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 .changeset/lock-lan-auth-enable.md diff --git a/.changeset/lock-lan-auth-enable.md b/.changeset/lock-lan-auth-enable.md new file mode 100644 index 000000000..3341334e8 --- /dev/null +++ b/.changeset/lock-lan-auth-enable.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The house password can only be turned on from the box itself. Saving Settings can no longer flip that lock through the whole config document. diff --git a/docs/operations.md b/docs/operations.md index 28883349e..19aebcd96 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -139,9 +139,12 @@ mutations remain locked. tunnel credential for future remote access. That expansion point is described in [architecture.md](architecture.md#future-remote-access-boundary). -`api.lan_auth` is off by default. Turn it on from Settings → System (LAN -password). When on, protected LAN routes need the house password. `curl` -sends `Authorization: Bearer `. The browser login form +`api.lan_auth` is off by default. Turn it on from the box itself +(`http://127.0.0.1:8080` Settings → System, or `curl` to `127.0.0.1`). +A first enable from another LAN address is refused, so a visitor cannot +set the password. `POST /api/config` cannot flip the flag. When on, +protected LAN routes need the house password. `curl` sends +`Authorization: Bearer `. The browser login form sets a session cookie (`ftw_lan`, 12 hours). Loopback (`127.0.0.1` / `::1`) never asks. Live status stays readable without the password; a viewer caller is minted for those reads. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 27c2ee4f8..80cd7f1d0 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1504,6 +1504,9 @@ func (s *Server) handlePostConfig(w http.ResponseWriter, r *http.Request) { s.deps.CfgMu.RLock() oldCfg := *s.deps.Cfg s.deps.CfgMu.RUnlock() + // The house-password flag only moves through POST /api/auth/password. + // A whole-document save must not turn the lock on (or off) by itself. + newCfg.API.LANAuth = oldCfg.API.LANAuth restartReasons := config.RestartRequiredFor(&oldCfg, &newCfg) // Persist atomically (Password has yaml:"-" so it won't appear in YAML) diff --git a/go/internal/api/api_lan_auth_test.go b/go/internal/api/api_lan_auth_test.go index 40d8d4711..93c8e558e 100644 --- a/go/internal/api/api_lan_auth_test.go +++ b/go/internal/api/api_lan_auth_test.go @@ -413,13 +413,22 @@ func TestAuthPasswordEnableAndStatus(t *testing.T) { } body := `{"password":"` + testHousePassword + `","enabled":true}` - post := httptest.NewRequest(http.MethodPost, "http://ftw.local:8080/api/auth/password", strings.NewReader(body)) - post.RemoteAddr = "192.168.1.10:43210" + lanEnable := httptest.NewRequest(http.MethodPost, "http://ftw.local:8080/api/auth/password", strings.NewReader(body)) + lanEnable.RemoteAddr = "192.168.1.10:43210" + lanEnable.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, lanEnable) + if rr.Code != http.StatusForbidden { + t.Fatalf("first LAN enable status = %d, want 403 (body=%s)", rr.Code, rr.Body.String()) + } + + post := httptest.NewRequest(http.MethodPost, "http://127.0.0.1:8080/api/auth/password", strings.NewReader(body)) + post.RemoteAddr = "127.0.0.1:43210" post.Header.Set("Content-Type", "application/json") rr = httptest.NewRecorder() srv.Handler().ServeHTTP(rr, post) if rr.Code != http.StatusOK { - t.Fatalf("first LAN enable status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + t.Fatalf("loopback enable status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) } req = httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/api/auth/status", nil) @@ -451,6 +460,17 @@ func TestAuthPasswordEnableAndStatus(t *testing.T) { } } +func TestPostConfigCannotEnableLANAuth(t *testing.T) { + srv, _, cfg := postConfigServer(t, nil) + body := strings.Replace(firstSiteMeterConfig, `"api": {"port": 8080}`, `"api": {"port": 8080, "lan_auth": true}`, 1) + if code := postConfig(t, srv, body); code != 200 { + t.Fatalf("POST /api/config = %d, want 200", code) + } + if cfg.API.LANAuth { + t.Fatal("config POST turned lan_auth on; only /api/auth/password may do that") + } +} + func TestGetConfigDoesNotContainLANPasswordHash(t *testing.T) { resetLANGuesses(t) srv := newLANAuthServer(t) diff --git a/go/internal/api/lan_auth.go b/go/internal/api/lan_auth.go index 411fd70e1..437febdbf 100644 --- a/go/internal/api/lan_auth.go +++ b/go/internal/api/lan_auth.go @@ -331,6 +331,19 @@ func (s *Server) handleAuthPassword(w http.ResponseWriter, r *http.Request) { enabled := *req.Enabled if enabled { + s.deps.CfgMu.RLock() + currentlyOn := s.deps.Cfg.API.LANAuth + s.deps.CfgMu.RUnlock() + // While the lock is off every LAN peer is an owner. First enable + // from the LAN is how a visitor sets their own password and + // locks Settings. The box itself (loopback) is the only door + // that may turn the lock on. + if !currentlyOn && !isLoopbackClient(r.RemoteAddr) { + writeJSON(w, http.StatusForbidden, map[string]string{ + "error": "enable the house password from the box (http://127.0.0.1:8080)", + }) + return + } already := lanPasswordConfigured(s.deps.State) switch { case req.Password == "": From b4aae5ad0bd9c576b23ad8ed708473df6adf49e4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 21 Aug 2026 10:18:04 +0200 Subject: [PATCH 2/2] fix(api): name the Docker Desktop house-password path First enable stays loopback-only. A published Docker port arrives as the bridge gateway, so the 403 and the Mac compose file now say to exec into the container and curl 127.0.0.1 from there. --- .changeset/lock-lan-auth-enable.md | 2 +- docker-compose.macos.yml | 4 ++++ docs/operations.md | 22 ++++++++++++++++++---- go/internal/api/api_lan_auth_test.go | 14 ++++++++++++++ go/internal/api/lan_auth.go | 2 +- 5 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.changeset/lock-lan-auth-enable.md b/.changeset/lock-lan-auth-enable.md index 3341334e8..a48b140e3 100644 --- a/.changeset/lock-lan-auth-enable.md +++ b/.changeset/lock-lan-auth-enable.md @@ -2,4 +2,4 @@ "ftw": patch --- -The house password can only be turned on from the box itself. Saving Settings can no longer flip that lock through the whole config document. +The house password can only be turned on from loopback inside the process. Saving Settings can no longer flip that lock through the whole config document. On Docker Desktop that means `compose exec`, not a host curl to localhost. diff --git a/docker-compose.macos.yml b/docker-compose.macos.yml index 88bce2710..b40136e54 100644 --- a/docker-compose.macos.yml +++ b/docker-compose.macos.yml @@ -22,6 +22,10 @@ # In config.yaml set the MQTT host to `mosquitto` (port 1883), NOT # `localhost` / `127.0.0.1`. There is no host networking to make # localhost mean "the broker". +# - First enable of the house password (api.lan_auth) is loopback-only +# inside the container. A Mac curl to http://127.0.0.1:8080 arrives as +# the bridge gateway. Use `docker compose -f docker-compose.macos.yml +# exec ftw` and then curl http://127.0.0.1:8080 from there. # - mDNS (`zap.local`) and UDP broadcast device discovery do NOT cross # the Docker Desktop VM boundary. Configure every driver with an # EXPLICIT IP address. Outbound unicast TCP (Modbus TCP to a known diff --git a/docs/operations.md b/docs/operations.md index 19aebcd96..8acea2698 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -139,10 +139,24 @@ mutations remain locked. tunnel credential for future remote access. That expansion point is described in [architecture.md](architecture.md#future-remote-access-boundary). -`api.lan_auth` is off by default. Turn it on from the box itself -(`http://127.0.0.1:8080` Settings → System, or `curl` to `127.0.0.1`). -A first enable from another LAN address is refused, so a visitor cannot -set the password. `POST /api/config` cannot flip the flag. When on, +`api.lan_auth` is off by default. Turn it on from loopback inside the +process. On a Pi with host networking that is +`http://127.0.0.1:8080` Settings → System, or `curl` to `127.0.0.1`. +On Docker Desktop (`docker-compose.macos.yml`) a host curl to localhost +arrives as the bridge gateway, not loopback, so Settings and host curl +get 403. Enable from inside the container: + +```bash +docker compose -f docker-compose.macos.yml exec ftw \ + wget -qO- --header='Content-Type: application/json' \ + --post-data='{"enabled":true,"password":"ETT-LANGT-LOSEN"}' \ + http://127.0.0.1:8080/api/auth/password +``` + +Do not treat that gateway as loopback: Desktop SNAT uses the same peer +for every published-port client, including LAN visitors. A first enable +from another LAN address is refused, so a visitor cannot set the +password. `POST /api/config` cannot flip the flag. When on, protected LAN routes need the house password. `curl` sends `Authorization: Bearer `. The browser login form sets a session cookie (`ftw_lan`, 12 hours). Loopback (`127.0.0.1` / `::1`) diff --git a/go/internal/api/api_lan_auth_test.go b/go/internal/api/api_lan_auth_test.go index 93c8e558e..a859b27ec 100644 --- a/go/internal/api/api_lan_auth_test.go +++ b/go/internal/api/api_lan_auth_test.go @@ -422,6 +422,20 @@ func TestAuthPasswordEnableAndStatus(t *testing.T) { t.Fatalf("first LAN enable status = %d, want 403 (body=%s)", rr.Code, rr.Body.String()) } + // Docker Desktop published ports SNAT to the bridge gateway, not + // loopback. That peer is RFC1918, same as a LAN visitor. + bridge := httptest.NewRequest(http.MethodPost, "http://127.0.0.1:8080/api/auth/password", strings.NewReader(body)) + bridge.RemoteAddr = "172.17.0.1:43210" + bridge.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, bridge) + if rr.Code != http.StatusForbidden { + t.Fatalf("docker-bridge enable status = %d, want 403 (body=%s)", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "docker compose") { + t.Fatalf("403 body should name the Docker Desktop exec path, got %s", rr.Body.String()) + } + post := httptest.NewRequest(http.MethodPost, "http://127.0.0.1:8080/api/auth/password", strings.NewReader(body)) post.RemoteAddr = "127.0.0.1:43210" post.Header.Set("Content-Type", "application/json") diff --git a/go/internal/api/lan_auth.go b/go/internal/api/lan_auth.go index 437febdbf..11be4dca1 100644 --- a/go/internal/api/lan_auth.go +++ b/go/internal/api/lan_auth.go @@ -340,7 +340,7 @@ func (s *Server) handleAuthPassword(w http.ResponseWriter, r *http.Request) { // that may turn the lock on. if !currentlyOn && !isLoopbackClient(r.RemoteAddr) { writeJSON(w, http.StatusForbidden, map[string]string{ - "error": "enable the house password from the box (http://127.0.0.1:8080)", + "error": "enable the house password from loopback inside the process. A published Docker port is not loopback — on Docker Desktop use docker compose -f docker-compose.macos.yml exec ftw, then curl http://127.0.0.1:8080", }) return }