Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/lock-lan-auth-enable.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docker-compose.macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 20 additions & 3 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <house-password>`. 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 <house-password>`. 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.
Expand Down
3 changes: 3 additions & 0 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +1507 to +1509

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize flag preservation with password updates

When POST /api/config overlaps POST /api/auth/password, this copies LANAuth under a short read lock and then releases it before saving and applying the whole config. If the password handler changes the flag after this read but before the config request finishes, the config request can persist/apply the stale value, undoing a successful enable or disable even though it is now supposed to be unable to flip this flag. Preserve the value in the same serialized transaction as the final save/apply, or otherwise coordinate these two handlers.

Useful? React with 👍 / 👎.

restartReasons := config.RestartRequiredFor(&oldCfg, &newCfg)

// Persist atomically (Password has yaml:"-" so it won't appear in YAML)
Expand Down
40 changes: 37 additions & 3 deletions go/internal/api/api_lan_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions go/internal/api/lan_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +341 to +345

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow first enable through the macOS host port

In the supported docker-compose.macos.yml deployment, FTW runs behind Docker Desktop bridge networking with 8080:8080, so a request to http://127.0.0.1:8080 on the Mac arrives at the container with the bridge gateway—not a loopback—address in r.RemoteAddr. This condition therefore returns 403 even when the operator follows the newly documented on-box procedure, making the house password impossible to enable from the browser or host-side curl on every macOS deployment; the check needs a trusted way to recognize traffic originating from the host or an actually usable in-container setup path.

Useful? React with 👍 / 👎.

}
already := lanPasswordConfigured(s.deps.State)
switch {
case req.Password == "":
Expand Down