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
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:
@@ -269,6 +269,14 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
m.mu.Unlock()
|
||||
return "", util.KindErrorf(ErrAlreadyDeployed, "stack %q is already deployed; use update instead", req.StackName)
|
||||
}
|
||||
// v0.320.0: a protected infra stack (traefik, cloudflared, filebrowser, samba, the controller) is
|
||||
// part of the box. It has no app.yaml, so it reads as not deployed — and until this check an
|
||||
// Install press on its page would have deployed the infra compose as an app. Refused before
|
||||
// anything is written. Pinned by TestDeployStack_RefusesProtectedStack.
|
||||
if sPtr.Protected || m.cfg.IsProtectedStack(req.StackName) {
|
||||
m.mu.Unlock()
|
||||
return "", util.KindErrorf(ErrProtectedStack, "stack %q is part of the box and cannot be installed", req.StackName)
|
||||
}
|
||||
sPtr.Deploying = true
|
||||
sPtr.DeployError = ""
|
||||
m.mu.Unlock()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// v0.320.0 — „Uj jelszo beallitasa": the box replaces a FileBrowser admin password it does not know. The apply
|
||||
// (stop / CLI / start) is seamed by fbSetPassword; the verify runs against the fake FileBrowser API, as R-513's does.
|
||||
|
||||
const fbUnknownOld = "TESTONLY-set-by-hand-9q"
|
||||
|
||||
func fbResetManager(t *testing.T, state string, apply func(f *fakeFB, pw string)) (*Manager, *settings.Settings, []byte, *fakeFB, *int) {
|
||||
t.Helper()
|
||||
fake := &fakeFB{password: fbUnknownOld}
|
||||
srv := httptest.NewServer(fake.handler())
|
||||
t.Cleanup(srv.Close)
|
||||
m, st, key := fbManager(t, srv.URL)
|
||||
if state != "" {
|
||||
if err := st.SetFileBrowserAdmin(state, "", "2026-10-16T00:00:00Z"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
calls := 0
|
||||
m.fbSetPassword = func(_ context.Context, pw string) error {
|
||||
calls++
|
||||
fake.mu.Lock()
|
||||
apply(fake, pw)
|
||||
fake.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
return m, st, key, fake, &calls
|
||||
}
|
||||
|
||||
// RED-PROOF (2026-10-16, REPORT): with the `crypto.Encrypt` line replaced by storing `pw` as it is, this failed at
|
||||
// „the stored value is the plaintext password".
|
||||
func TestFileBrowserReset_SetsVerifiesAndStoresEncrypted(t *testing.T) {
|
||||
m, st, key, fake, calls := fbResetManager(t, settings.FileBrowserAdminOperator, func(f *fakeFB, pw string) { f.password = pw })
|
||||
if err := m.ResetFileBrowserAdminPassword(context.Background()); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("apply ran %d times", *calls)
|
||||
}
|
||||
state, enc, _ := st.GetFileBrowserAdmin()
|
||||
if state != settings.FileBrowserAdminGenerated {
|
||||
t.Fatalf("state = %q, want generated", state)
|
||||
}
|
||||
if enc == "" || enc == fake.password {
|
||||
t.Fatal("the stored value is the plaintext password (or empty)")
|
||||
}
|
||||
if got, err := crypto.Decrypt(key, enc); err != nil || got != fake.password {
|
||||
t.Fatalf("the stored value does not decrypt to the password FileBrowser now has (err %v)", err)
|
||||
}
|
||||
if fake.password == fbUnknownOld || len(fake.password) != 16 {
|
||||
t.Fatalf("FileBrowser's password was not replaced by a generated 16-character one")
|
||||
}
|
||||
}
|
||||
|
||||
// RED-PROOF (2026-10-16, REPORT): with the `code != http.StatusOK` verify deleted, this failed at „a failed verify
|
||||
// recorded generated" — the box would offer a password that does not log in.
|
||||
func TestFileBrowserReset_FailedVerifyKeepsOperator(t *testing.T) {
|
||||
// The CLI „succeeded" but FileBrowser still has another password.
|
||||
m, st, _, _, _ := fbResetManager(t, settings.FileBrowserAdminOperator, func(f *fakeFB, _ string) { f.password = "TESTONLY-something-else" })
|
||||
err := m.ResetFileBrowserAdminPassword(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("a reset whose new password does not log in reported success")
|
||||
}
|
||||
if state, enc, _ := st.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator || enc != "" {
|
||||
t.Fatalf("a failed verify recorded %q (enc set: %v), want operator and nothing stored", state, enc != "")
|
||||
}
|
||||
// admin/admin still logging in after the apply is a failure too.
|
||||
m2, st2, _, _, _ := fbResetManager(t, settings.FileBrowserAdminOperator, func(f *fakeFB, _ string) { f.password = "admin" })
|
||||
if err := m2.ResetFileBrowserAdminPassword(context.Background()); err == nil {
|
||||
t.Fatal("a reset that left admin/admin working reported success")
|
||||
}
|
||||
if state, _, _ := st2.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator {
|
||||
t.Fatalf("state = %q, want operator", state)
|
||||
}
|
||||
}
|
||||
|
||||
// RED-PROOF (2026-10-16, REPORT): with the state check removed, this failed at „generated: the apply ran" — the
|
||||
// press would have replaced a password the household can already see.
|
||||
func TestFileBrowserReset_OnlyInOperatorState(t *testing.T) {
|
||||
for _, state := range []string{"", settings.FileBrowserAdminGenerated} {
|
||||
m, st, _, _, calls := fbResetManager(t, state, func(f *fakeFB, pw string) { f.password = pw })
|
||||
err := m.ResetFileBrowserAdminPassword(context.Background())
|
||||
if !errors.Is(err, ErrFileBrowserNotOperator) {
|
||||
t.Errorf("state %q: err = %v, want ErrFileBrowserNotOperator", state, err)
|
||||
}
|
||||
if *calls != 0 {
|
||||
t.Errorf("state %q: the apply ran", state)
|
||||
}
|
||||
if got, _, _ := st.GetFileBrowserAdmin(); got != state {
|
||||
t.Errorf("state %q changed to %q", state, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RED-PROOF (2026-10-16, REPORT): with the protected check removed from DeployStack, this failed at „filebrowser
|
||||
// (Protected=true): err = went on into the deploy".
|
||||
func TestDeployStack_RefusesProtectedStack(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Stacks.Protected = []string{"filebrowser"}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
protected bool
|
||||
}{{"filebrowser", true}, {"filebrowser", false}, {"samba", false}} {
|
||||
m := &Manager{cfg: cfg, stacks: map[string]*Stack{c.name: {Name: c.name, Protected: c.protected}}}
|
||||
var err error
|
||||
func() {
|
||||
// This bare manager has nothing a real deploy needs, so a call that gets PAST the guard panics — which
|
||||
// is itself the proof that it went on into the deploy.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = errors.New("went on into the deploy (panicked on the bare manager)")
|
||||
}
|
||||
}()
|
||||
_, err = m.DeployStack(DeployRequest{StackName: c.name})
|
||||
}()
|
||||
if !errors.Is(err, ErrProtectedStack) {
|
||||
t.Errorf("%s (Protected=%v): err = %v, want ErrProtectedStack", c.name, c.protected, err)
|
||||
}
|
||||
if m.stacks[c.name].Deploying {
|
||||
t.Errorf("%s: left Deploying set", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveLoginKind_DefaultAndUnknown(t *testing.T) {
|
||||
for in, want := range map[string]string{"": "first_visit", "first_visit": "first_visit", "email": "email",
|
||||
"none": "none", "magic": "first_visit", "EMAIL": "first_visit"} {
|
||||
if got := (AppInfo{LoginKind: in}).EffectiveLoginKind(); got != want {
|
||||
t.Errorf("login_kind %q → %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,6 +279,8 @@ type Manager struct {
|
||||
// R-513: FileBrowser admin-password seams (filebrowser_password.go); nil/"" in production.
|
||||
fbHTTP fbHTTPDo
|
||||
fbBaseURL 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.
|
||||
execFn func(name string, args ...string) (string, error)
|
||||
// composeExecFn replaces the initial deploy's compose call (composeExecWithEnv) in tests; nil in production.
|
||||
|
||||
@@ -169,6 +169,29 @@ type AppInfo struct {
|
||||
// Guide (v0.315.0): the app's own user guide, shown on the app page's „Utmutato" tab. Optional —
|
||||
// an app with no guide block renders the tab's existing content and says a guide is still to come.
|
||||
Guide AppGuide `yaml:"guide,omitempty" json:"guide,omitempty"`
|
||||
// LoginKind (v0.320.0): how the household signs in when the box holds no login for the app —
|
||||
// `first_visit` (the default), `email` or `none`. An enum, not copy: the sentence is the controller's,
|
||||
// so it is not in the overlay. Read only through EffectiveLoginKind.
|
||||
LoginKind string `yaml:"login_kind,omitempty" json:"login_kind,omitempty"`
|
||||
}
|
||||
|
||||
// The three values of app_info.login_kind. Anything else (a typo, a value a newer catalog adds) reads
|
||||
// as LoginKindFirstVisit — the sentence that promises least: „the box does not know the password".
|
||||
const (
|
||||
LoginKindFirstVisit = "first_visit"
|
||||
LoginKindEmail = "email"
|
||||
LoginKindNone = "none"
|
||||
)
|
||||
|
||||
// EffectiveLoginKind is app_info.login_kind with the default applied. Pinned by
|
||||
// TestEffectiveLoginKind_DefaultAndUnknown.
|
||||
func (a AppInfo) EffectiveLoginKind() string {
|
||||
switch a.LoginKind {
|
||||
case LoginKindEmail, LoginKindNone:
|
||||
return a.LoginKind
|
||||
default:
|
||||
return LoginKindFirstVisit
|
||||
}
|
||||
}
|
||||
|
||||
// AppGuide is the catalog's user guide for one app: numbered sections and a FAQ, both plain text.
|
||||
|
||||
Reference in New Issue
Block a user