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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 07:42:47 +02:00

237 lines
9.8 KiB
Go

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
}