diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 994e35c..f298609 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -368,6 +368,7 @@ func main() { logger.Fatalf("[FATAL] Failed to initialize stack manager: %v", err) } stackMgr.SetEncryptionKey(encKey) + stacks.SetControllerVersion(Version) // v0.287.0: a template's min_controller is enforced against this // Initial stack scan if err := stackMgr.ScanStacks(); err != nil { diff --git a/controller/internal/family/family.go b/controller/internal/family/family.go new file mode 100644 index 0000000..e5416e7 --- /dev/null +++ b/controller/internal/family/family.go @@ -0,0 +1,327 @@ +// Package family is the household's family list for the permanent family gate (`09` §3 decisions 63, 64; R-780). +// +// Each family member has their OWN name and password (never the dashboard's); the household's dashboard admin adds, +// resets and removes them. A member who signs in gets a SESSION (30 days). Every app cookie the gate mints names the +// session, so the gate asks this store on every request: a removed member, a reset password (the member's generation +// moves on) or a logout (the session is deleted) ends every app's access at the next request. +// +// A session with Member "" is the HOUSEHOLD's: minted when the dashboard's own session vouched for the browser (the +// setup gate's rule, decision 46). It is never a dashboard session and never opens the dashboard. +// +// Persisted as family.json (0600, tmp+fsync+rename) in the controller's data dir, so the controller's own backup and +// restore carry it and a restart keeps every session. Hashes only — a password is shown once, when it is made. +// Pinned by internal/family/family_test.go. +package family + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "regexp" + "sort" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// SessionLife is how long a family sign-in lasts. +const SessionLife = 30 * 24 * time.Hour + +// Member is one family member. Gen moves on at every password reset, ending the member's earlier sessions. +type Member struct { + Name string `json:"name"` + Hash string `json:"hash"` + Gen int `json:"gen"` + Created string `json:"created"` +} + +// Session is one sign-in. Member "" = the household (a dashboard session vouched for the browser). +type Session struct { + ID string `json:"id"` + Member string `json:"member"` + Gen int `json:"gen"` + Exp time.Time `json:"exp"` +} + +type file struct { + Members []Member `json:"members"` + Sessions []Session `json:"sessions"` +} + +// Store is the family list and its sessions. A nil *Store answers "nobody" to every question. +type Store struct { + path string + now func() time.Time + cost int + + dummyOnce sync.Once + dummy []byte + + mu sync.Mutex + members map[string]*Member + sessions map[string]Session +} + +var ( + ErrBadName = errors.New("a name is 1-32 characters: lower-case letters, digits, dot, dash, underscore; starting with a letter or digit") + ErrExists = errors.New("a member with that name already exists") + ErrNoMember = errors.New("no member with that name") +) + +var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`) + +// Open loads (or starts) the store at dir/family.json. A file that cannot be parsed is an error, never an empty list: +// an empty list would silently lock every member out, and a rewrite would destroy the household's list. +func Open(dir string) (*Store, error) { + s := &Store{path: filepath.Join(dir, "family.json"), now: time.Now, cost: bcrypt.DefaultCost, + members: map[string]*Member{}, sessions: map[string]Session{}} + b, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, err + } + var f file + if err := json.Unmarshal(b, &f); err != nil { + return nil, fmt.Errorf("family.json unreadable: %w", err) + } + for i := range f.Members { + m := f.Members[i] + s.members[m.Name] = &m + } + for _, se := range f.Sessions { + s.sessions[se.ID] = se + } + return s, nil +} + +// SetClock and SetCost are test seams. +func (s *Store) SetClock(f func() time.Time) { s.now = f } +func (s *Store) SetCost(c int) { s.cost = c } + +func (s *Store) saveLocked() error { + f := file{Members: []Member{}, Sessions: []Session{}} + for _, m := range s.members { + f.Members = append(f.Members, *m) + } + sort.Slice(f.Members, func(i, j int) bool { return f.Members[i].Name < f.Members[j].Name }) + now := s.now() + for id, se := range s.sessions { + if now.After(se.Exp) { + delete(s.sessions, id) + continue + } + f.Sessions = append(f.Sessions, se) + } + sort.Slice(f.Sessions, func(i, j int) bool { return f.Sessions[i].ID < f.Sessions[j].ID }) + b, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + fh, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err := fh.Write(b); err != nil { + fh.Close() + return err + } + if err := fh.Sync(); err != nil { + fh.Close() + return err + } + if err := fh.Close(); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// pwAlphabet leaves out look-alikes (0/o, 1/l/i). +const pwAlphabet = "abcdefghjkmnpqrstuvwxyz23456789" + +// newPassword is 4 groups of 4 from pwAlphabet (~79 bits), easy to read aloud and type on a phone. +func newPassword() string { + out := make([]byte, 0, 19) + for g := 0; g < 4; g++ { + if g > 0 { + out = append(out, '-') + } + for i := 0; i < 4; i++ { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(pwAlphabet)))) + out = append(out, pwAlphabet[n.Int64()]) + } + } + return string(out) +} + +// Add makes a member with a generated password, returned once. +func (s *Store) Add(name string) (string, error) { + if !nameRE.MatchString(name) { + return "", ErrBadName + } + pw := newPassword() + h, err := bcrypt.GenerateFromPassword([]byte(pw), s.cost) + if err != nil { + return "", err + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.members[name]; ok { + return "", ErrExists + } + s.members[name] = &Member{Name: name, Hash: string(h), Gen: 1, Created: s.now().UTC().Format(time.RFC3339)} + if err := s.saveLocked(); err != nil { + delete(s.members, name) + return "", err + } + return pw, nil +} + +// Reset gives a member a new generated password (returned once) and ends every earlier session of theirs. +func (s *Store) Reset(name string) (string, error) { + pw := newPassword() + h, err := bcrypt.GenerateFromPassword([]byte(pw), s.cost) + if err != nil { + return "", err + } + s.mu.Lock() + defer s.mu.Unlock() + m, ok := s.members[name] + if !ok { + return "", ErrNoMember + } + old := *m + m.Hash, m.Gen = string(h), m.Gen+1 + s.dropSessionsLocked(name) + if err := s.saveLocked(); err != nil { + *m = old + return "", err + } + return pw, nil +} + +// Remove deletes a member and every session of theirs. +func (s *Store) Remove(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.members[name]; !ok { + return ErrNoMember + } + delete(s.members, name) + s.dropSessionsLocked(name) + return s.saveLocked() +} + +func (s *Store) dropSessionsLocked(name string) { + for id, se := range s.sessions { + if se.Member == name { + delete(s.sessions, id) + } + } +} + +// Names lists the members, sorted. +func (s *Store) Names() []string { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, 0, len(s.members)) + for n := range s.members { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// Verify checks a member's password. An unknown name costs a bcrypt compare too, so timing does not tell names apart. +func (s *Store) Verify(name, password string) bool { + if s == nil { + return false + } + s.mu.Lock() + m, ok := s.members[name] + hash := "" + if ok { + hash = m.Hash + } + s.mu.Unlock() + if !ok { + s.dummyOnce.Do(func() { s.dummy, _ = bcrypt.GenerateFromPassword([]byte(newPassword()), s.cost) }) + _ = bcrypt.CompareHashAndPassword(s.dummy, []byte(password)) + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// NewSession starts a session for member ("" = the household). It refuses a member who does not exist. +func (s *Store) NewSession(member string) (string, error) { + if s == nil { + return "", ErrNoMember + } + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", err + } + id := hex.EncodeToString(b) + s.mu.Lock() + defer s.mu.Unlock() + gen := 0 + if member != "" { + m, ok := s.members[member] + if !ok { + return "", ErrNoMember + } + gen = m.Gen + } + s.sessions[id] = Session{ID: id, Member: member, Gen: gen, Exp: s.now().Add(SessionLife)} + if err := s.saveLocked(); err != nil { + delete(s.sessions, id) + return "", err + } + return id, nil +} + +// Valid reports whether a session still holds: it exists, has not expired, and — for a member — the member still +// exists at the generation the session was made in. +func (s *Store) Valid(id string) (Session, bool) { + if s == nil || id == "" { + return Session{}, false + } + s.mu.Lock() + defer s.mu.Unlock() + se, ok := s.sessions[id] + if !ok || s.now().After(se.Exp) { + return Session{}, false + } + if se.Member != "" { + m, ok := s.members[se.Member] + if !ok || m.Gen != se.Gen { + return Session{}, false + } + } + return se, true +} + +// EndSession deletes a session (logout). Unknown ids are not an error. +func (s *Store) EndSession(id string) error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.sessions[id]; !ok { + return nil + } + delete(s.sessions, id) + return s.saveLocked() +} diff --git a/controller/internal/family/family_test.go b/controller/internal/family/family_test.go new file mode 100644 index 0000000..015efa6 --- /dev/null +++ b/controller/internal/family/family_test.go @@ -0,0 +1,127 @@ +package family + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" +) + +func testStore(t *testing.T) (*Store, string) { + t.Helper() + dir := t.TempDir() + s, err := Open(dir) + if err != nil { + t.Fatal(err) + } + s.SetCost(bcrypt.MinCost) + return s, dir +} + +// A member signs in with their own password; the password is never stored; the list survives a reopen. +func TestFamily_AddVerifyPersist(t *testing.T) { + s, dir := testStore(t) + pw, err := s.Add("anna") + if err != nil || len(pw) != 19 { + t.Fatalf("add: %v %q", err, pw) + } + if !s.Verify("anna", pw) || s.Verify("anna", pw+"x") || s.Verify("bela", pw) { + t.Fatal("verify wrong") + } + b, _ := os.ReadFile(filepath.Join(dir, "family.json")) + if strings.Contains(string(b), pw) { + t.Fatal("the plain password reached family.json") + } + if st, _ := os.Stat(filepath.Join(dir, "family.json")); st.Mode().Perm() != 0o600 { + t.Fatalf("family.json mode %v", st.Mode().Perm()) + } + s2, err := Open(dir) + if err != nil || !s2.Verify("anna", pw) { + t.Fatalf("the list did not survive a reopen: %v", err) + } + if _, err := s.Add("anna"); err != ErrExists { + t.Fatalf("duplicate: %v", err) + } + for _, bad := range []string{"", "Anna", "a b", "../x", strings.Repeat("a", 33), "-x"} { + if _, err := s.Add(bad); err != ErrBadName { + t.Fatalf("name %q accepted", bad) + } + } +} + +// THE CONSEQUENCE: a reset or a removal ends the member's sessions at once; a logout ends one session; an expired +// session is refused; another member's session is untouched. +func TestFamily_SessionsEndOnResetRemoveLogout(t *testing.T) { + s, _ := testStore(t) + now := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC) + s.SetClock(func() time.Time { return now }) + s.Add("anna") + s.Add("bela") + a1, _ := s.NewSession("anna") + a2, _ := s.NewSession("anna") + b1, _ := s.NewSession("bela") + h1, _ := s.NewSession("") + for _, id := range []string{a1, a2, b1, h1} { + if _, ok := s.Valid(id); !ok { + t.Fatalf("fresh session %s refused", id) + } + } + if _, err := s.Reset("anna"); err != nil { + t.Fatal(err) + } + if _, ok := s.Valid(a1); ok { + t.Fatal("a reset password must end the member's earlier sessions") + } + if _, ok := s.Valid(b1); !ok { + t.Fatal("another member's session must survive") + } + s.EndSession(b1) + if _, ok := s.Valid(b1); ok { + t.Fatal("logout must end the session") + } + b2, _ := s.NewSession("bela") + s.Remove("bela") + if _, ok := s.Valid(b2); ok { + t.Fatal("a removed member's session must end") + } + if _, err := s.NewSession("bela"); err != ErrNoMember { + t.Fatal("no session for a removed member") + } + now = now.Add(SessionLife + time.Minute) + if _, ok := s.Valid(h1); ok { + t.Fatal("an expired session must be refused") + } +} + +// A gen bump survives a reopen: an OLD session read back from disk after a reset is still refused. +func TestFamily_ResetHoldsAcrossReopen(t *testing.T) { + s, dir := testStore(t) + s.Add("anna") + id, _ := s.NewSession("anna") + s.Reset("anna") + s2, _ := Open(dir) + if _, ok := s2.Valid(id); ok { + t.Fatal("after a reopen the pre-reset session must still be refused") + } +} + +func TestFamily_UnreadableFileIsAnError(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "family.json"), []byte("{not json"), 0o600) + if _, err := Open(dir); err == nil { + t.Fatal("an unreadable list must be an error, never an empty list") + } +} + +func TestFamily_NilStoreAnswersNobody(t *testing.T) { + var s *Store + if s.Verify("a", "b") || len(s.Names()) != 0 { + t.Fatal("nil store") + } + if _, ok := s.Valid("x"); ok { + t.Fatal("nil store valid") + } +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 26de62f..af02771 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2508,5 +2508,34 @@ "flash.offbox.enabled_all": "Off-site backup is on for every app.", "login.msg.empty_password": "Enter your password.", "login.msg.rate_limited": "Too many wrong tries from this address. Try again in a minute.", - "login.msg.wrong_password": "Wrong password." + "login.msg.wrong_password": "Wrong password.", + "err.stacks.family_gate_failed": "The family gate could not be prepared, so the app was not installed: %s", + "err.stacks.needs_newer_controller": "This app needs newer box software, so it was not installed: %s", + "family_gate.page_title": "Family sign-in", + "family_gate.page_body": "Open this app with your family name and password.", + "family_gate.name": "Your name", + "family_gate.password": "Your password", + "family_gate.sign_in": "Sign in", + "family_gate.sign_out": "Sign out", + "family_gate.signed_in_note": "You are signed in with your family account.", + "family_gate.msg.form_expired": "The form expired. Reload the page.", + "family_gate.msg.locked": "Too many wrong tries. Try again in a few minutes.", + "family_gate.msg.wrong": "Wrong name or password.", + "family_gate.msg.signed_in": "You are signed in. Open the app again.", + "family_gate.msg.signed_out": "You are signed out. The family apps ask you to sign in again.", + "family_gate.msg.store_unreadable": "The family list cannot be read right now.", + "family_gate.msg.bad_name": "A name is 1–32 characters: lower-case letters, digits, dot, dash or underscore.", + "family_gate.msg.exists": "A family member with that name already exists.", + "family_gate.msg.no_member": "No family member with that name.", + "family_gate.card_title": "Family", + "family_gate.card_desc": "Family members sign in to the family-gated apps with their own name and password. It does not open this dashboard.", + "family_gate.add": "Add a family member", + "family_gate.name_placeholder": "e.g. anna", + "family_gate.reset": "New password", + "family_gate.remove": "Remove", + "family_gate.none": "No family members yet.", + "family_gate.pw_once": "The password shows only now. Write it down, or give it to the family member:", + "family_gate.confirm_remove": "Remove them? Their sign-ins end at once.", + "family_gate.confirm_reset": "Give a new password? The old one stops at once.", + "family_gate.error": "That did not work. Try again." } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 4bc6246..06c942a 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2496,5 +2496,34 @@ "flash.offbox.enabled_all": "A távoli mentés be van kapcsolva minden alkalmazásra.", "login.msg.empty_password": "Add meg a jelszavad.", "login.msg.rate_limited": "Túl sok hibás próbálkozás erről a címről. Próbáld újra egy perc múlva.", - "login.msg.wrong_password": "Hibás jelszó." + "login.msg.wrong_password": "Hibás jelszó.", + "err.stacks.family_gate_failed": "A családi kapu nem készült el, ezért nem telepítettük az alkalmazást: %s", + "err.stacks.needs_newer_controller": "Ehhez az alkalmazáshoz újabb doboz-szoftver kell, ezért most nem telepítettük: %s", + "family_gate.page_title": "Családi belépés", + "family_gate.page_body": "Ezt az alkalmazást a családi neveddel és jelszavaddal nyithatod meg.", + "family_gate.name": "Neved", + "family_gate.password": "Jelszavad", + "family_gate.sign_in": "Belépés", + "family_gate.sign_out": "Kilépés", + "family_gate.signed_in_note": "Be vagy lépve a családi fiókoddal.", + "family_gate.msg.form_expired": "Az űrlap lejárt. Töltsd be újra az oldalt.", + "family_gate.msg.locked": "Túl sok hibás próbálkozás. Próbáld újra pár perc múlva.", + "family_gate.msg.wrong": "Hibás név vagy jelszó.", + "family_gate.msg.signed_in": "Beléptél. Nyisd meg újra az alkalmazást.", + "family_gate.msg.signed_out": "Kiléptél. A családi alkalmazások újra belépést kérnek.", + "family_gate.msg.store_unreadable": "A családi lista most nem olvasható.", + "family_gate.msg.bad_name": "A név 1–32 karakter: kisbetű, szám, pont, kötőjel vagy aláhúzás.", + "family_gate.msg.exists": "Már van ilyen nevű családtag.", + "family_gate.msg.no_member": "Nincs ilyen nevű családtag.", + "family_gate.card_title": "Család", + "family_gate.card_desc": "A családtagok a saját nevükkel és jelszavukkal lépnek be a családi kapus alkalmazásokba. Ezzel a vezérlőpultot nem érik el.", + "family_gate.add": "Családtag hozzáadása", + "family_gate.name_placeholder": "pl. anna", + "family_gate.reset": "Új jelszó", + "family_gate.remove": "Eltávolítás", + "family_gate.none": "Még nincs családtag.", + "family_gate.pw_once": "A jelszó csak most látszik. Írd fel, vagy add át a családtagnak:", + "family_gate.confirm_remove": "Eltávolítod? A belépései azonnal megszűnnek.", + "family_gate.confirm_reset": "Új jelszót adsz? A régi azonnal megszűnik.", + "family_gate.error": "Nem sikerült. Próbáld újra." } diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 26fde41..bcc97b4 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -184,6 +184,8 @@ type AppConfig struct { // SetupGate (v0.280.0, decision 46) is the app's setup gate: closed from a fresh install until the first // setup is done. A life record (carried across a restore). See setup_gate.go. SetupGate *SetupGateRecord `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"` + // FamilyGate (v0.287.0, decisions 63/64): the app's permanent family gate, on since its install. A life record. + FamilyGate *FamilyGateRecord `yaml:"family_gate,omitempty" json:"family_gate,omitempty"` // InstallHold (R-741, decision 45): an after_install app is held (the gate's door) from its fresh install until // its known first login is replaced. Same record shape as SetupGate. See install_hold.go. InstallHold *SetupGateRecord `yaml:"install_hold,omitempty" json:"install_hold,omitempty"` @@ -428,6 +430,12 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { } } + // v0.287.0: a template that needs a newer controller is refused before anything is written. + if err := checkMinController(&meta); err != nil { + clearDeploying() + m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: %v", req.StackName, err) + return "", util.MsgError("err.stacks.needs_newer_controller", err.Error()) + } // `09` §3 decision 46: a gated template is installed CLOSED, and the gate's traefik file is written BEFORE // the first start (spike F2). Cannot write it → the install is refused: never published open. var gate *SetupGateRecord @@ -455,6 +463,24 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { hold = h } + // v0.287.0 (decisions 63/64): a family app is never published open — its door is written before the first start. + var family *FamilyGateRecord + if meta.FamilyGate { + f, err := m.prepareFamilyGate(req.StackName, stack.ComposePath, env, &meta) + if err != nil { + clearDeploying() + if gate != nil { + _ = m.removeSetupGateFile(req.StackName) + } + if hold != nil { + _ = os.Remove(m.installHoldPath(req.StackName)) + } + m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: the family gate could not be prepared: %v", req.StackName, err) + return "", util.MsgError("err.stacks.family_gate_failed", err.Error()) + } + family = f + } + // Save app.yaml. // CTRL-T2-1: persist the env now, but mark the ON-DISK state Deployed:false // until `docker compose up -d` actually succeeds (done in runComposeDeploy). @@ -476,6 +502,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { DesiredState: DesiredStateRunning, SetupGate: gate, InstallHold: hold, + FamilyGate: family, } diskCfg := *appCfg @@ -772,6 +799,14 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string) // its setup: the lock record and its block, written HERE — before anything starts. An app that was never removed // keeps exactly the record it had (carryLifeRecords): a restore never adds a lock to an installed app that the // household has not closed (decision 49). Pinned by TestR773_*. + // v0.287.0: a removed family app restored from its backup gets its door before anything starts (the R-773 lesson). + if priorRaw == nil && cfg.FamilyGate == nil && meta.FamilyGate { + rec, err := m.prepareFamilyGate(name, stack.ComposePath, env, &meta) + if err != nil { + return fmt.Errorf("the family gate could not be prepared (the app was not started): %w", err) + } + cfg.FamilyGate = rec + } if priorRaw == nil && cfg.SetupGate == nil { rec, err := m.restoreSignupLock(name, stack.ComposePath, env, &meta) if err != nil { diff --git a/controller/internal/stacks/family_gate.go b/controller/internal/stacks/family_gate.go new file mode 100644 index 0000000..9048446 --- /dev/null +++ b/controller/internal/stacks/family_gate.go @@ -0,0 +1,236 @@ +package stacks + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/util" +) + +// ── The family gate (v0.287.0, `09` §3 decisions 63 and 64; R-780) ─────────────────────────────────────── +// +// A PERMANENT gate in front of an app whose template says `family_gate: true`: only a member of the household's family +// list (internal/family — each with their OWN password) or the household itself (a dashboard session vouching) gets +// through. Same mechanism as the setup gate (decision 46): a traefik file-provider file puts a forwardAuth door in +// front of every router the app publishes; internal/web/family_gate.go answers it. Differences, each deliberate: +// +// - it never opens: the file stays while the app is installed; +// - its priority is BELOW the install hold, the setup gate and the sign-up block (each is stricter), ABOVE the app's +// own docker routers; +// - `family_gate_except:` lists path prefixes a phone or e-reader app calls (Grimmory's OPDS/Kobo/KOReader). Each +// gets a router WITHOUT the door — the app's OWN login decides there. Every exception is ANCHORED at a path-segment +// boundary (`^/prefix(/|$)`): traefik's PathPrefix is a plain string prefix, and the spike measured +// `/api/v1/opdsx` walking past an unanchored `/api/v1/opds` (finding F1, audits/permanent-gate-2026-10-01/). +// +// The record (`family_gate:` in app.yaml) is written at install (and at the restore of a removed app — the R-773 +// lesson), so a catalog change never gates or un-gates an installed app; the exceptions follow the current template. +// Pinned by internal/stacks/family_gate_test.go. + +const ( + familyGateAuthURL = "http://felhom-controller:8080/__felhom_gate/family" + familyGatePriority = 40000 // < setupGatePriority (100000): the setup gate, sign-up block, install hold outrank it + familyExceptBoost = 20000 // an exception router outranks the family door, never the setup gate +) + +// FamilyGateRecord is the app's family gate: on since its install. A life record (carried across a restore). +type FamilyGateRecord struct { + Since string `yaml:"since" json:"since"` + Hosts []string `yaml:"hosts,omitempty" json:"hosts,omitempty"` +} + +// exceptPathRE: a literal path prefix — no traefik matcher, no regex. Anything else is refused, never escaped into +// something it did not say. +var exceptPathRE = regexp.MustCompile(`^/[A-Za-z0-9._~/-]*$`) + +// FamilyExceptRegexp turns one exception prefix into the anchored regexp the router uses: the prefix itself, or the +// prefix followed by "/". A trailing "/" in the template is the same prefix. +func FamilyExceptRegexp(p string) (string, error) { + if !exceptPathRE.MatchString(p) || strings.Contains(p, "//") || strings.Contains(p, "/../") || strings.HasSuffix(p, "/..") { + return "", fmt.Errorf("family_gate_except %q: a literal path prefix starting with /", p) + } + p = strings.TrimRight(p, "/") + if p == "" { + return "", fmt.Errorf("family_gate_except %q would except the whole app", "/") + } + return "^" + regexp.QuoteMeta(p) + "(/|$)", nil +} + +func renderFamilyGate(name string, rs []gateRouter, except []string) (string, error) { + var res []string + for _, p := range except { + re, err := FamilyExceptRegexp(p) + if err != nil { + return "", err + } + res = append(res, re) + } + var b strings.Builder + mw := "felhom-family-gate-" + name + fmt.Fprintf(&b, "# Family gate for %s — managed by felhom-controller (`09` §3 decisions 63-64).\n", name) + b.WriteString("# Only the household's family members (and the household) reach the app; the listed paths keep the app's own login.\n") + b.WriteString("http:\n middlewares:\n") + fmt.Fprintf(&b, " %s:\n forwardAuth:\n address: %q\n", mw, familyGateAuthURL) + b.WriteString(" routers:\n") + tls := func(r gateRouter) { + if r.CertResolver != "" { + fmt.Fprintf(&b, " tls:\n certResolver: %s\n", r.CertResolver) + } else { + b.WriteString(" tls: {}\n") + } + } + for _, r := range rs { + fmt.Fprintf(&b, " %s-%s:\n", mw, r.Name) + fmt.Fprintf(&b, " rule: %q\n", r.Rule) + fmt.Fprintf(&b, " priority: %d\n", familyGatePriority+len(r.Rule)) + b.WriteString(" entryPoints:\n - websecure\n") + tls(r) + fmt.Fprintf(&b, " middlewares:\n - %s@file\n", mw) + fmt.Fprintf(&b, " service: %q\n", r.Service+"@docker") + for i, re := range res { + rule := fmt.Sprintf("(%s) && PathRegexp(`%s`)", r.Rule, re) + fmt.Fprintf(&b, " %s-%s-except-%d:\n", mw, r.Name, i) + fmt.Fprintf(&b, " rule: %q\n", rule) + fmt.Fprintf(&b, " priority: %d\n", familyGatePriority+familyExceptBoost+len(rule)) + b.WriteString(" entryPoints:\n - websecure\n") + tls(r) + fmt.Fprintf(&b, " service: %q\n", r.Service+"@docker") + } + } + return b.String(), nil +} + +func (m *Manager) familyGatePath(name string) string { + return filepath.Join(m.setupGateDir(), "family-gate-"+name+".yml") +} + +// writeFamilyGate writes (or refreshes) the app's family-gate file. Returns the hosts it covers. +func (m *Manager) writeFamilyGate(name, composePath string, env map[string]string, except []string) ([]string, error) { + rs, err := gateRoutersFromCompose(composePath, env) + if err != nil { + return nil, err + } + want, err := renderFamilyGate(name, rs, except) + if err != nil { + return nil, err + } + if err := os.MkdirAll(m.setupGateDir(), 0o755); err != nil { + return nil, err + } + p := m.familyGatePath(name) + if cur, err := os.ReadFile(p); err == nil && string(cur) == want { + return gateHosts(rs), nil + } + tmp := p + ".tmp" + if err := os.WriteFile(tmp, []byte(want), 0o644); err != nil { + return nil, err + } + if err := os.Rename(tmp, p); err != nil { + return nil, err + } + return gateHosts(rs), nil +} + +// prepareFamilyGate is the install's (and a removed app's restore's) step: the file BEFORE the first start, then the +// record the caller saves. Cannot write it → the caller refuses: a family app is never published open. +func (m *Manager) prepareFamilyGate(name, composePath string, env map[string]string, meta *Metadata) (*FamilyGateRecord, error) { + hosts, err := m.writeFamilyGate(name, composePath, env, meta.FamilyGateExcept) + if err != nil { + return nil, err + } + m.logger.Printf("[INFO] [stacks] %s: family gate ON before the first start — only family members reach %v (exceptions: %v)", name, hosts, meta.FamilyGateExcept) + return &FamilyGateRecord{Since: m.now().UTC().Format(time.RFC3339), Hosts: hosts}, nil +} + +// FamilyGateHost maps a host to the family-gated app that owns it. +func (m *Manager) FamilyGateHost(host string) (name string, found bool) { + host = strings.ToLower(host) + m.mu.RLock() + defer m.mu.RUnlock() + for n, st := range m.stacks { + if st.Deployed && st.AppConfig != nil && st.AppConfig.FamilyGate != nil && containsStr(st.AppConfig.FamilyGate.Hosts, host) { + return n, true + } + } + return "", false +} + +// familyGateTick: every installed family app has its file (rewritten from the current template's exceptions); every +// other family-gate file goes. +func (m *Manager) familyGateTick() { + type item struct { + name, dir, compose string + except []string + } + var items []item + keep := map[string]bool{} + m.mu.RLock() + for n, st := range m.stacks { + if !st.Deployed || st.AppConfig == nil || st.AppConfig.FamilyGate == nil { + continue + } + items = append(items, item{name: n, dir: filepath.Dir(st.ComposePath), compose: st.ComposePath, + except: append([]string(nil), st.Meta.FamilyGateExcept...)}) + keep[n] = true + } + m.mu.RUnlock() + if ents, err := os.ReadDir(m.setupGateDir()); err == nil { + for _, e := range ents { + n := e.Name() + if !strings.HasPrefix(n, "family-gate-") || !strings.HasSuffix(n, ".yml") { + continue + } + app := strings.TrimSuffix(strings.TrimPrefix(n, "family-gate-"), ".yml") + if !keep[app] { + if err := os.Remove(m.familyGatePath(app)); err == nil { + m.logger.Printf("[INFO] [stacks] %s: removed the family-gate file of an app that is not installed", app) + } + } + } + } + sort.Slice(items, func(i, j int) bool { return items[i].name < items[j].name }) + for _, it := range items { + cfg := LoadAppConfigDecrypted(it.dir, m.encKey) + if cfg == nil { + continue + } + if _, err := m.writeFamilyGate(it.name, it.compose, cfg.Env, it.except); err != nil { + m.logger.Printf("[ERROR] [stacks] %s: the family gate's traefik file could not be (re)written: %v", it.name, err) + } + } +} + +// ── the template's minimum controller (v0.287.0) ───────────────────────────────────────────────────────── + +var controllerVersion string + +// SetControllerVersion tells the stacks package which controller it runs in (main.go). Empty = unknown (a dev build): +// min_controller is then not enforced, and that is logged. +func SetControllerVersion(v string) { controllerVersion = v } + +// ErrNeedsNewerController: the template needs a newer controller than this one. +var ErrNeedsNewerController = fmt.Errorf("the app needs a newer box software") + +// checkMinController refuses a template whose `min_controller` is above this controller. A family-gated app on a +// controller that does not know the field would be installed OPEN — this is the field a NEWER template uses to say so. +func checkMinController(meta *Metadata) error { + if strings.TrimSpace(meta.MinController) == "" { + return nil + } + need, err := util.ParseVersion(meta.MinController) + if err != nil { + return fmt.Errorf("min_controller %q unreadable: %w", meta.MinController, err) + } + have, err := util.ParseVersion(controllerVersion) + if err != nil { + return nil // a dev build: no version to compare (logged at the caller) + } + if have.Compare(need) < 0 { + return fmt.Errorf("%w (needs %s, this box runs %s)", ErrNeedsNewerController, need, have) + } + return nil +} diff --git a/controller/internal/stacks/family_gate_test.go b/controller/internal/stacks/family_gate_test.go new file mode 100644 index 0000000..a67fa9a --- /dev/null +++ b/controller/internal/stacks/family_gate_test.go @@ -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") + } +} diff --git a/controller/internal/stacks/life_records.go b/controller/internal/stacks/life_records.go index a872d1c..f60b17a 100644 --- a/controller/internal/stacks/life_records.go +++ b/controller/internal/stacks/life_records.go @@ -36,6 +36,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) { // opened; a gate that was still closed stays closed (its probe opens it if the restored data is set up). // No prior record (a removed app, kept data, a rebuilt guest) = no gate: the data comes back with its admin. cfg.SetupGate = prior.SetupGate + cfg.FamilyGate = prior.FamilyGate // v0.287.0: a restore never un-gates a family app cfg.InstallHold = prior.InstallHold // R-741: the loop opens it when the restored record says the login was replaced cfg.DefaultLogin = prior.DefaultLogin cfg.AfterSetup = prior.AfterSetup diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index 54ba4a8..55c7317 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -65,8 +65,15 @@ type Metadata struct { // setup gate opens. See signup_block.go. SignupBlock string `yaml:"signup_block,omitempty" json:"signup_block,omitempty"` // AfterSetup (v0.282.0, decisions 47/49): the app's OWN sign-up switch, set when the gate opens. See after_setup.go. - AfterSetup *AfterSetupSpec `yaml:"after_setup,omitempty" json:"after_setup,omitempty"` - Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"` + AfterSetup *AfterSetupSpec `yaml:"after_setup,omitempty" json:"after_setup,omitempty"` + // FamilyGate (v0.287.0, `09` §3 decisions 63/64): a PERMANENT gate — only family members (and the household) reach + // the app; FamilyGateExcept are literal path prefixes a phone/e-reader app calls, left to the app's own login + // (anchored at a segment boundary). See family_gate.go. + FamilyGate bool `yaml:"family_gate,omitempty" json:"family_gate,omitempty"` + FamilyGateExcept []string `yaml:"family_gate_except,omitempty" json:"family_gate_except,omitempty"` + // MinController (v0.287.0): the lowest box software that installs this template correctly; a lower one refuses. + MinController string `yaml:"min_controller,omitempty" json:"min_controller,omitempty"` + Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"` // InitialCreds: for apps that auto-generate a first-login credential into a file inside the // container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and // surfaces it on the app page, so the customer never has to dig through logs. Optional. diff --git a/controller/internal/stacks/setup_gate.go b/controller/internal/stacks/setup_gate.go index 80bff1f..a5de975 100644 --- a/controller/internal/stacks/setup_gate.go +++ b/controller/internal/stacks/setup_gate.go @@ -499,6 +499,7 @@ func (m *Manager) SetupGateTick() { } m.reconcileSignupBlocks() m.installHoldTick() + m.familyGateTick() } // RunSetupGateLoop runs SetupGateTick every interval until ctx ends. diff --git a/controller/internal/web/family_gate.go b/controller/internal/web/family_gate.go new file mode 100644 index 0000000..238087b --- /dev/null +++ b/controller/internal/web/family_gate.go @@ -0,0 +1,476 @@ +package web + +import ( + "crypto/hmac" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/family" +) + +// ── The family gate's answerer (v0.287.0, `09` §3 decisions 63 and 64; R-780) ───────────────────────────── +// +// traefik asks GET /__felhom_gate/family (forwardAuth) for every request to a family-gated app (except the anchored +// paths the template leaves to the app's own login — internal/stacks/family_gate.go writes that file). The answer: +// +// - a valid FAMILY APP COOKIE (`felhom_famgate`, host-only on the app host) → 200; +// - /__felhom_gate/fcb?t= → the token (60 s, one use, bound to the host and to a family session) becomes the +// app cookie, and the browser goes back where it was going; +// - a browser GET without one → 302 to https://felhom./__family/start?rd=; +// - anything else → 401 JSON. +// +// On the DASHBOARD host, outside the dashboard's own auth (CatchAllMiddleware answers them first): +// +// - /__family/start — with a valid FAMILY SESSION (`felhom_family`, Path=/__family, so the browser never even sends +// it to a dashboard page) or the household's dashboard session: a token and a 302 to the app; without: the family +// sign-in page; +// - /__family/login — a member's OWN name and password; counted per VISITOR (clientIP, R-753) and per NAME, short +// windows, never "everyone"; +// - /__family/logout — ends the family session, and with it every app cookie minted from it (the store is asked on +// every request). +// +// THE RULE: a family cookie never opens the dashboard. RequireAuth reads only `felhom_session`; nothing here ever sets +// it. An app cookie names a store session, so a removed member, a reset password or a logout ends access at the next +// request on every app. Pinned by internal/web/family_gate_test.go. + +const ( + familySessionCookie = "felhom_family" + familyAppCookie = "felhom_famgate" + familyAuthPath = "/__felhom_gate/family" + familyCallbackURI = "/__felhom_gate/fcb" + familyStartPath = "/__family/start" + familyLoginPath = "/__family/login" + familyLogoutPath = "/__family/logout" + familyCookiePath = "/__family" + familyFormLife = time.Hour + + familyVisitorMax = 5 + familyVisitorWindow = time.Minute + familyNameMax = 10 + familyNameWindow = 10 * time.Minute +) + +type familyState struct { + once sync.Once + store *family.Store + + mu sync.Mutex + visitor map[string][]time.Time + name map[string][]time.Time +} + +// familyStore opens the family list once. An unreadable list is logged and answers "nobody" (fail closed — the gate +// stays shut; the household still passes with its dashboard session). +func (s *Server) familyStore() *family.Store { + s.fam.once.Do(func() { + s.fam.visitor = map[string][]time.Time{} + s.fam.name = map[string][]time.Time{} + if s.familyStoreOverride != nil { + s.fam.store = s.familyStoreOverride + return + } + if s.cfg == nil || s.cfg.Paths.DataDir == "" { + return + } + st, err := family.Open(s.cfg.Paths.DataDir) + if err != nil { + s.logger.Printf("[ERROR] [web] family gate: the family list could not be read (%v) — only the household passes until it is fixed", err) + return + } + s.fam.store = st + }) + return s.fam.store +} + +// familyLocked reports whether this visitor or this name is out of tries. Windows slide; a success clears both. +func (s *Server) familyLocked(visitor, name string) bool { + s.familyStore() + now := s.gateNow() + s.fam.mu.Lock() + defer s.fam.mu.Unlock() + prune := func(m map[string][]time.Time, k string, win time.Duration) int { + var keep []time.Time + for _, t := range m[k] { + if now.Sub(t) < win { + keep = append(keep, t) + } + } + if len(keep) == 0 { + delete(m, k) + } else { + m[k] = keep + } + return len(keep) + } + v := prune(s.fam.visitor, visitor, familyVisitorWindow) + n := 0 + if name != "" { + n = prune(s.fam.name, name, familyNameWindow) + } + return v >= familyVisitorMax || n >= familyNameMax +} + +func (s *Server) familyFailed(visitor, name string) { + now := s.gateNow() + s.fam.mu.Lock() + defer s.fam.mu.Unlock() + s.fam.visitor[visitor] = append(s.fam.visitor[visitor], now) + if name != "" { + s.fam.name[name] = append(s.fam.name[name], now) + } +} + +func (s *Server) familyCleared(visitor, name string) { + s.fam.mu.Lock() + defer s.fam.mu.Unlock() + delete(s.fam.visitor, visitor) + delete(s.fam.name, name) +} + +// familyRDHost: the host of a return address that may be used — https, on this household's domain, a family-gated app. +func (s *Server) familyRDHost(rd string) (string, bool) { + u, err := url.Parse(rd) + if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || s.stackMgr == nil || s.cfg == nil { + return "", false + } + host := strings.ToLower(u.Hostname()) + if u.Port() != "" || !strings.HasSuffix(host, "."+strings.ToLower(s.cfg.Customer.Domain)) { + return "", false + } + if _, found := s.stackMgr.FamilyGateHost(host); !found { + return "", false + } + return host, true +} + +type familyToken struct { + Host string `json:"h"` + Sid string `json:"s"` + Exp int64 `json:"e"` + Nonce string `json:"n"` + RD string `json:"r"` + MAC string `json:"m"` +} + +func (s *Server) mintFamilyToken(host, sid, rd string) string { + nb := make([]byte, 12) + _, _ = rand.Read(nb) + t := familyToken{Host: host, Sid: sid, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd} + t.MAC = s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD) + b, _ := json.Marshal(t) + return base64.RawURLEncoding.EncodeToString(b) +} + +// takeFamilyToken checks a token for host, uses it up, and returns the session and where the browser was going. +func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) { + b, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return "", "", errors.New("malformed") + } + var t familyToken + if json.Unmarshal(b, &t) != nil { + return "", "", errors.New("malformed") + } + if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) { + return "", "", errors.New("bad signature") + } + if t.Host != host { + return "", "", errors.New("for another app") + } + now := s.gateNow() + if now.Unix() > t.Exp { + return "", "", errors.New("expired") + } + s.gateKey() + s.gate.mu.Lock() + defer s.gate.mu.Unlock() + for n, until := range s.gate.used { + if now.After(until) { + delete(s.gate.used, n) + } + } + if _, seen := s.gate.used["f:"+t.Nonce]; seen { + return "", "", errors.New("already used") + } + s.gate.used["f:"+t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second) + return t.Sid, t.RD, nil +} + +// familyAppCookieSession: ".." → the session, if the cookie is genuine for +// this host, unexpired, and the store still holds the session (a removed member / reset / logout fails here). +func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Session, bool) { + c, err := r.Cookie(familyAppCookie) + if err != nil { + return family.Session{}, false + } + parts := strings.Split(c.Value, ".") + if len(parts) != 3 { + return family.Session{}, false + } + n, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || s.gateNow().Unix() > n { + return family.Session{}, false + } + if !hmac.Equal([]byte(parts[2]), []byte(s.gateMAC("famcookie", host, parts[0], parts[1]))) { + return family.Session{}, false + } + return s.familyStore().Valid(parts[0]) +} + +func familyRefuse(w http.ResponseWriter, code int) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + _, _ = w.Write([]byte(`{"error":"sign in with your family login"}`)) +} + +// ServeFamilyGateAuth is traefik's forwardAuth answer for a family-gated app. Like the setup gate it trusts only the +// X-Forwarded-Host/-Uri/-Method traefik writes from the request it forwards (forwardAuth's own, never the client's). +func (s *Server) ServeFamilyGateAuth(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 uri == "" { + uri = "/" + } + method := r.Header.Get("X-Forwarded-Method") + if s.stackMgr == nil { + familyRefuse(w, http.StatusForbidden) + return + } + app, found := s.stackMgr.FamilyGateHost(host) + if !found { + s.logger.Printf("[WARN] [web] family gate: asked about %q, which no family app owns — refused", host) + familyRefuse(w, http.StatusForbidden) + return + } + if u, err := url.Parse(uri); err == nil && u.Path == familyCallbackURI { + sid, rd, err := s.takeFamilyToken(u.Query().Get("t"), host) + if err == nil { + if _, ok := s.familyStore().Valid(sid); !ok { + err = errors.New("the session ended") + } + } + if err != nil { + s.logger.Printf("[WARN] [web] family gate %s: a sign-in token was refused (%v) — visitor %s", app, err, clientIP(r)) + familyRefuse(w, http.StatusForbidden) + return + } + exp := strconv.FormatInt(s.gateNow().Add(family.SessionLife).Unix(), 10) + http.SetCookie(w, &http.Cookie{ + Name: familyAppCookie, Value: sid + "." + exp + "." + s.gateMAC("famcookie", host, sid, exp), Path: "/", + MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, + }) + s.logger.Printf("[INFO] [web] family gate %s: a signed-in browser passed — visitor %s", app, clientIP(r)) + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, rd, http.StatusFound) + return + } + if _, ok := s.familyAppCookieSession(r, host); ok { + w.WriteHeader(http.StatusOK) + return + } + if (method == "" || method == http.MethodGet) && strings.Contains(r.Header.Get("Accept"), "text/html") { + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, "https://felhom."+s.cfg.Customer.Domain+familyStartPath+"?"+url.Values{"rd": {"https://" + host + uri}}.Encode(), http.StatusFound) + return + } + if s.isDebug() { + s.logger.Printf("[DEBUG] [web] family gate %s: %s %s without a pass — 401 (visitor %s)", app, method, uri, clientIP(r)) + } + familyRefuse(w, http.StatusUnauthorized) +} + +// familySessionFromCookie: the family session this browser holds on the dashboard host. +func (s *Server) familySessionFromCookie(r *http.Request) (family.Session, bool) { + c, err := r.Cookie(familySessionCookie) + if err != nil { + return family.Session{}, false + } + return s.familyStore().Valid(c.Value) +} + +// ServeFamilyStart is /__family/start on the dashboard host. +func (s *Server) ServeFamilyStart(w http.ResponseWriter, r *http.Request) { + rd := r.URL.Query().Get("rd") + host, ok := s.familyRDHost(rd) + if !ok { + s.renderFamilyPage(w, r, "", "", http.StatusOK) + return + } + w.Header().Set("Cache-Control", "no-store") + se, ok := s.familySessionFromCookie(r) + if !ok && s.hasSession(r) { + // The household's own dashboard session vouches (decision 46's rule) — a household session in the family store, + // never the dashboard session itself. + if st := s.familyStore(); st != nil { + if sid, err := st.NewSession(""); err == nil { + se, ok = family.Session{ID: sid}, true + } + } + } + if ok { + http.Redirect(w, r, "https://"+host+familyCallbackURI+"?"+url.Values{"t": {s.mintFamilyToken(host, se.ID, rd)}}.Encode(), http.StatusFound) + return + } + s.renderFamilyPage(w, r, rd, "", http.StatusOK) +} + +// familyFormToken is the sign-in form's own CSRF (the visitor has no session): an HMAC over its expiry. +func (s *Server) familyFormToken() string { + exp := strconv.FormatInt(s.gateNow().Add(familyFormLife).Unix(), 10) + return exp + "." + s.gateMAC("famform", exp) +} + +func (s *Server) familyFormTokenValid(v string) bool { + exp, mac, ok := strings.Cut(v, ".") + n, err := strconv.ParseInt(exp, 10, 64) + if !ok || err != nil || s.gateNow().Unix() > n { + return false + } + return hmac.Equal([]byte(mac), []byte(s.gateMAC("famform", exp))) +} + +// ServeFamilyLogin is /__family/login: GET = the page, POST = a member's own name and password. +func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + s.renderFamilyPage(w, r, r.URL.Query().Get("rd"), "", http.StatusOK) + return + } + _ = r.ParseForm() + rd := r.FormValue("rd") + if !s.familyFormTokenValid(r.FormValue("_ft")) { + s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.form_expired"), http.StatusForbidden) + return + } + name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) + visitor := rateKey(r) + if s.familyLocked(visitor, name) { + s.logger.Printf("[WARN] [web] family sign-in: too many wrong tries — visitor %s, name %q", visitor, name) + s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.locked"), http.StatusTooManyRequests) + return + } + st := s.familyStore() + if st == nil || !st.Verify(name, r.FormValue("password")) { + s.familyFailed(visitor, name) + s.logger.Printf("[WARN] [web] family sign-in failed — visitor %s", visitor) + s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusUnauthorized) + return + } + sid, err := st.NewSession(name) + if err != nil { + s.logger.Printf("[ERROR] [web] family sign-in: the session could not be saved: %v", err) + s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusInternalServerError) + return + } + s.familyCleared(visitor, name) + http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: sid, Path: familyCookiePath, + MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}) + s.logger.Printf("[INFO] [web] family sign-in: %s — visitor %s", name, visitor) + if _, ok := s.familyRDHost(rd); ok { + http.Redirect(w, r, familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), http.StatusFound) + return + } + s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_in"), http.StatusOK) +} + +// ServeFamilyLogout is /__family/logout: the family session ends, and every app cookie minted from it with it. +func (s *Server) ServeFamilyLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(familySessionCookie); err == nil { + if err := s.familyStore().EndSession(c.Value); err != nil { + s.logger.Printf("[ERROR] [web] family sign-out: %v", err) + } + } + http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: "", Path: familyCookiePath, MaxAge: -1, HttpOnly: true, Secure: true}) + s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_out"), http.StatusOK) +} + +func (s *Server) renderFamilyPage(w http.ResponseWriter, r *http.Request, rd, notice string, code int) { + data := map[string]interface{}{"RD": rd, "Notice": notice, "FormToken": s.familyFormToken(), "Version": s.version} + if host, ok := s.familyRDHost(rd); ok { + data["Host"] = host + if app, found := s.stackMgr.FamilyGateHost(host); found { + if st, ok := s.stackMgr.GetStack(app); ok { + data["AppName"] = st.Meta.DisplayName + } + } + } + if _, ok := s.familySessionFromCookie(r); ok { + data["SignedIn"] = true + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + if err := s.executeTemplateLang(w, r, "familygate", data); err != nil { + s.logger.Printf("[ERROR] [web] family page: %v", err) + } +} + +// ── the dashboard's „Család" card (authenticated, CSRF-protected) ─────────────────────────────────────────── + +func (s *Server) familyMembersHandler(w http.ResponseWriter, r *http.Request) { + st := s.familyStore() + if st == nil { + escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "family_gate.msg.store_unreadable")) + return + } + escrowJSON(w, http.StatusOK, map[string]any{"members": st.Names()}, "") +} + +// familyMemberActionHandler: POST /family/members/{add,reset,remove} with `name`. add/reset answer the new password +// ONCE (never logged, never in a page render). +func (s *Server) familyMemberActionHandler(w http.ResponseWriter, r *http.Request, action string) { + st := s.familyStore() + if st == nil { + escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "family_gate.msg.store_unreadable")) + return + } + _ = r.ParseForm() + name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) + w.Header().Set("Cache-Control", "no-store") + var pw string + var err error + switch action { + case "add": + pw, err = st.Add(name) + case "reset": + pw, err = st.Reset(name) + case "remove": + err = st.Remove(name) + default: + escrowJSON(w, http.StatusNotFound, nil, "") + return + } + switch { + case errors.Is(err, family.ErrBadName): + escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "family_gate.msg.bad_name")) + return + case errors.Is(err, family.ErrExists): + escrowJSON(w, http.StatusConflict, nil, s.msg(r, "family_gate.msg.exists")) + return + case errors.Is(err, family.ErrNoMember): + escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "family_gate.msg.no_member")) + return + case err != nil: + s.logger.Printf("[ERROR] [web] family %s %q: %v", action, name, err) + escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "family_gate.msg.store_unreadable")) + return + } + s.logger.Printf("[INFO] [web] family: the household's %s of %q from %s (password never logged)", action, name, clientIP(r)) + out := map[string]any{"name": name, "members": st.Names()} + if pw != "" { + out["password"] = pw + } + escrowJSON(w, http.StatusOK, out, "") +} diff --git a/controller/internal/web/family_gate_test.go b/controller/internal/web/family_gate_test.go new file mode 100644 index 0000000..9648f48 --- /dev/null +++ b/controller/internal/web/family_gate_test.go @@ -0,0 +1,375 @@ +package web + +import ( + "html" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/family" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// v0.287.0 (`09` §3 decisions 63/64, R-780) — the family gate's answerer and its sign-in pages, driven through the +// handlers traefik and the browser reach (ServeFamilyGateAuth, ServeFamilyStart/Login/Logout) and through the +// dashboard's own RequireAuth. Docker is a stub on PATH. + +func familyHarness(t *testing.T) (*Server, *family.Store) { + t.Helper() + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + for _, d := range []string{bin, filepath.Join(dir, "data"), filepath.Join(dir, "stacks", "fapp"), filepath.Join(dir, "stacks", "gapp")} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + write := func(app, name, body string) { + if err := os.WriteFile(filepath.Join(dir, "stacks", app, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("fapp", "docker-compose.yml", "services:\n fapp:\n image: busybox\n") + write("fapp", ".felhom.yml", "display_name: Family App\nslug: fapp\nfamily_gate: true\n") + write("fapp", "app.yaml", "deployed: true\nfamily_gate:\n since: \"2026-10-02T00:00:00Z\"\n hosts: [fapp.example.hu]\n") + write("gapp", "docker-compose.yml", "services:\n gapp:\n image: busybox\n") + write("gapp", ".felhom.yml", "display_name: Gated App\nslug: gapp\nsetup_gate: true\n") + write("gapp", "app.yaml", "deployed: true\nsetup_gate:\n state: closed\n since: \"2026-09-29T00:00:00Z\"\n hosts: [gapp.example.hu]\n") + lg := log.New(io.Discard, "", 0) + cfg := config.Default() + cfg.Customer.Domain = "example.hu" + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Paths.DataDir = filepath.Join(dir, "data") + h, _ := bcrypt.GenerateFromPassword([]byte("dashboard-pass"), bcrypt.MinCost) + cfg.Web.PasswordHash = string(h) + sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + mgr, err := stacks.NewManager(cfg, lg) + if err != nil { + t.Fatal(err) + } + if err := mgr.ScanStacks(); err != nil { + t.Fatal(err) + } + st, err := family.Open(cfg.Paths.DataDir) + if err != nil { + t.Fatal(err) + } + st.SetCost(bcrypt.MinCost) + s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test", sessions: map[string]*session{}, + loginAttempts: map[string]*loginAttempt{}, familyStoreOverride: st} + s.loadTemplates() + return s, st +} + +func famAsk(s *Server, host, method, uri, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+familyAuthPath, nil) + r.Header.Set("X-Forwarded-Host", host) + r.Header.Set("X-Forwarded-Method", method) + r.Header.Set("X-Forwarded-Uri", uri) + r.Header.Set("Accept", accept) + for _, c := range cookies { + r.AddCookie(c) + } + w := httptest.NewRecorder() + s.ServeFamilyGateAuth(w, r) + return w +} + +func famCookie(w *httptest.ResponseRecorder, name string) *http.Cookie { + for _, c := range w.Result().Cookies() { + if c.Name == name { + return c + } + } + return nil +} + +// famLogin posts the sign-in form as visitor `remote` (a direct peer — clientIP is the peer). +func famLogin(s *Server, remote, name, pw, rd string) *httptest.ResponseRecorder { + form := url.Values{"_ft": {s.familyFormToken()}, "name": {name}, "password": {pw}, "rd": {rd}} + r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.RemoteAddr = remote + ":5000" + w := httptest.NewRecorder() + s.ServeFamilyLogin(w, r) + return w +} + +// famPass walks a signed-in browser (its family session cookie) through start → callback → app cookie. +func famPass(t *testing.T, s *Server, sess *http.Cookie) *http.Cookie { + t.Helper() + rd := "https://fapp.example.hu/books" + r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil) + r.AddCookie(sess) + w := httptest.NewRecorder() + s.ServeFamilyStart(w, r) + loc := w.Header().Get("Location") + if w.Code != http.StatusFound || !strings.HasPrefix(loc, "https://fapp.example.hu"+familyCallbackURI+"?t=") { + t.Fatalf("start with a family session: %d %q", w.Code, loc) + } + u, _ := url.Parse(loc) + cb := famAsk(s, "fapp.example.hu", "GET", u.RequestURI(), "text/html") + app := famCookie(cb, familyAppCookie) + if cb.Code != http.StatusFound || cb.Header().Get("Location") != rd || app == nil { + t.Fatalf("callback: %d %q cookie %v", cb.Code, cb.Header().Get("Location"), app) + } + return app +} + +// Exit item 1: a stranger reaches nothing — a browser is sent to the family sign-in, anything else is refused. +func TestFamilyGate_StrangerReachesNothing(t *testing.T) { + s, _ := familyHarness(t) + w := famAsk(s, "fapp.example.hu", "GET", "/", "text/html") + if w.Code != http.StatusFound || !strings.HasPrefix(w.Header().Get("Location"), "https://felhom.example.hu"+familyStartPath+"?rd=") { + t.Fatalf("a browser must be sent to the family sign-in: %d %q", w.Code, w.Header().Get("Location")) + } + for _, m := range []string{"GET", "POST"} { + if w := famAsk(s, "fapp.example.hu", m, "/api/x", "application/json"); w.Code != http.StatusUnauthorized { + t.Fatalf("%s API without a pass: %d", m, w.Code) + } + } + if w := famAsk(s, "other.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden { + t.Fatalf("a host no family app owns must be refused: %d", w.Code) + } + forged := &http.Cookie{Name: familyAppCookie, Value: "abc." + "99999999999" + ".deadbeef"} + if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", forged); w.Code != http.StatusUnauthorized { + t.Fatalf("a forged app cookie must be refused: %d", w.Code) + } +} + +// Exit item 2 + rule 1: a member's OWN login opens the app; the family session never opens the dashboard and the +// family pages never set the dashboard cookie. +// COMPANION RED-PROOF: make RequireAuth accept felhom_family → the dashboard assertion fails. +func TestFamilyGate_MemberPassesButNeverTheDashboard(t *testing.T) { + s, st := familyHarness(t) + pw, _ := st.Add("anna") + w := famLogin(s, "203.0.113.10", "anna", pw, "https://fapp.example.hu/books") + sess := famCookie(w, familySessionCookie) + if w.Code != http.StatusFound || sess == nil || sess.Path != familyCookiePath || sess.MaxAge < 7*24*3600 { + t.Fatalf("sign-in: %d cookie %+v", w.Code, sess) + } + if famCookie(w, sessionCookieName) != nil { + t.Fatal("the family sign-in must never set the dashboard cookie") + } + app := famPass(t, s, sess) + if app.MaxAge < 7*24*3600 || app.Domain != "" { + t.Fatalf("the app cookie must last days and be host-only: %+v", app) + } + if w := famAsk(s, "fapp.example.hu", "GET", "/books", "text/html", app); w.Code != http.StatusOK { + t.Fatalf("the member's app cookie must pass: %d", w.Code) + } + // the same cookies at the dashboard: refused + h := s.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(299) })) + for _, p := range []string{"/launcher", "/api/stacks", "/settings/security"} { + r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+p, nil) + r.AddCookie(&http.Cookie{Name: familySessionCookie, Value: sess.Value}) + r.AddCookie(&http.Cookie{Name: familyAppCookie, Value: app.Value}) + rw := httptest.NewRecorder() + h.ServeHTTP(rw, r) + if rw.Code == 299 { + t.Fatalf("a family cookie opened the dashboard at %s", p) + } + } + // a cookie for another app host does not pass + if w := famAsk(s, "fapp2.example.hu", "GET", "/", "application/json", app); w.Code == http.StatusOK { + t.Fatal("an app cookie must not pass another host") + } +} + +// Rule 3 + exit item 2's logout: a reset, a removal and a logout each end access on the NEXT request. +func TestFamilyGate_ResetRemoveLogoutEndAccessAtOnce(t *testing.T) { + s, st := familyHarness(t) + pw, _ := st.Add("anna") + login := func() *http.Cookie { + w := famLogin(s, "203.0.113.10", "anna", pw, "") + return famCookie(w, familySessionCookie) + } + ok := func(app *http.Cookie) bool { + return famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app).Code == http.StatusOK + } + sess := login() + app := famPass(t, s, sess) + pw, _ = st.Reset("anna") + if ok(app) { + t.Fatal("a reset password must end the member's app access at once") + } + sess = login() + app = famPass(t, s, sess) + r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLogoutPath, nil) + r.AddCookie(sess) + s.ServeFamilyLogout(httptest.NewRecorder(), r) + if ok(app) { + t.Fatal("logout must end the app access minted from that session") + } + sess = login() + app = famPass(t, s, sess) + st.Remove("anna") + if ok(app) { + t.Fatal("a removed member must lose access at once") + } +} + +// Rule 2: the sign-in is counted per VISITOR and per NAME — a stranger locks only himself, and a name under attack is +// locked for minutes, never the household. +func TestFamilyGate_LockPerVisitorAndPerName(t *testing.T) { + s, st := familyHarness(t) + now := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC) + s.gateClock = func() time.Time { return now } + pa, _ := st.Add("anna") + pb, _ := st.Add("bela") + for i := 0; i < familyVisitorMax; i++ { + famLogin(s, "198.51.100.66", "anna", "wrong", "") + } + if w := famLogin(s, "198.51.100.66", "anna", pa, ""); w.Code != http.StatusTooManyRequests { + t.Fatalf("the stranger's own address must be locked even with the right password: %d", w.Code) + } + if w := famLogin(s, "203.0.113.10", "anna", pa, ""); w.Code != http.StatusOK && w.Code != http.StatusFound { + t.Fatalf("anna from her own address must get in at once: %d", w.Code) + } + // a name under a spread attack (many addresses) + for i := 0; i < familyNameMax; i++ { + famLogin(s, "198.51.100."+string(rune('a'+i)), "bela", "wrong", "") + } + if w := famLogin(s, "203.0.113.20", "bela", pb, ""); w.Code != http.StatusTooManyRequests { + t.Fatalf("a name under a spread attack must be locked: %d", w.Code) + } + if w := famLogin(s, "203.0.113.20", "anna", pa, ""); w.Code == http.StatusTooManyRequests { + t.Fatal("another member must not be locked by bela's attack") + } + now = now.Add(familyNameWindow + time.Second) + if w := famLogin(s, "203.0.113.20", "bela", pb, ""); w.Code == http.StatusTooManyRequests { + t.Fatal("the name lock must pass after its window (minutes, not forever)") + } +} + +// The household's dashboard session vouches (decision 46's rule) — as a household session in the family store. +func TestFamilyGate_HouseholdPassesWithItsDashboardSession(t *testing.T) { + s, _ := familyHarness(t) + rd := "https://fapp.example.hu/" + r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil) + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: newTestSession(s)}) + w := httptest.NewRecorder() + s.ServeFamilyStart(w, r) + if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), familyCallbackURI) { + t.Fatalf("the household must pass: %d %q", w.Code, w.Header().Get("Location")) + } + // no session at all: the sign-in page, never a token + r2 := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil) + w2 := httptest.NewRecorder() + s.ServeFamilyStart(w2, r2) + if w2.Code != http.StatusOK || strings.Contains(w2.Header().Get("Location"), "t=") || !strings.Contains(w2.Body.String(), `name="password"`) { + t.Fatalf("no session → the sign-in page: %d", w2.Code) + } +} + +// A token is one-use, bound to its host, and refused once its session ended. +func TestFamilyGate_TokenOneUseBoundToHost(t *testing.T) { + s, st := familyHarness(t) + st.Add("anna") + sid, _ := st.NewSession("anna") + tok := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/") + if _, _, err := s.takeFamilyToken(tok, "other.example.hu"); err == nil { + t.Fatal("a token for another host must be refused") + } + if _, _, err := s.takeFamilyToken(tok, "fapp.example.hu"); err != nil { + t.Fatalf("first use: %v", err) + } + if _, _, err := s.takeFamilyToken(tok, "fapp.example.hu"); err == nil { + t.Fatal("a token must be one-use") + } + tok2 := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/") + st.EndSession(sid) + if w := famAsk(s, "fapp.example.hu", "GET", familyCallbackURI+"?t="+tok2, "text/html"); w.Code != http.StatusForbidden { + t.Fatalf("a token whose session ended must be refused: %d", w.Code) + } +} + +// Rule 4: the family gate leaves the setup gate as it was — the setup-gated app answers the setup gate's handler, the +// family handler knows nothing of it. +func TestFamilyGate_SetupGateUntouched(t *testing.T) { + s, _ := familyHarness(t) + if w := famAsk(s, "gapp.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden { + t.Fatalf("the family handler must not answer for a setup-gated app: %d", w.Code) + } + if w := gateAsk(s, "gapp.example.hu", "GET", "/", "text/html"); w.Code != http.StatusFound { + t.Fatalf("the setup gate must still send a browser to its start: %d", w.Code) + } + if w := gateAsk(s, "fapp.example.hu", "GET", "/", "application/json"); w.Code != http.StatusForbidden { + t.Fatalf("the setup gate must not answer for a family app: %d", w.Code) + } +} + +// The family sign-in's messages follow the reader (it has no session, so the language cookie decides). +func TestFamilyGate_MessagesFollowTheReader(t *testing.T) { + s, st := familyHarness(t) + st.Add("anna") + for _, c := range []struct{ lang, want, not string }{{"en", "Wrong name or password.", "Hibás név"}, {"hu", "Hibás név vagy jelszó.", "Wrong name"}} { + form := url.Values{"_ft": {s.familyFormToken()}, "name": {"anna"}, "password": {"x"}} + r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.RemoteAddr = "192.168.0.7:1" + r.AddCookie(&http.Cookie{Name: langCookieName, Value: c.lang}) + w := httptest.NewRecorder() + s.ServeFamilyLogin(w, r) + body := html.UnescapeString(w.Body.String()) + if !strings.Contains(body, c.want) || strings.Contains(body, c.not) { + t.Errorf("%s: want %q not %q", c.lang, c.want, c.not) + } + } + // an expired or forged form token is refused before any password check + form := url.Values{"_ft": {"1.deadbeef"}, "name": {"anna"}, "password": {"x"}} + r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.ServeFamilyLogin(w, r) + if w.Code != http.StatusForbidden { + t.Fatalf("a forged form token: %d", w.Code) + } +} + +// The dashboard card's acts: the password is in the answer ONCE (JSON), never in the page. +func TestFamilyGate_CardActsAndNoPasswordInThePage(t *testing.T) { + s, st := familyHarness(t) + act := func(a, name string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/family/members/"+a, strings.NewReader(url.Values{"name": {name}}.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.familyMemberActionHandler(w, r, a) + return w + } + w := act("add", "anna") + m := regexp.MustCompile(`"password":"([a-z0-9-]{19})"`).FindStringSubmatch(w.Body.String()) + if w.Code != http.StatusOK || m == nil || !st.Verify("anna", m[1]) || w.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("add: %d %s", w.Code, w.Body.String()) + } + if w := act("add", "anna"); w.Code != http.StatusConflict { + t.Fatalf("duplicate: %d", w.Code) + } + if w := act("add", "Not Valid"); w.Code != http.StatusBadRequest { + t.Fatalf("bad name: %d", w.Code) + } + if w := act("remove", "anna"); w.Code != http.StatusOK || strings.Contains(w.Body.String(), "password") { + t.Fatalf("remove: %d %s", w.Code, w.Body.String()) + } + if w := act("reset", "anna"); w.Code != http.StatusNotFound { + t.Fatalf("reset of a removed member: %d", w.Code) + } +} diff --git a/controller/internal/web/i18n_cases_c_test.go b/controller/internal/web/i18n_cases_c_test.go index 06731a0..b3c8a95 100644 --- a/controller/internal/web/i18n_cases_c_test.go +++ b/controller/internal/web/i18n_cases_c_test.go @@ -158,6 +158,12 @@ func i18nCasesC() []i18nCase { {"setupgate", "setupgate", func() map[string]interface{} { return m{"AppName": "Immich", "Host": "photos.example.hu", "LoginURL": "/login?next=%2F__gate%2Fstart%3Frd%3Dhttps%253A%252F%252Fphotos.example.hu%252F"} }}, + {"familygate", "familygate", func() map[string]interface{} { + return m{"AppName": "Grimmory", "Host": "books.example.hu", "RD": "https://books.example.hu/", "FormToken": "FT"} + }}, + {"familygate_signed_in", "familygate", func() map[string]interface{} { + return m{"SignedIn": true, "Notice": "Beléptél. Nyisd meg újra az alkalmazást."} + }}, {"catchall_unknown", "catchall", func() map[string]interface{} { return m{"ControllerURL": "https://felhom.example.hu", "Status": "unknown", "StatusText": "Ez a cím nem tartozik alkalmazáshoz", "Host": "x.example.hu"} }}, diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 08f913a..bcea836 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -477,6 +477,7 @@ var i18nDirectTemplates = map[string]bool{ "launcher_shared": true, "launcher_share_password": true, "catchall": true, "setupgate": true, // v0.280.0 (decision 46): the gate page a stranger meets "signupclosed": true, // v0.281.0 (decision 47): an app's sign-up address once closed + "familygate": true, // v0.287.0 (decisions 63/64): the family sign-in page } func i18nTestServer(t *testing.T) *Server { diff --git a/controller/internal/web/i18n_wiring_test.go b/controller/internal/web/i18n_wiring_test.go index caaa564..ad22952 100644 --- a/controller/internal/web/i18n_wiring_test.go +++ b/controller/internal/web/i18n_wiring_test.go @@ -315,6 +315,7 @@ var i18nDirectPages = []struct{ tmpl, caseName, enProbe string }{ {"catchall", "catchall_app", "Manage app"}, {"setupgate", "setupgate", "waiting for its first setup"}, {"signupclosed", "signupclosed", "You cannot sign up on this app"}, + {"familygate", "familygate", "Open this app with your family name and password."}, } // TestI18nDirectRenderPagesFollowLanguage — with the household language saved as English the page is diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 4fac20e..d826213 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "gitea.dooplex.hu/admin/felhom-controller/internal/family" "html/template" "io" "io/fs" @@ -48,11 +49,13 @@ type Server struct { version string encKey []byte // AES-256 key for decrypting app.yaml values // gate / gateClock (v0.280.0, decision 46): the setup gate's key + used tokens; the clock is a test seam. - gate gateState - gateClock func() time.Time - tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this - tmplByLang map[string]*template.Template - i18n *i18n.Bundle + gate gateState + fam familyState // v0.287.0: the family gate (family_gate.go) + familyStoreOverride *family.Store // test seam: the family list without a data dir + gateClock func() time.Time + tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this + tmplByLang map[string]*template.Template + i18n *i18n.Bundle // versionPosition (v0.275.0) — where a just-restored app stands against the catalog; nil → the stack // manager's RestoredVersionPosition. A seam so the restore sentence is testable without a catalog. @@ -673,6 +676,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.sharingShareOffsiteHandler(w, r) case path == "/settings/notifications" && r.Method == http.MethodGet: s.settingsNotificationsPageHandler(w, r) + // v0.287.0 (decisions 63/64): the „Család" card's member list and its three acts (session + CSRF like every POST). + case path == "/family/members" && r.Method == http.MethodGet: + s.familyMembersHandler(w, r) + case strings.HasPrefix(path, "/family/members/") && r.Method == http.MethodPost: + s.familyMemberActionHandler(w, r, strings.TrimPrefix(path, "/family/members/")) case path == "/settings/security" && r.Method == http.MethodGet: s.settingsSecurityPageHandler(w, r) case path == "/settings/password" && r.Method == http.MethodPost: @@ -862,6 +870,23 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler { s.ServeGateAuth(w, r) return } + if r.URL.Path == familyAuthPath { // v0.287.0 (decisions 63/64): the family gate's forwardAuth, any host + s.ServeFamilyGateAuth(w, r) + return + } + if strings.EqualFold(host, controllerHost) { + switch r.URL.Path { // v0.287.0: the family sign-in pages, outside the dashboard's own auth on purpose + case familyStartPath: + s.ServeFamilyStart(w, r) + return + case familyLoginPath: + s.ServeFamilyLogin(w, r) + return + case familyLogoutPath: + s.ServeFamilyLogout(w, r) + return + } + } if r.URL.Path == signupClosedPath { // v0.281.0 (decision 47): an app's own sign-up address while closed s.ServeSignupClosed(w, r) return diff --git a/controller/internal/web/templates/familygate.html b/controller/internal/web/templates/familygate.html new file mode 100644 index 0000000..c30dc84 --- /dev/null +++ b/controller/internal/web/templates/familygate.html @@ -0,0 +1,48 @@ +{{define "familygate"}} + + + + + + + {{if .AppName}}{{.AppName}} — {{end}}{{T "family_gate.page_title"}} + + + + + + +{{end}} diff --git a/controller/internal/web/templates/settings_security.html b/controller/internal/web/templates/settings_security.html index 52b8e35..772a35f 100644 --- a/controller/internal/web/templates/settings_security.html +++ b/controller/internal/web/templates/settings_security.html @@ -403,6 +403,65 @@ function openDialog(opts){ })(); + +
+

