diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab9a70..9d027fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## v0.327.1 — files. reaches FileBrowser again: a file route by name, not Docker labels (2026-10-17) + +**MinAgent: 0.158.1** — the floor's, carried. No golden. Task: as v0.327.0. + +- **Regression in v0.327.0, found by its own box walk:** on demo-hp and the N100 (demo-felhom) `files.` timed + out, through Traefik and over the public address, while FileBrowser sat on `traefik-public` (Tester 1 and 9202 worked). + With Docker labels, Traefik chose FileBrowser's address once, when the container started — on `felhom-filebrowser`, + before the box joined it to `traefik-public` — and the later network connect did not refresh it (it did on two boxes + only by the chance of a later health event). Now FileBrowser carries NO Traefik labels; `files.` is a + file-provider route (`dynamic/filebrowser.yml`, `infra.RenderFileBrowserRoute`) to `http://filebrowser:80` by NAME, + resolved per connection, and only while the R-1126 gate keeps FileBrowser on `traefik-public`. Every box recreates + FileBrowser once more (the compose changed). `TestFileBrowserRender_R1126_NotPublicAtStart` now also refuses any + Traefik label and checks the route, red-proved. + ## v0.327.0 — FileBrowser is published only after its default login is checked; the install check uses real memory amounts and says what to do (2026-10-17) **MinAgent: 0.158.1** — the floor's, carried. Nothing here calls the agent. No golden (`09` §3 decision 250). Task: diff --git a/controller/README.md b/controller/README.md index 2343884..8c0a556 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1093,7 +1093,7 @@ cloudflared is only deployed when a tunnel token is configured. **Triggers**: a > **Mount prerequisite (Section-G):** the controller writes these stacks under `/opt/docker/stacks` *inside its container*, but `docker compose up` runs on the **guest** Docker daemon. The golden's controller-bootstrap (`felhom-agent` `build-golden.sh`) therefore bind-mounts that path **same-path** (`-v /opt/docker/stacks:/opt/docker/stacks`) so the daemon resolves every relative bind source — without it, all bind-mounted stacks (base infra and customer apps) silently break. -**FileBrowser admin password (v0.243.0, R-513).** FileBrowser used to accept `admin`/`admin` on every box. After `ensureFileBrowser`, every tick until decided, `Manager.EnsureFileBrowserAdminPassword` (`internal/stacks/filebrowser_password.go`) **v0.327.0 (R-1126, D11 = A): FileBrowser starts OFF `traefik-public`** — on its own network `felhom-filebrowser`, which the controller joins — and is put on `traefik-public` (so `files.` is served) only after the check below has passed for THIS run (`stacks.ensureFileBrowserPublic`; also run right after `web.SyncFileBrowserMounts` re-creates it). FileBrowser's own admin-password setting cannot replace this: it re-applies at every start (1.5.6 `cmd/user.go:50-57`, measured), which would overwrite the household's own password. The check probes `http://filebrowser:80/api/auth/login` with admin/admin — **once per FileBrowser run since v0.326.0 (R-977)**: the probe repeats whenever FileBrowser's container StartedAt changes, so a re-created data volume that starts on the default again is reset at its first tick; a run on any other password changes nothing: **200** → a generated `password:16` is set through FileBrowser's API (never the `auth.adminPassword` config key — measured to overwrite a hand-set password on every start), verified both ways, and stored AES-encrypted in `settings.json` (`filebrowser_admin_state: generated`); **401** → `operator`, never touched. The FileBrowser app page shows user `admin` and the password behind the R-254 reveal (`POST /apps/filebrowser/initial-credentials/reveal`), or — `operator` — „Ezt a jelszót te állítottad be…" with „Új jelszó beállítása" (v0.320.0, the file browser's CLI with the server stopped; the `auth.adminPassword` key / `FILEBROWSER_ADMIN_PASSWORD` env re-apply on every start and are still never used). **v0.321.0 (R-1101):** in `generated`, the page first asks FileBrowser whether the stored password still logs in (`FileBrowserStoredPasswordRefused`; only a 401/403 counts); refused → „A Fájlkezelőben megváltoztattad a jelszót…" and the same press. The rendered config switches off FileBrowser's own update notice (`server.disableUpdateCheck`) and its „Install app" prompt (`frontend.disablePWAInstall`). +**FileBrowser admin password (v0.243.0, R-513).** FileBrowser used to accept `admin`/`admin` on every box. After `ensureFileBrowser`, every tick until decided, `Manager.EnsureFileBrowserAdminPassword` (`internal/stacks/filebrowser_password.go`) **v0.327.0 (R-1126, D11 = A): FileBrowser starts OFF `traefik-public`** — on its own network `felhom-filebrowser`, which the controller joins — and is put on `traefik-public` (so `files.` is served) only after the check below has passed for THIS run. Since v0.327.1 it carries no Traefik labels: `files.` is the file route `dynamic/filebrowser.yml` to `http://filebrowser:80` BY NAME (a label route kept the start-time address on the wrong network) (`stacks.ensureFileBrowserPublic`; also run right after `web.SyncFileBrowserMounts` re-creates it). FileBrowser's own admin-password setting cannot replace this: it re-applies at every start (1.5.6 `cmd/user.go:50-57`, measured), which would overwrite the household's own password. The check probes `http://filebrowser:80/api/auth/login` with admin/admin — **once per FileBrowser run since v0.326.0 (R-977)**: the probe repeats whenever FileBrowser's container StartedAt changes, so a re-created data volume that starts on the default again is reset at its first tick; a run on any other password changes nothing: **200** → a generated `password:16` is set through FileBrowser's API (never the `auth.adminPassword` config key — measured to overwrite a hand-set password on every start), verified both ways, and stored AES-encrypted in `settings.json` (`filebrowser_admin_state: generated`); **401** → `operator`, never touched. The FileBrowser app page shows user `admin` and the password behind the R-254 reveal (`POST /apps/filebrowser/initial-credentials/reveal`), or — `operator` — „Ezt a jelszót te állítottad be…" with „Új jelszó beállítása" (v0.320.0, the file browser's CLI with the server stopped; the `auth.adminPassword` key / `FILEBROWSER_ADMIN_PASSWORD` env re-apply on every start and are still never used). **v0.321.0 (R-1101):** in `generated`, the page first asks FileBrowser whether the stored password still logs in (`FileBrowserStoredPasswordRefused`; only a 401/403 counts); refused → „A Fájlkezelőben megváltoztattad a jelszót…" and the same press. The rendered config switches off FileBrowser's own update notice (`server.disableUpdateCheck`) and its „Install app" prompt (`frontend.disablePWAInstall`). **Controller routing + the wildcard cert anchor (`wireController` → `RenderControllerRoute`, v0.41.1 / v0.42.1).** filebrowser self-registers with traefik via Docker labels + `traefik-public` membership baked into its compose; the controller can't (it's started by the golden bootstrap *before* `traefik-public` exists, and the v2 `bootstrap.json` carries no domain — that comes from the hub pull). So `EnsureBaseStack` wires the controller **post-pull**: it `docker network connect traefik-public felhom-controller` and writes a traefik file-provider route `dynamic/controller.yml` (`Host(felhom.) → http://felhom-controller:8080`, write-if-changed). When DNS-01 ACME is configured, that route is **also the wildcard-cert anchor**: its router-level `tls.domains: *.` makes traefik **proactively obtain the wildcard `*.` + apex via Cloudflare DNS-01 at startup** (an entrypoint-level `http.tls.domains` does *not* trigger issuance in traefik v3 — only a router-level `tls.domains` does). Every other router then serves that one real wildcard cert by SNI — no per-app `certresolver` labels. This is what lets a LAN client reach the box directly at `*.` with the real cert (the `felhom-agent` split-horizon resolver depends on it). diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index 28ac063..2e9871c 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -245,13 +245,11 @@ services: timeout: 5s retries: 3 start_period: 15s - labels: - - "traefik.enable=true" - - "traefik.http.routers.filebrowser.rule=Host(`+"`"+`files.%s`+"`"+`)" - - "traefik.http.routers.filebrowser.entrypoints=websecure" - - "traefik.http.routers.filebrowser.tls=true" - - "traefik.http.services.filebrowser.loadbalancer.server.port=80" - - "traefik.docker.network=traefik-public" + # No Traefik labels (v0.327.1): the route is a file-provider route (RenderFileBrowserRoute) to http://filebrowser:80 + # BY NAME. Docker labels made Traefik pick FileBrowser's address once, at container start — on its own network, + # before the box joined it to traefik-public — and a later "network connect" did not refresh it (demo-hp and the + # N100 timed out after v0.327.0). By name, the address is resolved per connection, and only while FileBrowser is on + # traefik-public. volumes: filebrowser_data: @@ -259,7 +257,30 @@ volumes: networks: %s: name: %s -`, domain, FileBrowserImage, groupSection, storageSection, FileBrowserNetwork, domain, FileBrowserNetwork, FileBrowserNetwork) +`, domain, FileBrowserImage, groupSection, storageSection, FileBrowserNetwork, FileBrowserNetwork, FileBrowserNetwork) +} + +// RenderFileBrowserRoute is the traefik file-provider route for files. → http://filebrowser:80 by NAME (R-1126, +// v0.327.1). The name resolves only while FileBrowser is on traefik-public, which the box allows only after this run's +// default login was checked — so the route can stay in place and still never reach an unchecked FileBrowser (502 then). +// TLS as the old Docker label had it (`tls: {}`, the default certificate store / the wildcard). +func RenderFileBrowserRoute(domain string) string { + return fmt.Sprintf(`# Traefik dynamic route for FileBrowser (files.) — managed by felhom-controller (R-1126). +# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten. +http: + routers: + filebrowser: + rule: "Host(`+"`"+`files.%s`+"`"+`)" + entryPoints: + - websecure + service: filebrowser + tls: {} + services: + filebrowser: + loadBalancer: + servers: + - url: "http://filebrowser:80" +`, domain) } // RenderControllerRoute returns a traefik file-provider dynamic config routing the controller's own diff --git a/controller/internal/infra/infra_test.go b/controller/internal/infra/infra_test.go index 94ddbc5..bba5bca 100644 --- a/controller/internal/infra/infra_test.go +++ b/controller/internal/infra/infra_test.go @@ -216,8 +216,8 @@ func TestControllerRoute(t *testing.T) { func TestFileBrowserRender(t *testing.T) { compose := RenderFileBrowserCompose("demo-felhom.eu", nil) - if !strings.Contains(compose, "Host(`files.demo-felhom.eu`)") { - t.Errorf("domain not wired into filebrowser routing label: %q", compose) + if !strings.Contains(compose, "# Domain: files.demo-felhom.eu") { + t.Errorf("domain not named in the filebrowser compose header: %q", compose) } if !strings.Contains(compose, FileBrowserImage) { t.Errorf("expected pinned filebrowser image %q", FileBrowserImage) @@ -249,8 +249,10 @@ func TestFileBrowserRender(t *testing.T) { } // 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. +// is checked — and on its own network, which the controller joins for that check. v0.327.1: NO Traefik labels — the +// route is the file-provider route by NAME (a label route kept the address Traefik chose at container start, on the +// wrong network, and timed out on demo-hp and the N100). RED-PROOF: the service back on traefik-public, or the labels +// back → 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:")] @@ -262,7 +264,13 @@ func TestFileBrowserRender_R1126_NotPublicAtStart(t *testing.T) { 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") + if strings.Contains(compose, "traefik.") { + t.Fatalf("FileBrowser must carry no Traefik labels (the route is the file route by name):\n%s", compose) + } + route := RenderFileBrowserRoute("demo-felhom.eu") + for _, w := range []string{"Host(`files.demo-felhom.eu`)", `url: "http://filebrowser:80"`, "websecure", "tls: {}"} { + if !strings.Contains(route, w) { + t.Errorf("the file route misses %q:\n%s", w, route) + } } } diff --git a/controller/internal/stacks/infra.go b/controller/internal/stacks/infra.go index cf12a1e..70655db 100644 --- a/controller/internal/stacks/infra.go +++ b/controller/internal/stacks/infra.go @@ -77,6 +77,11 @@ func (m *Manager) EnsureBaseStack() error { if err := m.wireController(traefikDir); err != nil { errs = append(errs, fmt.Sprintf("controller-route: %v", err)) } + // R-1126 (v0.327.1): files. as a file-provider route by NAME — published only while the gate keeps + // FileBrowser on traefik-public. + if err := m.ensureFileBrowserRoute(traefikDir); err != nil { + errs = append(errs, fmt.Sprintf("filebrowser-route: %v", err)) + } if m.cfg.Infrastructure.CFTunnelToken != "" { // cloudflared moves to the tunnel network only once traefik is on it (it reaches traefik by name there); while @@ -279,6 +284,28 @@ func (m *Manager) ensureFileBrowser(dir string) error { return m.composeUp(dir) } +// ensureFileBrowserRoute writes traefik's dynamic route for files. (infra.RenderFileBrowserRoute), only when its +// content changes, like wireController. No domain → nothing (no route without a name to serve). +func (m *Manager) ensureFileBrowserRoute(traefikDir string) error { + domain := m.cfg.Customer.Domain + if domain == "" { + return nil + } + dynDir := filepath.Join(traefikDir, "dynamic") + if err := os.MkdirAll(dynDir, 0o755); err != nil { + return fmt.Errorf("mkdir dynamic: %w", err) + } + path := filepath.Join(dynDir, "filebrowser.yml") + want := infra.RenderFileBrowserRoute(domain) + if cur, err := os.ReadFile(path); err != nil || string(cur) != want { + if err := os.WriteFile(path, []byte(want), 0o644); err != nil { + return fmt.Errorf("write filebrowser route: %w", err) + } + m.logger.Printf("[INFO] [infra] wrote filebrowser route → %s (Host files.%s → filebrowser:80 by name, R-1126)", path, domain) + } + return nil +} + // ensureServersTransports writes the traefik file-provider dynamic config defining named backend // transports (the insecure-skip-verify transport for self-signed HTTPS backends like Crafty). Like // wireController, it writes only when the content changes so the traefik file watcher doesn't reload