977665d8c0
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
179 lines
7.1 KiB
Go
179 lines
7.1 KiB
Go
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")
|
||
}
|
||
}
|