Files
felhom-controller/controller/internal/stacks/filebrowser_password.go
T

167 lines
6.5 KiB
Go

package stacks
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"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
}