7e791d9b5d
gates / gates (push) Successful in 43s
R-1101: the change-it line is gone for FileBrowser; in the generated state the page asks FileBrowser whether
the stored password still logs in (only its own 401/403 counts) and, refused, says so and offers the
new-password press. The rendered FileBrowser config switches off its update notice and its Install-app
prompt. The Data tab's backup part is one section in plain words (no off-drive, no PBS); the export
password eye is a text button. R-1095: /apps/{slug}/install of an installed app 302s to its settings tab.
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
350 lines
16 KiB
Go
350 lines
16 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 (`operator`, or
|
|
// `generated` with the stored value refused — R-1101).
|
|
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()
|
|
// v0.321.0 (R-1101): also when the stored password is refused — the household changed it in FileBrowser.
|
|
if state, _, _ := m.settings.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator &&
|
|
!(state == settings.FileBrowserAdminGenerated && m.FileBrowserStoredPasswordRefused()) {
|
|
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
|
|
}
|
|
|
|
// ── v0.321.0 (R-1101): the stored password, checked against FileBrowser ─────────────────────────────────────────────
|
|
//
|
|
// In the `generated` state the box shows the password it set. If the household changed it in FileBrowser, that value
|
|
// is refused, and the page used to keep offering it. The page now asks FileBrowser first (once per page load, the same
|
|
// login R-513 uses, the value never logged) and, on a refusal, says so and offers the new-password press.
|
|
|
|
// fbCheckTimeout bounds the page's check, so a FileBrowser that does not answer never holds the page.
|
|
const fbCheckTimeout = 3 * time.Second
|
|
|
|
// FileBrowserStoredPasswordRefused reports true ONLY when FileBrowser positively refuses the stored password (401/403)
|
|
// in the `generated` state. Anything else — another state, no stored value, a decrypt error, FileBrowser not
|
|
// answering — is false: an unproven refusal must not tell the household its password changed. Pinned by
|
|
// TestFileBrowserStoredPassword_RefusedOnlyOnA401.
|
|
func (m *Manager) FileBrowserStoredPasswordRefused() bool {
|
|
if m.settings == nil || len(m.encKey) == 0 {
|
|
return false
|
|
}
|
|
state, enc, _ := m.settings.GetFileBrowserAdmin()
|
|
if state != settings.FileBrowserAdminGenerated || enc == "" {
|
|
return false
|
|
}
|
|
pw, err := crypto.Decrypt(m.encKey, enc)
|
|
if err != nil || strings.TrimSpace(pw) == "" {
|
|
return false
|
|
}
|
|
do := m.fbHTTP
|
|
if do == nil {
|
|
do = (&http.Client{Timeout: fbCheckTimeout}).Do
|
|
}
|
|
base := fileBrowserBaseURL
|
|
if m.fbBaseURL != "" {
|
|
base = m.fbBaseURL
|
|
}
|
|
_, code, err := fbLogin(do, base, pw)
|
|
refused := err == nil && (code == http.StatusUnauthorized || code == http.StatusForbidden)
|
|
if refused {
|
|
m.logger.Printf("[WARN] [infra] filebrowser: the stored admin password is refused (HTTP %d) — changed in FileBrowser; the page offers a new one (value never logged)", code)
|
|
}
|
|
return refused
|
|
}
|