From 3f0127778c6c084ed4ebed036bcb10a073b21469 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 17 Oct 2026 11:18:38 +0200 Subject: [PATCH] v0.326.0: FileBrowser's default password is checked at every FileBrowser start (R-977) The probe ran once per box; a re-created FileBrowser data volume came back on admin/admin and was never probed again (measured on scratch 9202 with 0.324.0). Now once per FileBrowser run (its StartedAt): the default found -> a generated password, verified and stored encrypted; any other password -> nothing changes. TestFileBrowserAdmin_R977_DefaultBackOnANewRunIsReplaced, red-proved. The comment "before a claim there is no tunnel" was false and is corrected; the tunnel is not changed. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 15 ++++- controller/README.md | 2 +- .../internal/stacks/filebrowser_password.go | 49 ++++++++++++-- .../stacks/filebrowser_password_test.go | 65 +++++++++++++++++++ controller/internal/stacks/manager.go | 4 ++ 5 files changed, 127 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e5b88..c301fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,17 @@ -## Unreleased (2026-10-17) — the decoy suite is a gate of the full run (R-1116); no code change +## v0.326.0 — FileBrowser's default password is checked at every FileBrowser start; the decoy suite is a gate (2026-10-17) + +**MinAgent: 0.158.1** — the floor's, carried. Nothing here calls the agent. No golden (`09` §3 decision 250). Task: +„Tester 1 back on the live catalog, decoy suites, memory limits, FileBrowser" (felhom.eu +`REPORT-tester1-limits-filebrowser-2026-10-17.md`). + +- **R-977 (FileBrowser part)** — `EnsureFileBrowserAdminPassword` probes the default login once per FileBrowser RUN + (the container's StartedAt), not once per box. Measured first on scratch 9202 with 0.324.0: FileBrowser and its data + volume removed, the box brought it back on its own — the default login answered 200 while the box kept its old + „generated" record. Now such a run is reset at its first tick (a generated password, verified both ways, stored + encrypted); a run on any other password — the household's own — changes nothing. `TestFileBrowserAdmin_R977_*`, + red-proved (the old once-per-box return keeps the default). The code comment „before a claim there is no tunnel" + was false (the claim is reached through the tunnel) and is corrected; the tunnel itself is NOT changed — R-1120. +- **R-1116** — the decoy suite is a gate of the full run (below, the helper's note). - **`gate-decoys`** (`controller/scripts/test_gate_decoys.py`) is now a row of `controller_gates.py`: the full run, blocking, NOT `--fast`. It was in no runner — the shape that let the catalog's R-1115 fail unseen for a night. Not diff --git a/controller/README.md b/controller/README.md index 96e2d73..c003b19 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1089,7 +1089,7 @@ cloudflared is only deployed when a tunnel token is configured. **Triggers**: a > **Mount prerequisite (Section-G):** the controller writes these stacks under `/opt/docker/stacks` *inside its container*, but `docker compose up` runs on the **guest** Docker daemon. The golden's controller-bootstrap (`felhom-agent` `build-golden.sh`) therefore bind-mounts that path **same-path** (`-v /opt/docker/stacks:/opt/docker/stacks`) so the daemon resolves every relative bind source — without it, all bind-mounted stacks (base infra and customer apps) silently break. -**FileBrowser admin password (v0.243.0, R-513).** FileBrowser used to accept `admin`/`admin` on every box. After `ensureFileBrowser`, every tick until decided, `Manager.EnsureFileBrowserAdminPassword` (`internal/stacks/filebrowser_password.go`) probes `http://filebrowser:80/api/auth/login` with admin/admin: **200** → a generated `password:16` is set through FileBrowser's API (never the `auth.adminPassword` config key — measured to overwrite a hand-set password on every start), verified both ways, and stored AES-encrypted in `settings.json` (`filebrowser_admin_state: generated`); **401** → `operator`, never touched. The FileBrowser app page shows user `admin` and the password behind the R-254 reveal (`POST /apps/filebrowser/initial-credentials/reveal`), or — `operator` — „Ezt a jelszót te állítottad be…" with „Új jelszó beállítása" (v0.320.0, the file browser's CLI with the server stopped; the `auth.adminPassword` key / `FILEBROWSER_ADMIN_PASSWORD` env re-apply on every start and are still never used). **v0.321.0 (R-1101):** in `generated`, the page first asks FileBrowser whether the stored password still logs in (`FileBrowserStoredPasswordRefused`; only a 401/403 counts); refused → „A Fájlkezelőben megváltoztattad a jelszót…" and the same press. The rendered config switches off FileBrowser's own update notice (`server.disableUpdateCheck`) and its „Install app" prompt (`frontend.disablePWAInstall`). +**FileBrowser admin password (v0.243.0, R-513).** FileBrowser used to accept `admin`/`admin` on every box. After `ensureFileBrowser`, every tick until decided, `Manager.EnsureFileBrowserAdminPassword` (`internal/stacks/filebrowser_password.go`) probes `http://filebrowser:80/api/auth/login` with admin/admin — **once per FileBrowser run since v0.326.0 (R-977)**: the probe repeats whenever FileBrowser's container StartedAt changes, so a re-created data volume that starts on the default again is reset at its first tick; a run on any other password changes nothing: **200** → a generated `password:16` is set through FileBrowser's API (never the `auth.adminPassword` config key — measured to overwrite a hand-set password on every start), verified both ways, and stored AES-encrypted in `settings.json` (`filebrowser_admin_state: generated`); **401** → `operator`, never touched. The FileBrowser app page shows user `admin` and the password behind the R-254 reveal (`POST /apps/filebrowser/initial-credentials/reveal`), or — `operator` — „Ezt a jelszót te állítottad be…" with „Új jelszó beállítása" (v0.320.0, the file browser's CLI with the server stopped; the `auth.adminPassword` key / `FILEBROWSER_ADMIN_PASSWORD` env re-apply on every start and are still never used). **v0.321.0 (R-1101):** in `generated`, the page first asks FileBrowser whether the stored password still logs in (`FileBrowserStoredPasswordRefused`; only a 401/403 counts); refused → „A Fájlkezelőben megváltoztattad a jelszót…" and the same press. The rendered config switches off FileBrowser's own update notice (`server.disableUpdateCheck`) and its „Install app" prompt (`frontend.disablePWAInstall`). **Controller routing + the wildcard cert anchor (`wireController` → `RenderControllerRoute`, v0.41.1 / v0.42.1).** filebrowser self-registers with traefik via Docker labels + `traefik-public` membership baked into its compose; the controller can't (it's started by the golden bootstrap *before* `traefik-public` exists, and the v2 `bootstrap.json` carries no domain — that comes from the hub pull). So `EnsureBaseStack` wires the controller **post-pull**: it `docker network connect traefik-public felhom-controller` and writes a traefik file-provider route `dynamic/controller.yml` (`Host(felhom.) → http://felhom-controller:8080`, write-if-changed). When DNS-01 ACME is configured, that route is **also the wildcard-cert anchor**: its router-level `tls.domains: *.` makes traefik **proactively obtain the wildcard `*.` + apex via Cloudflare DNS-01 at startup** (an entrypoint-level `http.tls.domains` does *not* trigger issuance in traefik v3 — only a router-level `tls.domains` does). Every other router then serves that one real wildcard cert by SNI — no per-app `certresolver` labels. This is what lets a LAN client reach the box directly at `*.` with the real cert (the `felhom-agent` split-horizon resolver depends on it). diff --git a/controller/internal/stacks/filebrowser_password.go b/controller/internal/stacks/filebrowser_password.go index e8ceb2e..81ad87f 100644 --- a/controller/internal/stacks/filebrowser_password.go +++ b/controller/internal/stacks/filebrowser_password.go @@ -30,11 +30,22 @@ import ( // THE DECISION (one mechanism for fresh and existing boxes): the API path, never the config key. The // key would silently undo the password the operator set by hand on the HP and the N100 (2026-09-15) // and any a household sets later. Cost: on a brand-new box admin/admin works from FileBrowser's first -// start until the next base-stack tick sets the password (seconds; before a claim there is no tunnel). +// start until the next base-stack tick sets the password (seconds). CORRECTED 2026-10-17 (R-977): this used to add +// „before a claim there is no tunnel" — false: EnsureBaseStack starts cloudflared from the token with no claim check, +// and the claim itself is reached through the tunnel (the claim mail links https://felhom.). The window is +// seconds, and it is now re-checked at EVERY FileBrowser start (below), not only once per box. // // The probe: login admin/admin → 200 ⇒ generate (password:16), PUT, verify new=200 AND admin=401, then // record "generated" with the encrypted value; 401 ⇒ record "operator" (somebody set it — leave it). // Anything else (container starting, network) ⇒ record nothing and try again next tick. +// +// R-977 (v0.326.0): the decision used to be made ONCE per box — any recorded state returned at once — so a FileBrowser +// whose data volume was re-created (a lost volume, a rebuild with settings restored) came back with admin/admin and was +// never probed again: measured on scratch 9202 2026-10-17 (state „generated", default login 200 after the volume was +// re-created by the box). Now the probe runs once per FileBrowser RUN (its container's StartedAt): admin/admin +// refused → nothing changes (a recorded state stays — the household's own password is never touched); admin/admin +// accepted → a generated password is set, exactly as on a new box, whatever was recorded before. Pinned by +// TestFileBrowserAdmin_R977_DefaultBackOnANewRunIsReplaced. const fileBrowserBaseURL = "http://filebrowser:80" @@ -65,14 +76,32 @@ func fbLogin(do fbHTTPDo, base, password string) (string, int, error) { return strings.Trim(strings.TrimSpace(string(b)), `"`), resp.StatusCode, nil } -// EnsureFileBrowserAdminPassword makes the one-time decision. Safe to call every tick: it returns at -// once when a decision is recorded. Returns an error only for logging. +// fbStartedAt identifies FileBrowser's current run (its container's .State.StartedAt); "" when unknown. Tests set +// fbStarted; with a test FileBrowser (fbHTTP / fbBaseURL) and no fbStarted it is "" — a test never reaches docker. +func (m *Manager) fbStartedAt() string { + if m.fbStarted != nil { + return m.fbStarted() + } + if m.fbHTTP != nil || m.fbBaseURL != "" { + return "" + } + out, err := dockerexec.Command("docker", "inspect", "--format", "{{.State.StartedAt}}", fileBrowserContainer).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// EnsureFileBrowserAdminPassword probes FileBrowser's default login once per FileBrowser run (R-977) and replaces it +// when it works. Safe to call every tick: a run already probed returns at once. Returns an error only for logging. func (m *Manager) EnsureFileBrowserAdminPassword() error { if m.settings == nil || len(m.encKey) == 0 { return nil } - if state, _, _ := m.settings.GetFileBrowserAdmin(); state != "" { - return nil + state, _, _ := m.settings.GetFileBrowserAdmin() + run := m.fbStartedAt() + if state != "" && (run == "" || run == m.fbProbedRun) { + return nil // decided, and this FileBrowser run was probed already (or its run cannot be told) } do := m.fbDo() base := fileBrowserBaseURL @@ -87,8 +116,15 @@ func (m *Manager) EnsureFileBrowserAdminPassword() error { } switch code { case http.StatusOK: - // default login still works — set a generated password below + // default login works — set a generated password below + if state != "" { + m.logger.Printf("[WARN] [infra] filebrowser: admin/admin logs in again on a new FileBrowser run (recorded state %q) — its data was re-created; replacing it (R-977)", state) + } case http.StatusUnauthorized, http.StatusForbidden: + m.fbProbedRun = run + if state != "" { + return nil // this run is not on the default: the recorded decision stands + } m.logger.Printf("[INFO] [infra] filebrowser: admin/admin is refused (HTTP %d) — the password was set by someone; leaving it and recording \"operator\"", code) return m.settings.SetFileBrowserAdmin(settings.FileBrowserAdminOperator, "", now) default: @@ -138,6 +174,7 @@ func (m *Manager) EnsureFileBrowserAdminPassword() error { m.logger.Printf("[ERROR] [infra] filebrowser: password CHANGED but settings save FAILED: %v — the generated password is lost; reset it with the filebrowser CLI", err) return err } + m.fbProbedRun = run m.logger.Printf("[INFO] [infra] filebrowser: admin/admin replaced by a generated password (value never logged); verified new=200 admin=401") return nil } diff --git a/controller/internal/stacks/filebrowser_password_test.go b/controller/internal/stacks/filebrowser_password_test.go index 496efa9..a1984ac 100644 --- a/controller/internal/stacks/filebrowser_password_test.go +++ b/controller/internal/stacks/filebrowser_password_test.go @@ -145,3 +145,68 @@ func TestFileBrowserAdmin_UnreachableRecordsNothing(t *testing.T) { t.Fatalf("an unreachable FileBrowser recorded a decision: %q", state) } } + +// R-977: the decision is no longer once per box. A FileBrowser whose data was re-created (a new run whose database +// starts on admin/admin again) is reset at its first tick; the same run is never probed twice; a new run on another +// password (the household's own) leaves the recorded decision alone. +// +// RED-PROOF (2026-10-17): with the old early return (`if state != "" { return nil }`) the second run keeps +// admin/admin and this fails at „the default login came back on a new run and was left". +func TestFileBrowserAdmin_R977_DefaultBackOnANewRunIsReplaced(t *testing.T) { + fb := &fakeFB{password: "admin"} + srv := httptest.NewServer(fb.handler()) + defer srv.Close() + m, st, key := fbManager(t, srv.URL) + run := "2026-10-17T06:54:56Z" + m.fbStarted = func() string { return run } + + if err := m.EnsureFileBrowserAdminPassword(); err != nil { // first run: a new box + t.Fatal(err) + } + _, enc1, _ := st.GetFileBrowserAdmin() + + // The data volume is re-created: FileBrowser starts again on its default. + fb.mu.Lock() + fb.password = "admin" + fb.mu.Unlock() + if err := m.EnsureFileBrowserAdminPassword(); err != nil { // the SAME run: not probed again + t.Fatal(err) + } + if fb.puts != 1 { + t.Fatalf("the same FileBrowser run was probed and changed again (puts=%d)", fb.puts) + } + run = "2026-10-17T09:12:00Z" // the box brought FileBrowser back: a new run + if err := m.EnsureFileBrowserAdminPassword(); err != nil { + t.Fatal(err) + } + if _, c, _ := fbLogin(srv.Client().Do, srv.URL, "admin"); c == http.StatusOK { + t.Fatal("the default login came back on a new run and was left") + } + state, enc2, _ := st.GetFileBrowserAdmin() + if state != settings.FileBrowserAdminGenerated || enc2 == "" || enc2 == enc1 { + t.Fatalf("a new generated password must be recorded: state=%q changed=%v", state, enc2 != enc1) + } + pw, err := crypto.Decrypt(key, enc2) + if err != nil { + t.Fatal(err) + } + if _, c, _ := fbLogin(srv.Client().Do, srv.URL, pw); c != http.StatusOK { + t.Fatalf("the recorded password does not log in (HTTP %d)", c) + } + + // The household changes it in FileBrowser; a later run on THEIR password changes nothing. + fb.mu.Lock() + fb.password = "the-households-own" + fb.mu.Unlock() + run = "2026-10-18T02:30:00Z" + puts := fb.puts + if err := m.EnsureFileBrowserAdminPassword(); err != nil { + t.Fatal(err) + } + if fb.puts != puts || fb.password != "the-households-own" { + t.Fatalf("a run on the household's own password was changed (puts %d -> %d)", puts, fb.puts) + } + if s, e, _ := st.GetFileBrowserAdmin(); s != settings.FileBrowserAdminGenerated || e != enc2 { + t.Fatalf("the recorded decision moved on a run that is not on the default: state=%q", s) + } +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index fe2fadc..5424a0c 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -279,6 +279,10 @@ type Manager struct { // R-513: FileBrowser admin-password seams (filebrowser_password.go); nil/"" in production. fbHTTP fbHTTPDo fbBaseURL string + // fbStarted replaces the docker read of FileBrowser's StartedAt in tests; fbProbedRun is the run whose default login + // was last probed (R-977 — once per FileBrowser run). Touched only under infraMu (the base-stack tick). + fbStarted func() string + fbProbedRun string // fbSetPassword replaces the stop / CLI / start sequence of ResetFileBrowserAdminPassword in tests; nil in production. fbSetPassword func(ctx context.Context, password string) error // execFn replaces execCommand's process boundary in tests; nil in production.