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
@@ -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
}