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
gates / gates (push) Successful in 27s
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
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -499,6 +499,7 @@ func (m *Manager) SetupGateTick() {
|
||||
}
|
||||
m.reconcileSignupBlocks()
|
||||
m.installHoldTick()
|
||||
m.familyGateTick()
|
||||
}
|
||||
|
||||
// RunSetupGateLoop runs SetupGateTick every interval until ctx ends.
|
||||
|
||||
Reference in New Issue
Block a user