Files
felhom-controller/controller/internal/stacks/filebrowser_password.go
T
admin f30a80841e
gates / gates (push) Successful in 50s
v0.320.0: FileBrowser is part of the box again; every app says where its login is; a „Fájlok" button
FileBrowser (and traefik, cloudflared, samba) has no app.yaml, so Deployed is false. Since v0.315.0 the
app page read Deployed alone and showed FileBrowser "not installed", with Install and without its login.
A protected infra stack is now installed by definition on the app page (Deployed || Protected, as every
other surface already counted): settings/guide/log tabs, Restart only, no install page, no data tab.
The Install press would have deployed the infra stack as an app: DeployStack and the deploy API now
refuse a protected stack.

In the operator state (the box does not know FileBrowser's password - both demo boxes) the login panel
offers "Új jelszó beállítása": the file browser's own CLI with the server stopped (spiked on demo-hp:
new 200, old 401, users/shares/settings unchanged), verified, stored encrypted, state generated.

Every installed app's settings tab has the login panel with exactly one answer: the box's fields, the
login the app wrote (InitialCreds), or a sentence chosen by the catalog's new app_info.login_kind
(first_visit default, email, none; none behind the family gate says the family sign-in is the login).

"Fájlok ↗" opens the library folder, else the first declared folder, else a media app's first present
drive's media folder. The data tab lists each drive's media folders; a folder whose drive is not bound
into FileBrowser is text, never a link.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 15:19:14 +02:00

306 lines
13 KiB
Go

