b50289074d
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
97 lines
9.3 KiB
Markdown
97 lines
9.3 KiB
Markdown
# Permanent household gate with family accounts — VERDICT
|
||
|
||
**The spike PASSES: exit items 1–5 all pass, measured on 9202 on 2026-10-01 between 19:19 and 19:25 UTC.**
|
||
The exit test (`EXIT-TEST.md`) was committed at 19:13 UTC (felhom.eu `7c50dba`), before anything was built or measured.
|
||
Operator ruling `09` §3 decision 63 (option A). **Nothing was built into the product. The build waits for the operator's
|
||
go.**
|
||
|
||
**Method.** The gate was a throwaway forwardAuth service (`familygate/main.go`, about 230 lines of Go; it never ran
|
||
outside 9202). It read the visitor by controller v0.286.0's rule. Grimmory v3.4.1 (with MariaDB 11.4) and MeTube
|
||
2026.09.29 were started by hand with `docker compose` on 9202, from `familygate/spike-compose.yml`. They were not from
|
||
the drill catalog: MeTube has no template, and a hand compose keeps the live catalog untouched either way. Requests
|
||
were made through 9202's traefik, both from the LAN and through the simulated tunnel (a container at cloudflared's
|
||
fixed address `172.16.253.2`, the same method as Part A).
|
||
|
||
## Exit items
|
||
|
||
| # | Item | Result | Measured |
|
||
|---|---|---|---|
|
||
| 1 | **A stranger reaches nothing.** | **PASS** | 18 paths × 2 routes (LAN and tunnel) = 36 stranger requests. All 36 got the gate's answer (302 to the sign-in page, or 401) and **0 reached an app**. The paths covered the front page, the API, setup, the app's own login, Grimmory's `/ws` websocket, MeTube's socket.io (polling and websocket upgrade), `/add`, `/download`, static files and an unknown path. Evidence: `items-1-2.txt`. |
|
||
| 2 | **Each family member has their own login. It lasts days. Logout works.** | **PASS** | Anna and Béla each signed in with their own password, not the dashboard's, and got the apps (200). MeTube's websocket upgrade gave Béla **101**; a stranger got 401 (`item-2-websocket.txt`). The cookie lasts 30 days (`Max-Age 2592000`), is HttpOnly, Secure and SameSite=Lax, and has no Domain attribute, so it is host-only. It survived a gate restart. After logout the old cookie was refused. A wrong password got 401 and no cookie. One app's cookie did not open another app (each app host has its own session). |
|
||
| 3 | **A stranger's wrong guesses lock only the stranger.** | **PASS** | The stranger tried 7 times through the tunnel, with a new forged leftmost address each time. Tries 1–5 got 401; from try 6 on, 429. Even Anna's right password got **429** while that visitor was locked. Then Anna from `203.0.113.10` (tunnel) and Béla from the LAN both signed in **at once** (302 + cookie). The gate's log counted the stranger at his real address, not the forged ones (`item-3.txt`). |
|
||
| 4 | **Grimmory's e-reader paths work through a per-app path exception, and the app's own login still applies there.** | **PASS, with one build requirement** | With no family cookie, through the tunnel or the LAN: OPDS v1 with the OPDS user's own login → **200** (the feed); wrong password → 401; no credentials → 401. Kobo `/v1/initialization` and `/v1/library/sync` with the device token → **200** (the first call took ~15 s: Grimmory asks Kobo's store first, then falls back); a made-up token → 401. KOReader `users/auth` with its own user and md5 key → **200**; wrong key → 401; `users/create` (registration) → 401. Komga API with no credentials → 401. Path tricks out of the exception (`../`, `%2e%2e`) → **the gate** (traefik cleans the path before it routes). **Finding F1:** `PathPrefix(/api/v1/opds)` also matched `/api/v1/opdsx`, which then reached the app ungated. Grimmory's own login refused it (401), but a build must anchor every exception: `PathRegexp(^/api/v1/opds(/\|$))`. Evidence: `item-4.txt`, `item-4-setup.txt` (secrets redacted). |
|
||
| 5 | **The family login cannot reach the box dashboard.** | **PASS** | The family cookie is host-only, so a browser never sends it to `felhom.<domain>`. Sent by hand anyway, the dashboard answered 302 to `/login`, and its API answered 401. Anna's family password at the dashboard login got "Hibás jelszó." and no session (`item-5-6.txt`). |
|
||
| 6 | **Cost.** | measured | **Time per gated request: +0.4 ms.** Median of 60 pairs: 14.6 ms gated vs 14.2 ms on an ungated name for the same service. Inside the controller, the setup gate already measured ~2 ms (decision 46). **Gate's answerer down:** every gated path answers **500**, so it fails closed, not open; the sign-in page answers 502; the e-reader exceptions keep working, because they never asked the gate. **Build cost:** two sessions; see below. |
|
||
|
||
## Answers to the brief's questions
|
||
|
||
- **Where do family accounts live, and who manages them?** In the controller's data directory, as a `family.json`
|
||
next to `settings.json`, with bcrypt hashes. That puts them in the controller's own backup and restore, and the hub
|
||
never sees them. **The household's dashboard admin manages them** from a "Család" (family) card: add a member with a
|
||
name and a generated password shown once, reset a password, remove a member. Removing a member ends their sessions.
|
||
Members have no dashboard access of any kind.
|
||
- **One sign-in for all gated apps, or one per app?** **One sign-in, with a cookie per app.** The spike signed in per
|
||
app host, and that works, but a family member would then sign in to every app separately. The setup gate already has
|
||
the right shape: a session on the dashboard host, plus a 60-second, one-use token that mints a host-only cookie for
|
||
each app (`/__gate/start`). A family session on the dashboard host would mint each app's cookie the same way.
|
||
- That family session is a different cookie from the household admin session, and it never opens the dashboard
|
||
(item 5's rule).
|
||
- Each app still gets its own host-only cookie, so no app's backend ever sees another app's session.
|
||
- **How are Radicale- and Dawarich-style API clients let through?** With an anchored per-app exception list, as
|
||
Grimmory's measured here.
|
||
- The list belongs in the template, e.g. `family_gate.except: ["^/api/v1/opds(/|$)", …]`, and the controller turns
|
||
it into a router that has no gate.
|
||
- Dawarich's phone app uses `/api/v1/*` with its API key; its exception keeps the app's own key check.
|
||
- **Radicale should not be family-gated.** Every request it serves comes from a calendar client. It already has its
|
||
own login, and the exception would be the whole host.
|
||
- **Does MeTube become publishable behind it?** **Yes, behind the gate and only behind it.** Its fit verdict R-767 was
|
||
"stop: no login at all"; the gate becomes its login.
|
||
- Measured: a stranger reached nothing, including socket.io and `/add`.
|
||
- Two caveats for its page: every family member shares one MeTube (one queue, one download folder), and downloads
|
||
fill the drive. It also still needs its own checklist record before publishing (new-app gate).
|
||
- Grimmory behind the gate makes R-775 doubly settled: Part A already makes its sign-in lock per visitor, and the
|
||
gate puts the web sign-in out of a stranger's reach.
|
||
|
||
## Build plan (if the operator says go)
|
||
|
||
**Session 1: the controller (one release).**
|
||
1. `family.json`: members with bcrypt hashes; add, reset and remove; removing a member revokes their sessions.
|
||
2. A dashboard card "Család", in both languages, with a member list and a password shown once.
|
||
3. A family session on the dashboard host, as a separate cookie that never opens the dashboard.
|
||
4. The `/__family/login` page, with lock-out per visitor (`clientIP`) and logout.
|
||
5. `ServeGateAuth` grows a permanent mode: for an app with `family_gate`, a valid family app-cookie → 200, otherwise
|
||
the same 302/401 as today, then the token handshake.
|
||
6. A traefik file per gated app (the setup gate's writer), plus an ungated router for each anchored exception.
|
||
7. Tests, red-proofed, for each exit item. Live on 9202 against items 1–5.
|
||
|
||
**Session 2: the catalog.**
|
||
1. `family_gate:` with `except:` in the `.felhom.yml` format and its gate.
|
||
2. Grimmory: the template with OPDS, Kobo, KOReader and Komga exceptions, its checklist record (R-775's held template
|
||
from `audits/new-apps-2026-10-01/wip/grimmory/`), published.
|
||
3. MeTube: a new template plus its checklist record, published behind the gate.
|
||
4. Both live on 9202 and one demo box.
|
||
|
||
**Not in the build:** an identity app (decision 63, option B), which stays possible later behind the same forwardAuth
|
||
hook; per-member rights inside an app (the app's own users do that).
|
||
|
||
**What a build inherits from the setup gate's measured costs:** a gated app answers 500 while the controller is
|
||
restarting or down, which is seconds during a self-update. A phone app reaches a gated app only through its exception
|
||
list.
|
||
|
||
## Findings
|
||
|
||
- **F1 (build requirement, not a product defect):** traefik's `PathPrefix` is a plain string prefix. An exception must
|
||
be anchored, or a look-alike path walks past the gate. Recorded in the build row.
|
||
- Kobo's first `/v1/initialization` takes ~15 s: Grimmory asks Kobo's store first, then falls back. Not a gate cost.
|
||
|
||
## Teardown (three layers)
|
||
|
||
- **Machine:**
|
||
- Removed with `docker compose -p spike down -v`: `spike-gate`, `gm-spike`, `gm-spike-db`, `mt-spike`, the
|
||
`gm-internal` network and the `gm_spike_db` volume.
|
||
- Images removed: grimmory, metube, alpine:3.20, mariadb:11.4.
|
||
- Deleted: `/root/spike` and traefik's `dynamic/spike-family.yml`.
|
||
- Checked afterwards: no `spike` container or volume is left.
|
||
- **Host:** nothing was provisioned on demo-hp itself; 9202's disk is its own.
|
||
- **Hub:** nothing. 9202 is not enrolled.
|