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 }