diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9f57f..ab43ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +## v0.325.0 — parallel password tries capped on two more doors; family sessions stored as fingerprints; samba renames apply; path guard widened; gates in a worktree (2026-10-17) + +**MinAgent: 0.158.1** — the floor's, carried. Nothing here calls the agent. No golden (`09` §3 decision 250). Task: +burndown 3 (felhom.eu `REPORT-burndown-3-2026-10-17.md`). + +- **R-1111** — the share-link password (`/s/`) and the escrow wizard's re-auth reserve a try BEFORE the slow + password compare (the R-983 shape): one body `reserveAttempt` for all three limiters (`reserveLoginAttempt`, + `shareReserve`); `shareRateLimited`/`escrowRateLimited` and their after-the-fact counters removed. Tests + `TestR1111_ParallelSharePasswordsHitTheLimit`, `TestR1111_ParallelEscrowReauthsHitTheLimit` (20 parallel wrong tries + against a slow fake compare → at most 5 reach it), red-proved on the old shape. +- **R-1112** — `family.json` stores each session as its fingerprint (`family.Fingerprint`, hex sha256), never the + cookie value; a file written before is converted at load and rewritten at once (a failed rewrite is an error — the + gate stays shut for members, the household passes). Family app cookies are derived from the fingerprint, so a cookie + minted before this version is refused ONCE and the browser passes through `/__family/start` again (no new sign-in + while the family session holds). A rollback below this version signs family members out once (the old reader does + not know `fp`). Tests `TestR1112_FamilyFileHoldsNoUsableSession`, `TestR1112_OldFamilyFileIsConvertedOnLoad`, + `TestFamily_OldFileThatCannotBeRewrittenIsAnError`, red-proved. +- **R-1113** — `/__family/start` with the household's dashboard session reuses the household's live family session + (`Store.HouseholdSession`; a new one only when less than half of the 30 days is left), instead of one more per pass. + `TestR1113_HouseholdPassesLeaveOneSession` (6 passes → 1 session), red-proved. +- **R-1109** — the samba compose carries `felhom.smb-conf-sha256` (the rendered smb.conf's hash): a share renamed at the + same folder changes the compose, so `compose up -d` recreates the container. An unchanged config renders a + byte-identical compose (no recreate per pass). Every box with sharing on recreates samba ONCE after this update (the + label is new). `TestRenderSambaCompose_ConfigHashLabel`, `TestSambaReconcile_SamePathRenameChangesCompose`, red-proved. +- **R-1110** — `TestNoBareDrivePathToPathHelpers`: the R-1017 guard for `RecoveryUnitPath`, `AppDataDir` and + `PrimaryBackupPath`, through both packages (41 calls). All passed a namespace root; two only by a comment — + `GetStackBackupData`'s parameter renamed `nsRoot` (its caller passes `AppNamespaceRoot`), and the storage page's app + size (`appDetailsForPath`) now goes through `appbackup.NamespaceRootFor` (same path on an enrolled drive). Loop + variables in `removed_units.go`, `offbox_placement.go`, `offbox.go` renamed. Red-proved three ways. +- **R-1114** — `scripts/shared_scripts.py`: ONE resolver for the felhom.eu sibling (`FELHOM_SHARED_SCRIPTS`, else the + main clone's sibling from `git worktree list`, else `../felhom.eu`), used by `controller_gates.py`, + `retrieval_promise_gate.py` and `golden_notice.py`; the runner hands the shared gates `FELHOM_WORKSPACE` so + reuse-refs searches beside the main clone (felhom.eu `reuse_refs_check.py`, same day). `test_shared_scripts.py`, + red-proved. `controller_gates.py --fast` passed in a worktree under `/mnt/5_hdd/felhom.eu/wt/` and in the clone. +- No page, template or i18n bundle changed: every parity fixture byte-identical. + ## v0.324.0 — a restore brings the database password back with the data; restic 0.19.1; dashboard hardening; three small fixes (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/REUSE.md b/REUSE.md index b62f5db..3f814fd 100644 --- a/REUSE.md +++ b/REUSE.md @@ -28,7 +28,7 @@ | `stacks.RunAfterInstall` / `expandAfterInstall` / `web.defaultLoginInEffect` (v0.279.0, decision 45) | controller/internal/stacks/after_install.go · controller/internal/web/known_login.go | `(name, wait)` / `(cmd, allowed, env)` / `(meta, cfg, installed)` | A fresh install replaces a known default login; the page says when a default is still in effect | **Only from the deploy-done hook** — never after a restore/kept load (R-694). A `success:` marker is required (exit 0 lies). Never log the expanded command | | `stacks.OpenSetupGate` / `SetupGateTick` / `SetupGateHost` · `web.ServeGateAuth` / `ServeGateStart` (v0.280.0, decision 46) | controller/internal/stacks/setup_gate.go · controller/internal/web/setup_gate.go | `(name, by)` / `()` / `(host)` · handlers | The setup gate: a `setup_gate: true` install is closed to everyone but the household until its probe or the household's press opens it | **Write the gate BEFORE the first start** (spike F2). Open = record first, then remove the file. Never widen the dashboard cookie — the handshake mints a host-bound one-use token | | `stacks.OpenSignupWindow` / `SignupBlocked` / `SetupGateProbe` · `web.ServeSignupClosed` (v0.281.0, decision 47) | controller/internal/stacks/signup_block.go · controller/internal/web/setup_gate.go | `(name)` | Sign-up closed at the app's own address once the gate opens; the household's 15-minute window; the press asks the probe | **The block goes up BEFORE the gate comes down** (a failed write keeps the gate closed). Never on an app this box did not gate | -| `family.Store` · `stacks.FamilyGateHost` / `familyGateTick` / `FamilyExceptRegexp` · `web.ServeFamilyGateAuth` / `ServeFamilyStart` / `ServeFamilyLogin` / `ServeFamilyLogout` (v0.287.0, decisions 63/64) | controller/internal/family/family.go · controller/internal/stacks/family_gate.go · controller/internal/web/family_gate.go | store `Add/Reset/Remove/Verify/NewSession/Valid/EndSession/Find/EndAll` · `(host)` / `()` / `(prefix)` · handlers | The PERMANENT family gate: family members with their own logins in front of a `family_gate: true` app | **A family cookie never opens the dashboard** (RequireAuth reads only `felhom_session`); the app cookie carries a token DERIVED per app host from a STORE session (`familyAppToken`, R-976 — never the session id itself), so the store is asked on every request (reset/remove/logout end access at once); **every exception goes through `FamilyExceptRegexp`** (anchored — never a hand-written PathPrefix); door written before the first start, like the setup gate. `familyStoreOverride` is the test seam. Fifth atomic-write helper (family.json, fsync) — see §6 | +| `family.Store` · `stacks.FamilyGateHost` / `familyGateTick` / `FamilyExceptRegexp` · `web.ServeFamilyGateAuth` / `ServeFamilyStart` / `ServeFamilyLogin` / `ServeFamilyLogout` (v0.287.0, decisions 63/64) | controller/internal/family/family.go · controller/internal/stacks/family_gate.go · controller/internal/web/family_gate.go | store `Add/Reset/Remove/Verify/NewSession/HouseholdSession/Valid/EndSession/Find/EndAll` + `family.Fingerprint` · `(host)` / `()` / `(prefix)` · handlers | The PERMANENT family gate: family members with their own logins in front of a `family_gate: true` app | **A family cookie never opens the dashboard** (RequireAuth reads only `felhom_session`); the app cookie carries a token DERIVED per app host from a STORE session (`familyAppToken`, R-976 — never the session id itself), so the store is asked on every request (reset/remove/logout end access at once); **every exception goes through `FamilyExceptRegexp`** (anchored — never a hand-written PathPrefix); door written before the first start, like the setup gate. `familyStoreOverride` is the test seam. Fifth atomic-write helper (family.json, fsync) — see §6. **R-1112: a session is stored as its FINGERPRINT (`Session.FP`, the R-35 shape), never the cookie value** — `NewSession` returns the value once; `Valid`/`EndSession` take the value and hash it; `Find` and `familyAppToken` work on the fingerprint. An old file (`"id"` = the value) is converted and rewritten in `Open`; a failed rewrite is an error. **R-1113: the household path uses `HouseholdSession`** (its live session, a new one only past half its life) — never `NewSession("")` per pass | | `stacks.OpenInstallHold` / `installHoldTick` (v0.284.0, R-741) | controller/internal/stacks/install_hold.go | `(name, by)` / `()` | An `after_install` app held behind the setup gate's door until its known login is replaced | **Written before the first start**, like the gate; opens on `after_install` success or the household's "I changed it"; the door (`SetupGateHost`) reads holds first | | `stacks.RetainImagesAfterUpdate` / `RetainImagesAfterRemove` / `RunImageRetentionOnce` (→ `(deleted, done)` since v0.294.0) / `RunImageRetentionOnceUntilDone` · seam `imageDocker` (v0.284.0, decision 53) | controller/internal/stacks/image_retention.go | `(name, previous)` / `(name, repos)` / `()` | Deletes an app's images older than its running + previous one | **The keep set is box-wide and read at delete time** (containers, installed composes, installed/previous records); exact id, never forced or pruned; skipped while any update runs AND while any image-pulling compose command runs (R-863, `dockerexec.TryImageCleanup`); the one-time pass writes its marker only after a pass that RAN; tests use the `imageDocker` seam, never Docker | | `dockerexec.BeginImageWork(args)` / `TryImageCleanup()` / `ImagePulling(args)` (v0.294.0, R-863) | controller/internal/dockerexec/imagework.go | `defer dockerexec.BeginImageWork(args)()` | **Every NEW place that runs `compose up/pull/create/run` must hold it** (the stacks compose helpers, appexport's restore and the FileBrowser sync already do) | An image pulled by compose is named by no container until compose creates one; a clean-up pass in that window deleted it (measured, R-863). The pass takes the lock exclusively without waiting; pulling commands wait for a running pass (seconds) | @@ -285,7 +285,7 @@ | `report.OperatorActions` | controller/internal/report/opactions.go | `Reconcile(reply)` → results in the next report | Hub-requested actions in the report reply — the `selftail.go` pattern plus results | The action table is CLOSED and pinned by `TestOpActions_ClosedList`; once per id in memory; every entry must be safe to repeat | | `web.sessionFingerprint` / `loadSessions` / `saveSessionsLocked` / `saveSessionsRevokingLocked` | controller/internal/web/session_store.go | dashboard sessions on disk (R-35) | Any session lookup | The map is keyed by sha256(cookie), NEVER the cookie — hash with `sessionFingerprint` first; a path that ENDS sessions uses the revoking save | | `web.endEverySession` / `rotateGateKey` (R-976, v0.324.0) | controller/internal/web/auth.go · controller/internal/web/setup_gate.go | `(why string)` / `()` | A dashboard password change or claim/reset — ANY act that must end every way in | Ends the dashboard sessions, every family session (`family.Store.EndAll`) AND every setup-gate pass / family app cookie (the gate key is replaced; a failed save REMOVES the old key file so a restart cannot bring old passes back). Call this, never `invalidateAllSessions` alone — that ends only the dashboard's. Pinned by `TestR976_PasswordChangeEndsFamilyAndGateSessions` / `TestR976_ClaimResetEndsFamilyAndGateSessions` | -| `web.reserveLoginAttempt` / `familyReserve` / `allowResetRequest` (R-983, v0.324.0) | controller/internal/web/auth.go · family_gate.go · claim.go | `(key) → may go ahead` | Any limiter in front of a slow check (bcrypt, a hub call) | **Check and count in ONE locked step, BEFORE the slow work** — a count taken after the compare lets N parallel tries all pass the check (the shape R-983 removed from the dashboard and family sign-ins). A success clears the count afterwards. `shareRateLimited` and `escrowRateLimited` still have the old check-then-count shape | +| `web.reserveLoginAttempt` / `reserveAttempt` / `shareReserve` / `familyReserve` / `allowResetRequest` (R-983, v0.324.0; R-1111) | controller/internal/web/auth.go · share.go · family_gate.go · claim.go | `(key) → may go ahead` | Any limiter in front of a slow check (bcrypt, a hub call) | **Check and count in ONE locked step, BEFORE the slow work** — a count taken after the compare lets N parallel tries all pass the check (the shape R-983 removed from the dashboard and family sign-ins, and R-1111 from the share password and the escrow re-auth). A success clears the count afterwards. `reserveAttempt(mu, &map, key)` is the one body of the 5-per-minute password limiters — a new one passes its OWN map to it, never a third shape; the escrow re-auth counts on the login map (`reserveLoginAttempt`), the share password on its own (`shareReserve`). The compare goes through `comparePassword`, so `passwordCompare` (the slow-compare seam) reaches all three | | `Scheduler.Every` / `Daily` | controller/internal/scheduler/scheduler.go | `(name, interval/"HH:MM", fn)` | ALL background jobs | Daily is Europe/Budapest, DST-safe (`nextDailyRun` avoids Add(24h)); register in main.go block (§5) | | `Scheduler.UpdateDaily` | controller/internal/scheduler/scheduler.go | `(name, "HH:MM") bool` | Retime a daily job at runtime (no restart) | Per-job buffered `resched` chan + select case in `runDailyJob`; false (WARN) on invalid time / unknown-or-non-daily name; read `Schedule` under the mutex in the loop | | `backupwindow.*` (LegTimes / GateWindow / EffectiveWindow / ParseHHMM / FmtHHMM / Valid) | controller/internal/backupwindow/backupwindow.go | pure `string`↔`int` | Backup-window arithmetic (v0.168.0) | Offsets (W+60m/W+105m, gate W+2h..W+6h) are CONSTANTS — derived, never stored; wrap-safe modulo 1440; `EffectiveWindow(settings, yaml)` = settings>yaml>"02:30" | diff --git a/controller/README.md b/controller/README.md index c10c5e1..96e2d73 100644 --- a/controller/README.md +++ b/controller/README.md @@ -2133,7 +2133,9 @@ that folder is never a dead end, and an install never runs into it silently (R-6 page) and the household itself get through; `family_gate_except:` lists literal path prefixes left to the app's own login (anchored `^/prefix(/|$)`). Door file `family-gate-.yml`, written before the first start; answerer `/__felhom_gate/family`; sign-in pages `/__family/start|login|logout` on the dashboard host; sessions 30 days in - `family.json`. A family cookie never opens the dashboard. `min_controller:` refuses a template too new for the box. + `family.json`, stored as their FINGERPRINT (sha256 of the cookie value, the dashboard's R-35 shape — v0.325.0, R-1112; + an older file is converted at load and rewritten). The household's pass reuses its one live family session (a new one + only when less than half of its life is left — R-1113). A family cookie never opens the dashboard. `min_controller:` refuses a template too new for the box. Code: `internal/family/`, `internal/stacks/family_gate.go`, `internal/web/family_gate.go`. - **A removed app restored from its backup gets its lock back (v0.286.0, R-773)** — with no app.yaml left, the restore (`PersistUnitRedeployConfig`) writes the lock record (`opened_by: restore`) and the block BEFORE anything starts; the @@ -3951,7 +3953,9 @@ container recreation. Built by `controller/scripts/build-samba-image.sh` (never Pure renderers produce a hardened `smb.conf` (`server min protocol = SMB2`, `bind interfaces only` on `lo eth0`, `disable netbios = no`, `map to guest = never`, per-share `force user/group = felhom` so every SMB write lands as uid:gid 1000) and a compose file (`network_mode: host`, pinned image, -config `:ro`, passdb volume, one bind per share — `:ro` for read-only shares as defence in depth). +config `:ro`, passdb volume, one bind per share — `:ro` for read-only shares as defence in depth). The compose carries +the label `felhom.smb-conf-sha256` (v0.325.0, R-1109): smb.conf is a bind-mounted FILE, so a change to it alone (a +share renamed at the same folder) must change the compose too, or `compose up -d` keeps the old config running. `ensureSamba` joins `EnsureBaseStack` after filebrowser, gated on `SMB.Enabled` (the cloudflared conditional-deploy precedent); `ReconcileSamba` runs after every mutation. Both are idempotent — diff --git a/controller/internal/appbackup/userdata_bare_drive_guard_test.go b/controller/internal/appbackup/userdata_bare_drive_guard_test.go index c82df59..4da9477 100644 --- a/controller/internal/appbackup/userdata_bare_drive_guard_test.go +++ b/controller/internal/appbackup/userdata_bare_drive_guard_test.go @@ -36,7 +36,7 @@ import ( // naming the file and function; (2) userdataKept (stacks/delete.go) reverted to the bare hddPath → FAIL. func TestNoBareDrivePathToUserdataDir(t *testing.T) { root := moduleRoot(t) - calls, violations := scanUserdataDirCalls(t, root) + calls, violations := scanUserdataDirCalls(t, root, map[string]bool{"UserdataDir": true}) // Positive control (rule 3: an absent finding is not evidence): the scan MUST reach the known callers, // or a scope bug (wrong root, one-level walk) would turn this test green on an empty census. @@ -76,6 +76,57 @@ func TestNoBareDrivePathToUserdataDir(t *testing.T) { } } +// TestNoBareDrivePathToPathHelpers is the same guard for UserdataDir's three siblings (R-1110): RecoveryUnitPath, +// AppDataDir and PrimaryBackupPath take a NAMESPACE ROOT too (the NamespaceRootFor contract names all four), and +// package backup re-exports them, so both packages' calls are read. Same acceptance rule as above. +// +// THE CENSUS on 2026-10-17 (41 calls): every one passed a namespace root, but two only by a comment — +// stacks/delete.go GetStackBackupData's parameter was named drivePath (its one caller passes AppNamespaceRoot), +// and web/handlers.go appDetailsForPath passed a registered StoragePath, which is its namespace root only while +// the system drive is never a StoragePath. The first was renamed nsRoot; the second now goes through +// appbackup.NamespaceRootFor (same path on an enrolled drive, correct on the fallback). The loop variables of +// removed_units.go, offbox_placement.go and offbox.go (root, nr — each built by namespaceRoot) were renamed nsRoot. +// +// COMPANION RED-PROOFS (R-1110): (1) appDetailsForPath back to backup.AppDataDir(storagePath, name) → FAIL naming +// internal/web/handlers.go:appDetailsForPath; (2) GetStackBackupData's parameter back to drivePath → FAIL naming +// internal/stacks/delete.go; (3) an unqualified RecoveryUnitPath(drivePath, …) inside package backup → FAIL. +func TestNoBareDrivePathToPathHelpers(t *testing.T) { + root := moduleRoot(t) + calls, violations := scanUserdataDirCalls(t, root, map[string]bool{ + "RecoveryUnitPath": true, "AppDataDir": true, "PrimaryBackupPath": true, + }) + // Positive control: the census must reach callers in all four places the helpers are used from — an + // appbackup-qualified call (stacks), an unqualified call inside package backup, a backup-qualified call (web, + // api) — or a scope bug would turn this green on an empty census. + for _, want := range []string{ + "internal/stacks/migrate.go", + "internal/stacks/delete.go:GetStackBackupData", + "internal/backup/removed_units.go:ListRemovedAppUnits", + "internal/backup/tier2_capture.go", + "internal/web/handlers.go:appDetailsForPath", + "internal/api/router.go", + } { + found := false + for _, c := range calls { + if strings.HasPrefix(c, want) { + found = true + break + } + } + if !found { + t.Errorf("the census did not reach %s — the scan is blind (calls seen: %v)", want, calls) + } + } + if len(calls) < 30 { + t.Errorf("the census found %d calls; 41 on 2026-10-17 — a shrink this large is a scope bug, not a cleanup", len(calls)) + } + for _, v := range violations { + t.Errorf("%s:%d (%s): %s — the first argument is not a namespace root by construction. "+ + "Put a drive path through appbackup.NamespaceRootFor(drive, systemDataPath) first (R-203, R-1110).", + v.file, v.line, v.fn, v.arg) + } +} + // bareDriveAllowed is the count. Keyed "relative/file.go:Function" (not a line number, which drifts). var bareDriveAllowed = map[string]string{ "internal/stacks/media_library.go:mediaBindSource": "drive comes from GetStoragePaths (MediaDrives); the system " + @@ -90,6 +141,10 @@ type bareCall struct { const appbackupImport = "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" +// backupImport: package backup re-exports RecoveryUnitPath / AppDataDir / PrimaryBackupPath as one-line bridges +// (appbackup_bridge.go), so a bare drive path can reach the same helper through either package (R-1110). +const backupImport = "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + func moduleRoot(t *testing.T) string { t.Helper() dir, err := filepath.Abs(".") @@ -108,9 +163,10 @@ func moduleRoot(t *testing.T) string { } } -// scanUserdataDirCalls returns every UserdataDir call site ("file:Func") and those whose argument is not a -// namespace root by construction. Recursive walk (os.walk, not one level — the R-421 scope trap). -func scanUserdataDirCalls(t *testing.T, root string) (calls []string, bad []bareCall) { +// scanUserdataDirCalls returns every call site ("file:Func") of the named helpers (UserdataDir, or the R-1110 +// siblings) and those whose FIRST argument is not a namespace root by construction. Recursive walk (os.walk, not +// one level — the R-421 scope trap). +func scanUserdataDirCalls(t *testing.T, root string, helpers map[string]bool) (calls []string, bad []bareCall) { t.Helper() fset := token.NewFileSet() err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { @@ -133,7 +189,7 @@ func scanUserdataDirCalls(t *testing.T, root string) (calls []string, bad []bare } rel, _ := filepath.Rel(root, p) rel = filepath.ToSlash(rel) - c, b := scanFile(fset, f, rel) + c, b := scanFile(fset, f, rel, helpers) calls = append(calls, c...) bad = append(bad, b...) return nil @@ -145,29 +201,34 @@ func scanUserdataDirCalls(t *testing.T, root string) (calls []string, bad []bare return calls, bad } -// scanFile finds the UserdataDir references of one file. Inside package appbackup the call is unqualified; -// elsewhere it is .UserdataDir for the appbackup import (alias honoured). -func scanFile(fset *token.FileSet, f *ast.File, rel string) (calls []string, bad []bareCall) { - inPkg := f.Name.Name == "appbackup" - alias := "" +// scanFile finds the references to the named helpers in one file. Inside package appbackup (and, for the bridged +// siblings, package backup) the call is unqualified; elsewhere it is .Helper for either import (alias +// honoured). +func scanFile(fset *token.FileSet, f *ast.File, rel string, helpers map[string]bool) (calls []string, bad []bareCall) { + inPkg := (f.Name.Name == "appbackup" && strings.HasPrefix(rel, "internal/appbackup/")) || + (f.Name.Name == "backup" && strings.HasPrefix(rel, "internal/backup/")) + aliases := map[string]bool{} for _, imp := range f.Imports { - if path, _ := strconv.Unquote(imp.Path.Value); path == appbackupImport { - alias = "appbackup" - if imp.Name != nil { - alias = imp.Name.Name - } + path, _ := strconv.Unquote(imp.Path.Value) + if path != appbackupImport && path != backupImport { + continue } + name := filepath.Base(path) + if imp.Name != nil { + name = imp.Name.Name + } + aliases[name] = true } - if !inPkg && alias == "" { + if !inPkg && len(aliases) == 0 { return nil, nil } isUserdataDir := func(e ast.Expr) bool { switch x := e.(type) { case *ast.Ident: - return inPkg && x.Name == "UserdataDir" + return inPkg && helpers[x.Name] case *ast.SelectorExpr: id, ok := x.X.(*ast.Ident) - return ok && alias != "" && id.Name == alias && x.Sel.Name == "UserdataDir" + return ok && aliases[id.Name] && helpers[x.Sel.Name] } return false } @@ -176,11 +237,11 @@ func scanFile(fset *token.FileSet, f *ast.File, rel string) (calls []string, bad case *ast.Ident: return inPkg && (x.Name == "NamespaceRootFor" || x.Name == "NamespaceRoot") case *ast.SelectorExpr: - if id, ok := x.X.(*ast.Ident); ok && alias != "" && id.Name == alias { + if id, ok := x.X.(*ast.Ident); ok && aliases[id.Name] { return x.Sel.Name == "NamespaceRootFor" || x.Sel.Name == "NamespaceRoot" } switch x.Sel.Name { - case "GetStackNamespaceRoot", "StackNamespaceRoot", "namespaceRoot": + case "GetStackNamespaceRoot", "StackNamespaceRoot", "namespaceRoot", "AppNamespaceRoot": return true } } @@ -192,7 +253,12 @@ func scanFile(fset *token.FileSet, f *ast.File, rel string) (calls []string, bad case *ast.ParenExpr: return nsRootArg(x.X) case *ast.Ident: - return x.Name == "nsRoot" + // nsRoot, or a qualified one (sourceNsRoot, targetNsRoot) — R-1110 widened the name rule; same LIMIT. + return x.Name == "nsRoot" || strings.HasSuffix(x.Name, "NsRoot") + case *ast.SelectorExpr: + // The migration job's two RESOLVED roots (stacks/migrate.go: `json:"source_ns"` — „resolved felhom-data + // namespace root", set only from appbackup.NamespaceRoot). A field, so the name is the declaration. + return x.Sel.Name == "SourceNS" || x.Sel.Name == "TargetNS" case *ast.CallExpr: if sel, ok := x.Fun.(*ast.SelectorExpr); ok { if id, ok := sel.X.(*ast.Ident); ok && id.Name == "filepath" && sel.Sel.Name == "Clean" && len(x.Args) == 1 { @@ -213,9 +279,9 @@ func scanFile(fset *token.FileSet, f *ast.File, rel string) (calls []string, bad } called[call.Fun] = true calls = append(calls, rel+":"+fn) - if len(call.Args) != 1 || !nsRootArg(call.Args[0]) { + if len(call.Args) < 1 || !nsRootArg(call.Args[0]) { arg := "?" - if len(call.Args) == 1 { + if len(call.Args) >= 1 { arg = exprString(fset, call.Args[0]) } bad = append(bad, bareCall{file: rel, fn: fn, arg: arg, line: fset.Position(call.Pos()).Line}) @@ -223,13 +289,21 @@ func scanFile(fset *token.FileSet, f *ast.File, rel string) (calls []string, bad return true }) // A func-value reference (f := appbackup.UserdataDir) escapes the argument check — refuse it. - ast.Inspect(body, func(n ast.Node) bool { + var refs func(n ast.Node) bool + refs = func(n ast.Node) bool { e, ok := n.(ast.Expr) if ok && isUserdataDir(e) && !called[e] { bad = append(bad, bareCall{file: rel, fn: fn, arg: "", line: fset.Position(e.Pos()).Line}) } + // The Sel of pkg.Helper is an Ident with the helper's name; inside package backup it would read as an + // unqualified reference to backup's own bridge. Judge the selector as a whole, then only its X. + if sel, ok := n.(*ast.SelectorExpr); ok { + ast.Inspect(sel.X, refs) + return false + } return true - }) + } + ast.Inspect(body, refs) } for _, decl := range f.Decls { switch d := decl.(type) { diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 81858ca..c9c504b 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -1303,11 +1303,11 @@ func (m *Manager) offboxCandidateNSRoots() []string { // back to the unit dir mtime) and WARNs about the others. Independent of the app's live deploy state. func (m *Manager) discoverOffboxUnit(app string) (string, bool) { var foundSrc, foundManifest []string - for _, nr := range m.offboxCandidateNSRoots() { - p := RecoveryUnitPath(nr, app) + for _, nsRoot := range m.offboxCandidateNSRoots() { + p := RecoveryUnitPath(nsRoot, app) if fi, err := os.Stat(p); err == nil && fi.IsDir() { foundSrc = append(foundSrc, p) - foundManifest = append(foundManifest, RecoveryUnitManifestPath(nr, app)) + foundManifest = append(foundManifest, RecoveryUnitManifestPath(nsRoot, app)) } } switch len(foundSrc) { diff --git a/controller/internal/backup/offbox_placement.go b/controller/internal/backup/offbox_placement.go index 09251d9..1eae7f5 100644 --- a/controller/internal/backup/offbox_placement.go +++ b/controller/internal/backup/offbox_placement.go @@ -164,8 +164,8 @@ func (m *Manager) RecordedUnitForStack(stack string) (RecordedPlacement, Recorde } addRoot(m.systemDataPath) - for _, root := range roots { - unit := RecoveryUnitPath(root, stack) + for _, nsRoot := range roots { + unit := RecoveryUnitPath(nsRoot, stack) man := readManifest(filepath.Join(unit, "manifest.json")) if man == nil { continue diff --git a/controller/internal/backup/removed_units.go b/controller/internal/backup/removed_units.go index e682a36..0e23823 100644 --- a/controller/internal/backup/removed_units.go +++ b/controller/internal/backup/removed_units.go @@ -121,8 +121,8 @@ func (m *Manager) ListRemovedAppUnits() []RemovedAppUnit { } seen := make(map[string]bool) var out []RemovedAppUnit - for _, root := range m.primaryUnitRoots() { - entries, err := os.ReadDir(PrimaryBackupPath(root)) + for _, nsRoot := range m.primaryUnitRoots() { + entries, err := os.ReadDir(PrimaryBackupPath(nsRoot)) if err != nil { continue } @@ -131,7 +131,7 @@ func (m *Manager) ListRemovedAppUnits() []RemovedAppUnit { if !e.IsDir() || deployed[name] || seen[name] { continue } - unitDir := RecoveryUnitPath(root, name) + unitDir := RecoveryUnitPath(nsRoot, name) man := readManifest(UnitManifestFile(unitDir)) if man == nil { continue @@ -149,7 +149,7 @@ func (m *Manager) ListRemovedAppUnits() []RemovedAppUnit { StackName: name, DisplayName: display, UnitDir: unitDir, - DriveLabel: m.driveLabelForRoot(root), + DriveLabel: m.driveLabelForRoot(nsRoot), Time: newest.UTC().Format(time.RFC3339), }) } diff --git a/controller/internal/family/family.go b/controller/internal/family/family.go index 4556049..98380f1 100644 --- a/controller/internal/family/family.go +++ b/controller/internal/family/family.go @@ -10,12 +10,15 @@ // setup gate's rule, decision 46). It is never a dashboard session and never opens the dashboard. // // Persisted as family.json (0600, tmp+fsync+rename) in the controller's data dir, so the controller's own backup and -// restore carry it and a restart keeps every session. Hashes only — a password is shown once, when it is made. -// Pinned by internal/family/family_test.go. +// restore carry it and a restart keeps every session. Hashes only — a password is shown once, when it is made, and a +// session is stored as its FINGERPRINT (R-1112, the R-35 shape of the dashboard's sessions): the cookie value itself is +// never written, and presenting a fingerprint as a cookie is hashed again and misses. A file written before R-1112 +// holds the values themselves; Open converts it and rewrites it at once. Pinned by internal/family/family_test.go. package family import ( "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "errors" @@ -42,9 +45,11 @@ type Member struct { Created string `json:"created"` } -// Session is one sign-in. Member "" = the household (a dashboard session vouched for the browser). +// Session is one sign-in. Member "" = the household (a dashboard session vouched for the browser). FP is the session's +// fingerprint (Fingerprint of the cookie value) — the store's key and what is written to disk; the cookie value is +// known only to the browser that holds it. type Session struct { - ID string `json:"id"` + FP string `json:"fp"` Member string `json:"member"` Gen int `json:"gen"` Exp time.Time `json:"exp"` @@ -55,6 +60,22 @@ type file struct { Sessions []Session `json:"sessions"` } +// oldFile reads both shapes: a session written before R-1112 carries the cookie value itself as "id". +type oldFile struct { + Members []Member `json:"members"` + Sessions []struct { + Session + ID string `json:"id"` + } `json:"sessions"` +} + +// Fingerprint is a session's key in the store and on disk: hex(sha256(cookie value)) — sessionFingerprint's shape in +// internal/web (R-35). 24 random bytes cannot be found from it, and a fingerprint presented as a cookie misses. +func Fingerprint(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + // Store is the family list and its sessions. A nil *Store answers "nobody" to every question. type Store struct { path string @@ -79,6 +100,10 @@ var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`) // Open loads (or starts) the store at dir/family.json. A file that cannot be parsed is an error, never an empty list: // an empty list would silently lock every member out, and a rewrite would destroy the household's list. +// +// R-1112: a session still stored as its cookie value is converted to its fingerprint and the file is rewritten at once +// (tmp+fsync+rename), so its members stay signed in and the value leaves the disk. A rewrite that fails is an error +// too (the gate stays shut for members, the household still passes): carrying on would leave the values on disk. func Open(dir string) (*Store, error) { s := &Store{path: filepath.Join(dir, "family.json"), now: time.Now, cost: bcrypt.DefaultCost, members: map[string]*Member{}, sessions: map[string]Session{}} @@ -89,7 +114,7 @@ func Open(dir string) (*Store, error) { if err != nil { return nil, err } - var f file + var f oldFile if err := json.Unmarshal(b, &f); err != nil { return nil, fmt.Errorf("family.json unreadable: %w", err) } @@ -97,8 +122,25 @@ func Open(dir string) (*Store, error) { m := f.Members[i] s.members[m.Name] = &m } - for _, se := range f.Sessions { - s.sessions[se.ID] = se + converted := 0 + for _, e := range f.Sessions { + se := e.Session + if se.FP == "" && e.ID != "" { + se.FP = Fingerprint(e.ID) + converted++ + } + if se.FP == "" { + continue + } + s.sessions[se.FP] = se + } + if converted > 0 { + s.mu.Lock() + err := s.saveLocked() + s.mu.Unlock() + if err != nil { + return nil, fmt.Errorf("family.json: %d session(s) stored the old way could not be rewritten as fingerprints: %w", converted, err) + } } return s, nil } @@ -114,14 +156,14 @@ func (s *Store) saveLocked() error { } sort.Slice(f.Members, func(i, j int) bool { return f.Members[i].Name < f.Members[j].Name }) now := s.now() - for id, se := range s.sessions { + for fp, se := range s.sessions { if now.After(se.Exp) { - delete(s.sessions, id) + delete(s.sessions, fp) continue } f.Sessions = append(f.Sessions, se) } - sort.Slice(f.Sessions, func(i, j int) bool { return f.Sessions[i].ID < f.Sessions[j].ID }) + sort.Slice(f.Sessions, func(i, j int) bool { return f.Sessions[i].FP < f.Sessions[j].FP }) b, err := json.MarshalIndent(f, "", " ") if err != nil { return err @@ -222,9 +264,9 @@ func (s *Store) Remove(name string) error { } func (s *Store) dropSessionsLocked(name string) { - for id, se := range s.sessions { + for fp, se := range s.sessions { if se.Member == name { - delete(s.sessions, id) + delete(s.sessions, fp) } } } @@ -264,7 +306,8 @@ func (s *Store) Verify(name, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } -// NewSession starts a session for member ("" = the household). It refuses a member who does not exist. +// NewSession starts a session for member ("" = the household) and returns its cookie value — the only time the value +// exists outside the browser; the store keeps its Fingerprint. It refuses a member who does not exist. func (s *Store) NewSession(member string) (string, error) { if s == nil { return "", ErrNoMember @@ -273,7 +316,8 @@ func (s *Store) NewSession(member string) (string, error) { if _, err := rand.Read(b); err != nil { return "", err } - id := hex.EncodeToString(b) + value := hex.EncodeToString(b) + fp := Fingerprint(value) s.mu.Lock() defer s.mu.Unlock() gen := 0 @@ -284,23 +328,62 @@ func (s *Store) NewSession(member string) (string, error) { } gen = m.Gen } - s.sessions[id] = Session{ID: id, Member: member, Gen: gen, Exp: s.now().Add(SessionLife)} + s.sessions[fp] = Session{FP: fp, Member: member, Gen: gen, Exp: s.now().Add(SessionLife)} if err := s.saveLocked(); err != nil { - delete(s.sessions, id) + delete(s.sessions, fp) return "", err } - return id, nil + return value, nil } -// Valid reports whether a session still holds: it exists, has not expired, and — for a member — the member still -// exists at the generation the session was made in. -func (s *Store) Valid(id string) (Session, bool) { - if s == nil || id == "" { +// HouseholdSession returns the household's live session, starting one only when there is none with at least half of +// SessionLife left (R-1113). Before, every household pass through /__family/start minted a new 30-day session and +// family.json grew by one each time. The household's session never reaches a browser (only app tokens derived from +// its fingerprint do), so one serves every pass; the half-life rule keeps an app cookie minted from it backed for at +// least 15 days. Check and start happen under one lock, so parallel passes start at most one. +func (s *Store) HouseholdSession() (Session, error) { + if s == nil { + return Session{}, ErrNoMember + } + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + var best Session + for _, se := range s.sessions { + if se.Member == "" && se.Exp.After(best.Exp) { + best = se + } + } + if best.FP != "" && best.Exp.Sub(now) >= SessionLife/2 { + return best, nil + } + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return Session{}, err + } + fp := Fingerprint(hex.EncodeToString(b)) + se := Session{FP: fp, Exp: now.Add(SessionLife)} + s.sessions[fp] = se + if err := s.saveLocked(); err != nil { + delete(s.sessions, fp) + return Session{}, err + } + return se, nil +} + +// Valid reports whether the session a browser presents (its cookie value) still holds: it exists, has not expired, +// and — for a member — the member still exists at the generation the session was made in. +func (s *Store) Valid(value string) (Session, bool) { + if s == nil || value == "" { return Session{}, false } s.mu.Lock() defer s.mu.Unlock() - se, ok := s.sessions[id] + return s.validLocked(Fingerprint(value)) +} + +func (s *Store) validLocked(fp string) (Session, bool) { + se, ok := s.sessions[fp] if !ok || s.now().After(se.Exp) { return Session{}, false } @@ -313,26 +396,21 @@ func (s *Store) Valid(id string) (Session, bool) { return se, true } -// Find returns the first session that match accepts and that still holds (the same test as Valid). It lets a caller -// look a session up by something DERIVED from its id — the per-app token the family gate puts in an app's cookie -// (R-976) — without that id ever leaving the dashboard host. -func (s *Store) Find(match func(id string) bool) (Session, bool) { +// Find returns the first session whose fingerprint match accepts and that still holds (the same test as Valid). It +// lets a caller look a session up by something DERIVED from it — the per-app token the family gate puts in an app's +// cookie (R-976) — without the session ever leaving the dashboard host. +func (s *Store) Find(match func(fp string) bool) (Session, bool) { if s == nil || match == nil { return Session{}, false } s.mu.Lock() - var found string - for id := range s.sessions { - if match(id) { - found = id - break + defer s.mu.Unlock() + for fp := range s.sessions { + if match(fp) { + return s.validLocked(fp) } } - s.mu.Unlock() - if found == "" { - return Session{}, false - } - return s.Valid(found) + return Session{}, false } // EndAll deletes every session — every member's and the household's — and keeps the members (R-976: a dashboard @@ -357,16 +435,17 @@ func (s *Store) EndAll() (int, error) { return n, nil } -// EndSession deletes a session (logout). Unknown ids are not an error. -func (s *Store) EndSession(id string) error { +// EndSession deletes the session a browser presents (logout, by its cookie value). Unknown values are not an error. +func (s *Store) EndSession(value string) error { if s == nil { return nil } + fp := Fingerprint(value) s.mu.Lock() defer s.mu.Unlock() - if _, ok := s.sessions[id]; !ok { + if _, ok := s.sessions[fp]; !ok { return nil } - delete(s.sessions, id) + delete(s.sessions, fp) return s.saveLocked() } diff --git a/controller/internal/family/family_test.go b/controller/internal/family/family_test.go index 015efa6..54448cc 100644 --- a/controller/internal/family/family_test.go +++ b/controller/internal/family/family_test.go @@ -125,3 +125,24 @@ func TestFamily_NilStoreAnswersNobody(t *testing.T) { t.Fatal("nil store valid") } } + +// R-1112: a file written before fingerprints that cannot be rewritten is an error, never a store that carries on with +// the session values left on disk. +func TestFamily_OldFileThatCannotBeRewrittenIsAnError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root writes into a read-only directory") + } + dir := t.TempDir() + exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + old := `{"members":[],"sessions":[{"id":"a1b2c3","member":"","gen":0,"exp":"` + exp + `"}]}` + os.WriteFile(filepath.Join(dir, "family.json"), []byte(old), 0o600) + os.Chmod(dir, 0o500) + defer os.Chmod(dir, 0o700) + if _, err := Open(dir); err == nil { + t.Fatal("an old file that could not be rewritten opened as a store") + } + b, _ := os.ReadFile(filepath.Join(dir, "family.json")) + if string(b) != old { + t.Fatal("the failed rewrite changed the file") + } +} diff --git a/controller/internal/infra/samba.go b/controller/internal/infra/samba.go index 5c8f5d3..68b1f34 100644 --- a/controller/internal/infra/samba.go +++ b/controller/internal/infra/samba.go @@ -1,6 +1,8 @@ package infra import ( + "crypto/sha256" + "encoding/hex" "fmt" "sort" "strings" @@ -96,6 +98,13 @@ func RenderSambaConfig(d SambaData) string { // the default bridge is deaf to LAN multicast), the pinned image, smb.conf bind-mounted read-only, // the passdb named volume, and one bind per share (:ro for read-only shares — defense in depth beside // the smb.conf-level `read only`). Deterministic: share binds are sorted so the output is stable. +// +// The label felhom.smb-conf-sha256 carries the hash of the smb.conf this compose runs with (R-1109). smb.conf is a +// bind-mounted FILE replaced by atomic rename, so a running container keeps the old inode, and `compose up -d` +// recreates only when the COMPOSE changes: a share renamed at the same path changed smb.conf alone and was never +// served. With the hash in the compose, a config change is a compose change, so compose recreates — and an +// unchanged config renders a byte-identical compose, so a pass with nothing new recreates nothing. +// Pinned by TestRenderSambaCompose_ConfigHashLabel (infra) and TestSambaReconcile_SamePathRenameChangesCompose. func RenderSambaCompose(d SambaData) string { shares := make([]SambaShareRender, len(d.Shares)) copy(shares, d.Shares) @@ -121,6 +130,8 @@ services: container_name: %[1]s restart: unless-stopped network_mode: host + labels: + - felhom.smb-conf-sha256=%[9]s environment: - FELHOM_SERVER_NAME=%[3]s - FELHOM_IFACE=%[8]s @@ -132,5 +143,12 @@ services: %[7]s volumes: %[5]s: -`, SambaContainerName, SambaImage, d.ServerName, d.UID, SambaPassdbVolume, SambaPassdbMount, binds.String(), SambaHostInterface) +`, SambaContainerName, SambaImage, d.ServerName, d.UID, SambaPassdbVolume, SambaPassdbMount, binds.String(), SambaHostInterface, + SambaConfigHash(d)) +} + +// SambaConfigHash is the sha256 (hex) of the smb.conf RenderSambaConfig produces for d — the compose label value. +func SambaConfigHash(d SambaData) string { + sum := sha256.Sum256([]byte(RenderSambaConfig(d))) + return hex.EncodeToString(sum[:]) } diff --git a/controller/internal/infra/samba_test.go b/controller/internal/infra/samba_test.go index 0f4cade..4b224f6 100644 --- a/controller/internal/infra/samba_test.go +++ b/controller/internal/infra/samba_test.go @@ -108,3 +108,31 @@ func TestRenderSambaCompose(t *testing.T) { t.Error("writable share must NOT be :ro") } } + +// R-1109: a share renamed at the SAME path changes smb.conf only. The compose must change with it (the config-hash +// label), or `compose up -d` keeps the running container on the old bind-mounted inode and the new name is never +// served. And an unchanged config must render a byte-identical compose (no recreate on every pass). +func TestRenderSambaCompose_ConfigHashLabel(t *testing.T) { + path := "/mnt/felhom-drives/hdd_1/userdata/shares/iratok" + before := SambaData{ServerName: "FELHOM", UID: 1000, Shares: []SambaShareRender{{Name: "dokumentumok", Path: path}}} + after := SambaData{ServerName: "FELHOM", UID: 1000, Shares: []SambaShareRender{{Name: "iratok", Path: path}}} + + if RenderSambaConfig(before) == RenderSambaConfig(after) { + t.Fatal("setup: the rename must change smb.conf") + } + cb, ca := RenderSambaCompose(before), RenderSambaCompose(after) + if cb == ca { + t.Fatalf("a same-path rename left the compose byte-identical — compose up -d will not recreate (R-1109):\n%s", ca) + } + if want := "- felhom.smb-conf-sha256=" + SambaConfigHash(after); !strings.Contains(ca, want) { + t.Errorf("compose missing the label %q\n%s", want, ca) + } + if RenderSambaCompose(after) != ca { + t.Error("the same data must render the same compose (a recreate on every pass otherwise)") + } + // The premise of the bug, pinned: the binds are the same on both sides, so before the label nothing else differed. + bind := " - " + path + ":" + path + "\n" + if !strings.Contains(cb, bind) || !strings.Contains(ca, bind) { + t.Errorf("both composes must bind %q", bind) + } +} diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 048d3d4..8d6df83 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -884,14 +884,14 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo } // GetStackBackupData returns information about backup data for a stack — what the remove dialog -// sizes "delete backups" from. drivePath is the app's namespace root; mirrorDirs are the app's +// sizes "delete backups" from. nsRoot is the app's namespace root; mirrorDirs are the app's // Tier-2 mirror directories (backup.Manager.Tier2MirrorDirsForApp — stacks cannot import backup). // // R-485 (v0.240.0): until v0.239.0 this read `/backups/primary//db-dumps` and a pre-v2 // `/backups/secondary//rsync` path — both dead for an app whose backups are the recovery // unit and a v2 mirror — and answered `has_backups:false` over 484 MB (measured on demo-hp // 2026-09-13). It now sizes the whole recovery unit and every mirror. -func (m *Manager) GetStackBackupData(name string, drivePath string, mirrorDirs []string) (*BackupDataResponse, error) { +func (m *Manager) GetStackBackupData(name string, nsRoot string, mirrorDirs []string) (*BackupDataResponse, error) { _, ok := m.GetStack(name) if !ok { return nil, util.KindErrorf(ErrStackNotFound, "stack %q not found", name) @@ -901,16 +901,17 @@ func (m *Manager) GetStackBackupData(name string, drivePath string, mirrorDirs [ Stack: name, } - if drivePath == "" { + if nsRoot == "" { if m.isDebug() { - m.logger.Printf("[DEBUG] [stacks] GetStackBackupData %s: no drive path provided, returning empty", name) + m.logger.Printf("[DEBUG] [stacks] GetStackBackupData %s: no namespace root provided, returning empty", name) } return resp, nil } - // The recovery unit — definition, db-dumps and volume-dumps together. drivePath is the felhom-data - // namespace ROOT (Model A: the in-guest drive mount itself), so backups/ sits directly under it. - resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(appbackup.RecoveryUnitPath(drivePath, name))) + // The recovery unit — definition, db-dumps and volume-dumps together. nsRoot is the felhom-data + // namespace ROOT (its one caller passes backup.AppNamespaceRoot), so backups/ sits directly under it. + // Named nsRoot (was drivePath) so TestNoBareDrivePathToPathHelpers reads it (R-1110). + resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(appbackup.RecoveryUnitPath(nsRoot, name))) for _, d := range mirrorDirs { resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(d)) } diff --git a/controller/internal/stacks/samba_test.go b/controller/internal/stacks/samba_test.go index f710b16..105eaa9 100644 --- a/controller/internal/stacks/samba_test.go +++ b/controller/internal/stacks/samba_test.go @@ -167,6 +167,53 @@ func TestSambaReconcile_IdempotentNoComposeCall(t *testing.T) { } } +// R-1109: the household renames a share at the same folder (remove + add, the UI's way). The rendered compose on +// disk must change, so the compose up the reconcile runs RECREATES the container and the new name is served; a +// second pass with nothing new performs no compose call. +func TestSambaReconcile_SamePathRenameChangesCompose(t *testing.T) { + m, sett, root, upCalls := newSambaManager(t) + shareDir := seedShare(t, sett, root, "dokumentumok") + enableSMB(t, sett) + if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir}); err != nil { + t.Fatal(err) + } + if err := m.ReconcileSamba(); err != nil { + t.Fatal(err) + } + composePath := filepath.Join(m.sambaDir(), "docker-compose.yml") + c1, err := os.ReadFile(composePath) + if err != nil { + t.Fatal(err) + } + m.sambaRunFn = func() bool { return true } + if err := sett.RemoveSMBShare("dokumentumok"); err != nil { + t.Fatal(err) + } + if err := sett.AddSMBShare(settings.SMBShare{Name: "iratok", Path: shareDir}); err != nil { + t.Fatal(err) + } + before := *upCalls + if err := m.ReconcileSamba(); err != nil { + t.Fatal(err) + } + c2, err := os.ReadFile(composePath) + if err != nil { + t.Fatal(err) + } + if string(c1) == string(c2) { + t.Fatalf("a same-path rename left docker-compose.yml unchanged — the running container is never recreated (R-1109)") + } + if *upCalls != before+1 { + t.Errorf("the rename must run exactly one compose up, got %d", *upCalls-before) + } + if err := m.ReconcileSamba(); err != nil { + t.Fatal(err) + } + if *upCalls != before+1 { + t.Errorf("a pass with nothing new must perform no compose call, got %d more", *upCalls-before-1) + } +} + // Edge case: a share on a DISCONNECTED storage path is never exported (config retained). func TestSambaReconcile_DeadMountOmitted(t *testing.T) { m, sett, root, _ := newSambaManager(t) diff --git a/controller/internal/web/auth.go b/controller/internal/web/auth.go index 6f99d6f..ed80d0e 100644 --- a/controller/internal/web/auth.go +++ b/controller/internal/web/auth.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "strings" + "sync" "time" "golang.org/x/crypto/bcrypt" @@ -218,20 +219,28 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { // reserveLoginAttempt counts one sign-in try for this visitor BEFORE the password is compared and reports whether it // may go ahead (and the count it found). Check and count happen under one lock, so parallel tries cannot all pass a -// check that none of them has counted yet. A window that has passed starts clean. +// check that none of them has counted yet. A window that has passed starts clean. The escrow wizard's re-auth uses it +// too (the same counter: a wrong password is a wrong password, wherever it was typed). func (s *Server) reserveLoginAttempt(ip string) (int, bool) { - s.loginAttemptMu.Lock() - defer s.loginAttemptMu.Unlock() - if s.loginAttempts == nil { - s.loginAttempts = map[string]*loginAttempt{} + return reserveAttempt(&s.loginAttemptMu, &s.loginAttempts, ip) +} + +// reserveAttempt is the one body of the 5-per-minute password limiters: check and count under mu, in one step. Each +// limiter keeps its OWN map (the share password's is not the dashboard's, so a guest and the owner never share a +// counter); the map is allocated lazily, so struct-literal test servers that skip NewServer still work. +func reserveAttempt(mu *sync.Mutex, attempts *map[string]*loginAttempt, key string) (int, bool) { + mu.Lock() + defer mu.Unlock() + if *attempts == nil { + *attempts = map[string]*loginAttempt{} } - a := s.loginAttempts[ip] + a := (*attempts)[key] if a != nil && time.Since(a.lastFail) > loginWindowDuration { a = nil } if a == nil { a = &loginAttempt{} - s.loginAttempts[ip] = a + (*attempts)[key] = a } if a.count >= loginMaxAttempts { return a.count, false @@ -241,7 +250,8 @@ func (s *Server) reserveLoginAttempt(ip string) (int, bool) { return a.count, true } -// comparePassword is bcrypt's compare; passwordCompare replaces it in tests (a slow compare makes a race measurable). +// comparePassword is bcrypt's compare for the dashboard sign-in, the escrow re-auth and the share password; +// passwordCompare replaces it in tests (a slow compare makes a race measurable). func (s *Server) comparePassword(hash, pw []byte) error { if s.passwordCompare != nil { return s.passwordCompare(hash, pw) diff --git a/controller/internal/web/escrow_handlers.go b/controller/internal/web/escrow_handlers.go index 079d636..3b5c03c 100644 --- a/controller/internal/web/escrow_handlers.go +++ b/controller/internal/web/escrow_handlers.go @@ -9,7 +9,6 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/util" - "golang.org/x/crypto/bcrypt" ) // Controller-driven escrow ceremony wizard (v0.127.0, agent ≥ v0.88.0; mechanics validated by @@ -185,14 +184,15 @@ func (s *Server) escrowStartAPIHandler(w http.ResponseWriter, r *http.Request) { return } ip := rateKey(r) - if s.escrowRateLimited(ip) { + // R-1111: the try is reserved on the login counter BEFORE the slow compare (the R-983 shape); a right password + // clears it below. Pinned by TestR1111_ParallelEscrowReauthsHitTheLimit. + if _, ok := s.reserveLoginAttempt(ip); !ok { s.logger.Printf("[WARN] [web] escrow start rate limited for %s", ip) escrowJSON(w, http.StatusTooManyRequests, nil, s.msg(r, "api.escrow.too_many_attempts")) return } - if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))); err != nil { + if err := s.comparePassword([]byte(hash), []byte(r.FormValue("password"))); err != nil { s.logger.Printf("[WARN] [web] escrow start: failed re-auth from %s", r.RemoteAddr) - s.recordEscrowAuthFailure(ip) escrowJSON(w, http.StatusUnauthorized, nil, "Hibás jelszó.") return } @@ -313,33 +313,8 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) { _ = code } -// escrowRateLimited / recordEscrowAuthFailure / clearAuthFailures ride the SAME per-IP counter as -// the login form (loginAttempts, loginMaxAttempts, loginWindowDuration) — a wrong wizard password -// is a wrong password, wherever it was typed. -func (s *Server) escrowRateLimited(ip string) bool { - s.loginAttemptMu.Lock() - defer s.loginAttemptMu.Unlock() - attempt := s.loginAttempts[ip] - if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration { - delete(s.loginAttempts, ip) - return false - } - return attempt != nil && attempt.count >= loginMaxAttempts -} - -func (s *Server) recordEscrowAuthFailure(ip string) { - s.loginAttemptMu.Lock() - defer s.loginAttemptMu.Unlock() - if s.loginAttempts == nil { - s.loginAttempts = map[string]*loginAttempt{} - } - if s.loginAttempts[ip] == nil { - s.loginAttempts[ip] = &loginAttempt{} - } - s.loginAttempts[ip].count++ - s.loginAttempts[ip].lastFail = time.Now() -} - +// clearAuthFailures clears the login counter after a right re-auth password — the counter reserveLoginAttempt counts +// on, shared with the login form (a wrong wizard password is a wrong password, wherever it was typed). func (s *Server) clearAuthFailures(ip string) { s.loginAttemptMu.Lock() defer s.loginAttemptMu.Unlock() diff --git a/controller/internal/web/family_gate.go b/controller/internal/web/family_gate.go index f80a05e..84e0ffb 100644 --- a/controller/internal/web/family_gate.go +++ b/controller/internal/web/family_gate.go @@ -158,8 +158,10 @@ func (s *Server) familyRDHost(rd string) (string, bool) { // family-gated app's backend receives its cookies, and a session id lifted from one app, set as the family session // cookie on the dashboard host, minted passes into EVERY family app for 30 days. A derived token cannot be turned back // into the session, and it opens only its own host. Pinned by TestR976_AnAppCookieCannotBeLiftedIntoAnotherApp. -func (s *Server) familyAppToken(host, sid string) string { - return s.gateMAC("famapp", host, sid) +// R-1112: it is derived from the session's FINGERPRINT (fp) — all the store keeps — so app cookies minted before +// R-1112 (derived from the value) are refused once and the browser passes through the sign-in start again. +func (s *Server) familyAppToken(host, fp string) string { + return s.gateMAC("famapp", host, fp) } // familySessionForAppToken finds the session an app token was derived from (still valid, as Store.Valid judges it). @@ -167,8 +169,8 @@ func (s *Server) familySessionForAppToken(host, at string) (family.Session, bool if at == "" { return family.Session{}, false } - return s.familyStore().Find(func(id string) bool { - return hmac.Equal([]byte(s.familyAppToken(host, id)), []byte(at)) + return s.familyStore().Find(func(fp string) bool { + return hmac.Equal([]byte(s.familyAppToken(host, fp)), []byte(at)) }) } @@ -181,10 +183,10 @@ type familyToken struct { MAC string `json:"m"` } -func (s *Server) mintFamilyToken(host, sid, rd string) string { +func (s *Server) mintFamilyToken(host, fp, rd string) string { nb := make([]byte, 12) _, _ = rand.Read(nb) - t := familyToken{Host: host, App: s.familyAppToken(host, sid), Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd} + t := familyToken{Host: host, App: s.familyAppToken(host, fp), Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd} t.MAC = s.gateMAC("ftoken", t.Host, t.App, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD) b, _ := json.Marshal(t) return base64.RawURLEncoding.EncodeToString(b) @@ -334,16 +336,18 @@ func (s *Server) ServeFamilyStart(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") se, ok := s.familySessionFromCookie(r) if !ok && s.hasSession(r) { - // The household's own dashboard session vouches (decision 46's rule) — a household session in the family store, - // never the dashboard session itself. + // The household's own dashboard session vouches (decision 46's rule) — the household's session in the family + // store, never the dashboard session itself. R-1113: its live one, not a new one on every pass. if st := s.familyStore(); st != nil { - if sid, err := st.NewSession(""); err == nil { - se, ok = family.Session{ID: sid}, true + if hs, err := st.HouseholdSession(); err == nil { + se, ok = hs, true + } else { + s.logger.Printf("[ERROR] [web] family start: the household's family session could not be saved: %v", err) } } } if ok { - http.Redirect(w, r, "https://"+host+familyCallbackURI+"?"+url.Values{"t": {s.mintFamilyToken(host, se.ID, rd)}}.Encode(), http.StatusFound) + http.Redirect(w, r, "https://"+host+familyCallbackURI+"?"+url.Values{"t": {s.mintFamilyToken(host, se.FP, rd)}}.Encode(), http.StatusFound) return } s.renderFamilyPage(w, r, rd, "", http.StatusOK) @@ -409,9 +413,9 @@ func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) { s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_in"), http.StatusOK) } -// familyLogoutToken is the sign-out form's token, bound to the family session it ends (R-983, v0.324.0). The sign-in -// form's token (familyFormToken) is the same for every visitor, so a stranger could fetch one and use it; this one -// names the session, which another page never sees. +// familyLogoutToken is the sign-out form's token, bound to the family session it ends (R-983, v0.324.0) — to the +// cookie value the browser holds (sid). The sign-in form's token (familyFormToken) is the same for every visitor, so a +// stranger could fetch one and use it; this one names the session, which another page never sees. func (s *Server) familyLogoutToken(sid string) string { return s.gateMAC("famlogout", sid) } @@ -448,9 +452,10 @@ func (s *Server) renderFamilyPage(w http.ResponseWriter, r *http.Request, rd, no } } } - if se, ok := s.familySessionFromCookie(r); ok { + if _, ok := s.familySessionFromCookie(r); ok { + c, _ := r.Cookie(familySessionCookie) // present: familySessionFromCookie found it data["SignedIn"] = true - data["LogoutToken"] = s.familyLogoutToken(se.ID) + data["LogoutToken"] = s.familyLogoutToken(c.Value) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") diff --git a/controller/internal/web/family_gate_test.go b/controller/internal/web/family_gate_test.go index 369ec83..b601067 100644 --- a/controller/internal/web/family_gate_test.go +++ b/controller/internal/web/family_gate_test.go @@ -287,7 +287,7 @@ func TestFamilyGate_TokenOneUseBoundToHost(t *testing.T) { s, st := familyHarness(t) st.Add("anna") sid, _ := st.NewSession("anna") - tok := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/") + tok := s.mintFamilyToken("fapp.example.hu", family.Fingerprint(sid), "https://fapp.example.hu/") if _, _, err := s.takeFamilyToken(tok, "other.example.hu"); err == nil { t.Fatal("a token for another host must be refused") } @@ -297,7 +297,11 @@ func TestFamilyGate_TokenOneUseBoundToHost(t *testing.T) { if _, _, err := s.takeFamilyToken(tok, "fapp.example.hu"); err == nil { t.Fatal("a token must be one-use") } - tok2 := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/") + tok2 := s.mintFamilyToken("fapp.example.hu", family.Fingerprint(sid), "https://fapp.example.hu/") + tok3 := s.mintFamilyToken("fapp.example.hu", family.Fingerprint(sid), "https://fapp.example.hu/") + if w := famAsk(s, "fapp.example.hu", "GET", familyCallbackURI+"?t="+tok3, "text/html"); w.Code != http.StatusFound { + t.Fatalf("a token whose session holds must pass: %d", w.Code) + } st.EndSession(sid) if w := famAsk(s, "fapp.example.hu", "GET", familyCallbackURI+"?t="+tok2, "text/html"); w.Code != http.StatusForbidden { t.Fatalf("a token whose session ended must be refused: %d", w.Code) diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index b26c6bf..191a605 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -3427,11 +3427,13 @@ func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail { } // Try to get data size from the storage subdirectory. F-S2: the app's real appdata dir name is // NOT always the stack name (paperless-ngx writes appdata/paperless) — sum the resolved dir(s). - // Here hddPath == storagePath (the drive's in-guest mount is the namespace root, Model A). + // storagePath is a DRIVE path; through the resolver (R-1110). On an enrolled drive that is the path + // itself (Model A), on the system-data fallback it adds felhom-data — the only correct root either way. + nsRoot := appbackup.NamespaceRootFor(storagePath, s.cfg.Paths.SystemDataPath) var total int64 var any bool for _, name := range s.stackMgr.ResolveAppDataDirNames(stack.Name) { - d := backup.AppDataDir(storagePath, name) + d := backup.AppDataDir(nsRoot, name) if fi, err := os.Stat(d); err == nil && fi.IsDir() { total += dirSizeBytesWalk(d) any = true diff --git a/controller/internal/web/r1111_r1113_test.go b/controller/internal/web/r1111_r1113_test.go new file mode 100644 index 0000000..7577be4 --- /dev/null +++ b/controller/internal/web/r1111_r1113_test.go @@ -0,0 +1,310 @@ +package web + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + + "gitea.dooplex.hu/admin/felhom-controller/internal/family" +) + +// R-1111 (the share-password and escrow re-auth limiters reserve the try before the slow compare), R-1112 (family +// sessions are stored as fingerprints) and R-1113 (the household's family session is reused, not minted on every pass). +// Each test drives the handler the browser reaches. + +// slowWrongCompare makes s's password compare wait until `tries` callers have arrived (or a short deadline) and then +// answer "wrong": with check-then-count every try reaches it; with the try reserved first only loginMaxAttempts do. +// It returns the counter of tries that reached the compare. +func slowWrongCompare(s *Server, tries int32) *int32 { + var calls int32 + all := make(chan struct{}) + var once sync.Once + s.passwordCompare = func(hash, pw []byte) error { + if atomic.AddInt32(&calls, 1) == tries { + once.Do(func() { close(all) }) + } + select { + case <-all: + case <-time.After(400 * time.Millisecond): + } + return errors.New("mismatch") + } + return &calls +} + +// inParallel runs f(i) for i in [0,n) released at once, and waits for all. +func inParallel(n int, f func(i int)) { + var wg sync.WaitGroup + start := make(chan struct{}) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + f(i) + }(i) + } + close(start) + wg.Wait() +} + +// ── R-1111: parallel share-password tries are counted ──────────────────────────────────────────────────────── + +func TestR1111_ParallelSharePasswordsHitTheLimit(t *testing.T) { + s := shareTestServer(t) + mux := s.fullMux() + s.settings.SetLauncherShareToken(testShareToken) + hash, _ := bcrypt.GenerateFromPassword([]byte("guest-secret"), bcrypt.MinCost) + s.settings.SetLauncherSharePasswordHash(string(hash)) + path := "/s/" + testShareToken + const tries = 20 + calls := slowWrongCompare(s, tries) + var locked int32 + inParallel(tries, func(int) { + if strings.Contains(s.sharePOST(mux, path, "wrong").Body.String(), "Túl sok sikertelen") { + atomic.AddInt32(&locked, 1) + } + }) + if got := atomic.LoadInt32(calls); got > loginMaxAttempts { + t.Fatalf("%d parallel wrong share passwords reached the compare — the limit is %d per visitor", got, loginMaxAttempts) + } + if got := atomic.LoadInt32(&locked); got != tries-loginMaxAttempts { + t.Fatalf("%d tries were told they were locked, want %d", got, tries-loginMaxAttempts) + } + // the share's counter is its own: the household's dashboard counter is untouched + if len(s.loginAttempts) != 0 { + t.Fatalf("the share password counted on the dashboard's counter: %v", s.loginAttempts) + } + // another visitor with the right password still gets in, and a right password clears that visitor's count + s.passwordCompare = nil + csrf := s.shareCSRFToken() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(url.Values{"_csrf": {csrf}, "password": {"guest-secret"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: shareCSRFCookie, Value: csrf}) + req.RemoteAddr = "203.0.113.77:4000" + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusSeeOther { + t.Fatalf("another visitor with the right share password: %d", rr.Code) + } + if s.shareAttempts["203.0.113.77"] != nil { + t.Fatal("a right share password must clear the visitor's count") + } +} + +// ── R-1111: parallel escrow re-auth tries are counted ──────────────────────────────────────────────────────── + +func TestR1111_ParallelEscrowReauthsHitTheLimit(t *testing.T) { + s := rateLimitTestServer(t) + const tries = 20 + calls := slowWrongCompare(s, tries) + var wrong, locked int32 + inParallel(tries, func(i int) { + r := httptest.NewRequest(http.MethodPost, "/api/escrow/start", strings.NewReader(url.Values{"password": {"wrong"}}.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.RemoteAddr = fmt.Sprintf("198.51.100.9:%d", 6000+i) + w := httptest.NewRecorder() + s.escrowStartAPIHandler(w, r) + switch w.Code { + case http.StatusUnauthorized: + atomic.AddInt32(&wrong, 1) + case http.StatusTooManyRequests: + atomic.AddInt32(&locked, 1) + } + }) + if got := atomic.LoadInt32(calls); got > loginMaxAttempts { + t.Fatalf("%d parallel wrong re-auth passwords reached the compare — the limit is %d per visitor", got, loginMaxAttempts) + } + if wrong+locked != tries { + t.Fatalf("every try must be answered as wrong or locked: %d + %d of %d", wrong, locked, tries) + } + // the re-auth counts on the login counter: the same visitor's dashboard sign-in is locked too + s.passwordCompare = nil + if body := doLogin(s, "198.51.100.9:7000", "", "correct-pass").Body.String(); !strings.Contains(body, "Túl sok hibás próbálkozás") { + t.Fatalf("the re-auth tries must count on the login counter; the sign-in was not locked: %s", ex(body)) + } +} + +// ── R-1112: family.json holds no value a request can use as a family session ──────────────────────────────── + +// familyFileStrings is every string in family.json (keys and values, at any depth). +func familyFileStrings(t *testing.T, s *Server) []string { + t.Helper() + b, err := os.ReadFile(filepath.Join(s.cfg.Paths.DataDir, "family.json")) + if err != nil { + t.Fatal(err) + } + var v any + if err := json.Unmarshal(b, &v); err != nil { + t.Fatal(err) + } + var out []string + var walk func(any) + walk = func(v any) { + switch x := v.(type) { + case string: + out = append(out, x) + case []any: + for _, e := range x { + walk(e) + } + case map[string]any: + for k, e := range x { + out = append(out, k) + walk(e) + } + } + } + walk(v) + return out +} + +// familyStartMints reports whether /__family/start mints a pass for a browser holding cookie as its family session. +func familyStartMints(s *Server, cookie string) bool { + r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {"https://fapp.example.hu/"}}.Encode(), nil) + r.AddCookie(&http.Cookie{Name: familySessionCookie, Value: cookie}) + w := httptest.NewRecorder() + s.ServeFamilyStart(w, r) + return strings.Contains(w.Header().Get("Location"), familyCallbackURI) +} + +func assertFileHoldsNoSession(t *testing.T, s *Server, cookie string) { + t.Helper() + strs := familyFileStrings(t, s) + if len(strs) < 5 { + t.Fatalf("family.json holds too little to judge: %d strings", len(strs)) + } + for _, v := range strs { + if v == cookie { + t.Fatal("family.json holds the family session's cookie value itself") + } + if familyStartMints(s, v) { + t.Fatalf("a string read from family.json (len %d) minted a pass when presented as the family session", len(v)) + } + } + if !familyStartMints(s, cookie) { + t.Fatal("the browser's own family session must still mint a pass") + } +} + +func TestR1112_FamilyFileHoldsNoUsableSession(t *testing.T) { + s, st := familyHarness(t) + pw, _ := st.Add("anna") + sess := famCookie(famLogin(s, "203.0.113.10", "anna", pw, ""), familySessionCookie) + if sess == nil { + t.Fatal("no family session after the sign-in") + } + assertFileHoldsNoSession(t, s, sess.Value) + // and after a restart (the store read back from the file) the cookie still holds + st2, err := family.Open(s.cfg.Paths.DataDir) + if err != nil { + t.Fatal(err) + } + if _, ok := st2.Valid(sess.Value); !ok { + t.Fatal("after a reopen the member's family session must still hold") + } +} + +// A family.json written before R-1112 (sessions stored as their cookie values) is converted on load and rewritten: the +// members stay signed in, and the values leave the disk. +func TestR1112_OldFamilyFileIsConvertedOnLoad(t *testing.T) { + s, _ := familyHarness(t) + h, _ := bcrypt.GenerateFromPassword([]byte("anna-pass"), bcrypt.MinCost) + member := "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718" // 24 random bytes in hex: the old session id shape + household := "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978" + exp := time.Now().Add(20 * 24 * time.Hour).UTC().Format(time.RFC3339) + old := `{"members":[{"name":"anna","hash":"` + string(h) + `","gen":1,"created":"2026-10-01T00:00:00Z"}],` + + `"sessions":[{"id":"` + member + `","member":"anna","gen":1,"exp":"` + exp + `"},` + + `{"id":"` + household + `","member":"","gen":0,"exp":"` + exp + `"}]}` + path := filepath.Join(s.cfg.Paths.DataDir, "family.json") + if err := os.WriteFile(path, []byte(old), 0o600); err != nil { + t.Fatal(err) + } + st, err := family.Open(s.cfg.Paths.DataDir) + if err != nil { + t.Fatal(err) + } + st.SetCost(bcrypt.MinCost) + s.familyStoreOverride = st + b, _ := os.ReadFile(path) + if strings.Contains(string(b), member) || strings.Contains(string(b), household) { + t.Fatal("the old session values are still in family.json after the load") + } + assertFileHoldsNoSession(t, s, member) + // the member's existing sign-in goes on opening the app + app := famPass(t, s, &http.Cookie{Name: familySessionCookie, Value: member}) + if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app); w.Code != http.StatusOK { + t.Fatalf("the converted session's app cookie must pass: %d", w.Code) + } +} + +// ── R-1113: household passes reuse one family session ───────────────────────────────────────────────────────── + +func familyFileSessions(t *testing.T, s *Server) []map[string]any { + t.Helper() + b, err := os.ReadFile(filepath.Join(s.cfg.Paths.DataDir, "family.json")) + if err != nil { + t.Fatal(err) + } + var f struct { + Sessions []map[string]any `json:"sessions"` + } + if err := json.Unmarshal(b, &f); err != nil { + t.Fatal(err) + } + return f.Sessions +} + +func TestR1113_HouseholdPassesLeaveOneSession(t *testing.T) { + s, st := familyHarness(t) + now := time.Now() + st.SetClock(func() time.Time { return now }) + dash := &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)} + var apps []*http.Cookie + for i := 0; i < 6; i++ { + apps = append(apps, famPass(t, s, dash)) + } + if got := familyFileSessions(t, s); len(got) != 1 { + t.Fatalf("6 household passes left %d family sessions in family.json, want 1", len(got)) + } + for i, app := range apps { + if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app); w.Code != http.StatusOK { + t.Fatalf("pass %d: the household's app cookie must pass: %d", i, w.Code) + } + } + // past half its life the session is no longer handed out: a pass starts a fresh one, and the old one keeps the + // app cookies already minted from it until it expires — the list holds two at most + now = now.Add(family.SessionLife/2 + time.Hour) + famPass(t, s, dash) + famPass(t, s, dash) + if got := familyFileSessions(t, s); len(got) != 2 { + t.Fatalf("past half-life: %d household sessions, want 2", len(got)) + } + if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", apps[0]); w.Code != http.StatusOK { + t.Fatalf("an app cookie minted from the older session must pass until it expires: %d", w.Code) + } + // once the older one has expired only the newer is live (an expired row leaves the file at the next save) + now = now.Add(family.SessionLife / 2) + famPass(t, s, dash) + live := 0 + for _, se := range familyFileSessions(t, s) { + if exp, err := time.Parse(time.RFC3339Nano, fmt.Sprint(se["exp"])); err == nil && exp.After(now) { + live++ + } + } + if live != 1 { + t.Fatalf("after the older one expired: %d live household sessions, want 1", live) + } +} diff --git a/controller/internal/web/r983_r976_test.go b/controller/internal/web/r983_r976_test.go index a166193..ed2b11f 100644 --- a/controller/internal/web/r983_r976_test.go +++ b/controller/internal/web/r983_r976_test.go @@ -17,6 +17,8 @@ import ( "time" "golang.org/x/crypto/bcrypt" + + "gitea.dooplex.hu/admin/felhom-controller/internal/family" ) // R-983 (security review 2026-10-12, six remaining items) and R-976 (family and gate sessions), v0.324.0. Each test @@ -348,8 +350,8 @@ func TestR976_AnAppCookieCannotBeLiftedIntoAnotherApp(t *testing.T) { t.Fatalf("a value lifted from an app cookie minted a pass: %q", loc) } // the sign-in token in flight carries no session either - tok := s.mintFamilyToken("fapp.example.hu", sess.Value, rd) - if dec, _ := base64.RawURLEncoding.DecodeString(tok); strings.Contains(string(dec), sess.Value) { + tok := s.mintFamilyToken("fapp.example.hu", family.Fingerprint(sess.Value), rd) + if dec, _ := base64.RawURLEncoding.DecodeString(tok); strings.Contains(string(dec), sess.Value) || strings.Contains(string(dec), family.Fingerprint(sess.Value)) { t.Fatal("the sign-in token carries the family session id") } // a cookie of the old shape (the session id itself, correctly signed) is refused diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 143b2b6..588730d 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -74,8 +74,8 @@ type Server struct { sessionsMu sync.RWMutex loginAttempts map[string]*loginAttempt loginAttemptMu sync.Mutex - // passwordCompare replaces bcrypt's compare at the dashboard sign-in in tests (R-983: a slow compare makes the - // parallel-try race measurable). nil → bcrypt. + // passwordCompare replaces bcrypt's compare at the dashboard sign-in, the escrow re-auth and the share password in + // tests (R-983, R-1111: a slow compare makes the parallel-try race measurable). nil → bcrypt. passwordCompare func(hash, pw []byte) error done chan struct{} closeOnce sync.Once diff --git a/controller/internal/web/share.go b/controller/internal/web/share.go index af2cb67..55a2a28 100644 --- a/controller/internal/web/share.go +++ b/controller/internal/web/share.go @@ -108,35 +108,15 @@ func (s *Server) validShareCSRF(r *http.Request) bool { return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1 } -// ── the share-password brute-force limiter (own per-IP map; same 5-attempts / 1-min window as the -// admin login, copied — never shared) ───────────────────────────────────────────────────────────── +// ── the share-password brute-force limiter (own per-IP map; same 5-attempts / 1-min window and the same +// reserveAttempt body as the admin login — the counter is never shared) ────────────────────────── -// shareRateLimited reports whether this IP is currently blocked (>= loginMaxAttempts within the -// window). An expired window resets the counter first, so a fresh burst starts clean. -func (s *Server) shareRateLimited(ip string) bool { - s.shareAttemptMu.Lock() - defer s.shareAttemptMu.Unlock() - a := s.shareAttempts[ip] - if a != nil && time.Since(a.lastFail) > loginWindowDuration { - delete(s.shareAttempts, ip) - a = nil - } - return a != nil && a.count >= loginMaxAttempts -} - -// shareRegisterFailure bumps this IP's failed-attempt counter (lazily allocating the map so -// struct-literal test servers that skip NewServer still work). -func (s *Server) shareRegisterFailure(ip string) { - s.shareAttemptMu.Lock() - defer s.shareAttemptMu.Unlock() - if s.shareAttempts == nil { - s.shareAttempts = make(map[string]*loginAttempt) - } - if s.shareAttempts[ip] == nil { - s.shareAttempts[ip] = &loginAttempt{} - } - s.shareAttempts[ip].count++ - s.shareAttempts[ip].lastFail = time.Now() +// shareReserve counts one share-password try for this visitor BEFORE the password is compared and reports whether it +// may go ahead — check and count in one locked step (reserveAttempt, R-1111), so parallel tries cannot all pass a +// check that none of them has counted yet. A right password clears the count (shareClearFailures). +func (s *Server) shareReserve(ip string) bool { + _, ok := reserveAttempt(&s.shareAttemptMu, &s.shareAttempts, ip) + return ok } // shareClearFailures clears this IP's counter after a successful gate pass. diff --git a/controller/internal/web/share_handlers.go b/controller/internal/web/share_handlers.go index 2165562..58d7826 100644 --- a/controller/internal/web/share_handlers.go +++ b/controller/internal/web/share_handlers.go @@ -114,13 +114,14 @@ func (s *Server) shareGuestPasswordHandler(w http.ResponseWriter, r *http.Reques return } ip := rateKey(r) - if s.shareRateLimited(ip) { + // R-1111: the try is reserved BEFORE the slow compare (the R-983 shape). Before, it was counted only after a + // wrong password, so N parallel tries all passed the check. Pinned by TestR1111_ParallelSharePasswordsHitTheLimit. + if !s.shareReserve(ip) { s.logger.Printf("[WARN] [web] share password rate limited for %s", ip) s.renderSharePasswordPage(w, r, s.msg(r, "api.share.too_many_attempts")) return } - if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(r.FormValue("password"))) != nil { - s.shareRegisterFailure(ip) + if s.comparePassword([]byte(pwHash), []byte(r.FormValue("password"))) != nil { s.renderSharePasswordPage(w, r, s.msg(r, "flash.share.wrong_password")) // R-577: in the visitor's language return } diff --git a/controller/scripts/controller_gates.py b/controller/scripts/controller_gates.py index 617ce7d..6125410 100644 --- a/controller/scripts/controller_gates.py +++ b/controller/scripts/controller_gates.py @@ -46,12 +46,17 @@ import sys SCRIPTS = os.path.dirname(os.path.abspath(__file__)) CTRL = os.path.dirname(SCRIPTS) # /controller — every gate's cwd REPO = os.path.dirname(CTRL) # — the root REUSE.md lives here -SHARED_REUSE = os.path.join(os.path.dirname(REPO), "felhom.eu", "scripts", "reuse_refs_check.py") -SHARED_INSTRUCTIONS = os.path.join( - os.path.dirname(REPO), "felhom.eu", "scripts", "instructions_gate.py") +# The felhom.eu sibling's scripts/: env override, else the main clone's sibling (a worktree outside git/), else +# ../felhom.eu — one resolver for every gate that reads it (shared_scripts.py, R-1114). +sys.path.insert(0, SCRIPTS) +from shared_scripts import shared_scripts_dir, workspace_dir # noqa: E402 +SHARED_DIR = shared_scripts_dir(REPO) +# reuse_refs_check searches the sibling repos for cross-repo citations; from a worktree they sit beside the MAIN clone. +GATE_ENV = dict(os.environ, FELHOM_WORKSPACE=workspace_dir(REPO)) +SHARED_REUSE = os.path.join(SHARED_DIR, "reuse_refs_check.py") +SHARED_INSTRUCTIONS = os.path.join(SHARED_DIR, "instructions_gate.py") # R-389 — shared, like the two above: it lives in felhom.eu/scripts/ and is never copied. -SHARED_OBSERVATIONS = os.path.join( - os.path.dirname(REPO), "felhom.eu", "scripts", "observations_gate.py") +SHARED_OBSERVATIONS = os.path.join(SHARED_DIR, "observations_gate.py") # R-404 — the golden NOTICE. Lives here, beside the runner, because it is about THIS repo's # releases; it imports the felhom.eu gate rather than copying its comparison. @@ -135,7 +140,7 @@ def run_gate(label, path, args): (" " + " ".join(args)) if args else "")) print("=" * 78, flush=True) # stream the gate's own output rather than capturing it — its diagnostics are the point. - return subprocess.call([sys.executable, path] + args, cwd=CTRL) + return subprocess.call([sys.executable, path] + args, cwd=CTRL, env=GATE_ENV) def main(argv): diff --git a/controller/scripts/golden_notice.py b/controller/scripts/golden_notice.py index 1f9cc07..f5410d0 100644 --- a/controller/scripts/golden_notice.py +++ b/controller/scripts/golden_notice.py @@ -42,8 +42,9 @@ REPO_DEFAULT = os.path.dirname(CTRL) def load_gate(repo_root): """Import the sibling felhom.eu gate. Returns (module, tried_path) or (None, tried_path).""" - path = os.path.join(os.path.dirname(repo_root), "felhom.eu", "scripts", - "golden_currency_gate.py") + sys.path.insert(0, HERE) + from shared_scripts import shared_scripts_dir # R-1114: the main clone's sibling from a worktree too + path = os.path.join(shared_scripts_dir(repo_root), "golden_currency_gate.py") if not os.path.isfile(path): return None, path try: diff --git a/controller/scripts/retrieval_promise_gate.py b/controller/scripts/retrieval_promise_gate.py index 6d3705e..b830a4e 100644 --- a/controller/scripts/retrieval_promise_gate.py +++ b/controller/scripts/retrieval_promise_gate.py @@ -57,8 +57,10 @@ GO_SOURCES = [ # sibling clone is read the way controller_gates.py reads reuse_refs_check.py: CI clones felhom.eu # beside this repo, and an ABSENT clone is INCONCLUSIVE (exit 2) — never a pass on an empty list. # FELHOM_SHARED_SCRIPTS overrides the directory (the decoy suite uses it; nothing else should). -_SHARED_SCRIPTS = os.environ.get("FELHOM_SHARED_SCRIPTS") or os.path.normpath( - os.path.join(_HERE, "..", "..", "..", "felhom.eu", "scripts")) +# The lookup itself is shared_scripts.py's (R-1114): the env override first, then the main clone's sibling, so a +# worktree outside git/ finds it too. +from shared_scripts import shared_scripts_dir # noqa: E402 (the script dir is on sys.path, above) +_SHARED_SCRIPTS = shared_scripts_dir(os.path.normpath(os.path.join(_HERE, "..", ".."))) if not os.path.isfile(os.path.join(_SHARED_SCRIPTS, "customer_copy_vocab.py")): print("retrieval-promise gate INCONCLUSIVE: the shared vocabulary is missing (%s) — the felhom.eu " "clone must sit beside this repo; without the stems nothing can be judged" % diff --git a/controller/scripts/shared_scripts.py b/controller/scripts/shared_scripts.py new file mode 100644 index 0000000..6c355fa --- /dev/null +++ b/controller/scripts/shared_scripts.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""shared_scripts.py — where the felhom.eu sibling clone's scripts/ directory is (R-1114). + +Four of this repo's gates use a script that lives ONCE in felhom.eu/scripts/ and is never copied +(reuse_refs_check, instructions_gate, observations_gate; customer_copy_vocab for the retrieval-promise +gate; golden_currency_gate for the golden notice). They used to look only at `/../felhom.eu`. In a +git worktree outside `git/` (e.g. /mnt/5_hdd/felhom.eu/wt/) that directory does not exist, the +gates reported MISSING and the runner failed — measured 2026-10-16 night by two helper sessions. + +The order, one place for all of them: + 1. FELHOM_SHARED_SCRIPTS, when set (the decoy suite points it at a fixture; nothing else should). + 2. The MAIN clone's sibling: `git worktree list --porcelain` names the main working tree first. Used only + when repo_root IS a git top level, so a fixture directory inside the real repo never resolves to the + real felhom.eu. + 3. `/../felhom.eu/scripts` — the normal clone and CI (which clones felhom.eu beside the repo). +An absent directory is returned as-is: each caller already reports a missing shared script as a +failure or INCONCLUSIVE, never a pass. Pinned by test_shared_scripts.py. +""" +import os +import subprocess + + +def _git(repo_root, *args): + try: + p = subprocess.run(["git", "-C", repo_root] + list(args), capture_output=True, text=True, timeout=20) + except (OSError, subprocess.SubprocessError): + return None + return p.stdout if p.returncode == 0 else None + + +def main_clone(repo_root): + """The main working tree of the git repository repo_root is the top level of, or None.""" + top = _git(repo_root, "rev-parse", "--show-toplevel") + if top is None or os.path.realpath(top.strip()) != os.path.realpath(repo_root): + return None + out = _git(repo_root, "worktree", "list", "--porcelain") + if not out: + return None + for line in out.splitlines(): + if line.startswith("worktree "): + return line[len("worktree "):].strip() + return None + + +def shared_scripts_dir(repo_root): + """felhom.eu/scripts for this repo root: env override, else the main clone's sibling, else ../felhom.eu.""" + env = os.environ.get("FELHOM_SHARED_SCRIPTS") + if env: + return env + main = main_clone(repo_root) + if main: + cand = os.path.join(os.path.dirname(main), "felhom.eu", "scripts") + if os.path.isdir(cand): + return cand + return os.path.join(os.path.dirname(os.path.abspath(repo_root)), "felhom.eu", "scripts") + + +def workspace_dir(repo_root): + """The directory the sibling repos live in: the main clone's parent (for a worktree outside git/), else + repo_root's parent. The runner hands it to the shared gates as FELHOM_WORKSPACE.""" + main = main_clone(repo_root) + if main and os.path.isdir(os.path.join(os.path.dirname(main), "felhom.eu")): + return os.path.dirname(main) + return os.path.dirname(os.path.abspath(repo_root)) diff --git a/controller/scripts/test_shared_scripts.py b/controller/scripts/test_shared_scripts.py new file mode 100644 index 0000000..3bbb943 --- /dev/null +++ b/controller/scripts/test_shared_scripts.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""test_shared_scripts.py — the felhom.eu sibling is found from a worktree too (R-1114). + +Builds a throwaway workspace in a temp dir — a `git/` with a main clone and a felhom.eu sibling, and a +worktree of the main clone under `wt/` — so nothing here reads the real repos. Git only; no docker. + +Red-proof (run 2026-10-17): with step 2 (the main clone) removed from shared_scripts_dir, W1 fails +("worktree resolved to …/wt/felhom.eu/scripts"). + +Run from `controller/`: python3 scripts/test_shared_scripts.py +Exit 0 all pass · 1 a case failed. +""" +import os +import shutil +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import shared_scripts # noqa: E402 + +fails = [] + + +def git(cwd, *args): + subprocess.run(["git", "-C", cwd] + list(args), check=True, capture_output=True, + env=dict(os.environ, GIT_AUTHOR_NAME="t", GIT_AUTHOR_EMAIL="t@t", GIT_COMMITTER_NAME="t", + GIT_COMMITTER_EMAIL="t@t")) + + +def check(name, got, want): + if os.path.realpath(got) == os.path.realpath(want): + print(" ok %s" % name) + else: + fails.append("%s: resolved to %s, want %s" % (name, got, want)) + + +def main(): + tmp = tempfile.mkdtemp(prefix="r1114-") + old_env = os.environ.pop("FELHOM_SHARED_SCRIPTS", None) + try: + ws = os.path.join(tmp, "git") + main_clone = os.path.join(ws, "felhom-controller") + sibling = os.path.join(ws, "felhom.eu", "scripts") + os.makedirs(main_clone) + os.makedirs(sibling) + git(main_clone, "init", "-q") + git(main_clone, "commit", "-q", "--allow-empty", "-m", "base") + wt = os.path.join(tmp, "wt", "b3") + os.makedirs(os.path.dirname(wt)) + git(main_clone, "worktree", "add", "-q", "--detach", wt) + + check("N1 the normal clone finds ../felhom.eu", shared_scripts.shared_scripts_dir(main_clone), sibling) + check("W1 a worktree outside git/ finds the MAIN clone's sibling", + shared_scripts.shared_scripts_dir(wt), sibling) + check("W2 a worktree's workspace is the MAIN clone's parent (FELHOM_WORKSPACE for reuse-refs)", + shared_scripts.workspace_dir(wt), ws) + check("N2 the normal clone's workspace is its parent", shared_scripts.workspace_dir(main_clone), ws) + # A fixture directory INSIDE a repo is not that repo's top level: it must not borrow the real sibling. + fixture = os.path.join(main_clone, "testdata", "fake-repo") + os.makedirs(fixture) + check("F1 a fixture dir inside a repo keeps its own ../felhom.eu", + shared_scripts.shared_scripts_dir(fixture), os.path.join(main_clone, "testdata", "felhom.eu", "scripts")) + check("G1 no git at all keeps ../felhom.eu", shared_scripts.shared_scripts_dir(os.path.join(tmp, "plain")), + os.path.join(tmp, "felhom.eu", "scripts")) + os.environ["FELHOM_SHARED_SCRIPTS"] = os.path.join(tmp, "override") + check("E1 FELHOM_SHARED_SCRIPTS wins", shared_scripts.shared_scripts_dir(wt), os.path.join(tmp, "override")) + finally: + os.environ.pop("FELHOM_SHARED_SCRIPTS", None) + if old_env is not None: + os.environ["FELHOM_SHARED_SCRIPTS"] = old_env + shutil.rmtree(tmp, ignore_errors=True) + + if fails: + print("\nFAIL:") + for f in fails: + print(" - " + f) + return 1 + print("shared-scripts resolver OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main())