Evidence: Part A (visitors apart — measurements, design, sweep, 9202 live) and the permanent-gate spike (items 1–6, VERDICT: PASS, build plan)
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
This commit is contained in:
2026-10-01 21:26:23 +02:00
parent 7c50dba454
commit b50289074d
37 changed files with 1269 additions and 0 deletions
@@ -0,0 +1,96 @@
# 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.
@@ -0,0 +1,5 @@
module familygate
go 1.22
require golang.org/x/crypto v0.31.0 // indirect
@@ -0,0 +1,225 @@
// familygate — THROWAWAY spike (permanent-gate-2026-10-01). A traefik forwardAuth answerer with a family list: each
// member signs in with their OWN name and password; the session lasts 30 days, survives a restart, and logout revokes
// it. Wrong passwords are counted per VISITOR, read by controller v0.286's rule (clientaddr.go): believed only from
// traefik; the rightmost X-Forwarded-For entry is the hop traefik saw; the tunnel's fixed address → CF-Connecting-IP.
// Never shipped: the build, if the operator says go, lives in the controller.
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
cookieName = "felhom_family"
life = 30 * 24 * time.Hour
tunnelAddr = "172.16.253.2"
maxWrong = 5
window = time.Minute
dataDir = "/data"
)
type sess struct {
User string `json:"u"`
Host string `json:"h"`
Exp time.Time `json:"e"`
}
var (
mu sync.Mutex
users map[string]string // name -> bcrypt
sessions = map[string]sess{}
wrong = map[string][]time.Time{}
traefik []string
trAt time.Time
)
func save() {
b, _ := json.Marshal(sessions)
_ = os.WriteFile(dataDir+"/sessions.json", b, 0o600)
}
func isTraefik(ip string) bool {
if time.Since(trAt) > 30*time.Second {
traefik, _ = net.LookupHost("traefik")
trAt = time.Now()
}
for _, a := range traefik {
if a == ip {
return true
}
}
return false
}
func visitor(r *http.Request) string {
peer, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
peer = r.RemoteAddr
}
if !isTraefik(peer) {
return peer
}
var hops []string
for _, v := range r.Header.Values("X-Forwarded-For") {
for _, h := range strings.Split(v, ",") {
if h = strings.TrimSpace(h); h != "" {
hops = append(hops, h)
}
}
}
if len(hops) == 0 || net.ParseIP(hops[len(hops)-1]) == nil {
return peer
}
hop := hops[len(hops)-1]
if hop == tunnelAddr {
if cf := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); net.ParseIP(cf) != nil {
return cf
}
}
return hop
}
func valid(r *http.Request, host string) (string, bool) {
c, err := r.Cookie(cookieName)
if err != nil {
return "", false
}
mu.Lock()
defer mu.Unlock()
s, ok := sessions[c.Value]
if !ok || time.Now().After(s.Exp) || s.Host != host {
return "", false
}
return s.User, true
}
// /auth — traefik forwardAuth.
func auth(w http.ResponseWriter, r *http.Request) {
host := strings.ToLower(r.Header.Get("X-Forwarded-Host"))
if i := strings.LastIndex(host, ":"); i != -1 {
host = host[:i]
}
uri := r.Header.Get("X-Forwarded-Uri")
if u, ok := valid(r, host); ok {
w.Header().Set("X-Family-User", u)
w.WriteHeader(200)
return
}
m := r.Header.Get("X-Forwarded-Method")
if (m == "" || m == "GET") && strings.Contains(r.Header.Get("Accept"), "text/html") {
http.Redirect(w, r, "https://"+host+"/__family/login?"+url.Values{"rd": {uri}}.Encode(), http.StatusFound)
return
}
log.Printf("refused %s %s%s from %s", m, host, uri, visitor(r))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
fmt.Fprint(w, `{"error":"sign in with your family login"}`)
}
func page(w http.ResponseWriter, msg, rd string, code int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
fmt.Fprintf(w, `<!doctype html><title>Belépés</title><p>%s</p><form method=post action="/__family/login">
<input name=user placeholder="Neved"><input name=password type=password placeholder="Jelszavad">
<input type=hidden name=rd value="%s"><button>Belépés</button></form>`, html.EscapeString(msg), html.EscapeString(rd))
}
func login(w http.ResponseWriter, r *http.Request) {
rd := r.FormValue("rd")
if !strings.HasPrefix(rd, "/") || strings.HasPrefix(rd, "//") {
rd = "/"
}
if r.Method != http.MethodPost {
page(w, "", rd, 200)
return
}
v := visitor(r)
now := time.Now()
mu.Lock()
var keep []time.Time
for _, t := range wrong[v] {
if now.Sub(t) < window {
keep = append(keep, t)
}
}
wrong[v] = keep
if len(keep) >= maxWrong {
mu.Unlock()
log.Printf("locked: visitor %s (%d wrong in %s)", v, len(keep), window)
page(w, "Túl sok hibás próbálkozás erről a címről. Próbáld újra egy perc múlva.", rd, 429)
return
}
hash, known := users[r.FormValue("user")]
mu.Unlock()
if !known || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
mu.Lock()
wrong[v] = append(wrong[v], now)
mu.Unlock()
log.Printf("wrong password from visitor %s", v)
page(w, "Hibás név vagy jelszó.", rd, 401)
return
}
b := make([]byte, 32)
_, _ = rand.Read(b)
id := hex.EncodeToString(b)
host := strings.ToLower(strings.Split(r.Host, ":")[0])
mu.Lock()
delete(wrong, v)
sessions[id] = sess{User: r.FormValue("user"), Host: host, Exp: now.Add(life)}
save()
mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: id, Path: "/", MaxAge: int(life.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
log.Printf("signed in: %s on %s from visitor %s", r.FormValue("user"), host, v)
http.Redirect(w, r, rd, http.StatusFound)
}
func logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName); err == nil {
mu.Lock()
delete(sessions, c.Value)
save()
mu.Unlock()
}
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true})
http.Redirect(w, r, "/__family/login", http.StatusFound)
}
func main() {
if len(os.Args) == 3 && os.Args[1] == "hash" { // familygate hash <password> — for the users file
h, _ := bcrypt.GenerateFromPassword([]byte(os.Args[2]), bcrypt.DefaultCost)
fmt.Println(string(h))
return
}
raw, err := os.ReadFile(dataDir + "/users.json")
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(raw, &users); err != nil {
log.Fatal(err)
}
if b, err := os.ReadFile(dataDir + "/sessions.json"); err == nil {
_ = json.Unmarshal(b, &sessions)
}
_ = hmac.New(sha256.New, nil)
http.HandleFunc("/auth", auth)
http.HandleFunc("/__family/login", login)
http.HandleFunc("/__family/logout", logout)
log.Printf("familygate: %d members, %d sessions", len(users), len(sessions))
log.Fatal(http.ListenAndServe(":8080", nil))
}
@@ -0,0 +1,75 @@
# THROWAWAY — permanent-gate spike 2026-10-01 on 9202 only. Removed afterwards.
services:
spike-gate:
image: alpine:3.20
container_name: spike-gate
command: ["/gate/familygate"]
volumes:
- /root/spike/bin:/gate:ro
- /root/spike/gate-data:/data
networks: [traefik-public]
grimmory:
image: ghcr.io/grimmory-tools/grimmory:v3.4.1
container_name: gm-spike
environment:
- TZ=Europe/Budapest
- USER_ID=1000
- GROUP_ID=1000
- DATABASE_URL=jdbc:mariadb://gm-spike-db:3306/grimmory
- DATABASE_USERNAME=grimmory
- DATABASE_PASSWORD=${DB_PASSWORD}
- SWAGGER_ENABLED=false
- FORCE_DISABLE_OIDC=true
volumes:
- /root/spike/gm/data:/app/data
- /root/spike/gm/books:/books
- /root/spike/gm/bookdrop:/bookdrop
networks: [traefik-public, gm-internal]
depends_on:
grimmory-db: {condition: service_healthy}
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.gm-spike.rule=Host(`gm-spike.enkisfelhom.hu`)"
- "traefik.http.routers.gm-spike.entrypoints=websecure"
- "traefik.http.routers.gm-spike.tls=true"
- "traefik.http.routers.gm-spike.middlewares=spike-family-auth@file"
- "traefik.http.routers.gm-spike.service=gm-spike"
# the e-reader path exception: NO family gate here — Grimmory's own authentication decides
- "traefik.http.routers.gm-spike-ereader.rule=Host(`gm-spike.enkisfelhom.hu`) && (PathPrefix(`/api/v1/opds`) || PathPrefix(`/api/v2/opds`) || PathPrefix(`/api/kobo/`) || PathPrefix(`/api/koreader/`) || PathPrefix(`/komga/api/`))"
- "traefik.http.routers.gm-spike-ereader.entrypoints=websecure"
- "traefik.http.routers.gm-spike-ereader.tls=true"
- "traefik.http.routers.gm-spike-ereader.service=gm-spike"
- "traefik.http.services.gm-spike.loadbalancer.server.port=6060"
grimmory-db:
image: mariadb:11.4
container_name: gm-spike-db
environment:
- MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
- MARIADB_DATABASE=grimmory
- MARIADB_USER=grimmory
- MARIADB_PASSWORD=${DB_PASSWORD}
volumes: [gm_spike_db:/var/lib/mysql]
networks: [gm-internal]
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
retries: 10
start_period: 30s
metube:
image: ghcr.io/alexta69/metube:2026.09.29
container_name: mt-spike
volumes: [/root/spike/mt:/downloads]
networks: [traefik-public]
labels:
- "traefik.enable=true"
- "traefik.http.routers.mt-spike.rule=Host(`mt-spike.enkisfelhom.hu`)"
- "traefik.http.routers.mt-spike.entrypoints=websecure"
- "traefik.http.routers.mt-spike.tls=true"
- "traefik.http.routers.mt-spike.middlewares=spike-family-auth@file"
- "traefik.http.services.mt-spike.loadbalancer.server.port=8081"
volumes:
gm_spike_db:
networks:
traefik-public: {external: true}
gm-internal:
@@ -0,0 +1,24 @@
# THROWAWAY — permanent-gate spike 2026-10-01 (9202 only). The family gate's middleware and its sign-in pages.
http:
middlewares:
spike-family-auth:
forwardAuth:
address: "http://spike-gate:8080/auth"
routers:
spike-gm-direct:
rule: "Host(`gm-direct.enkisfelhom.hu`)"
entryPoints: [websecure]
tls: {}
service: gm-spike@docker
spike-family-pages:
rule: "(Host(`gm-spike.enkisfelhom.hu`) || Host(`mt-spike.enkisfelhom.hu`)) && PathPrefix(`/__family/`)"
priority: 100000
entryPoints: [websecure]
tls: {}
service: spike-gate
services:
spike-gate:
loadBalancer:
servers:
- url: "http://spike-gate:8080"
# item 6 only: the same Grimmory service on a second name WITHOUT the gate, to time the gate's cost
@@ -0,0 +1,89 @@
2026/10/01 19:17:15 familygate: 2 members, 0 sessions
2026/10/01 19:19:38 refused GET gm-spike.enkisfelhom.hu/ from 192.168.0.180
2026/10/01 19:19:40 refused GET gm-spike.enkisfelhom.hu/ from 198.51.100.66
2026/10/01 19:19:40 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 192.168.0.180
2026/10/01 19:19:41 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 198.51.100.66
2026/10/01 19:19:42 refused GET gm-spike.enkisfelhom.hu/api/v1/healthcheck from 192.168.0.180
2026/10/01 19:19:43 refused GET gm-spike.enkisfelhom.hu/api/v1/healthcheck from 198.51.100.66
2026/10/01 19:19:43 refused GET gm-spike.enkisfelhom.hu/api/v1/public-settings from 192.168.0.180
2026/10/01 19:19:44 refused GET gm-spike.enkisfelhom.hu/api/v1/public-settings from 198.51.100.66
2026/10/01 19:19:45 refused POST gm-spike.enkisfelhom.hu/api/v1/setup from 192.168.0.180
2026/10/01 19:19:46 refused POST gm-spike.enkisfelhom.hu/api/v1/setup from 198.51.100.66
2026/10/01 19:19:46 refused POST gm-spike.enkisfelhom.hu/api/v1/auth/login from 192.168.0.180
2026/10/01 19:19:48 refused POST gm-spike.enkisfelhom.hu/api/v1/auth/login from 198.51.100.66
2026/10/01 19:19:48 refused GET gm-spike.enkisfelhom.hu/ws/websocket from 192.168.0.180
2026/10/01 19:19:49 refused GET gm-spike.enkisfelhom.hu/ws/websocket from 198.51.100.66
2026/10/01 19:19:49 refused GET gm-spike.enkisfelhom.hu/assets/index.js from 192.168.0.180
2026/10/01 19:19:51 refused GET gm-spike.enkisfelhom.hu/assets/index.js from 198.51.100.66
2026/10/01 19:19:54 refused GET mt-spike.enkisfelhom.hu/ from 192.168.0.180
2026/10/01 19:19:55 refused GET mt-spike.enkisfelhom.hu/ from 198.51.100.66
2026/10/01 19:19:55 refused GET mt-spike.enkisfelhom.hu/history from 192.168.0.180
2026/10/01 19:19:57 refused GET mt-spike.enkisfelhom.hu/history from 198.51.100.66
2026/10/01 19:19:57 refused POST mt-spike.enkisfelhom.hu/add from 192.168.0.180
2026/10/01 19:19:58 refused POST mt-spike.enkisfelhom.hu/add from 198.51.100.66
2026/10/01 19:19:58 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=polling from 192.168.0.180
2026/10/01 19:20:00 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket from 192.168.0.180
2026/10/01 19:20:01 refused GET mt-spike.enkisfelhom.hu/download/x.mp4 from 192.168.0.180
2026/10/01 19:20:03 refused GET mt-spike.enkisfelhom.hu/download/x.mp4 from 198.51.100.66
2026/10/01 19:20:03 refused GET mt-spike.enkisfelhom.hu/version from 192.168.0.180
2026/10/01 19:20:04 refused GET mt-spike.enkisfelhom.hu/version from 198.51.100.66
2026/10/01 19:20:05 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180
2026/10/01 19:20:05 signed in: bela on mt-spike.enkisfelhom.hu from visitor 192.168.0.180
2026/10/01 19:20:06 familygate: 2 members, 2 sessions
2026/10/01 19:20:10 wrong password from visitor 192.168.0.180
2026/10/01 19:20:10 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180
2026/10/01 19:20:16 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 192.168.0.180
2026/10/01 19:20:22 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 192.168.0.180
2026/10/01 19:20:33 refused GET gm-spike.enkisfelhom.hu/ from 192.168.0.180
2026/10/01 19:20:34 refused GET gm-spike.enkisfelhom.hu/ from 198.51.100.66
2026/10/01 19:20:34 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 192.168.0.180
2026/10/01 19:20:36 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 198.51.100.66
2026/10/01 19:20:36 refused GET gm-spike.enkisfelhom.hu/api/v1/healthcheck from 192.168.0.180
2026/10/01 19:20:37 refused GET gm-spike.enkisfelhom.hu/api/v1/healthcheck from 198.51.100.66
2026/10/01 19:20:37 refused GET gm-spike.enkisfelhom.hu/api/v1/public-settings from 192.168.0.180
2026/10/01 19:20:39 refused GET gm-spike.enkisfelhom.hu/api/v1/public-settings from 198.51.100.66
2026/10/01 19:20:39 refused POST gm-spike.enkisfelhom.hu/api/v1/setup from 192.168.0.180
2026/10/01 19:20:40 refused POST gm-spike.enkisfelhom.hu/api/v1/setup from 198.51.100.66
2026/10/01 19:20:41 refused POST gm-spike.enkisfelhom.hu/api/v1/auth/login from 192.168.0.180
2026/10/01 19:20:42 refused POST gm-spike.enkisfelhom.hu/api/v1/auth/login from 198.51.100.66
2026/10/01 19:20:42 refused GET gm-spike.enkisfelhom.hu/ws/websocket from 192.168.0.180
2026/10/01 19:20:43 refused GET gm-spike.enkisfelhom.hu/ws/websocket from 198.51.100.66
2026/10/01 19:20:44 refused GET gm-spike.enkisfelhom.hu/assets/index.js from 192.168.0.180
2026/10/01 19:20:45 refused GET gm-spike.enkisfelhom.hu/assets/index.js from 198.51.100.66
2026/10/01 19:20:48 refused GET mt-spike.enkisfelhom.hu/ from 192.168.0.180
2026/10/01 19:20:50 refused GET mt-spike.enkisfelhom.hu/ from 198.51.100.66
2026/10/01 19:20:50 refused GET mt-spike.enkisfelhom.hu/history from 192.168.0.180
2026/10/01 19:20:51 refused GET mt-spike.enkisfelhom.hu/history from 198.51.100.66
2026/10/01 19:20:51 refused POST mt-spike.enkisfelhom.hu/add from 192.168.0.180
2026/10/01 19:20:53 refused POST mt-spike.enkisfelhom.hu/add from 198.51.100.66
2026/10/01 19:20:53 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=polling from 192.168.0.180
2026/10/01 19:20:54 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=polling from 198.51.100.66
2026/10/01 19:20:54 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket from 192.168.0.180
2026/10/01 19:20:56 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket from 198.51.100.66
2026/10/01 19:20:56 refused GET mt-spike.enkisfelhom.hu/download/x.mp4 from 192.168.0.180
2026/10/01 19:20:57 refused GET mt-spike.enkisfelhom.hu/download/x.mp4 from 198.51.100.66
2026/10/01 19:20:57 refused GET mt-spike.enkisfelhom.hu/version from 192.168.0.180
2026/10/01 19:20:59 refused GET mt-spike.enkisfelhom.hu/version from 198.51.100.66
2026/10/01 19:20:59 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180
2026/10/01 19:20:59 signed in: bela on mt-spike.enkisfelhom.hu from visitor 192.168.0.180
2026/10/01 19:21:01 familygate: 2 members, 4 sessions
2026/10/01 19:21:04 wrong password from visitor 192.168.0.180
2026/10/01 19:21:04 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180
2026/10/01 19:21:19 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket from 192.168.0.180
2026/10/01 19:21:33 wrong password from visitor 198.51.100.66
2026/10/01 19:21:34 wrong password from visitor 198.51.100.66
2026/10/01 19:21:36 wrong password from visitor 198.51.100.66
2026/10/01 19:21:38 wrong password from visitor 198.51.100.66
2026/10/01 19:21:39 wrong password from visitor 198.51.100.66
2026/10/01 19:21:41 locked: visitor 198.51.100.66 (5 wrong in 1m0s)
2026/10/01 19:21:42 locked: visitor 198.51.100.66 (5 wrong in 1m0s)
2026/10/01 19:21:44 locked: visitor 198.51.100.66 (5 wrong in 1m0s)
2026/10/01 19:21:45 signed in: anna on gm-spike.enkisfelhom.hu from visitor 203.0.113.10
2026/10/01 19:21:46 signed in: bela on gm-spike.enkisfelhom.hu from visitor 192.168.0.180
2026/10/01 19:22:04 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180
2026/10/01 19:22:35 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180
2026/10/01 19:23:20 refused GET gm-spike.enkisfelhom.hu/api/api/v1/books from 198.51.100.66
2026/10/01 19:23:21 refused GET gm-spike.enkisfelhom.hu/api/api/v1/books from 198.51.100.66
2026/10/01 19:23:23 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 198.51.100.66
2026/10/01 19:24:09 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180
2026/10/01 19:24:15 familygate: 2 members, 9 sessions
@@ -0,0 +1,3 @@
# item 2 (websocket): MeTube's socket.io websocket upgrade over HTTP/1.1, through traefik (curl stops after the handshake)
bela (family cookie): 101 APP ['Upgrade: websocket']
stranger (no cookie): 401 GATE []
@@ -0,0 +1,23 @@
# item 3, 2026-10-01T19:21:31Z: the stranger 198.51.100.66 through the SIMULATED tunnel, guessing anna's password, a new forged leftmost address each try
try 1: 401 wrong
try 2: 401 wrong
try 3: 401 wrong
try 4: 401 wrong
try 5: 401 wrong
try 6: 429 LOCKED
try 7: 429 LOCKED
the stranger with anna's RIGHT password while locked: 429
anna herself from 203.0.113.10 (tunnel), at once: 302, cookie set
bela from the LAN (192.168.0.180), at once: 302, cookie set
2026/10/01 19:21:04 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180
2026/10/01 19:21:19 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket from 192.168.0.180
2026/10/01 19:21:33 wrong password from visitor 198.51.100.66
2026/10/01 19:21:34 wrong password from visitor 198.51.100.66
2026/10/01 19:21:36 wrong password from visitor 198.51.100.66
2026/10/01 19:21:38 wrong password from visitor 198.51.100.66
2026/10/01 19:21:39 wrong password from visitor 198.51.100.66
2026/10/01 19:21:41 locked: visitor 198.51.100.66 (5 wrong in 1m0s)
2026/10/01 19:21:42 locked: visitor 198.51.100.66 (5 wrong in 1m0s)
2026/10/01 19:21:44 locked: visitor 198.51.100.66 (5 wrong in 1m0s)
2026/10/01 19:21:45 signed in: anna on gm-spike.enkisfelhom.hu from visitor 203.0.113.10
2026/10/01 19:21:46 signed in: bela on gm-spike.enkisfelhom.hu from visitor 192.168.0.180
@@ -0,0 +1,12 @@
# item 4 setup — as anna (through the family gate): Grimmory's first admin, OPDS on + an OPDS user, a Kobo token, a KOReader user
POST /api/v1/setup -> 200
app login -> 200 token
OPDS on -> 200
OPDS user -> 200
GET /api/v1/kobo-settings -> 500 {"message":"An unexpected error occurred.","status":500,"timestamp":"2026-10-01T21:22:05.673392212"}
PUT /api/v1/kobo-settings/token -> 200 {"autoAddToShelf":false,"hardcoverApiKey":null,"hardcoverSyncEnabled":false,"id":1,"progressMarkAsFinishedThreshold":99.0,"progressMarkAsReadingThreshold":1.0,"
GET /api/v1/kobo-settings -> 200 {"autoAddToShelf":false,"hardcoverApiKey":null,"hardcoverSyncEnabled":false,"id":1,"progressMarkAsFinishedThreshold":99.0,"progressMarkAsReadingThreshold":1.0,"
GET /api/v1/koreader-users/me -> 404 {"message":"Koreader user not found for BookLore user ID: 1","status":404,"timestamp":"2026-10-01T21:22:05.907223998"}
PUT /api/v1/koreader-users/me -> 200 {"id":1,"username":"korolvaso","password":"<redacted>","passwordMD5":"<redacted>","syncEnabled":false,"syncWithWebReader":false}
POST /api/v1/koreader-users/me -> 405 {"message":"Method 'POST' is not supported.","status":405,"timestamp":"2026-10-01T21:22:06.024563401"}
GET /api/v1/koreader-users/me -> 200 {"id":1,"username":"korolvaso","password":"<redacted>","passwordMD5":"<redacted>","syncEnabled":false,"syncWithWebReader":false}
@@ -0,0 +1,25 @@
(setup, as anna) PUT /api/v1/koreader-users/me/sync?enabled=true -> 405
(setup, as anna) PATCH /api/v1/koreader-users/me/sync?enabled=true -> 204
# item 4, 2026-10-01T19:22:35Z: e-reader clients on the PATH EXCEPTION — no family cookie; the app's own login decides
OPDS catalog, the OPDS user's own login LAN -> 200 app '<?xml version="1.0" encoding="UTF-8"?>\n<feed xmlns="http://www.w3.org/'
OPDS catalog, the OPDS user's own login tunnel -> 200 app '<?xml version="1.0" encoding="UTF-8"?>\n<feed xmlns="http://www.w3.org/'
OPDS v2, the OPDS user's own login LAN -> 200 app '<!doctype html>\n<html lang="en">\n\n<head>\n <meta charset="utf-8">\n <t'
OPDS, WRONG password (a stranger) LAN -> 401 app 'HTTP Status 401 - Bad credentials'
OPDS, no credentials (a stranger) tunnel -> 401 app 'HTTP Status 401 - Full authentication is required to access this resou'
Kobo sync initialization, the device's token LAN -> 000 app ''
Kobo library sync, the device's token tunnel -> 000 app ''
Kobo, a made-up token (a stranger) tunnel -> 401 app '{"timestamp":"2026-10-01T19:23:12.405Z","status":401,"error":"Unauthor'
KOReader sign-in, its own user + md5 key LAN -> 200 app '{"username":"korolvaso"}'
KOReader progress, its own user + md5 key tunnel -> 404 app '{"message":"Book not found for hash 0000","status":404,"timestamp":"20'
KOReader sign-in, WRONG key (a stranger) tunnel -> 401 app '{"timestamp":"2026-10-01T19:23:15.609Z","status":401,"error":"Unauthor'
KOReader create-user (registration, a stranger) tunnel -> 401 app '{"timestamp":"2026-10-01T19:23:17.132Z","status":401,"error":"Unauthor'
Komga API (Mihon/Tachiyomi), the OPDS user LAN -> 403 app '{"timestamp":"2026-10-01T19:23:17.408Z","status":403,"error":"Forbidde'
Komga API, no credentials (a stranger) tunnel -> 401 app 'HTTP Status 401 - Full authentication is required to access this resou'
-- the exception must not leak the rest of the app:
path trick ../ out of the exception (raw) tunnel -> 401 GATE '{"error":"sign in with your family login"}'
path trick %2e%2e out of the exception tunnel -> 401 GATE '{"error":"sign in with your family login"}'
an app API path that is NOT excepted tunnel -> 401 GATE '{"error":"sign in with your family login"}'
prefix look-alike /api/v1/opdsx tunnel -> 401 app '{"timestamp":"2026-10-01T19:23:24.761Z","status":401,"error":"Unauthor'
## item 4 (Kobo, again with a 90 s wait), 2026-10-01T19:23:39Z
Kobo /v1/initialization, the device's token (LAN) -> 200 in 0.2s app '{"Resources":{"user_tasteprofile_genre":"https://storeapi.kobo.com/v2/user/tasteprofile/ge'
Kobo /v1/library/sync, the device's token (LAN) -> 200 in 0.3s app '[{"DeletedTag":{"Tag":{"Id":"BL-S-1"}}}]'
@@ -0,0 +1,13 @@
# item 5, 2026-10-01T19:24:09Z: the family login and the box dashboard (felhom.enkisfelhom.hu)
anna's family cookie sent to the dashboard (a browser would not even send it — host-only): 302 -> ['location: /login']
... to the dashboard API: 401 '{"ok":false,"error":"authentication required"}'
anna's family password at the dashboard login: 200, session cookie none, says wrong password: True
# item 6, cost: the same request (GET /api/v1/healthcheck) through the gated name and an ungated name, 60 each, alternating
status gated 200 / ungated 200; pairs kept 60
median gated 14.6 ms, ungated 14.2 ms -> the gate adds 0.4 ms (p90 16.4 vs 16.2)
gate's answerer STOPPED: gm-spike/ (with anna's cookie) -> 500 ''
gate's answerer STOPPED: gm-spike/api/v1/books (with anna's cookie) -> 500 ''
gate's answerer STOPPED: mt-spike/ (with anna's cookie) -> 500 ''
gate's answerer STOPPED: gm-spike/__family/login (with anna's cookie) -> 502 'Bad Gateway'
gate STOPPED: the e-reader exception /api/v1/opds -> 401 (not behind the gate)
gate back: anna -> 200
@@ -0,0 +1,36 @@
# items 1–2, 2026-10-01T19:20:31Z — 9202, throwaway familygate (forwardAuth), Grimmory v3.4.1 + MeTube 2026.09.29
## item 1 — a STRANGER (no cookie), from the LAN and through the simulated tunnel; GATE = the gate answered, APP = the app did
gm-spike GET / LAN 302 GATE | tunnel 302 GATE
gm-spike GET / LAN 401 GATE | tunnel 401 GATE
gm-spike GET /api/v1/books LAN 401 GATE | tunnel 401 GATE
gm-spike GET /api/v1/healthcheck LAN 401 GATE | tunnel 401 GATE
gm-spike GET /api/v1/public-settings LAN 401 GATE | tunnel 401 GATE
gm-spike POST /api/v1/setup LAN 401 GATE | tunnel 401 GATE
gm-spike POST /api/v1/auth/login LAN 401 GATE | tunnel 401 GATE
gm-spike GET /ws/websocket LAN 401 GATE | tunnel 401 GATE
gm-spike GET /assets/index.js LAN 401 GATE | tunnel 401 GATE
gm-spike GET /no-such-page LAN 302 GATE | tunnel 302 GATE
mt-spike GET / LAN 302 GATE | tunnel 302 GATE
mt-spike GET / LAN 401 GATE | tunnel 401 GATE
mt-spike GET /history LAN 401 GATE | tunnel 401 GATE
mt-spike POST /add LAN 401 GATE | tunnel 401 GATE
mt-spike GET /socket.io/?EIO=4&transport=polling LAN 401 GATE | tunnel 401 GATE
mt-spike GET /socket.io/?EIO=4&transport=websocket LAN 401 GATE | tunnel 401 GATE
mt-spike GET /download/x.mp4 LAN 401 GATE | tunnel 401 GATE
mt-spike GET /version LAN 401 GATE | tunnel 401 GATE
item 1: 0 app answers of 36 stranger requests -> PASS
## item 2 — family members with their OWN logins (not the dashboard password)
anna signs in on gm-spike.enkisfelhom.hu: 302; cookie set; Max-Age 2592000 s = 30 days; flags: HttpOnly, Secure, SameSite=Lax; Domain attr: none (host-only)
anna GET gm-spike.enkisfelhom.hu/ -> 200 APP
anna GET gm-spike.enkisfelhom.hu/api/v1/healthcheck -> 406 APP
bela signs in on mt-spike.enkisfelhom.hu: 302; cookie set
bela GET mt-spike.enkisfelhom.hu/ -> 200 APP
bela GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=polling -> 200 APP
bela GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket -> 400 APP
bela GET mt-spike.enkisfelhom.hu/history -> 200 APP
bela's MeTube cookie sent to Grimmory -> 302 GATE (a session is per app host)
a made-up session id -> 302 GATE
after the gate RESTARTED, anna's cookie -> 200 APP (session survived)
anna logs out -> 302 /__family/login
anna's OLD cookie after logout -> 302 GATE (refused)
anna with a WRONG password -> 401, cookie none
@@ -0,0 +1,59 @@
"""Exit items 1 and 2 of EXIT-TEST.md."""
import json, re, subprocess, sys, time
from sp import *
out = open(sys.argv[1], "w", buffering=1)
def say(*a):
s = " ".join(map(str, a)); print(s); out.write(s + "\n")
say(f"# items 1–2, {time.strftime('%FT%TZ', time.gmtime())} — 9202, throwaway familygate (forwardAuth), Grimmory v3.4.1 + MeTube 2026.09.29")
WS = ("Connection: Upgrade", "Upgrade: websocket", "Sec-WebSocket-Version: 13", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==")
cases = [
(GM, "GET", "/", ("Accept: text/html",), None), (GM, "GET", "/", (), None), (GM, "GET", "/api/v1/books", (), None),
(GM, "GET", "/api/v1/healthcheck", (), None), (GM, "GET", "/api/v1/public-settings", (), None),
(GM, "POST", "/api/v1/setup", ("Content-Type: application/json",), '{"username":"x","password":"Stranger-1234","email":"x@x.hu","name":"x"}'),
(GM, "POST", "/api/v1/auth/login", ("Content-Type: application/json",), '{"username":"admin","password":"guess"}'),
(GM, "GET", "/ws/websocket", WS, None), (GM, "GET", "/assets/index.js", (), None), (GM, "GET", "/no-such-page", ("Accept: text/html",), None),
(MT, "GET", "/", ("Accept: text/html",), None), (MT, "GET", "/", (), None), (MT, "GET", "/history", (), None),
(MT, "POST", "/add", ("Content-Type: application/json",), '{"url":"https://example.com/v","quality":"best"}'),
(MT, "GET", "/socket.io/?EIO=4&transport=polling", (), None), (MT, "GET", "/socket.io/?EIO=4&transport=websocket", WS, None),
(MT, "GET", "/download/x.mp4", (), None), (MT, "GET", "/version", (), None),
]
say("## item 1 — a STRANGER (no cookie), from the LAN and through the simulated tunnel; GATE = the gate answered, APP = the app did")
app_answers = 0
for host, m, p, h, d in cases:
c, head, body = curl(host, p, method=m, data=d, hdrs=h)
hl = list(h)
tc, thead, tbody = tunnel(host, p, "198.51.100.66", None, "-X", m, *sum([["-H", x] for x in hl], []), *(["--data", d] if d else []))
g1, g2 = is_gate(c, head, body), is_gate(tc, thead, tbody)
app_answers += (not g1) + (not g2)
say(f" {host.split('.')[0]:9s} {m:4s} {p:42s} LAN {c} {'GATE' if g1 else 'APP!'} | tunnel {tc} {'GATE' if g2 else 'APP!'}")
say(f"item 1: {app_answers} app answers of {2*len(cases)} stranger requests -> {'PASS' if app_answers == 0 else 'FAIL'}")
say("## item 2 — family members with their OWN logins (not the dashboard password)")
c, ck, head = family_login(GM, "anna")
ma = re.search(r"(?i)max-age=(\d+)", head)
say(f" anna signs in on {GM}: {c}; cookie {'set' if ck else 'NONE'}; Max-Age {ma.group(1) if ma else '?'} s = {int(ma.group(1))/86400 if ma else 0:.0f} days; flags: "
f"{', '.join(f for f in ('HttpOnly','Secure','SameSite=Lax') if f.lower() in head.lower())}; Domain attr: {'YES' if re.search(r'(?i)set-cookie: felhom_family=[^\n]*domain=', head) else 'none (host-only)'}")
for p in ("/", "/api/v1/healthcheck"):
cc, hh, bb = curl(GM, p, cookie=ck, hdrs=("Accept: text/html",))
say(f" anna GET {GM}{p} -> {cc} {'GATE' if is_gate(cc, hh, bb) else 'APP'}")
c2, ck2, head2 = family_login(MT, "bela")
say(f" bela signs in on {MT}: {c2}; cookie {'set' if ck2 else 'NONE'}")
for host, m, p, h, d in [(MT, "GET", "/", ("Accept: text/html",), None), (MT, "GET", "/socket.io/?EIO=4&transport=polling", (), None),
(MT, "GET", "/socket.io/?EIO=4&transport=websocket", WS, None), (MT, "GET", "/history", (), None)]:
cc, hh, bb = curl(host, p, method=m, cookie=ck2, hdrs=h, timeout=5)
say(f" bela {m} {host}{p} -> {cc} {'GATE' if is_gate(cc, hh, bb) else 'APP'}")
cc, hh, bb = curl(GM, "/", cookie=ck2.replace("felhom_family", "felhom_family") if ck2 else None, hdrs=("Accept: text/html",))
say(f" bela's MeTube cookie sent to Grimmory -> {cc} {'GATE' if is_gate(cc, hh, bb) else 'APP!'} (a session is per app host)")
cc, hh, bb = curl(GM, "/", cookie="felhom_family=" + "0"*64, hdrs=("Accept: text/html",))
say(f" a made-up session id -> {cc} {'GATE' if is_gate(cc, hh, bb) else 'APP!'}")
subprocess.run([GSH, "9202", "docker restart spike-gate >/dev/null; sleep 3"], capture_output=True)
cc, hh, bb = curl(GM, "/", cookie=ck, hdrs=("Accept: text/html",))
say(f" after the gate RESTARTED, anna's cookie -> {cc} {'GATE' if is_gate(cc, hh, bb) else 'APP (session survived)'}")
lc, lh, lb = curl(GM, "/__family/logout", cookie=ck)
say(f" anna logs out -> {lc} {re.search(r'(?im)^location: (.*)$', lh).group(1).strip() if re.search(r'(?im)^location:', lh) else ''}")
cc, hh, bb = curl(GM, "/", cookie=ck, hdrs=("Accept: text/html",))
say(f" anna's OLD cookie after logout -> {cc} {'GATE (refused)' if is_gate(cc, hh, bb) else 'APP! (still in)'}")
c3, ck3, _ = family_login(GM, "anna", "wrong-password-x")
say(f" anna with a WRONG password -> {c3}, cookie {'set!' if ck3 else 'none'}")
json.dump({"anna_gm": family_login(GM, "anna")[1], "bela_mt": ck2}, open(f"{SPIKE}/cookies.json", "w")); import os; os.chmod(f"{SPIKE}/cookies.json", 0o600)
@@ -0,0 +1,55 @@
"""sp.py — the permanent-gate spike's client (2026-10-01, 9202). curl through 9202's traefik over the LAN, or through the
SIMULATED tunnel (a curl container at 172.16.253.2 on 9202, sending what Cloudflare sends). Secrets live in the 0600
scratch files named by $SPIKE; never printed."""
import json, os, re, subprocess, time
SPIKE = os.environ["SPIKE"] # scratch dir: family-pw.json, secrets.json
BASE = "https://192.168.0.114"
DOM = "enkisfelhom.hu"
GM, MT = f"gm-spike.{DOM}", f"mt-spike.{DOM}"
FAM = json.load(open(f"{SPIKE}/family-pw.json"))
SEC = json.load(open(f"{SPIKE}/secrets.json"))
GSH = "/tmp/claude-1000/-mnt-5-hdd-felhom-eu-git/05c3d295-b388-481a-8c36-44a2a80b7d7d/scratchpad/g.sh"
def curl(host, path, *a, method=None, data=None, cookie=None, hdrs=(), out_body=True, timeout=15):
"""LAN request. Returns (code, headers-text, body)."""
args = ["curl", "-sk", "--max-time", str(timeout), "-D", "-", "-H", f"Host: {host}"]
for h in hdrs: args += ["-H", h]
if cookie: args += ["-H", f"Cookie: {cookie}"]
if method: args += ["-X", method]
if data is not None: args += ["--data", data]
args += list(a) + [f"{BASE}{path}"]
r = subprocess.run(args, capture_output=True, text=True, errors="replace")
raw = r.stdout
raw = raw.replace("\r\n", "\n")
head, _, body = raw.partition("\n\n")
while head.startswith("HTTP/") and (" 100 " in head.split("\n")[0]) and body:
head, _, body = body.partition("\n\n")
m = re.match(r"HTTP/\S+ (\d+)", head)
return (m.group(1) if m else "000"), head, body
def tunnel(host, path, visitor, forged_left=None, *a):
"""Through the SIMULATED tunnel: a curl container at 172.16.253.2 with Cloudflare's headers. Returns (code, headers, body)."""
xff = f"{forged_left}, {visitor}" if forged_left else visitor
extra = " ".join("'" + x.replace("'", "'\\''") + "'" for x in a)
cmd = (f"docker run --rm --network felhom-tunnel --ip 172.16.253.2 curlimages/curl:8.11.1 -sk --max-time 15 -D - "
f"'https://traefik{path}' -H 'Host: {host}' -H 'X-Forwarded-For: {xff}' -H 'CF-Connecting-IP: {visitor}' {extra}")
r = subprocess.run([GSH, "9202", cmd], capture_output=True, text=True, errors="replace")
head, _, body = r.stdout.replace("\r\n", "\n").partition("\n\n")
m = re.match(r"HTTP/\S+ (\d+)", head)
return (m.group(1) if m else "000"), head, body
def cookie_from(head, name="felhom_family"):
m = re.search(rf"(?im)^set-cookie: {name}=([^;]*)", head)
return f"{name}={m.group(1)}" if m and m.group(1) else None
def family_login(host, member, password=None):
from urllib.parse import urlencode
code, head, body = curl(host, "/__family/login", method="POST",
data=urlencode({"user": member, "password": password or FAM[member], "rd": "/"}),
hdrs=("Content-Type: application/x-www-form-urlencoded",))
return code, cookie_from(head), head
def is_gate(code, head, body):
"""True when the answer is the GATE's (302 to /__family/login, or its 401 JSON) — never the app's."""
if code == "302" and re.search(r"(?im)^location: https://[^/]+/__family/login", head): return True
return code == "401" and "sign in with your family login" in body
@@ -0,0 +1,96 @@
# Part A — the box tells visitors apart (R-753): design, measurements, decision
Written 2026-10-01 evening, before the release was built (the build followed the measurements below; one bug the hand
prototype found is folded in). Rule that binds every choice: **never believe an address a client can write.**
## 1. The paths, measured
Two outside addresses were available: DooPlex's public IPv4 `37.191.56.193` (no IPv6), and ep0 (one request, used for the
live proof in §6, not here). Venue: demo-hp's REAL tunnel (`*.enkisfelhom.hu → https://traefik`) and an echo app
(`traefik/whoami:v1.11`) on demo-hp, removed afterwards.
| file | what |
|---|---|
| `M1-status-quo.txt` | through the tunnel, today's traefik (trusts nothing): XFF and X-Real-Ip = cloudflared `172.18.0.5` for EVERY visitor; `CF-Connecting-IP` = the visitor; a client's `Forwarded`, `True-Client-Ip` pass traefik untouched; **a client-sent `CF-Connecting-IP` is refused by Cloudflare's edge with 403** |
| `M2-what-cloudflared-delivers.txt` | traefik `insecure` for one minute (shows what arrives): `X-Forwarded-For: 6.6.6.6,37.191.56.193, 172.18.0.5` — **Cloudflare APPENDS the visitor to a client-written chain**; a client's `X-Real-IP` does NOT arrive (stripped); a client's **`X-Forwarded-Host: evil.example` and `X-Forwarded-Port: 8443` DO arrive**; `X-Forwarded-Proto` is overwritten (`https`) |
| `M3-restored.txt` | traefik back as it was |
| `M4-lan-path.txt` | LAN, forged headers: XFF / X-Real-Ip = the real LAN address (traefik drops the forged chain); **a forged `CF-Connecting-IP: 7.7.7.7` arrives** |
| path | TCP peer at traefik | XFF traefik forwards today | the real visitor is in | forgeable by the visitor |
|---|---|---|---|---|
| tunnel | cloudflared, docker-assigned (`172.18.0.5`) | cloudflared's address, for everyone | `CF-Connecting-IP`; Cloudflare's XFF (rightmost of its part) | XFF leftmost: yes (once trusted); CF-Connecting-IP: no (edge 403) |
| LAN | the LAN client | the LAN client | XFF / X-Real-Ip | no (traefik drops a forged chain); CF-Connecting-IP: YES |
## 2. Options, and the one taken
**Question:** how do the box and its apps learn each visitor's own address, without believing anything a client writes?
- **(a) Controller only.** No traefik change; the controller believes `CF-Connecting-IP` only when the hop traefik saw is
cloudflared's address. Cost: apps keep "one address" for every tunnel visitor (R-775 Grimmory, Home Assistant's
`local_only` hole, Kimai/zipline/vikunja lockouts stay); cloudflared's address must be fixed anyway.
- **(b) traefik trusts cloudflared's fixed address** (the reviewer's sketch). Apps that read X-Forwarded-For from the RIGHT
get the real visitor; the controller the same. Cost: (1) every app that reads the LEFTMOST entry would believe a
stranger's address (the sweep found 19); (2) traefik then keeps a client's `X-Forwarded-Host`/`-Port` (M2) — host-header
poisoning for apps that build links from it.
- **(c) A traefik plugin or our controller as `forwardAuth` for every request** to rewrite the chain to one address.
Cost: a new external dependency (plugin), or the controller in every request path (an outage takes every app down).
**Taken: (b), with both of its costs paid in the same rollout.** It is the only one that gives the APPS the visitor
(decision 63's purpose), needs no new dependency, and keeps the controller out of the request path.
- Cost (2): an entrypoint middleware `felhom-forwarded@file` removes every header a client could write a host, path or
address into (`X-Forwarded-Host/-Uri/-Method/-Prefix/-Tls-Client-Cert(-Info)`, `Forwarded`, `True-Client-Ip`,
`X-Client-Ip`, `X-Cluster-Client-Ip`, `Client-Ip`, `X-Original-Forwarded-For`) and fixes `X-Forwarded-Port: 443`.
An app that falls back from X-Forwarded-Host reads `Host`, which names the same app.
- Cost (1): the 19 leftmost readers carry a router middleware that removes the chain (`<router>-xff`); measured (P1) that
such an app then receives NO X-Forwarded-For and reads X-Real-Ip (traefik-set) or its peer — exactly as unforgeable as
today. Shipped in the catalog BEFORE the controller release (harmless without the trust).
**Docs quoted.** traefik (v3.6, `doc.traefik.io/traefik/reference/install-configuration/entrypoints`): *"forwardedHeaders.
trustedIPs — Trust only forwarded headers from selected IPs"*; the forwardAuth reference: *"trustForwardHeader is deprecated
… configure trusted IPs at the EntryPoint level using forwardedHeaders.trustedIPs"*. traefik source v3.6.7
(`pkg/middlewares/forwardedheaders/forwarded_header.go`): an untrusted peer's `X-Forwarded-*`/`X-Real-Ip` are DELETED;
a trusted peer's are KEPT and `X-Real-Ip` is set only when absent. Cloudflare's HTTP-headers page: X-Forwarded-For — *"If
an X-Forwarded-For header was already present in the request to Cloudflare, Cloudflare appends the IP address of the HTTP
proxy connecting to Cloudflare"* — measured in M2.
## 3. The shape built (controller v0.286.x, `internal/infra` + `internal/stacks/infra.go`)
- Network `felhom-tunnel` `172.16.253.0/29`, gateway `.1`, docker's allocation confined to `--ip-range 172.16.253.4/30`;
cloudflared ALONE on it at `.2`, traefik at `.3` (and on `traefik-public`). Why 172.16.x: private (apps' default proxy
lists — Tomcat, Rack, remote_ip — skip it) and outside docker's default pools (172.17–172.31, 192.168). **Found by the
hand prototype on 9202 (P1): without the ip-range and traefik's own fixed address, traefik joining first was given `.2`.**
- traefik `websecure`: `forwardedHeaders.trustedIPs: ["172.16.253.2/32"]` and `http.middlewares: [felhom-forwarded@file]`.
- `EnsureBaseStack` reconciles a RUNNING traefik/cloudflared when the rendered files differ (recreate; refuses a rewrite that
would drop the running certificate resolver); writes the middleware file before `traefik.yml`; moves cloudflared only
once traefik is on the tunnel network. If the network cannot be made, nothing is trusted and cloudflared stays put.
- **One rule for the controller** (`internal/web/clientaddr.go`): believed only when the TCP peer is traefik (docker DNS);
the RIGHTMOST X-Forwarded-For entry is the hop traefik saw; that hop being `172.16.253.2` → `CF-Connecting-IP`. It holds
for the dashboard (the whole chain) and the setup gate's forwardAuth request (only traefik's hop), and with or without
the trust. Readers in apps: from the RIGHT, skipping trusted proxies — **a fixed count from the right is wrong for one of
the two paths** (tunnel: 2nd from the right; LAN: 1st), so count-based readers (calibre-web, tandoor, wger) are left as
they are.
## 4. What it gives the apps (sweep, READ in source — `sweep/sweep-1..4.md`)
- **Real visitor with no change:** actualbudget, immich, dawarich, claper (tunnel), termix, **Home Assistant** (it treated
every internet visitor as "local" — a `local_only` user could sign in from the internet; fixed by this), **Grimmory**
(R-775: its IP lock becomes per visitor; the per-NAME lock stays).
- **Need one setting to see it** (catalog, after the release): bookstack `APP_PROXIES`, kimai `TRUSTED_PROXIES`, zipline
`CORE_TRUST_PROXY`/`CORE_TRUSTED_PROXIES`, vikunja `VIKUNJA_SERVICE_IPEXTRACTIONMETHOD=xff`, nextcloud `TRUSTED_PROXIES`;
Jellyfin `KnownProxies` (no env — `network.xml`).
- **Chain removed on their router (19):** adventurelog, audiobookshelf, code-server, docmost, emby, ghost, gokapi, komga,
mealie, opengist, outline, paperless-ngx, papra, plant-it, rallly, romm, seerr, sparkyfitness, uptime-kuma.
- **Stay "one address" on the tunnel, unforgeable:** X-Real-Ip readers (vaultwarden, grafana, gitea, crafty, homebox),
count readers (calibre-web, tandoor, wger), peer readers (gramps-web, navidrome, radicale, wanderer, privatebin).
- **Settings that must never be turned on** (they read the leftmost): glance `proxied`, karakeep `RATE_LIMITING_ENABLED`,
onlyoffice ipfilter, vaultwarden `IP_HEADER=X-Forwarded-For`, PocketBase `UseLeftmostIP`, navidrome's reverse-proxy
whitelist (header login), Plex `ALLOWED_NETWORKS`.
## 5. Risks stated
- A box that rolls back to ≤ 0.285 keeps the new traefik (an old controller never rewrites a running traefik); its
`clientIP` takes the LEFTMOST entry, which a stranger then writes — the dashboard's counter becomes dodgeable until the box
moves forward. The floor never moves back; the self-update's crash roll-back is the window. Row filed.
- A NEW catalog app that reads the leftmost entry is forgeable unless its onboarding finds it — checklist row added.
- Emby: every tunnel visitor is "LAN" today and stays so (its chain is removed); Jellyfin likewise until `KnownProxies`.
@@ -0,0 +1,12 @@
2026/10/01 19:05:37 infra.go:338: [INFO] [infra] connected felhom-controller to traefik-public
2026/10/01 19:05:37 infra.go:91: [INFO] [infra] cloudflared skipped — no cf_tunnel_token configured (LAN-only node)
2026/10/01 19:06:42 auth.go:184: [WARN] [web] Failed login from 198.51.100.66
2026/10/01 19:06:43 auth.go:184: [WARN] [web] Failed login from 198.51.100.66
2026/10/01 19:06:43 auth.go:184: [WARN] [web] Failed login from 198.51.100.66
2026/10/01 19:06:44 auth.go:184: [WARN] [web] Failed login from 198.51.100.66
2026/10/01 19:06:44 auth.go:184: [WARN] [web] Failed login from 198.51.100.66
2026/10/01 19:06:45 auth.go:176: [WARN] [web] Login rate limited for 198.51.100.66 (5 attempts)
2026/10/01 19:06:45 auth.go:176: [WARN] [web] Login rate limited for 198.51.100.66 (5 attempts)
2026/10/01 19:06:45 auth.go:222: [INFO] [web] Login from 203.0.113.10
2026/10/01 19:06:55 auth.go:184: [WARN] [web] Failed login from 192.168.0.180
2026/10/01 19:06:56 auth.go:184: [WARN] [web] Failed login from 172.18.0.8
@@ -0,0 +1,15 @@
# L1 — controller 0.286.0 on scratch 9202 (hand-set image, no floor), 2026-10-01 19:06 UTC. Method: the exact endpoint the
# login form posts (POST /login on felhom.enkisfelhom.hu) through traefik. 9202 has no tunnel: the tunnel hop is SIMULATED
# by a curl container AT 172.16.253.2 on felhom-tunnel (cloudflared's fixed address) sending what Cloudflare sends.
# On start the release found traefik's files equal to its render (the hand prototype, P1) and did NOT recreate traefik.
stranger 198.51.100.66, 7 wrong passwords, a NEW forged leftmost address each time (XFF "10.0.0.<i>, 198.51.100.66"):
try 1..5 -> Hibás jelszó.
try 6,7 -> Túl sok hibás próbálkozás erről a címről. Próbáld újra egy perc múlva.
household 203.0.113.10, the right password, at once (seconds later):
HTTP/2 302, location: /, set-cookie: felhom_session=<redacted>
LAN (DooPlex 192.168.0.180 straight to 9202:443) forging X-Forwarded-For / CF-Connecting-IP / X-Real-IP = 198.51.100.77:
counted as 192.168.0.180
impostor container on traefik-public (NOT the tunnel address) sending CF-Connecting-IP 198.51.100.88:
counted as 172.18.0.8 (its own address)
Controller log lines: L1-9202-controller-log.txt
@@ -0,0 +1,21 @@
## M1 status-quo traefik (trusts nothing), from DooPlex public 37.191.56.193 — plain, 2026-10-01T18:25:46Z
RemoteAddr: 172.18.0.3:60646
Cf-Connecting-Ip: 37.191.56.193
X-Forwarded-For: 172.18.0.5
X-Forwarded-Host: a1-echo.enkisfelhom.hu
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 499d523532f2
X-Real-Ip: 172.18.0.5
## M1 status-quo traefik (trusts nothing), from DooPlex public 37.191.56.193 — forged: XFF 6.6.6.6, X-Real-IP 8.8.4.4, True-Client-IP 9.9.9.9, X-Forwarded-Host evil.example, X-Forwarded-Port 8443, X-Forwarded-Proto http, Forwarded for=5.5.5.5
RemoteAddr: 172.18.0.3:60646
Cf-Connecting-Ip: 37.191.56.193
Forwarded: for=5.5.5.5
True-Client-Ip: 9.9.9.9
X-Forwarded-For: 172.18.0.5
X-Forwarded-Host: a1-echo.enkisfelhom.hu
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 499d523532f2
X-Real-Ip: 172.18.0.5
## M1 status-quo traefik (trusts nothing), from DooPlex public 37.191.56.193 — forged CF-Connecting-IP 7.7.7.7 alone: HTTP 403 (Cloudflare's edge answers; the request never reaches the box)
@@ -0,0 +1,21 @@
## M2 TEMPORARY traefik forwardedHeaders.insecure (shows what cloudflared delivers), from DooPlex public 37.191.56.193 — plain, 2026-10-01T18:26:09Z
RemoteAddr: 172.18.0.3:42728
Cf-Connecting-Ip: 37.191.56.193
X-Forwarded-For: 37.191.56.193, 172.18.0.5
X-Forwarded-Host: a1-echo.enkisfelhom.hu
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 499d523532f2
X-Real-Ip: 172.18.0.5
## M2 TEMPORARY traefik forwardedHeaders.insecure (shows what cloudflared delivers), from DooPlex public 37.191.56.193 — forged: XFF 6.6.6.6, X-Real-IP 8.8.4.4, True-Client-IP 9.9.9.9, X-Forwarded-Host evil.example, X-Forwarded-Port 8443, X-Forwarded-Proto http, Forwarded for=5.5.5.5
RemoteAddr: 172.18.0.3:42728
Cf-Connecting-Ip: 37.191.56.193
Forwarded: for=5.5.5.5
True-Client-Ip: 9.9.9.9
X-Forwarded-For: 6.6.6.6,37.191.56.193, 172.18.0.5
X-Forwarded-Host: evil.example
X-Forwarded-Port: 8443
X-Forwarded-Proto: https
X-Forwarded-Server: 499d523532f2
X-Real-Ip: 172.18.0.5
## M2 TEMPORARY traefik forwardedHeaders.insecure (shows what cloudflared delivers), from DooPlex public 37.191.56.193 — forged CF-Connecting-IP 7.7.7.7 alone: HTTP 403 (Cloudflare's edge answers; the request never reaches the box)
@@ -0,0 +1,12 @@
## M3 traefik restored to status quo — plain, 2026-10-01T18:27:31Z
RemoteAddr: 172.18.0.3:45692
Cf-Connecting-Ip: 37.191.56.193
X-Forwarded-For: 172.18.0.5
X-Forwarded-Host: a1-echo.enkisfelhom.hu
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 499d523532f2
X-Real-Ip: 172.18.0.5
## M3 traefik restored to status quo — forged: XFF 6.6.6.6, X-Real-IP 8.8.4.4, True-Client-IP 9.9.9.9, X-Forwarded-Host evil.example, X-Forwarded-Port 8443, X-Forwarded-Proto http, Forwarded for=5.5.5.5
RemoteAddr: 172.18.0.3:45692
Cf-Connecting-Ip: 37.191.56.193
@@ -0,0 +1,11 @@
## M4 LAN path: DooPlex 192.168.0.180 straight to demo-hp guest 192.168.0.155:443 (no tunnel), status-quo traefik, forged headers
RemoteAddr: 172.18.0.3:45692
Cf-Connecting-Ip: 7.7.7.7
Forwarded: for=5.5.5.5
True-Client-Ip: 9.9.9.9
X-Forwarded-For: 192.168.0.180
X-Forwarded-Host: a1-echo.enkisfelhom.hu
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 499d523532f2
X-Real-Ip: 192.168.0.180
@@ -0,0 +1,46 @@
# 9202 prototype of the Part A design (hand-made, traefik static + forwarded.yml rendered by the new code), 2026-10-01T18:44:33Z
### T (tunnel simulated): a container AT 172.16.253.2 on felhom-tunnel sends what cloudflared sends (Cloudflare's chain 6.6.6.6 client-written, 203.0.113.9 real) -> p-echo
RemoteAddr: 172.18.0.5:42224
Cf-Connecting-Ip: 203.0.113.9
X-Forwarded-For: 6.6.6.6,203.0.113.9, 172.16.253.2
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 364d78f29dbd
X-Real-Ip: 172.16.253.2
### P (impostor): a container on traefik-public (NOT the tunnel address) sends the same -> p-echo
RemoteAddr: 172.18.0.5:42224
Cf-Connecting-Ip: 203.0.113.9
X-Forwarded-For: 172.18.0.8
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 364d78f29dbd
X-Real-Ip: 172.18.0.8
### T (tunnel simulated): a container AT 172.16.253.2 on felhom-tunnel sends what cloudflared sends (Cloudflare's chain 6.6.6.6 client-written, 203.0.113.9 real) -> p-echo-reset
RemoteAddr: 172.18.0.5:43836
Cf-Connecting-Ip: 203.0.113.9
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 364d78f29dbd
X-Real-Ip: 172.16.253.2
### P (impostor): a container on traefik-public (NOT the tunnel address) sends the same -> p-echo-reset
RemoteAddr: 172.18.0.5:43836
Cf-Connecting-Ip: 203.0.113.9
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 364d78f29dbd
X-Real-Ip: 172.18.0.8
### L (LAN): DooPlex 192.168.0.180 straight to 9202:443, forged headers -> p-echo
RemoteAddr: 172.18.0.5:42224
Cf-Connecting-Ip: 7.7.7.7
X-Forwarded-For: 192.168.0.180
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 364d78f29dbd
X-Real-Ip: 192.168.0.180
### L (LAN): DooPlex 192.168.0.180 straight to 9202:443, forged headers -> p-echo-reset
RemoteAddr: 172.18.0.5:43836
Cf-Connecting-Ip: 7.7.7.7
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: 364d78f29dbd
X-Real-Ip: 192.168.0.180
@@ -0,0 +1,17 @@
## RP-A1 mutant: clientIP = the pre-R-753 LEFTMOST X-Forwarded-For hop (2026-10-01T18:37:48Z)
=== RUN TestClientIP_Paths
clientaddr_test.go:76: tunnel, forged leftmost: clientIP(remote="172.18.0.3:5000" xff="6.6.6.6,37.191.56.193, 172.16.253.2" cf="37.191.56.193") = "6.6.6.6", want "37.191.56.193"
clientaddr_test.go:76: gate request through the tunnel: clientIP(remote="172.18.0.3:5000" xff="172.16.253.2" cf="203.0.113.50") = "172.16.253.2", want "203.0.113.50"
clientaddr_test.go:76: direct, forged headers: clientIP(remote="192.168.0.50:4000" xff="1.2.3.4" cf="5.6.7.8") = "1.2.3.4", want "192.168.0.50"
clientaddr_test.go:76: old cloudflared address: clientIP(remote="172.18.0.3:5000" xff="6.6.6.6, 172.18.0.5" cf="9.9.9.9") = "6.6.6.6", want "172.18.0.5"
clientaddr_test.go:76: traefik, garbage hop: clientIP(remote="172.18.0.3:5000" xff="1.2.3.4, garbage" cf="") = "1.2.3.4", want "172.18.0.3"
--- FAIL: TestClientIP_Paths (0.00s)
=== RUN TestLogin_StrangerThroughTheTunnelLocksOnlyHimself
clientaddr_test.go:156: the stranger rotating a forged leftmost address must be locked after 5 tries; got:
--- FAIL: TestLogin_StrangerThroughTheTunnelLocksOnlyHimself (0.11s)
=== RUN TestLoginRateLimit_RotatingXFF_Limited
ratelimit_ip_test.go:90: a rotating X-Forwarded-For from a direct peer must NOT evade the counter; got:
--- FAIL: TestLoginRateLimit_RotatingXFF_Limited (0.11s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-controller/internal/web 0.268s
FAIL
@@ -0,0 +1,7 @@
## RP-A2 mutant: the tunnel hop (cloudflared) is the key — every tunnel visitor shares it (2026-10-01T18:38:03Z)
=== RUN TestLogin_StrangerThroughTheTunnelLocksOnlyHimself
clientaddr_test.go:160: the household must sign in at once from its own address; got 200
--- FAIL: TestLogin_StrangerThroughTheTunnelLocksOnlyHimself (0.08s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-controller/internal/web 0.089s
FAIL
@@ -0,0 +1,11 @@
## RP-A3 mutant: ensureTraefik returns early when traefik runs (pre-R-753) (2026-10-01T18:38:17Z)
=== RUN TestEnsureTraefik_ReconcilesARunningTraefik
infra_tunnel_test.go:148: traefik.yml was not rewritten with the tunnel trust:
--- FAIL: TestEnsureTraefik_ReconcilesARunningTraefik (0.00s)
=== RUN TestEnsureBaseStack_TunnelOrder
=== RUN TestEnsureBaseStack_TunnelOrder/network_made
infra_tunnel_test.go:246: network made → traefik trusts the tunnel and cloudflared moved; trust false moved true
=== RUN TestEnsureBaseStack_TunnelOrder/network_refused
--- FAIL: TestEnsureBaseStack_TunnelOrder (0.02s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-controller/internal/stacks 0.042s
@@ -0,0 +1,45 @@
# Traefik Static Configuration
# Generated by felhom-controller (base-infra bring-up). Do not edit — regenerated on bring-up.
api:
dashboard: true
insecure: false
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
http:
tls:
certResolver: letsencrypt
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: traefik-public
file:
directory: /etc/traefik/dynamic
watch: true
log:
level: INFO
accessLog: {}
certificatesResolvers:
letsencrypt:
acme:
email: doodoo21@freemail.hu
storage: /etc/traefik/acme.json
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "8.8.8.8:53"
@@ -0,0 +1,20 @@
# Catalog sweep 1/4 — READ in source at each pinned tag (not measured live). Subagent report 2026-10-01, condensed.
Apps: actualbudget adventurelog audiobookshelf bentopdf bookstack calcom calibre-web claper code-server crafty-controller dawarich docmost emby ghost.
NEW chain = tunnel "forged…, real, 172.16.253.2"; LAN "lanclient".
| App (tag) | How it reads the visitor | Used for | Verdict | Evidence |
|---|---|---|---|---|
| actualbudget 26.9.0 | Express trust proxy, CIDR list from the RIGHT (`ACTUAL_TRUSTED_PROXIES` default private ranges) | login limiter 5/15 min by IP | SAFE; real client on both paths after Part A | packages/sync-server/src/app.ts:31; load-config.js:133-143; app-account.js:26-33 |
| adventurelog v0.13.0 | django-allauth 0.63.3 LEFTMOST XFF | allauth defaults: login_failed 10/m/ip + 5/300s/username | RISK (per-IP part forgeable) → router reset | allauth account/adapter.py:704-710; app settings.py:326,375-386 |
| audiobookshelf 2.37.1 | request-ip: x-client-ip, LEFTMOST XFF, cf-connecting-ip, x-real-ip | auth limiter 40/10 min by IP ONLY | RISK (unlimited guessing) → router reset | server/utils/rateLimiterFactory.js:9-10,53-61; libs/requestIp/index.js:16-68 |
| bentopdf v2.8.6 | — static | nothing | SAFE | Dockerfile:80,105 |
| bookstack 26.09.1 | Laravel TrustProxies from the RIGHT, only with `APP_PROXIES` (empty) | login 5/min `username\|ip`; MFA limiter by IP; audit IP | SAFE as is (sees traefik); `APP_PROXIES=172.16.0.0/12` → real client on both paths | TrustProxies.php; ThrottlesLogins.php:63-66; MfaVerificationLimiter.php:60 |
| calcom v6.2.0 | cf-connecting-ip, true-client-ip, LEFTMOST XFF, x-real-ip | limits are no-ops without UNKEY_ROOT_KEY; IP_BANLIST unset | SAFE as is (revisit if those are set) | packages/lib/getIP.ts:22-33; rateLimit.ts:33-41 |
| calibre-web CWA v4.0.8 | werkzeug ProxyFix count from the right (`TRUSTED_PROXY_COUNT`=1) | login limit by USERNAME; register/kobo by IP; session bound to IP | SAFE as is (count 1 → cloudflared on the tunnel, LAN client on the LAN); count 2 breaks the LAN path and proto/host — keep 1 | cps/__init__.py:89-92; cps/web.py:2056-2057,2218-2219 |
| claper v2.5.0 | remote_ip from the RIGHT skipping private ranges | auth limiter 10/min by IP | SAFE; real client on the tunnel | lib/claper_web/endpoint.ex:63-65; remote_ip lib/remote_ip.ex:252-278 |
| code-server 4.129.0 | logs the raw XFF | global login limiter | SAFE (log text client-written) → router reset for the log | src/node/routes/login.ts:12-26,105-111 |
| crafty-controller 4.11.0 | X-Real-IP first, then leftmost XFF | logs; lockout keys never match (inert, inferred) | SAFE (X-Real-Ip is traefik-set) | base_handler.py:71-101 |
| dawarich 1.15.3 | Rack Request#ip from the RIGHT, default trusted private | Rack::Attack logins/ip 20/min, logins/email 5/min; Devise lockable | SAFE; real client after Part A | config/initializers/rack_attack.rb:269-292 |
| docmost 0.96.0 | Fastify trustProxy true = LEFTMOST | AUTH throttler 10/min by IP ONLY | RISK → router reset | apps/server/src/main.ts:25,97; user-throttler.guard.ts |
| emby 4.11.0.4 (decompiled) | LEFTMOST XFF, else X-Real-IP; wizard forces AllAddresses | LAN PRIVILEGES (remote access off-users, IP filter bypass, forgot-password PIN) | RISK → router reset; and a risk TODAY: cloudflared/traefik are private → every tunnel visitor is "LAN" (row) | BaseRequest.cs InitRemoteConnectionInfo; NetworkManager.cs:397-437 |
| ghost 6.67.0 | Express trust proxy true = LEFTMOST | brute: userLogin IP+username, globalBlock per IP | RISK → router reset | ghost/core/core/shared/express.js:21-25; brute.js |
@@ -0,0 +1,27 @@
# Catalog sweep 2/4 — who reads the visitor's address (READ in source at each pinned tag; not measured live)
Subagent report, 2026-10-01 evening, copied verbatim in substance. Apps: gitea glance gokapi grafana gramps-web
home-assistant homebox homepage immich jellyfin karakeep kimai komga mealie.
| App (tag) | Reads forwarded headers? | How / setting (default) | Used for | Verdict for the new chain | Evidence |
|---|---|---|---|---|---|
| gitea 1.27.3 | Only from a trusted peer; default trust = loopback, so traefik is not trusted | chi proxy: X-Real-IP first, then XFF count-from-right (`REVERSE_PROXY_LIMIT`=1); `REVERSE_PROXY_TRUSTED_PROXIES` (default `127.0.0.0/8,::1/128`) | logs ("Failed authentication attempt … from"), `InitialIP`; no IP lockout | SAFE AS IS (sees traefik). Trusting 172.16.0.0/12 would show the LAN client, but cloudflared on the tunnel (X-Real-IP wins). Never forgeable | modules/setting/security.go:133-137; routers/common/middleware.go:33,123-133; chi-middleware/proxy v1.1.1 middleware.go:50-73; routers/web/auth/auth.go:310 |
| glance v0.8.5 | Only with `server.proxied: true` (off; not in our seed) | leftmost XFF when on | login limit by IP, only with `auth:` (our seed has none) | SAFE AS IS. **Never set `proxied: true`** (leftmost) | internal/glance/glance.go:364-389; auth.go:24-25,142-160 |
| gokapi v1.9.6 | always: first parseable XFF, X-Real-IP, peer | leftmost, no setting | download log only when `SaveIp` (seed: false); no login limit | SAFE AS IS (unused) | internal/logging/Logging.go:43-48,65-93 |
| grafana 13.2.3 | always | X-Real-IP first, then leftmost XFF; no trust setting | lockout by USERNAME (5/5 min); IP lockout OFF by default; logs, session client-IP | SAFE AS IS (X-Real-Ip is traefik-set: cloudflared on the tunnel, LAN client on the LAN). **Do not turn on IP lockout** | pkg/web/context.go:71-96; conf/defaults.ini:498-507; loginattemptimpl/login_attempt.go:65-99 |
| gramps-web v25.6.0 | no (flask_limiter `get_remote_address` = TCP peer) | peer | `1/second` on token/login/register, keyed on the peer = ONE bucket for all | SAFE AS IS, no change from Part A | gramps-web-api v3.3.0 ratelimiter.py:5-9; token.py:73,104,128,143 (API version INFERRED from `FROM dmstraub/gramps-webapi:latest`) |
| home-assistant 2026.9.4 | yes; template sets `use_x_forwarded_for: true`, `trusted_proxies: [172.16.0.0/12]` | walks from the RIGHT skipping trusted; all trusted → leftmost; non-IP entry → 400 | `ip_ban` (threshold -1 = off); **LAN privilege**: `local_only` users, remember-me preselect | SAFE AS IS and **FIXED by Part A**: today every tunnel visitor arrives as cloudflared's PRIVATE address → "local" → a `local_only` user can sign in from the internet. After: skips 172.16.253.2, takes the real public client | components/http/forwarded.py:83-144; http/auth_util.py:15-43; util/network.py:51-53 |
| homebox 0.26.2 | only with `HBOX_OPTIONS_TRUST_PROXY=true` (default false, not set) | X-Real-IP first, then leftmost XFF | login/forgot/reset limiter keyed `IP\|path` (5/min) | SAFE AS IS (not forgeable; but one bucket = a stranger can lock everyone out — as today). Turning trust on helps the LAN only (X-Real-Ip = cloudflared on the tunnel); also makes it trust X-Forwarded-Host — optional, small gain | backend/app/api/middleware.go:454-490,556-575; internal/sys/config/conf.go:74,179-184 |
| homepage v1.13.2 | no | — | no auth; Host check on `/api/*` | SAFE AS IS | src/middleware.js:3-18 |
| immich v3.2.4 | yes (Express trust proxy) | walks from the RIGHT (proxy-addr); `IMMICH_TRUSTED_PROXIES` default linklocal,uniquelocal (+loopback) | logs only; no IP lockout | SAFE AS IS and **sees the real client** after Part A | server/src/app.common.ts:49; config.repository.ts:327; auth.service.ts:71 |
| jellyfin 10.11.11 | only when `KnownProxies` set (default empty) | ASP.NET ForwardedHeaders from the RIGHT | **LAN privileges** (remote access per user, remote bitrate, public user list, restart for non-admins, ForgotPassword); lockout per user | **NEEDS A SETTING — and a RISK TODAY that Part A alone does not change**: the TCP peer is traefik (private) → every internet visitor is "LAN". Fix: KnownProxies `172.16.0.0/12` in `network.xml` (no env var) | ApiServiceCollectionExtensions.cs:169-190,282-326; NetworkManager.cs:309-340,942-960; UserManager.cs:595-596 |
| karakeep 0.33.2 | always (`request-ip`) | X-Client-IP, then LEFTMOST XFF, then CF-Connecting-IP, … | login + tRPC limits keyed by IP — **only with `RATE_LIMITING_ENABLED=true`** (default false, not set) | SAFE AS IS (limiter off). **Do not turn the limiter on** — after Part A its key would be the client-written leftmost | apps/web/server/auth.ts:129-137; packages/trpc/lib/rateLimit.ts:21-39; request-ip src/index.js:39-41,59-97 |
| kimai 2.67.0 | only from `TRUSTED_PROXIES` (image default `nginx,localhost,127.0.0.1`) | Symfony: from the RIGHT, dropping trusted | login throttling 5/5 min (Symfony default username+IP plus a per-IP limit — from Symfony docs, not Kimai code); IP-keyed limiters (session-ID guard, password reset, old API tokens) | **NEEDS A SETTING**: today every key is traefik → one attacker trips the IP limiters for all. `TRUSTED_PROXIES=127.0.0.1,172.16.0.0/12` → tunnel real client, LAN client | Dockerfile:256; config/packages/security.yaml:70-72; rate_limiter.yaml |
| komga 1.28.0 | always (`forward-headers-strategy: framework`) | LEFTMOST | the authentication-activity audit IP only | SAFE AS IS for security; the audit IP becomes client-written on the tunnel (today: cloudflared). `SERVER_FORWARDHEADERSSTRATEGY=native` would fix it — not without a live test | application.yml:63; LoginListener.kt:30-96 |
| mealie v3.28.0 | yes; and `/api/auth/token` reads raw XFF | LEFTMOST | log lines; lockout per ACCOUNT | SAFE AS IS; the logged IP becomes client-written on the tunnel. No setting fixes it | routes/auth/auth.py:141-147; credentials_provider.py:41-54 |
Notes from the report: leftmost readers (glance if `proxied`, gokapi, karakeep, komga, mealie) use the address for nothing
or for logs/audit only as configured — two switches must stay off (karakeep `RATE_LIMITING_ENABLED`, glance `proxied`).
Not checked live: ASP.NET (Jellyfin) and HA with a 3-entry XFF (HA refuses when X-Forwarded-Proto has neither 1 entry nor
as many as XFF — traefik sends 1); Kimai's exact throttling keys. Side observations: glance's seed has no `auth:` (public
dashboard); homepage `/api/*` refuses a Host not in `HOMEPAGE_ALLOWED_HOSTS` (inferred, not set by the template).
@@ -0,0 +1,19 @@
# Catalog sweep 3/4 — READ in source at each pinned tag (not measured live). Subagent report 2026-10-01, condensed.
Apps: n8n navidrome nextcloud onlyoffice opengist outline paperless-ngx papra plant-it plex privatebin radarr radicale rallly.
| App (pin) | How | Used for | Verdict | Evidence |
|---|---|---|---|---|
| n8n 2.42.1 | Express trust proxy hop count (`N8N_PROXY_HOPS`=0) | IP limit 1000/5min + per email | SAFE; optional `N8N_PROXY_HOPS=2` | packages/@n8n/config/src/index.ts:266-268 |
| navidrome 0.64.2 | peer unless ExtAuth.TrustedSources set (not set) | login 5/20s per IP | SAFE. **Never set the whitelist** (opens Remote-User login) | server/middlewares.go:171-201 |
| nextcloud 34.0.4 | Apache mod_remoteip X-Real-IP from private; core trusted_proxies from the right | brute-force throttle by IP | SAFE as is (tunnel = one bucket); `TRUSTED_PROXIES=172.16.0.0/12` → real client | Request.php:591-631; Throttler.php:50-59 |
| onlyoffice 9.4.0 | leftmost XFF only with ipfilter on (off) | — | SAFE; never enable ipfilter | Common/sources/utils.js:1052-1066 |
| opengist 1.15 | echo RealIP LEFTMOST | logs only | log text forgeable → router reset | echo context.go:309-331 |
| outline 1.10.1 | Koa proxy LEFTMOST (`PROXY_IP_HEADER` X-Forwarded-For) | per-IP limits; sign-in link bound to IP | RISK → router reset (or `PROXY_IP_HEADER=X-Real-IP`) | server/services/web.ts:31-39; rateLimiter.ts:31-52 |
| paperless-ngx 2.20.15 | allauth 65.12.1 LEFTMOST | login_failed 10/m/ip + 5/300s/username | RISK (per-IP part) → router reset | allauth account/adapter.py:774-780 |
| papra 26.6.2 | better-auth LEFTMOST; invalid → rate limit SKIPPED | sign-in/up limit per IP | RISK (junk value turns the limit off) → router reset (or `AUTH_IP_ADDRESS_HEADERS=x-real-ip`) | auth.config.ts:70-82; better-auth rate-limiter/index.ts:167-171 |
| plant-it 0.10.0 | LEFTMOST | per-IP limiter in an unbounded map | RISK (evasion + memory growth) → router reset | RateLimitFilter.java:36-60 |
| plex 1.41.4 | closed source; XFF "in most places" (inferred) | allowedNetworks auth bypass (unset) | SAFE as templated; never set ALLOWED_NETWORKS | forum links (inferred) |
| privatebin 2.0.6 | only with `[traffic] header` (unset) | paste flood limit | SAFE; never `X_FORWARDED_FOR` | TrafficLimiter.php:52-154 |
| radarr 6.4.4 | ASP.NET from the right, TrustedNetworks (loopback) | local-address auth bypass (needs no leftover XFF) | SAFE; optional TrustedNetworks 172.16.0.0/12 | ForwardedHeadersConfigurator.cs:15-38 |
| radicale 3.8.1 | REMOTE_ADDR | logs | SAFE | radicale/app/__init__.py:449-458 |
| rallly 4.15.3 | better-auth (multi-hop unresolvable → shared); /api/event LEFTMOST | sign-in limits; /api/event limit | auth SAFE; /api/event forgeable → router reset | better-auth utils/ip.ts:283-372; api/event route.ts:72-79 |
@@ -0,0 +1,21 @@
# Catalog sweep 4/4 — READ in source at each pinned tag (not measured live). Subagent report 2026-10-01, condensed.
Apps: recipe-importer romm seerr sonarr sparkyfitness tandoor termix uptime-kuma vaultwarden vikunja wanderer wger wishlist zipline grimmory metube.
| App (pin) | How | Used for | Verdict | Evidence |
|---|---|---|---|---|
| recipe-importer v0.9.11 | gunicorn peer | logs | SAFE | app/main.py:31-53 |
| romm 5.3.1 | `--forwarded-allow-ips=*` uvicorn LEFTMOST (hard-coded) | per-IP pair-code / device-auth limits | RISK (low: codes unguessable) → router reset | docker/init_scripts/init:141; backend/utils/rate_limit.py:9 |
| seerr 2.7.3 | trustProxy setting (UI, default off) → LEFTMOST | logs; forwards XFF to Jellyfin | SAFE while off → router reset (household-switchable) | server/index.ts:140-141 |
| sonarr 4.0.20 | ASP.NET from the right, TrustedNetworks | local bypass (refused while XFF left over) | SAFE; optional | ForwardedHeadersConfigurator.cs:15-44 |
| sparkyfitness v0.17.3 | better-auth LEFTMOST; Express trust proxy 1 | sign-in 3/10s per IP; nginx global 5 r/s | RISK → router reset | better-auth get-request-ip.ts:18-26; docker/nginx.conf |
| tandoor 2.6.15 | allauth `ALLAUTH_TRUSTED_PROXY_COUNT`=1 (count from the right) | login 5/m/ip etc. | SAFE as is (one bucket); no count fits both paths — leave | recipes/settings.py:712-719; allauth httpkit.py:197-220 |
| termix 2.8.0 | bundled nginx real_ip recursive from the right | login limiter per IP and per username | SAFE and better: real client after Part A | docker/nginx.conf:44-49 |
| uptime-kuma 2.5.5 | trustProxy setting (DB, default off) → LEFTMOST | logs only (limiters global) | SAFE → router reset (household-switchable) | server/uptime-kuma-server.js:160-195 |
| vaultwarden 1.36.0 | `IP_HEADER` X-Real-IP (default) | login/admin limits per IP | SAFE (X-Real-Ip traefik-set; tunnel = one bucket). **Never** `IP_HEADER=X-Forwarded-For` | src/config.rs:669-671; src/auth.rs:1053-1066 |
| vikunja 2.6.0 | `VIKUNJA_SERVICE_IPEXTRACTIONMETHOD` direct (peer); xff walks from the right | login floor 10/min per IP | needs a setting: `xff` → real client on both paths | pkg/routes/ip.go:39-52; echo ip.go:242-266 |
| wanderer v0.20.0 | PocketBase peer (TrustedProxy empty) | PocketBase limiter off | SAFE; never UseLeftmostIP | core/event_request.go:40-74 |
| wger 2.7 | axes/ipware REMOTE_ADDR, proxy count 0 | lockout per USERNAME (decision 58) | SAFE; leave (no count fits both paths) | settings/main.py:267-274 |
| wishlist v0.67.1 | — | — | SAFE (HEADER_USERNAME must stay unset) | src/hooks.server.ts:45 |
| zipline 4.8.0 | Fastify trustProxy with CIDR list (off) | login 7/10s by IP (limiter on) | needs a setting: `CORE_TRUST_PROXY=true`, `CORE_TRUSTED_PROXIES=172.16.0.0/12` | src/server/index.ts:55-67 |
| grimmory v3.4.1 (wip) | Tomcat RemoteIpValve, internalProxies incl. 172.16.0.0/12 — from the right | `login:ip:` 5/15 min (+ `login:user:`) | SAFE and FIXED by Part A (R-775's IP lock becomes per visitor); the per-NAME lock remains | application.yaml:63; Spring Boot TomcatServerProperties.java:756-757; AuthRateLimitService.java:18-57 |
| metube 2026.09.29 | — | — (no auth) | SAFE | app/main.py:400-420 |
@@ -0,0 +1,15 @@
# R-772 live on 9202 (controller 0.286.0): stop paperless-webserver (the probe's container), poll GET /api/stacks/paperless-ngx
before: running healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
stopped at 19:07:48
19:07:53 running healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
19:07:58 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
19:08:03 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
19:08:08 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
19:08:13 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
19:08:18 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
19:08:23 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
19:08:28 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
started at 19:08:30
19:08:40 starting healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
19:08:50 starting healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
19:09:00 running healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z
@@ -0,0 +1,10 @@
## RP-D1 mutant: the no-container record says healthy: true (pre-R-772) (2026-10-01T18:46:26Z)
=== RUN TestRunHealthProbes_NoContainerIsNotHealthy
r772_not_checked_test.go:48: a check that did not run must read NOT healthy + not_checked, got &{Healthy:true LastCheck:2026-10-01 20:46:30.111045773 +0200 CEST m=+0.010995017 Details:[{Type:none Target:karakeep Healthy:true Status:0 Latency: Error:Nem futott egészségellenőrzés: nincs hozzá tartozó konténer. MessageKey:health.no_probe_container}] NotChecked:true}
--- FAIL: TestRunHealthProbes_NoContainerIsNotHealthy (0.00s)
=== RUN TestRunHealthProbes_NotCheckedIsLookedAtAgainSoon
r772_not_checked_test.go:65: a not-checked app must be looked at again within the 10-second cycle; last check still 2026-10-01 20:46:10.111364238 +0200 CEST m=-19.988686522
--- FAIL: TestRunHealthProbes_NotCheckedIsLookedAtAgainSoon (0.00s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-controller/internal/stacks 0.021s
FAIL
@@ -0,0 +1,7 @@
## RP-D1b mutant: the interval of the last HEALTHY record is checked before the container (the 0.286.0 order, found live) (2026-10-01T19:09:45Z)
=== RUN TestRunHealthProbes_AStoppedContainerIsSeenAtOnce
r772_not_checked_test.go:91: a stopped probe container must be recorded not checked on the next tick, got &{Healthy:true LastCheck:2026-10-01 21:07:48.756949461 +0200 CEST m=-119.998618869 Details:[] NotChecked:false}
--- FAIL: TestRunHealthProbes_AStoppedContainerIsSeenAtOnce (0.00s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-controller/internal/stacks 0.006s
FAIL
@@ -0,0 +1,11 @@
## RP-D2 mutant: the restore does not re-apply the sign-up lock (pre-R-773) (2026-10-01T18:49:03Z)
=== RUN TestR773_ARemovedAppComesBackWithSignupClosed
r773_restore_signup_lock_test.go:25: the restore must record the sign-up lock (an OPEN gate record, by restore), got &{Deployed:true DeployedAt:2026-10-01T18:49:06Z Env:map[DOMAIN:example.hu SUBDOMAIN:gapp] LockedFields:[DOMAIN SUBDOMAIN] EmailEnabled:false DesiredState: InstalledImages:map[] PinnedImages:map[] LastUpdateUndone:<nil> FailedStep:<nil> LastAutoUpdate:<nil> ConversionCopy:<nil> EarlierConversionCopies:[] RestoredLogins:[] AfterInstall:<nil> SetupGate:<nil> InstallHold:<nil> PreviousImages:map[] DefaultLogin:<nil> AfterSetup:<nil>}
--- FAIL: TestR773_ARemovedAppComesBackWithSignupClosed (0.00s)
=== RUN TestR773_AnInstalledAppWithoutALockGetsNone
--- PASS: TestR773_AnInstalledAppWithoutALockGetsNone (0.00s)
=== RUN TestR773_NoLockInTheTemplateNoRecord
--- PASS: TestR773_NoLockInTheTemplateNoRecord (0.00s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-controller/internal/stacks 0.013s
FAIL
@@ -0,0 +1,9 @@
##### R-773 live on 9202 — controller gitea.dooplex.hu/admin/felhom-controller:0.286.0 (2026-10-01T19:11:19Z)
deploy -> True
karakeep: users.create (the first account) http=200 role=admin
karakeep: POST /api/v1/bookmarks http=201
seed (first account) -> ok
BEFORE remove — lock record + files: setup_gate: | state: open | since: "2026-10-01T19:11:19Z" | hosts: | - bookmarks.enkisfelhom.hu | opened_at: "2026-10-01T19:15:19Z" | opened_by: household | native_lock: applied | after_setup: | --- | signup-block-karakeep.yml
BEFORE remove — a stranger: {'GET /signup': '403', 'POST users.create': '403'}
night chain (debug action) -> 202 {'data': {'legs': ['db-dump', 'tier2', 'update-leg']}, 'message': 'started', 'ok': True}
restore points offered: [(None, '2026-10-01T19:15:29Z')]
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# probe.sh <label> — requests through the REAL tunnel to the echo app: plain, then with forged headers
H=a1-echo.enkisfelhom.hu
F='^(RemoteAddr|X-Forwarded|X-Real|Cf-Connecting|True-Client|Forwarded)'
echo "## $1 — plain, $(date -u +%FT%TZ)"
curl -s --max-time 20 "https://$H/plain-$RANDOM" | grep -iE "$F"
echo "## $1 — forged: XFF 6.6.6.6, X-Real-IP 8.8.4.4, True-Client-IP 9.9.9.9, X-Forwarded-Host evil.example, X-Forwarded-Port 8443, X-Forwarded-Proto http, Forwarded for=5.5.5.5"
curl -s --max-time 20 "https://$H/forged-$RANDOM" -H 'X-Forwarded-For: 6.6.6.6' -H 'X-Real-IP: 8.8.4.4' \
-H 'True-Client-IP: 9.9.9.9' -H 'X-Forwarded-Host: evil.example' -H 'X-Forwarded-Port: 8443' -H 'X-Forwarded-Proto: http' -H 'Forwarded: for=5.5.5.5' \
| grep -iE "$F"
echo "## $1 — forged CF-Connecting-IP 7.7.7.7 alone: HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "https://$H/cfci-$RANDOM" -H 'CF-Connecting-IP: 7.7.7.7') (Cloudflare's edge answers; the request never reaches the box)"
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""r773_live.py — R-773 on 9202 through the product's own endpoints: install Karakeep (the household passes the setup
gate and makes the first account — the fixture), see sign-up closed, take the app's backup with the night chain's debug
action (the sanctioned by-day trigger), REMOVE keeping backups, press restore, and ask as a STRANGER (no session, no gate
cookie, through traefik) whether sign-up is closed again. Env: SC (0600 scratch with .ctlpw), EV. Secrets never printed."""
import json, os, subprocess, sys, time
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w
import upgrade_fixtures_box as fixtures
APP, SUB = "karakeep", "bookmarks"
HOST = f"{SUB}.{w.DOMAIN}"
os.makedirs(f"{w.EV}", exist_ok=True)
log = open(f"{w.EV}/r773-live.txt", "a", buffering=1)
def say(*a):
w.say(*a); log.write(" ".join(map(str, a)) + "\n")
def stranger():
def c(*a):
r = subprocess.run(["curl", "-sk", "--max-time", "10", "-o", "/dev/null", "-w", "%{http_code}", "-H", f"Host: {HOST}"] + list(a),
capture_output=True, text=True)
return r.stdout.strip()
body = json.dumps({"0": {"json": {"name": "stranger", "email": "stranger@example.com", "password": "Stranger-pass-123",
"confirmPassword": "Stranger-pass-123"}}})
return {"GET /signup": c(f"{w.BASE}/signup"),
"POST users.create": c("-X", "POST", "-H", "Content-Type: application/json", "--data", body,
f"{w.BASE}/api/trpc/users.create?batch=1")}
def record():
out = w.guest(f"grep -A8 '^setup_gate:' /opt/docker/stacks/{APP}/app.yaml 2>/dev/null; echo ---; "
f"ls /opt/docker/stacks/traefik/dynamic/ | grep -E 'signup-block-{APP}|setup-gate-{APP}' || echo 'no block/gate file'")
return " | ".join(l.strip() for l in out.strip().splitlines() if l.strip())
w.login()
say(f"##### R-773 live on 9202 — controller {w.guest('docker inspect felhom-controller --format {{.Config.Image}}').strip()} ({time.strftime('%FT%TZ', time.gmtime())})")
ok = w.deploy(APP, SUB)
say("deploy ->", ok)
tok = fixtures.FIXTURES[APP].seed(w, SUB, say) # the household: through the gate, the first account
say("seed (first account) ->", "ok" if tok else "FAILED")
for _ in range(60): # the gate opens by its probe → the block goes up
if "signup-block" in record(): break
time.sleep(5)
say("BEFORE remove — lock record + files:", record())
say("BEFORE remove — a stranger:", stranger())
code, d = w.ctl("POST", "/api/debug/backup/night-chain")
say("night chain (debug action) ->", code, str(d)[:160])
for _ in range(120):
if w.snapshots(APP): break
time.sleep(10)
say("restore points offered:", [(s.get("id") or s.get("snapshot_id"), s.get("time") or s.get("created")) for s in w.snapshots(APP)][:3])
for _ in range(90): # let the chain finish before the remove
out = w.guest("docker logs --since 30m felhom-controller 2>&1 | grep -c 'night-chain\\] update leg: done\\|night-chain.*chain done\\|night-chain\\] manual run.*done'").strip()
if out not in ("", "0"): break
time.sleep(10)
w.ctl("POST", f"/api/stacks/{APP}/stop"); time.sleep(10)
code, d = w.ctl("POST", f"/api/stacks/{APP}/remove", {"remove_hdd_data": False, "remove_backups": False})
say(f"remove KEEPING backups -> {code} {str(d)[:200]}")
time.sleep(8)
say("AFTER remove — lock record + files:", record())
r = w.restore(APP)
say("restore:", {k: r.get(k) for k in ("ok", "snapshot_id", "http", "seconds", "state_after", "hold_after", "why")})
w.wait_app(SUB, "/", tries=40)
say("AFTER remove + restore — lock record + files:", record())
say("AFTER remove + restore — a stranger:", stranger())
say("the household's data back:", fixtures.FIXTURES[APP].verify(w, SUB, tok, say) if tok else "no seed")
say("controller log:", w.guest("docker logs --since 40m felhom-controller 2>&1 | grep -E 'karakeep: (restored after a removal|the household|sign-up)|signup' | tail -6"))