v0.326.0: FileBrowser's default password is checked at every FileBrowser start (R-977)
gates / gates (push) Successful in 49s

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
This commit is contained in:
2026-10-17 11:18:38 +02:00
parent 55868cb3e0
commit 3f0127778c
5 changed files with 127 additions and 8 deletions
+14 -1
View File
@@ -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
+1 -1
View File
@@ -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.<domain>) → 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: *.<domain>` makes traefik **proactively obtain the wildcard `*.<domain>` + 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 `*.<domain>` with the real cert (the `felhom-agent` split-horizon resolver depends on it).
@@ -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.<domain>). 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
}
@@ -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)
}
}
+4
View File
@@ -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.