v0.320.0: FileBrowser is part of the box again; every app says where its login is; a „Fájlok" button
gates / gates (push) Successful in 50s

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
This commit is contained in:
2026-10-16 15:19:14 +02:00
parent 39bf56298d
commit f30a80841e
59 changed files with 5669 additions and 104 deletions
@@ -2,6 +2,7 @@ package stacks
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -10,6 +11,7 @@ import (
"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"
)
@@ -164,3 +166,140 @@ func fbSelfID(do fbHTTPDo, base, token string) (int, error) {
}
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
}