{{T "family_gate.card_title"}}

+

{{T "family_gate.card_desc"}}

+
+ + +
+ + +
+ +
+ + {{if .HasRetrievalPassword}}
diff --git a/controller/internal/web/testdata/i18n_parity/familygate.html b/controller/internal/web/testdata/i18n_parity/familygate.html new file mode 100644 index 0000000..ca5c3e1 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/familygate.html @@ -0,0 +1,39 @@ + + + + + + + + Grimmory — Családi belépés + + + + + + diff --git a/controller/internal/web/testdata/i18n_parity/familygate_signed_in.html b/controller/internal/web/testdata/i18n_parity/familygate_signed_in.html new file mode 100644 index 0000000..efc9af0 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/familygate_signed_in.html @@ -0,0 +1,28 @@ + + + + + + + + Családi belépés + + + + + + diff --git a/controller/internal/web/testdata/i18n_parity/settings_security_full.html b/controller/internal/web/testdata/i18n_parity/settings_security_full.html index 7d52d80..24a9c68 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_security_full.html +++ b/controller/internal/web/testdata/i18n_parity/settings_security_full.html @@ -562,6 +562,64 @@ function openDialog(opts){ +
+

Család

+

A családtagok a saját nevükkel és jelszavukkal lépnek be a családi kapus alkalmazásokba. Ezzel a vezérlőpultot nem érik el.

+
+ + +
+ + +
+ +
+ + +

Vészhelyzeti információk

diff --git a/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html b/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html index 9dffe4d..82ad6d7 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html +++ b/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html @@ -493,6 +493,64 @@ function openDialog(opts){ +
+

Család

+

A családtagok a saját nevükkel és jelszavukkal lépnek be a családi kapus alkalmazásokba. Ezzel a vezérlőpultot nem érik el.

+
+ + +
+ + +
+ +
+ + + diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 70592e1..f97f3d4 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -132,7 +132,18 @@ "flash.offbox.enabled_all": "v0.283.0 decision 50: born as a key (the one-press off-site offer)", "login.msg.empty_password": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader.", "login.msg.rate_limited": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader.", - "login.msg.wrong_password": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader." + "login.msg.wrong_password": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader.", + "err.stacks.family_gate_failed": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "err.stacks.needs_newer_controller": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "family_gate.msg.bad_name": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "family_gate.msg.exists": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "family_gate.msg.form_expired": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "family_gate.msg.locked": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "family_gate.msg.no_member": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "family_gate.msg.signed_in": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "family_gate.msg.signed_out": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "family_gate.msg.store_unreadable": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader.", + "family_gate.msg.wrong": "BORN AS A KEY, v0.287.0 (the family gate, decisions 63/64) -- a NEW sentence, never a Go literal. Pinned in both languages by TestFamilyGate_MessagesFollowTheReader." }, "flash.share.already_on": "A megosztás már be van kapcsolva.", "flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.", diff --git a/controller/scripts/secret_in_markup_gate.py b/controller/scripts/secret_in_markup_gate.py index 8fb3d0b..535c1e8 100644 --- a/controller/scripts/secret_in_markup_gate.py +++ b/controller/scripts/secret_in_markup_gate.py @@ -70,6 +70,8 @@ ALLOWLIST = { # and it MUST be in the form for the form to work. ".CSRFToken": "CSRF token — session-bound, must be in the page for any POST to work", ".CSRFField": "CSRF token — same", + ".FormToken": "the family sign-in form's own CSRF (an HMAC over its expiry, v0.287.0) — the visitor has no " + "session; it opens nothing, it only proves the POST came from the page", }