The family gate (decisions 63/64, R-780): family members with their own logins, a permanent forwardAuth door per family app, anchored exceptions, min_controller
gates / gates (push) Successful in 27s

- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json
  (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request.
- internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written
  BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop;
  priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1).
- internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store
  session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family);
  sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches.
  RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove.
Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-02 07:42:47 +02:00
parent a4a753b9e2
commit 977665d8c0
25 changed files with 2168 additions and 10 deletions
@@ -0,0 +1,178 @@
package stacks
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
// v0.287.0 (`09` §3 decisions 63/64) — the family gate's traefik side.
const familyYml = "display_name: Family App\nfamily_gate: true\n" +
"family_gate_except: [\"/api/v1/opds\", \"/api/kobo/\"]\n" +
"deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n"
// Rule 5 (finding F1): every exception is anchored at a path-segment boundary — `/api/v1/opds` must not match
// `/api/v1/opdsx` or `/api/v1/opds-evil`; a regex or matcher in the template is refused, never escaped into meaning.
// COMPANION RED-PROOF: return "^"+QuoteMeta(p) (no boundary) → the look-alikes match and this fails.
func TestFamilyExceptRegexp_Anchored(t *testing.T) {
re, err := FamilyExceptRegexp("/api/v1/opds")
if err != nil {
t.Fatal(err)
}
rx := regexp.MustCompile(re)
for p, want := range map[string]bool{
"/api/v1/opds": true, "/api/v1/opds/": true, "/api/v1/opds/catalog": true,
"/api/v1/opdsx": false, "/api/v1/opds-evil": false, "/api/v1/opds.json": false, "/x/api/v1/opds": false, "/api/v1/opd": false,
} {
if rx.MatchString(p) != want {
t.Errorf("%q: match=%v want %v (regexp %s)", p, !want, want, re)
}
}
if re2, _ := FamilyExceptRegexp("/api/kobo/"); re2 != re2 || !regexp.MustCompile(re2).MatchString("/api/kobo/tok/v1/x") || regexp.MustCompile(re2).MatchString("/api/koboz") {
t.Errorf("a trailing slash is the same prefix: %s", re2)
}
for _, bad := range []string{"", "/", "api", "/api/(.*)", "/api/v1/opds|/", "PathPrefix(`/x`)", "/a//b", "/a/../b", "/a/..", "/a b"} {
if _, err := FamilyExceptRegexp(bad); err == nil {
t.Errorf("%q must be refused", bad)
}
}
}
// The install writes the family door BEFORE the first start; exceptions get routers WITHOUT the door, above the family
// router and below the setup gate; the record is saved.
// COMPANION RED-PROOF: drop the prepareFamilyGate block in DeployStack → "the family-gate file did not exist" fails.
func TestFamilyGate_WrittenBeforeTheFirstStart(t *testing.T) {
m := gateManager(t, familyYml)
p := m.familyGatePath("gapp")
var atUp string
existed := false
m.composeExecFn = func(_ string, _ map[string]string, args ...string) (string, error) {
if len(args) > 0 && args[0] == "up" {
b, err := os.ReadFile(p)
existed, atUp = err == nil, string(b)
}
return "", nil
}
done := make(chan bool, 1)
m.SetDeployDoneHook(func(_ string, ok bool, _ string) { done <- ok })
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err != nil {
t.Fatal(err)
}
select {
case <-done:
case <-time.After(20 * time.Second):
t.Fatal("the deploy never ended")
}
if !existed {
t.Fatal("the family-gate file did not exist when the app was first started — it was published open")
}
for _, want := range []string{"http://felhom-controller:8080/__felhom_gate/family", "felhom-family-gate-gapp@file",
"PathRegexp(`^/api/v1/opds(/|$)`)", "PathRegexp(`^/api/kobo(/|$)`)"} {
if !strings.Contains(atUp, want) {
t.Errorf("the file lacks %q:\n%s", want, atUp)
}
}
// each except router has NO middleware; each family router has one
blocks := strings.Split(atUp, "\n felhom-family-gate-gapp-")
nExcept, nDoor := 0, 0
for _, b := range blocks[1:] {
isExcept := strings.Contains(strings.SplitN(b, "\n", 2)[0], "-except-")
hasMW := strings.Contains(b, "middlewares:")
if isExcept && hasMW {
t.Errorf("an exception router carries the door:\n%s", b)
}
if !isExcept && !hasMW {
t.Errorf("a family router lacks the door:\n%s", b)
}
if isExcept {
nExcept++
} else {
nDoor++
}
for _, line := range strings.Split(b, "\n") {
var pr int
if _, err := fmt.Sscanf(strings.TrimSpace(line), "priority: %d", &pr); err == nil && pr >= setupGatePriority {
t.Errorf("a family router outranks the setup gate (%d)", pr)
}
}
}
if nDoor != 2 || nExcept != 4 {
t.Errorf("want 2 door routers and 4 exception routers (2 app routers × 2 exceptions), got %d/%d", nDoor, nExcept)
}
cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp"))
if cfg == nil || cfg.FamilyGate == nil || strings.Join(cfg.FamilyGate.Hosts, ",") != "gapp.example.hu" {
t.Fatalf("record: %+v", cfg)
}
if n, ok := m.FamilyGateHost("GAPP.example.hu"); !ok || n != "gapp" {
t.Fatalf("FamilyGateHost: %q %v", n, ok)
}
}
// An unanchorable exception in the template refuses the install — never published open.
func TestFamilyGate_BadExceptionRefusesTheInstall(t *testing.T) {
m := gateManager(t, strings.Replace(familyYml, `"/api/kobo/"`, `"/api/(.*)"`, 1))
m.composeExecFn = func(_ string, _ map[string]string, _ ...string) (string, error) { return "", nil }
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err == nil {
t.Fatal("a template with an unanchorable exception must be refused")
}
if _, err := os.Stat(m.familyGatePath("gapp")); !os.IsNotExist(err) {
t.Fatal("no file may be left behind")
}
}
// A REMOVED family app restored from its backup gets its door before anything starts; the loop keeps it; a stale
// file of an uninstalled app goes.
func TestFamilyGate_RestoreOfARemovedAppAndTheLoop(t *testing.T) {
m := gateManager(t, familyYml)
must(t, m.PersistUnitRedeployConfig("gapp", map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}))
cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp"))
if cfg == nil || cfg.FamilyGate == nil {
t.Fatal("the restore of a removed family app must record its door")
}
if _, err := os.Stat(m.familyGatePath("gapp")); err != nil {
t.Fatal("the restore must write the door before the start")
}
must(t, os.Remove(m.familyGatePath("gapp")))
stale := m.familyGatePath("ghost")
must(t, os.WriteFile(stale, []byte("x"), 0o644))
m.SetupGateTick()
if _, err := os.Stat(m.familyGatePath("gapp")); err != nil {
t.Fatal("the loop must put the door back")
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Fatal("the loop must remove a stale family-gate file")
}
}
// min_controller: a template that needs a newer box is refused before anything is written.
func TestMinController(t *testing.T) {
old := controllerVersion
t.Cleanup(func() { controllerVersion = old })
controllerVersion = "0.286.1"
if err := checkMinController(&Metadata{MinController: "0.287.0"}); err == nil {
t.Fatal("0.286.1 must refuse a template needing 0.287.0")
}
controllerVersion = "0.287.0"
if err := checkMinController(&Metadata{MinController: "0.287.0"}); err != nil {
t.Fatalf("equal version must pass: %v", err)
}
if err := checkMinController(&Metadata{}); err != nil {
t.Fatal("no field must pass")
}
if err := checkMinController(&Metadata{MinController: "garbage"}); err == nil {
t.Fatal("an unreadable min_controller must refuse")
}
m := gateManager(t, familyYml+"min_controller: \"9.9.9\"\n")
controllerVersion = "0.287.0"
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err == nil {
t.Fatal("DeployStack must refuse a template needing a newer controller")
}
if _, err := os.Stat(m.familyGatePath("gapp")); !os.IsNotExist(err) {
t.Fatal("nothing may be written for a refused template")
}
}