v0.326.0: FileBrowser's default password is checked at every FileBrowser start (R-977)
gates / gates (push) Successful in 49s
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user