FileBrowser is published only after its default login is checked (R-1126); the memory refusal says what to do (R-1125)
R-1126 (D11 = A): FileBrowser starts off traefik-public, on its own network felhom-filebrowser; the controller joins it there, checks this run's default login, and only then puts FileBrowser on traefik-public (files.<domain> served). FileBrowser's own admin-password setting re-applies at every start (1.5.6 cmd/user.go:50-57, measured on 9202), so it cannot carry the box's password without overwriting the household's own. Tests red-proved. R-1125 (D10 = A): err.stacks.not_enough_memory says the memory is held by the installed apps and to remove one or use a machine with more memory (HU + EN, reworded on purpose). TestR1125_NotEnoughMemorySaysWhatToDo, red-proved. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -1412,7 +1412,7 @@
|
||||
"err.stacks.migracio_mar_folyamatban": "a migration is already running",
|
||||
"err.stacks.migracios_naplo_irasa": "writing the migration log: %s",
|
||||
"err.stacks.nincs_eleg_hely_a_celtarolon_kb": "not enough space on the target storage (about %d GB needed, %.1f GB free)",
|
||||
"err.stacks.not_enough_memory": "There is not enough memory to install this app. Needs %d MB, %d MB is free (%d MB in total, %d MB of it used, %d MB reserved for the system)",
|
||||
"err.stacks.not_enough_memory": "There is not enough memory for this app next to the apps already installed: it needs %d MB, %d MB is free (%d MB in total, %d MB of it held by the installed apps, %d MB for the system). Remove an app you do not need, or use a machine with more memory.",
|
||||
"err.stacks.ugyfel_adatok_ellenorzese_sikertelen": "checking your own files failed: %s",
|
||||
"err.stacks.ugyfel_adatok_osszefesulese_sikertelen": "merging your own files failed: %s",
|
||||
"err.stacks.ujratelepites_sikertelen": "the reinstall failed (%s): %s",
|
||||
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"err.stacks.migracio_mar_folyamatban": "migráció már folyamatban",
|
||||
"err.stacks.migracios_naplo_irasa": "migrációs napló írása: %s",
|
||||
"err.stacks.nincs_eleg_hely_a_celtarolon_kb": "nincs elég hely a céltárolón (kb. %d GB szükséges, %.1f GB szabad)",
|
||||
"err.stacks.not_enough_memory": "Nincs elég memória az alkalmazás telepítéséhez. Szükséges: %d MB, Elérhető: %d MB (összesen: %d MB, ebből %d MB használt, %d MB rendszer számára fenntartva)",
|
||||
"err.stacks.not_enough_memory": "Nincs elég memória ehhez az alkalmazáshoz a már telepítettek mellett: kell %d MB, szabad %d MB (összesen %d MB, ebből %d MB-ot a telepített alkalmazások foglalnak, %d MB a rendszeré). Távolíts el egy alkalmazást, amelyre nincs szükséged, vagy használj több memóriájú gépet.",
|
||||
"err.stacks.ugyfel_adatok_ellenorzese_sikertelen": "ügyfél-adatok ellenőrzése sikertelen: %s",
|
||||
"err.stacks.ugyfel_adatok_osszefesulese_sikertelen": "ügyfél-adatok összefésülése sikertelen: %s",
|
||||
"err.stacks.ujratelepites_sikertelen": "újratelepítés sikertelen (%s): %s",
|
||||
|
||||
@@ -188,6 +188,10 @@ func RenderCloudflared(d CloudflaredData) (map[string]FileSpec, error) {
|
||||
// RenderFileBrowserCompose returns FileBrowser's docker-compose.yml for the given domain and storage
|
||||
// volume-mount lines. Ported verbatim from internal/web/handlers.go (the single source of truth now
|
||||
// lives here so the pinned image can't diverge between bring-up and the web storage-sync path).
|
||||
// FileBrowserNetwork is FileBrowser's own network (R-1126): the controller joins it to check the default login before
|
||||
// FileBrowser is put on traefik-public.
|
||||
const FileBrowserNetwork = "felhom-filebrowser"
|
||||
|
||||
func RenderFileBrowserCompose(domain string, storageMounts []string, groupAdd ...int) string {
|
||||
// R-691 (v0.275.0): supplementary groups — the owning groups of kept folders the view must READ (a
|
||||
// 0770 folder another user owns, e.g. nextcloud's www-data). Their binds are `:ro`. Absent → the
|
||||
@@ -225,8 +229,12 @@ services:
|
||||
volumes:
|
||||
- filebrowser_data:/home/filebrowser/data
|
||||
- ./config.yaml:/home/filebrowser/config.yaml:ro%s
|
||||
# R-1126 (D11 = A): NOT on traefik-public at start. The box joins it to traefik-public only after it has checked
|
||||
# THIS run's default login is refused (stacks.ensureFileBrowserPublic), so files.<domain> never answers with
|
||||
# FileBrowser's default password — a re-created container always starts unreachable from outside. The box reaches
|
||||
# it for that check over its own network.
|
||||
networks:
|
||||
- traefik-public
|
||||
- %s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -249,9 +257,9 @@ volumes:
|
||||
filebrowser_data:
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
`, domain, FileBrowserImage, groupSection, storageSection, domain)
|
||||
%s:
|
||||
name: %s
|
||||
`, domain, FileBrowserImage, groupSection, storageSection, FileBrowserNetwork, domain, FileBrowserNetwork, FileBrowserNetwork)
|
||||
}
|
||||
|
||||
// RenderControllerRoute returns a traefik file-provider dynamic config routing the controller's own
|
||||
|
||||
@@ -247,3 +247,22 @@ func TestFileBrowserRender(t *testing.T) {
|
||||
t.Errorf("filebrowser compose missing the umask-002 entrypoint wrapper: %q", compose)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1126 (D11 = A): FileBrowser starts OFF traefik-public — the box joins it there only after this run's default login
|
||||
// is checked — and on its own network, which the controller joins for that check. The Traefik labels stay, so the route
|
||||
// appears the moment the box joins it. RED-PROOF: the service back on traefik-public → this fails.
|
||||
func TestFileBrowserRender_R1126_NotPublicAtStart(t *testing.T) {
|
||||
compose := RenderFileBrowserCompose("demo-felhom.eu", nil)
|
||||
svc := compose[strings.Index(compose, " filebrowser:"):strings.Index(compose, "\nvolumes:")]
|
||||
nets := svc[strings.Index(svc, " networks:"):]
|
||||
nets = nets[:strings.Index(nets, " deploy:")]
|
||||
if strings.Contains(nets, "traefik-public") {
|
||||
t.Fatalf("FileBrowser starts on traefik-public — it would be served before its default login is checked:\n%s", nets)
|
||||
}
|
||||
if !strings.Contains(nets, "- "+FileBrowserNetwork) || !strings.Contains(compose, "name: "+FileBrowserNetwork) {
|
||||
t.Fatalf("FileBrowser must sit on %s (named), got:\n%s", FileBrowserNetwork, compose)
|
||||
}
|
||||
if !strings.Contains(compose, "traefik.docker.network=traefik-public") || !strings.Contains(compose, "Host(`files.demo-felhom.eu`)") {
|
||||
t.Fatal("the Traefik labels must stay, so joining traefik-public is all that publishes it")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
)
|
||||
|
||||
// R-1126 (operator decision D11 = A, 2026-10-17): FileBrowser is never reachable from outside on its default password.
|
||||
//
|
||||
// WHY THIS SHAPE. The tunnel runs before the claim (the claim mail links https://felhom.<domain>), and files.<domain> is
|
||||
// published through it. A new FileBrowser database starts on admin/admin. FileBrowser's own admin-password setting
|
||||
// (config `auth.adminPassword` / env FILEBROWSER_ADMIN_PASSWORD) is RE-APPLIED AT EVERY START (gtstef/filebrowser
|
||||
// 1.5.6-stable backend/cmd/user.go:50-57, called from cmd/root.go:144 each start) — measured on scratch 9202: a password
|
||||
// the household set in FileBrowser was replaced at the next restart. So the box cannot hand FileBrowser its password up
|
||||
// front without overwriting the household's own (R-513's reason, re-proven). Instead FileBrowser starts OFF the
|
||||
// traefik-public network (infra.RenderFileBrowserCompose) and the box joins it there only after it has checked THIS run:
|
||||
// the default login refused, or replaced and verified (EnsureFileBrowserAdminPassword sets fbProbedRun). A re-created
|
||||
// container — the only way a new database appears — always starts unreachable from outside, whoever re-created it.
|
||||
// Pinned by TestFileBrowserGate_*.
|
||||
|
||||
// fbNetOn reports whether container is on network (test seam: fbNetOnFn; a test FileBrowser and no seam → false).
|
||||
func (m *Manager) fbNetOn(container, network string) bool {
|
||||
if m.fbNetOnFn != nil {
|
||||
return m.fbNetOnFn(container, network)
|
||||
}
|
||||
if m.fbHTTP != nil || m.fbBaseURL != "" {
|
||||
return false
|
||||
}
|
||||
return containerOnNetwork(container, network)
|
||||
}
|
||||
|
||||
// fbNetConnect attaches container to network (test seam: fbNetConnectFn; a test FileBrowser and no seam → no-op).
|
||||
func (m *Manager) fbNetConnect(container, network string) error {
|
||||
if m.fbNetConnectFn != nil {
|
||||
return m.fbNetConnectFn(container, network)
|
||||
}
|
||||
if m.fbHTTP != nil || m.fbBaseURL != "" {
|
||||
return nil
|
||||
}
|
||||
out, err := dockerexec.Command("docker", "network", "connect", network, container).CombinedOutput()
|
||||
if err != nil && !strings.Contains(string(out), "already exists") {
|
||||
return &netConnectError{out: strings.TrimSpace(string(out)), err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type netConnectError struct {
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *netConnectError) Error() string { return e.out + ": " + e.err.Error() }
|
||||
|
||||
// joinFileBrowserNetwork puts the controller on FileBrowser's own network, so the default-login check reaches it before
|
||||
// FileBrowser is public. Missing network (FileBrowser not deployed yet) → nothing, the next round tries again.
|
||||
func (m *Manager) joinFileBrowserNetwork() {
|
||||
if m.fbNetOn(controllerContainer, infra.FileBrowserNetwork) {
|
||||
return
|
||||
}
|
||||
if err := m.fbNetConnect(controllerContainer, infra.FileBrowserNetwork); err != nil {
|
||||
if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [infra] filebrowser: controller not joined to %s yet: %v", infra.FileBrowserNetwork, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
m.logger.Printf("[INFO] [infra] connected %s to %s (the FileBrowser default-login check, R-1126)", controllerContainer, infra.FileBrowserNetwork)
|
||||
}
|
||||
|
||||
// ensureFileBrowserPublic joins FileBrowser to traefik-public only when THIS run was checked and its default login is
|
||||
// refused. Anything less — the run unknown, not checked yet, the check failed — leaves it unreachable from outside.
|
||||
func (m *Manager) ensureFileBrowserPublic() {
|
||||
run := m.fbStartedAt()
|
||||
if run == "" || run != m.fbProbedRun {
|
||||
return
|
||||
}
|
||||
if m.fbNetOn(fileBrowserContainer, traefikNetwork) {
|
||||
return
|
||||
}
|
||||
if err := m.fbNetConnect(fileBrowserContainer, traefikNetwork); err != nil {
|
||||
m.logger.Printf("[WARN] [infra] filebrowser: could not join %s (files.<domain> stays unreachable): %v", traefikNetwork, err)
|
||||
return
|
||||
}
|
||||
m.logger.Printf("[INFO] [infra] filebrowser: default login refused for this run — joined %s, files.<domain> served (R-1126)", traefikNetwork)
|
||||
}
|
||||
|
||||
// EnsureFileBrowserGate is the whole check outside the base-stack round — called after web.SyncFileBrowserMounts
|
||||
// re-creates FileBrowser, so a household's storage change does not leave the file browser offline until the next round.
|
||||
func (m *Manager) EnsureFileBrowserGate() {
|
||||
m.infraMu.Lock()
|
||||
defer m.infraMu.Unlock()
|
||||
m.fileBrowserGateLocked()
|
||||
}
|
||||
|
||||
// fileBrowserGateLocked runs join → check → publish. The caller holds infraMu.
|
||||
func (m *Manager) fileBrowserGateLocked() {
|
||||
m.joinFileBrowserNetwork()
|
||||
if err := m.EnsureFileBrowserAdminPassword(); err != nil {
|
||||
// R-513: retried every round until decided. Logged, never fatal to the base stack.
|
||||
m.logger.Printf("[WARN] [infra] %v", err)
|
||||
}
|
||||
m.ensureFileBrowserPublic()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -244,3 +245,54 @@ func TestFileBrowserAdmin_R977_NewRunIsWaitedForInTheSameTick(t *testing.T) {
|
||||
t.Fatalf("state %q, want generated", s)
|
||||
}
|
||||
}
|
||||
|
||||
// R-1126 (D11 = A): files.<domain> is published (FileBrowser joined to traefik-public) only AFTER this run's default
|
||||
// login was checked; a check that cannot finish leaves it unpublished; a later run already published is not touched.
|
||||
// RED-PROOF (2026-10-17): ensureFileBrowserPublic without the „this run was checked" test → the unreachable case fails.
|
||||
func TestFileBrowserGate_PublishedOnlyAfterTheCheck(t *testing.T) {
|
||||
var events []string
|
||||
fb := &fakeFB{password: "admin"}
|
||||
inner := fb.handler()
|
||||
down := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if down {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPut {
|
||||
events = append(events, "password replaced")
|
||||
}
|
||||
inner.ServeHTTP(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
m, _, _ := fbManager(t, srv.URL)
|
||||
run := "run-1"
|
||||
m.fbStarted = func() string { return run }
|
||||
nets := map[string]bool{}
|
||||
m.fbNetOnFn = func(c, n string) bool { return nets[c+"@"+n] }
|
||||
m.fbNetConnectFn = func(c, n string) error {
|
||||
nets[c+"@"+n] = true
|
||||
events = append(events, "connect "+c+" "+n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// A new run whose FileBrowser does not answer: nothing is published.
|
||||
down = true
|
||||
m.fileBrowserGateLocked()
|
||||
if nets["filebrowser@traefik-public"] {
|
||||
t.Fatal("published while this run's default login was never checked")
|
||||
}
|
||||
// It answers, on its default: replaced first, published after.
|
||||
down = false
|
||||
m.fileBrowserGateLocked()
|
||||
want := []string{"connect felhom-controller felhom-filebrowser", "password replaced", "connect filebrowser traefik-public"}
|
||||
if strings.Join(events, " | ") != strings.Join(want, " | ") {
|
||||
t.Fatalf("order:\n got %v\n want %v", events, want)
|
||||
}
|
||||
// A new run (a restart) on the box's own password: checked, nothing changes, nothing connected twice.
|
||||
run = "run-2"
|
||||
m.fileBrowserGateLocked()
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("a checked restart did more: %v", events)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,9 +94,9 @@ func (m *Manager) EnsureBaseStack() error {
|
||||
|
||||
if err := m.ensureFileBrowser(filepath.Join(base, "filebrowser")); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("filebrowser: %v", err))
|
||||
} else if err := m.EnsureFileBrowserAdminPassword(); err != nil {
|
||||
// R-513: one-time; retried every tick until decided. Logged, never fatal to the base stack.
|
||||
m.logger.Printf("[WARN] [infra] %v", err)
|
||||
} else {
|
||||
// R-513 + R-977 + R-1126: check this run's default login, then (only then) publish files.<domain>.
|
||||
m.fileBrowserGateLocked()
|
||||
}
|
||||
|
||||
// samba (LAN network-sharing, R-7) — conditional deploy, same shape as cloudflared: only when the
|
||||
|
||||
@@ -283,6 +283,9 @@ type Manager struct {
|
||||
// was last probed (R-977 — once per FileBrowser run). Touched only under infraMu (the base-stack tick).
|
||||
fbStarted func() string
|
||||
fbProbedRun string
|
||||
// fbNetOnFn / fbNetConnectFn replace the docker network reads and connects of the R-1126 gate in tests.
|
||||
fbNetOnFn func(container, network string) bool
|
||||
fbNetConnectFn func(container, network string) error
|
||||
// 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.
|
||||
|
||||
@@ -133,8 +133,10 @@ func TestR553_MemoryRefusalIsWrappedWithItsKind(t *testing.T) {
|
||||
t.Error("the memory refusal does not carry ErrNotEnoughMemory — the API would answer 500 " +
|
||||
"for a refusal the customer can act on")
|
||||
}
|
||||
const wantHU = "Nincs elég memória az alkalmazás telepítéséhez. Szükséges: 900 MB, Elérhető: 100 MB " +
|
||||
"(összesen: 2048 MB, ebből 1436 MB használt, 512 MB rendszer számára fenntartva)"
|
||||
// R-1125 (v0.327.0): reworded — the memory is held by the installed apps, and the sentence says what to do.
|
||||
const wantHU = "Nincs elég memória ehhez az alkalmazáshoz a már telepítettek mellett: kell 900 MB, szabad 100 MB " +
|
||||
"(összesen 2048 MB, ebből 1436 MB-ot a telepített alkalmazások foglalnak, 512 MB a rendszeré). " +
|
||||
"Távolíts el egy alkalmazást, amelyre nincs szükséged, vagy használj több memóriájú gépet."
|
||||
if e.Error() != wantHU {
|
||||
t.Errorf("the Hungarian refusal moved:\n got %q\nwant %q", e.Error(), wantHU)
|
||||
}
|
||||
@@ -154,3 +156,24 @@ func TestR553_MemoryRefusalIsWrappedWithItsKind(t *testing.T) {
|
||||
t.Error("the memory refusal is back to a bare error: its kind is gone (R-553)")
|
||||
}
|
||||
}
|
||||
|
||||
// R-1125 (operator decision D10 = A): the install check counts the apps' real memory, so a household meets this refusal
|
||||
// more often. It must say, in both languages, that the memory is held by the apps already installed and what to do.
|
||||
// RED-PROOF (2026-10-17): the old sentence („Szükséges … Elérhető …", no advice) fails every check below.
|
||||
func TestR1125_NotEnoughMemorySaysWhatToDo(t *testing.T) {
|
||||
e := util.MsgErrorf(ErrNotEnoughMemory, "err.stacks.not_enough_memory", 600, 200, 3712, 3310, 384)
|
||||
for _, c := range []struct {
|
||||
lang string
|
||||
want []string
|
||||
}{
|
||||
{"hu", []string{"a már telepítettek mellett", "kell 600 MB", "szabad 200 MB", "Távolíts el egy alkalmazást", "több memóriájú gépet"}},
|
||||
{"en", []string{"next to the apps already installed", "needs 600 MB", "200 MB is free", "Remove an app", "a machine with more memory"}},
|
||||
} {
|
||||
got := util.ErrText(c.lang, e)
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("%s: the refusal does not say %q: %q", c.lang, w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3884,6 +3884,11 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||||
s.logger.Printf("[ERROR] [web] Failed to bring up FileBrowser: %s — %v", string(out), err)
|
||||
} else if changed {
|
||||
s.logger.Printf("[INFO] [web] FileBrowser mounts synced (recreated) — %d storage path(s), config updated", len(paths))
|
||||
// R-1126: a re-created FileBrowser starts off traefik-public; check its default login and publish it now, not
|
||||
// at the next base-stack round (in the background: the check may wait ~20 s for FileBrowser to answer).
|
||||
if s.stackMgr != nil {
|
||||
go s.stackMgr.EnsureFileBrowserGate()
|
||||
}
|
||||
} else {
|
||||
s.logger.Printf("[INFO] [web] FileBrowser sync — no config/compose change, ensured running without recreate (%d storage path(s))", len(paths))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user