package stacks
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-513 — FileBrowser's admin password.
//
// MEASURED FIRST (2026-09-15, gtstef/filebrowser:1.3.3-stable, evidence-p1fixes-2026-09-15/B1):
// - with no `auth.adminPassword` key and no FILEBROWSER_ADMIN_PASSWORD env — the controller's
// render — a new database accepts admin/admin;
// - the config key or the env var DOES set the password, on a fresh AND on an existing database —
// but it RE-APPLIES ON EVERY START: a password changed by hand afterwards is overwritten at the
// next restart;
// - the API changes it once and it sticks: `PUT /api/users?id=<id>` with
// `{"which":["password"],"data":{"id":<id>,"username":"admin","password":<new>}}`, the session
// token, and `X-Password: <current>` → 204.
//
// 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).
//
// 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.
const fileBrowserBaseURL = "http://filebrowser:80"
// fbHTTPDo is the network seam (tests inject a fake FileBrowser).
type fbHTTPDo func(req *http.Request) (*http.Response, error)
func (m *Manager) fbDo() fbHTTPDo {
if m.fbHTTP != nil {
return m.fbHTTP
}
c := &http.Client{Timeout: 10 * time.Second}
return c.Do
}
// fbLogin returns (token, status, err). status 200 → token set; 401 → wrong password.
func fbLogin(do fbHTTPDo, base, password string) (string, int, error) {
req, _ := http.NewRequest(http.MethodPost, base+"/api/auth/login?username=admin", nil)
req.Header.Set("X-Password", password)
resp, err := do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
return "", resp.StatusCode, nil
}
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.
func (m *Manager) EnsureFileBrowserAdminPassword() error {
if m.settings == nil || len(m.encKey) == 0 {
return nil
}
if state, _, _ := m.settings.GetFileBrowserAdmin(); state != "" {
return nil
}
do := m.fbDo()
base := fileBrowserBaseURL
if m.fbBaseURL != "" {
base = m.fbBaseURL
}
now := time.Now().UTC().Format(time.RFC3339)
token, code, err := fbLogin(do, base, "admin")
if err != nil {
return fmt.Errorf("filebrowser admin probe: %w (will retry)", err)
}
switch code {
case http.StatusOK:
// default login still works — set a generated password below
case http.StatusUnauthorized, http.StatusForbidden:
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:
return fmt.Errorf("filebrowser admin probe: HTTP %d (will retry)", code)
}
id, err := fbSelfID(do, base, token)
if err != nil {
return fmt.Errorf("filebrowser: read admin user id: %w (will retry)", err)
}
pw, err := generateValue("password:16")
if err != nil {
return fmt.Errorf("filebrowser: generate password: %w", err)
}
body, _ := json.Marshal(map[string]any{
"which": []string{"password"},
"data": map[string]any{"id": id, "username": "admin", "password": pw},
})
req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/users?id=%d", base, id), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Auth", token)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Password", "admin")
resp, err := do(req)
if err != nil {
return fmt.Errorf("filebrowser: set password: %w (will retry)", err)
}
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("filebrowser: set password: HTTP %d (will retry)", resp.StatusCode)
}
// Verify the consequence, both directions, before recording anything.
if _, c, err := fbLogin(do, base, pw); err != nil || c != http.StatusOK {
return fmt.Errorf("filebrowser: new password does not log in (HTTP %d, err %v) — NOT recorded, will retry", c, err)
}
if _, c, err := fbLogin(do, base, "admin"); err != nil || c == http.StatusOK {
return fmt.Errorf("filebrowser: admin/admin still logs in after the change (HTTP %d, err %v) — NOT recorded", c, err)
}
enc, err := crypto.Encrypt(m.encKey, pw)
if err != nil {
return fmt.Errorf("filebrowser: encrypt password: %w", err)
}
if err := m.settings.SetFileBrowserAdmin(settings.FileBrowserAdminGenerated, enc, now); err != nil {
// The password IS changed and we could not record it: say so loudly — the household cannot
// be shown a password that is not stored. Recoverable by the operator (FileBrowser CLI).
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.logger.Printf("[INFO] [infra] filebrowser: admin/admin replaced by a generated password (value never logged); verified new=200 admin=401")
return nil
}
// fbSelfID reads the logged-in user's id (GET /api/users?id=self).
func fbSelfID(do fbHTTPDo, base, token string) (int, error) {
req, _ := http.NewRequest(http.MethodGet, base+"/api/users?id=self", nil)
req.Header.Set("X-Auth", token)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var u struct {
ID int `json:"id"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&u); err != nil {
return 0, err
}
if u.ID <= 0 {
return 0, fmt.Errorf("no user id in response")
}
return u.ID, nil
}
// ── v0.320.0: a new password when the box does not know FileBrowser's (`operator`) ───────────────────────────────────
//
// R-513 left a password it found set by hand alone and recorded `operator`. Nobody remembers that password on either
// demo box, so the household was locked out of the file manager and every folder link led to a login it could not
// pass. The household's press now replaces it.
//
// THE METHOD, spiked first (felhom.eu audits/logins-folders-2026-10-16/P1b, gtstef/filebrowser:1.5.6-stable):
// - FileBrowser's own CLI, `filebrowser set -u admin,<pw> -c <config>` (backend/cmd/cli.go:206-269 at the tag),
// sets an existing user's password with NO old password. Measured: new=200, old=401, admin/admin=401, and the
// other users, the shares and the settings unchanged.
// - The CLI opens the database with no lock timeout (backend/database/storage/storage.go:26), so the server is
// STOPPED while it runs, in a one-shot container with the running container's volumes; then started again.
// The file browser is away for ~7 s.
// - Not FILEBROWSER_ADMIN_PASSWORD: it re-applies on every start, so it would need a compose change and two
// recreates, and the value would sit in the container's environment.
//
// The password is 16 letters and digits (generateValue "password:16") — never a comma, which the CLI's `user,pw`
// form would split on. It is on the one-shot container's command line for about a second, inside the box.
// ErrFileBrowserNotOperator: the reset is offered only while the box does not know the password.
var ErrFileBrowserNotOperator = fmt.Errorf("the file manager's password is not in the operator state")
// fileBrowserContainer is the infra container's fixed name (infra.RenderFileBrowserCompose).
const fileBrowserContainer = "filebrowser"
// ResetFileBrowserAdminPassword replaces a FileBrowser admin password the box does not know with a generated one.
// Only in the `operator` state. The new password is applied, then VERIFIED (new=200, admin/admin refused) before
// anything is recorded; a failed verify leaves the state `operator` and returns an error that says so. The value is
// never logged. Pinned by TestFileBrowserReset_*.
func (m *Manager) ResetFileBrowserAdminPassword(ctx context.Context) error {
if m.settings == nil {
return fmt.Errorf("settings unavailable")
}
// The base-stack tick must not start the file browser while it is stopped for the CLI.
m.infraMu.Lock()
defer m.infraMu.Unlock()
if state, _, _ := m.settings.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator {
return ErrFileBrowserNotOperator
}
if len(m.encKey) == 0 {
return fmt.Errorf("encryption key unavailable — nothing changed")
}
pw, err := generateValue("password:16")
if err != nil {
return fmt.Errorf("generate password: %w", err)
}
set := m.fbSetPassword
if set == nil {
set = fileBrowserCLISetPassword
}
m.logger.Printf("[INFO] [infra] filebrowser: the household asked for a new admin password — stopping the file browser for its CLI")
start := time.Now()
if err := set(ctx, pw); err != nil {
return fmt.Errorf("apply the new password: %w — state left operator", err)
}
do := m.fbDo()
base := fileBrowserBaseURL
if m.fbBaseURL != "" {
base = m.fbBaseURL
}
// Wait for the file browser to answer, then verify the consequence both ways.
code, lerr := 0, error(nil)
for i := 0; i < 30; i++ {
_, code, lerr = fbLogin(do, base, pw)
if lerr == nil && (code == http.StatusOK || code == http.StatusUnauthorized) {
break
}
select {
case <-ctx.Done():
return fmt.Errorf("waiting for the file browser: %w — state left operator", ctx.Err())
case <-time.After(m.fbWaitStep()):
}
}
if lerr != nil || code != http.StatusOK {
return fmt.Errorf("the new password does not log in (HTTP %d, err %v) — state left operator", code, lerr)
}
if _, c, err := fbLogin(do, base, "admin"); err != nil || c == http.StatusOK {
return fmt.Errorf("admin/admin logs in after the reset (HTTP %d, err %v) — state left operator", c, err)
}
enc, err := crypto.Encrypt(m.encKey, pw)
if err != nil {
return fmt.Errorf("encrypt the new password: %w — the password IS changed; state left operator", err)
}
now := time.Now().UTC().Format(time.RFC3339)
if err := m.settings.SetFileBrowserAdmin(settings.FileBrowserAdminGenerated, enc, now); err != nil {
m.logger.Printf("[ERROR] [infra] filebrowser: password CHANGED but settings save FAILED: %v — press „new password” again", err)
return err
}
m.logger.Printf("[INFO] [infra] filebrowser: admin password replaced at the household's press (value never logged); verified new=200 admin=refused in %s",
time.Since(start).Round(time.Second))
return nil
}
// fbWaitStep is the pause between two readiness probes (short in tests, where the fake answers at once).
func (m *Manager) fbWaitStep() time.Duration {
if m.fbSetPassword != nil {
return time.Millisecond
}
return 2 * time.Second
}
// fileBrowserCLISetPassword is the production apply: stop the server, run its CLI in a one-shot container with the
// same volumes, start it again — always, even when the CLI failed. Every docker call has a deadline.
func fileBrowserCLISetPassword(ctx context.Context, pw string) error {
run := func(timeout time.Duration, args ...string) (string, error) {
// Detached from the caller's cancel: a stop that ran must be followed by its start, whatever the request does.
c, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
out, err := dockerexec.CommandContext(c, "docker", args...).CombinedOutput()
return strings.TrimSpace(string(out)), err
}
image, err := run(15*time.Second, "inspect", "--format", "{{.Config.Image}}", fileBrowserContainer)
if err != nil || image == "" {
return fmt.Errorf("read the file browser's image: %v", err)
}
if out, err := run(60*time.Second, "stop", fileBrowserContainer); err != nil {
return fmt.Errorf("stop the file browser: %s: %v", out, err)
}
defer func() {
// Never leave it stopped. A failure here is logged by the caller's verify (the login will not answer).
_, _ = run(60*time.Second, "start", fileBrowserContainer)
}()
out, err := run(90*time.Second, "run", "--rm", "--volumes-from", fileBrowserContainer, "--user", "1000:1000",
"--workdir", "/home/filebrowser", "--entrypoint", "/home/filebrowser/filebrowser", image,
"set", "-u", "admin,"+pw, "-c", "/home/filebrowser/config.yaml")
// The CLI prints only „successfully updated user: admin" or an error — never the password; still, the output is
// reduced to a verdict before it can reach a log.
if err != nil || !strings.Contains(out, "successfully") {
verdict := "no success line"
if err != nil {
verdict = err.Error()
}
return fmt.Errorf("the file browser's CLI did not confirm (%s)", verdict)
}
return nil
}