977665d8c0
gates / gates (push) Successful in 27s
- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request. - internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop; priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1). - internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family); sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches. RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove. Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
237 lines
9.8 KiB
Go
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
|
|
}
|