v0.325.0: share + escrow limiters reserve first; family sessions as fingerprints; samba renames apply; path guard widened; gates in a worktree
gates / gates (push) Successful in 45s
gates / gates (push) Successful in 45s
R-1111 reserveAttempt is the one limiter body (share password, escrow re-auth, dashboard sign-in). R-1112 family.json holds fingerprints; old files are converted at load. R-1113 the household's pass reuses its family session. R-1109 the samba compose carries the smb.conf hash. R-1110 TestNoBareDrivePathToPathHelpers. R-1114 shared_scripts.py resolves the felhom.eu sibling from a worktree. Each red-proved. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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/<token>`) 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:
|
||||
|
||||
@@ -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" |
|
||||
|
||||
@@ -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-<app>.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 —
|
||||
|
||||
@@ -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 <alias>.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 <alias>.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: "<not a direct call>", 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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `<ns>/backups/primary/<app>/db-dumps` and a pre-v2
|
||||
// `<ns>/backups/secondary/<app>/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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -46,12 +46,17 @@ import sys
|
||||
SCRIPTS = os.path.dirname(os.path.abspath(__file__))
|
||||
CTRL = os.path.dirname(SCRIPTS) # <repo>/controller — every gate's cwd
|
||||
REPO = os.path.dirname(CTRL) # <repo> — 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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" %
|
||||
|
||||
@@ -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 `<repo>/../felhom.eu`. In a
|
||||
git worktree outside `git/` (e.g. /mnt/5_hdd/felhom.eu/wt/<name>) 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. `<repo_root>/../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))
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user