package stacks import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "gopkg.in/yaml.v3" ) // ── The setup gate (v0.280.0, `09` §3 decision 46) ───────────────────────────────────────────────────── // // 34 catalog apps let the FIRST VISITOR create the admin, and every app is on the internet from its first // minute. So an app whose template says `setup_gate: true` is installed CLOSED: traefik sends each request to // the controller first (forwardAuth), and the controller lets it through only for the household — a browser // that holds a valid dashboard session (the handshake lives in internal/web/setup_gate.go). The gate OPENS // when the app's own status says the first admin exists (`setup_done_probe:`), or when the household presses // "Done, I set it up". Opening REMOVES the gate's traefik file: the app's own docker-label router is then the // only one, exactly as if it had never been gated. // // setup_gate: true // setup_done_probe: # optional; without it, the household's button opens it // url: http://n8n:5678/rest/settings # read on the docker network, never through traefik // field: data.userManagement.showSetupOnFirstLoad # dotted JSON path // done: "false" # the field's value once the setup is done, as text // // Order is load-bearing (spike F2, audits/login-gate-2026-09-29/B): the gate file is written BEFORE the app's // first start. traefik holds a file router whose service does not exist yet and enables it the moment the // app's service appears — measured: 0 app answers to a stranger across ~530 polls during two installs. // A gate that cannot be written refuses the install; a gated app is never published open. // // The record (`setup_gate:` in app.yaml) is a life record: it survives a controller restart (the file on disk // is reconciled from it) and a restore (carryLifeRecords). An install that LOADS kept data never gates — the // data comes back with its admin. // Pinned by internal/stacks/setup_gate_test.go. // SetupDoneProbe is `.felhom.yml`'s `setup_done_probe:`. type SetupDoneProbe struct { URL string `yaml:"url" json:"url"` Field string `yaml:"field" json:"field"` Done string `yaml:"done" json:"done"` // DoneStatus (v0.282.0, R-715): a non-200 HTTP status that itself means "set up" (gramps-web answers 405). DoneStatus int `yaml:"done_status,omitempty" json:"done_status,omitempty"` } // Setup gate states. const ( SetupGateClosed = "closed" SetupGateOpen = "open" // Who opened it. SetupGateByProbe = "probe" SetupGateByHousehold = "household" ) // SetupGateRecord is app.yaml's `setup_gate:`. type SetupGateRecord struct { State string `yaml:"state" json:"state"` Since string `yaml:"since" json:"since"` Hosts []string `yaml:"hosts,omitempty" json:"hosts,omitempty"` OpenedAt string `yaml:"opened_at,omitempty" json:"opened_at,omitempty"` OpenedBy string `yaml:"opened_by,omitempty" json:"opened_by,omitempty"` // SignupOpenUntil (v0.281.0, decision 47): the household opened sign-up until this time (signup_block.go). SignupOpenUntil string `yaml:"signup_open_until,omitempty" json:"signup_open_until,omitempty"` // NativeLock (v0.282.0): the app's own sign-up switch — "applied", "lifted" (the window), or "" (never set). NativeLock string `yaml:"native_lock,omitempty" json:"native_lock,omitempty"` } // Closed reports whether the gate stands. func (r *SetupGateRecord) Closed() bool { return r != nil && r.State == SetupGateClosed } // setupGateAuthURL is where traefik asks. The controller sits on traefik-public as felhom-controller (wireController). const setupGateAuthURL = "http://felhom-controller:8080/__felhom_gate/auth" // setupGatePriority beats every docker-label router (traefik's default priority is the rule's length). The rule's // length is ADDED so an app's path-scoped router (adventurelog's backend) still wins over its host-only one, as // it does without the gate. const setupGatePriority = 100000 // gateRouter is one traefik router an app publishes, as its compose labels define it. type gateRouter struct { Name string Rule string Service string CertResolver string } var hostInRule = regexp.MustCompile("Host\\(`([^`]+)`\\)") // expandComposeVars fills ${NAME} and ${NAME:-default} from env, as compose does for a label value. func expandComposeVars(s string, env map[string]string) string { return os.Expand(s, func(v string) string { if name, def, ok := strings.Cut(v, ":-"); ok { if val := env[name]; val != "" { return val } return def } return env[v] }) } // gateRoutersFromCompose reads the routers an app publishes from its compose labels, filled with the app's // env. A router with no `service` label takes its container's only service; two services and no label is // refused (the gate would not know where to send the household). func gateRoutersFromCompose(composePath string, env map[string]string) ([]gateRouter, error) { data, err := os.ReadFile(composePath) if err != nil { return nil, err } var doc struct { Services map[string]struct { Labels interface{} `yaml:"labels"` } `yaml:"services"` } if err := yaml.Unmarshal(data, &doc); err != nil { return nil, fmt.Errorf("compose: %w", err) } var out []gateRouter names := make([]string, 0, len(doc.Services)) for n := range doc.Services { names = append(names, n) } sort.Strings(names) for _, svc := range names { labels := map[string]string{} switch l := doc.Services[svc].Labels.(type) { case []interface{}: for _, e := range l { k, v, _ := strings.Cut(fmt.Sprint(e), "=") labels[strings.TrimSpace(k)] = strings.TrimSpace(v) } case map[string]interface{}: for k, v := range l { labels[k] = fmt.Sprint(v) } } if strings.ToLower(labels["traefik.enable"]) != "true" { continue } var services []string routers := map[string]*gateRouter{} for k, v := range labels { parts := strings.Split(k, ".") if len(parts) < 5 || parts[0] != "traefik" || parts[1] != "http" { continue } switch parts[2] { case "services": if !containsStr(services, parts[3]) { services = append(services, parts[3]) } case "routers": r := routers[parts[3]] if r == nil { r = &gateRouter{Name: parts[3]} routers[parts[3]] = r } switch strings.Join(parts[4:], ".") { case "rule": r.Rule = expandComposeVars(v, env) case "service": r.Service = expandComposeVars(v, env) case "tls.certresolver": r.CertResolver = v } } } rnames := make([]string, 0, len(routers)) for n := range routers { rnames = append(rnames, n) } sort.Strings(rnames) for _, n := range rnames { r := routers[n] if r.Rule == "" { continue } if r.Service == "" { if len(services) != 1 { return nil, fmt.Errorf("router %s (service %s) names no traefik service and its container has %d", n, svc, len(services)) } r.Service = services[0] } out = append(out, *r) } } if len(out) == 0 { return nil, fmt.Errorf("the compose file publishes no traefik router") } return out, nil } // gateHosts is every host the routers match. func gateHosts(rs []gateRouter) []string { var hosts []string for _, r := range rs { for _, m := range hostInRule.FindAllStringSubmatch(r.Rule, -1) { h := strings.ToLower(m[1]) if !containsStr(hosts, h) { hosts = append(hosts, h) } } } sort.Strings(hosts) return hosts } // renderSetupGate is the traefik file-provider config that puts the gate in front of every router the app // publishes: same rule, higher priority, forwardAuth, then the app's own docker service. func renderSetupGate(name string, rs []gateRouter) string { var b strings.Builder mw := "felhom-setup-gate-" + name fmt.Fprintf(&b, "# Setup gate for %s — managed by felhom-controller (`09` §3 decision 46).\n", name) b.WriteString("# The app is closed to everyone but the household until its first setup is done; then this file is removed.\n") b.WriteString("http:\n middlewares:\n") fmt.Fprintf(&b, " %s:\n forwardAuth:\n address: %q\n", mw, setupGateAuthURL) b.WriteString(" routers:\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", setupGatePriority+len(r.Rule)) b.WriteString(" entryPoints:\n - websecure\n") if r.CertResolver != "" { fmt.Fprintf(&b, " tls:\n certResolver: %s\n", r.CertResolver) } else { b.WriteString(" tls: {}\n") } fmt.Fprintf(&b, " middlewares:\n - %s@file\n", mw) fmt.Fprintf(&b, " service: %q\n", r.Service+"@docker") } return b.String() } func (m *Manager) setupGateDir() string { return filepath.Join(m.cfg.Paths.StacksDir, "traefik", "dynamic") } func (m *Manager) setupGatePath(name string) string { return filepath.Join(m.setupGateDir(), "setup-gate-"+name+".yml") } // writeSetupGate writes (or refreshes) the app's gate file. Returns the hosts it covers. func (m *Manager) writeSetupGate(name, composePath string, env map[string]string) ([]string, error) { rs, err := gateRoutersFromCompose(composePath, env) if err != nil { return nil, err } if err := os.MkdirAll(m.setupGateDir(), 0o755); err != nil { return nil, err } want := renderSetupGate(name, rs) p := m.setupGatePath(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 } func (m *Manager) removeSetupGateFile(name string) error { err := os.Remove(m.setupGatePath(name)) if err != nil && !os.IsNotExist(err) { return err } return nil } // prepareSetupGate is DeployStack's step for a `setup_gate: true` template on a FRESH install: the file first, // then the record the caller saves with the app. func (m *Manager) prepareSetupGate(name, composePath string, env map[string]string) (*SetupGateRecord, error) { hosts, err := m.writeSetupGate(name, composePath, env) if err != nil { return nil, err } m.logger.Printf("[INFO] [stacks] %s: setup gate CLOSED before the first start — only the household reaches %v until the first setup is done", name, hosts) return &SetupGateRecord{State: SetupGateClosed, Since: m.now().UTC().Format(time.RFC3339), Hosts: hosts}, nil } // OpenSetupGate opens an app's gate: the record first, then the file (a failed removal is retried by the // reconcile; the reverse order could re-gate an opened app). by = SetupGateByProbe | SetupGateByHousehold. func (m *Manager) OpenSetupGate(name, by string) error { st, ok := m.GetStack(name) if !ok { return fmt.Errorf("stack %q not found", name) } if st.AppConfig == nil || !st.AppConfig.SetupGate.Closed() { return ErrSetupGateNotClosed } dir := filepath.Dir(st.ComposePath) now := m.now().UTC().Format(time.RFC3339) // Decision 47: the sign-up block goes up BEFORE the gate comes down, so there is no moment where a stranger // can sign up. Cannot write it → the gate stays closed (the loop or the next press tries again). block := strings.TrimSpace(st.Meta.SignupBlock) if block != "" { if err := m.writeSignupBlock(name, st.AppConfig.SetupGate.Hosts, block); err != nil { return fmt.Errorf("setup gate %s: the sign-up block could not be written, so the gate stays closed: %w", name, err) } } opened := false m.mutateAppConfig(name, dir, "setup_gate", func(cfg *AppConfig) bool { if !cfg.SetupGate.Closed() { return false } cfg.SetupGate.State, cfg.SetupGate.OpenedAt, cfg.SetupGate.OpenedBy = SetupGateOpen, now, by opened = true return true }) if !opened { if block != "" { _ = m.removeSignupBlockFile(name) // still gated: the household may still need the sign-up address } return fmt.Errorf("setup gate %s: the record could not be written", name) } if err := m.removeSetupGateFile(name); err != nil { m.logger.Printf("[ERROR] [stacks] %s: setup gate opened but its traefik file could not be removed (%v) — the reconcile retries", name, err) } m.logger.Printf("[INFO] [stacks] %s: setup gate OPENED by %s — the app is reached as without a gate", name, by) // v0.282.0 (decision 47): the app's own sign-up switch, after the block is up and the gate is down. One restart. if st.Meta.AfterSetup != nil { m.goNativeLock(name, true, "the gate opened ("+by+")") } return nil } // ErrSetupGateNotClosed: the app has no closed gate (never gated, or already open). var ErrSetupGateNotClosed = fmt.Errorf("the app has no closed setup gate") // SetupGateHost maps a host to the app that owns it and whether that app's gate is closed. func (m *Manager) SetupGateHost(host string) (name string, closed bool, found bool) { host = strings.ToLower(host) m.mu.RLock() defer m.mu.RUnlock() // R-741: a closed install hold answers first (its routers outrank the gate's); an app with both is closed while // either is. for n, st := range m.stacks { if st.AppConfig == nil || !st.AppConfig.InstallHold.Closed() { continue } if containsStr(st.AppConfig.InstallHold.Hosts, host) { return n, true, true } } for n, st := range m.stacks { if st.AppConfig == nil || st.AppConfig.SetupGate == nil { continue } if containsStr(st.AppConfig.SetupGate.Hosts, host) { return n, st.AppConfig.SetupGate.Closed(), true } } return "", false, false } // setupGateProbeFetch reads a probe URL: the HTTP status and the body (a seam: tests never reach a network). var setupGateProbeFetch = func(url string) (int, []byte, error) { c := &http.Client{Timeout: 5 * time.Second} resp, err := c.Get(url) if err != nil { return 0, nil, err } defer resp.Body.Close() b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) return resp.StatusCode, b, err } // probeOnce asks the app's own status once. v0.282.0 (R-715): `done_status:` — an app that answers a fixed non-200 // status once it is set up (gramps-web: 405 "Users already exist") — counts as done on that status alone; any other // non-200 is "cannot read" (fail closed). A 200 is read as JSON: `field` is a dotted path whose parts may be list // indexes (`setup.0.status` — ghost; `0.done` — home-assistant), compared as text with `done`. func probeOnce(p *SetupDoneProbe) (done bool, got string, err error) { status, body, err := setupGateProbeFetch(p.URL) if err != nil { return false, "", err } if p.DoneStatus != 0 && status == p.DoneStatus { return true, fmt.Sprintf("HTTP %d", status), nil } if status != http.StatusOK { return false, fmt.Sprintf("HTTP %d", status), fmt.Errorf("HTTP %d", status) } if p.Field == "" { return false, "no field", fmt.Errorf("the probe names no field") } done, got = probeSaysDone(body, p.Field, p.Done) return done, got, nil } // probeSaysDone reads the field at the dotted path (a numeric part indexes a list) and compares its text form with // done. Anything it cannot read is "not done" — the gate stays closed (fail closed). func probeSaysDone(body []byte, field, done string) (bool, string) { var v interface{} if err := json.Unmarshal(body, &v); err != nil { return false, "not JSON" } for _, k := range strings.Split(field, ".") { switch cur := v.(type) { case map[string]interface{}: nv, ok := cur[k] if !ok { return false, "no field " + field } v = nv case []interface{}: n, err := strconv.Atoi(k) if err != nil || n < 0 || n >= len(cur) { return false, "no field " + field } v = cur[n] default: return false, "no field " + field } } got := fmt.Sprint(v) return got == done, got } // SetupGateTick is one pass of the gate's loop: every closed gate's file exists; every app whose gate is not // closed has none (a removed app, an opened gate whose removal failed); a closed gate whose app is running and // whose probe says done opens. func (m *Manager) SetupGateTick() { type item struct { name, dir, compose string rec *SetupGateRecord probe *SetupDoneProbe running bool } var items []item keep := map[string]bool{} m.mu.RLock() for n, st := range m.stacks { if !st.Deployed || st.AppConfig == nil || !st.AppConfig.SetupGate.Closed() { continue } rec := *st.AppConfig.SetupGate items = append(items, item{name: n, dir: filepath.Dir(st.ComposePath), compose: st.ComposePath, rec: &rec, probe: st.Meta.SetupDoneProbe, running: st.State == StateRunning || st.State == StateUnhealthy}) keep[n] = true } m.mu.RUnlock() // Stale files: a gate file whose app is not closed-gated any more. if ents, err := os.ReadDir(m.setupGateDir()); err == nil { for _, e := range ents { n := e.Name() if !strings.HasPrefix(n, "setup-gate-") || !strings.HasSuffix(n, ".yml") { continue } app := strings.TrimSuffix(strings.TrimPrefix(n, "setup-gate-"), ".yml") if !keep[app] { if err := m.removeSetupGateFile(app); err == nil { m.logger.Printf("[INFO] [stacks] %s: removed a setup-gate file for an app whose gate is not closed", app) } } } } for _, it := range items { cfg := LoadAppConfigDecrypted(it.dir, m.encKey) if cfg != nil { if _, err := m.writeSetupGate(it.name, it.compose, cfg.Env); err != nil { m.logger.Printf("[ERROR] [stacks] %s: the setup gate's traefik file could not be (re)written: %v", it.name, err) } } if it.probe == nil || it.probe.URL == "" || !it.running { continue } done, got, err := probeOnce(it.probe) if err != nil { if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] %s: setup probe unreadable (%v) — gate stays closed", it.name, err) } continue } if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] %s: setup probe %s = %q (done when %q)", it.name, it.probe.Field, got, it.probe.Done) } if done { if err := m.OpenSetupGate(it.name, SetupGateByProbe); err != nil { m.logger.Printf("[ERROR] [stacks] %s: the probe says the setup is done but the gate did not open: %v", it.name, err) } } } m.reconcileSignupBlocks() m.installHoldTick() } // RunSetupGateLoop runs SetupGateTick every interval until ctx ends. func (m *Manager) RunSetupGateLoop(ctx context.Context, interval time.Duration) { t := time.NewTicker(interval) defer t.Stop() m.SetupGateTick() for { select { case <-ctx.Done(): return case <-t.C: m.SetupGateTick() } } } // MarkDefaultLoginChanged records the household's word that it changed the template's known default login by // hand (R-710). The page then stops naming the default. It changes nothing in the app. func (m *Manager) MarkDefaultLoginChanged(name, by string) error { st, ok := m.GetStack(name) if !ok || !st.Deployed { return fmt.Errorf("stack %q is not installed", name) } rec := &DefaultLoginRecord{ChangedAt: m.now().UTC().Format(time.RFC3339), By: by} done := false m.mutateAppConfig(name, filepath.Dir(st.ComposePath), "default_login", func(cfg *AppConfig) bool { cfg.DefaultLogin = rec done = true return true }) if !done { return fmt.Errorf("%s: app.yaml could not be read", name) } m.logger.Printf("[INFO] [stacks] %s: the household says it changed the default login by hand — the page stops naming it", name) if err := m.OpenInstallHold(name, InstallHoldByHousehold); err != nil { // R-741 m.logger.Printf("[ERROR] [stacks] %s: %v — the loop retries", name, err) } return nil }