diff --git a/.changeset/lock-lan-auth-enable.md b/.changeset/lock-lan-auth-enable.md new file mode 100644 index 00000000..a48b140e --- /dev/null +++ b/.changeset/lock-lan-auth-enable.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +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 88bce271..b40136e5 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 28883349..8acea269 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -139,9 +139,26 @@ 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 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`) 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 27c2ee4f..80cd7f1d 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 40d8d471..a859b27e 100644 --- a/go/internal/api/api_lan_auth_test.go +++ b/go/internal/api/api_lan_auth_test.go @@ -413,13 +413,36 @@ 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()) + } + + // 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") 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 +474,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 411fd70e..11be4dca 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 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 + } already := lanPasswordConfigured(s.deps.State) switch { case req.Password == "":