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
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:
@@ -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
@@ -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"))
|
||||
Reference in New Issue
Block a user