v0.280.0: the setup gate (decision 46); R-710 'I changed it' + absent-record window; R-709 password fields off the page; password:N:special generator
gates / gates (push) Successful in 25s

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:
2026-09-29 08:51:46 +02:00
parent 2548b4c924
commit 7fa8768cfd
37 changed files with 4015 additions and 107 deletions
+71 -2
View File
@@ -181,6 +181,18 @@ type AppConfig struct {
RestoredLogins []string `yaml:"restored_logins,omitempty" json:"restored_logins,omitempty"`
// AfterInstall (v0.279.0, decision 45) is what the template's one-time after_install command did.
AfterInstall *AfterInstallRecord `yaml:"after_install,omitempty" json:"after_install,omitempty"`
// 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"`
// DefaultLogin (v0.280.0, R-710) is the household's own word that it changed the template's known default
// login by hand. The page stops naming the default. See internal/web/known_login.go.
DefaultLogin *DefaultLoginRecord `yaml:"default_login,omitempty" json:"default_login,omitempty"`
}
// DefaultLoginRecord is app.yaml's `default_login:`.
type DefaultLoginRecord struct {
ChangedAt string `yaml:"changed_at" json:"changed_at"`
By string `yaml:"by" json:"by"`
}
// InstalledImage is one compose service's observed image. See AppConfig.InstalledImages.
@@ -408,6 +420,19 @@ func (m *Manager) DeployStack(req DeployRequest) (string, 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
if meta.SetupGate {
g, err := m.prepareSetupGate(req.StackName, stack.ComposePath, env)
if err != nil {
clearDeploying()
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: the setup gate could not be prepared: %v", req.StackName, err)
return "", util.MsgError("err.stacks.setup_gate_failed", err.Error())
}
gate = g
}
// 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).
@@ -427,6 +452,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
// (isBootOrphan gates on Deployed first), and if the compose-up then fails, runComposeDeploy
// reverts Deployed to false — so a failed deploy can never present as an app owed a restart.
DesiredState: DesiredStateRunning,
SetupGate: gate,
}
diskCfg := *appCfg
@@ -739,6 +765,9 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
// so USERDATA_PATH must be injected here too (mirrors stackEnv), else the FIRST deploy resolves
// ${USERDATA_PATH} to "" and binds a bogus root-owned dir at the container root.
func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) {
if m.composeExecFn != nil { // test seam (v0.280.0): a deploy test never reaches Docker
return m.composeExecFn(dir, env, args...)
}
cmdEnv := os.Environ()
for k, v := range env {
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
@@ -1116,11 +1145,22 @@ func generateValue(spec string) (string, error) {
switch parts[0] {
case "password":
// "password:N" letters and digits; "password:N:special" (v0.280.0) also carries one of
// passwordSpecials and at least one lower, upper and digit — for an app whose own policy demands it
// (calibre-web). The deploy page's generatePassword (deploy.html) makes the same shape.
lenStr, form, _ := strings.Cut(parts[1], ":")
length := 0
if _, err := fmt.Sscanf(parts[1], "%d", &length); err != nil || length <= 0 {
if _, err := fmt.Sscanf(lenStr, "%d", &length); err != nil || length <= 0 {
return "", fmt.Errorf("invalid password length: %q", parts[1])
}
return randomAlphanumeric(length)
switch form {
case "":
return randomAlphanumeric(length)
case "special":
return randomWithSpecial(length)
default:
return "", fmt.Errorf("unknown password form %q (want special)", form)
}
case "hex":
byteLen := 0
if _, err := fmt.Sscanf(parts[1], "%d", &byteLen); err != nil || byteLen <= 0 {
@@ -1286,6 +1326,35 @@ func containsStr(slice []string, s string) bool {
return false
}
// passwordSpecials are safe in a compose env value, a shell's double quotes, a URL form and a "user:password"
// argument: no quote, no $, no backslash, no colon, no space.
const passwordSpecials = "-_.!@#%+="
// randomWithSpecial: length >= 8, first character a letter or digit, at least one lower, upper, digit and special.
func randomWithSpecial(length int) (string, error) {
if length < 8 {
return "", fmt.Errorf("a password with a special character needs at least 8 characters, not %d", length)
}
chars := alphanumChars + passwordSpecials
for {
b := make([]byte, length)
for i := range b {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
return "", err
}
b[i] = chars[n.Int64()]
}
p := string(b)
if strings.ContainsAny(p[:1], passwordSpecials) || !strings.ContainsAny(p, "abcdefghijklmnopqrstuvwxyz") ||
!strings.ContainsAny(p, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") || !strings.ContainsAny(p, "0123456789") ||
!strings.ContainsAny(p, passwordSpecials) {
continue
}
return p, nil
}
}
func randomAlphanumeric(length int) (string, error) {
result := make([]byte, length)
for i := range result {
@@ -32,6 +32,11 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) {
cfg.LastUpdateUndone = prior.LastUpdateUndone
cfg.LastAutoUpdate = prior.LastAutoUpdate
cfg.AfterInstall = prior.AfterInstall // v0.279.0: what the install's one-time command did stays true after a restore
// v0.280.0 (decision 46): the setup gate is the app's life here too. A restore never re-gates an app whose gate
// 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.DefaultLogin = prior.DefaultLogin
if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 {
cur := ""
if prior.ConversionCopy != nil {
+2
View File
@@ -268,6 +268,8 @@ type Manager struct {
fbBaseURL string
// execFn replaces execCommand's process boundary in tests; nil in production.
execFn func(name string, args ...string) (string, error)
// composeExecFn replaces the initial deploy's compose call (composeExecWithEnv) in tests; nil in production.
composeExecFn func(dir string, env map[string]string, args ...string) (string, error)
// --- guarded update (slice 4, update.go) ---
updateGuards UpdateGuards // init-only, SetUpdateGuards; nil ⇒ every update is REFUSED
+6 -1
View File
@@ -56,7 +56,12 @@ type Metadata struct {
// AfterInstall (v0.279.0, `09` §3 decision 45) is ONE command the box runs once after a FRESH install —
// to replace a known default login with the generated one. See after_install.go.
AfterInstall *AfterInstallCommand `yaml:"after_install,omitempty" json:"after_install,omitempty"`
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
// SetupGate (v0.280.0, `09` §3 decision 46): a fresh install is CLOSED to everyone but the household until the
// app's first setup is done — for an app whose first visitor creates the admin. See setup_gate.go.
SetupGate bool `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"`
// SetupDoneProbe (v0.280.0) is the app's own read-only "an admin exists" status; absent = the household's button.
SetupDoneProbe *SetupDoneProbe `yaml:"setup_done_probe,omitempty" json:"setup_done_probe,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.
+474
View File
@@ -0,0 +1,474 @@
package stacks
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"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"`
}
// 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"`
}
// 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)
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 {
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)
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()
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
}
// setupGateProbeGet reads a probe URL (a seam: tests never reach a network).
var setupGateProbeGet = func(url string) ([]byte, error) {
c := &http.Client{Timeout: 5 * time.Second}
resp, err := c.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
}
// probeSaysDone reads the field at the dotted path 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, ".") {
obj, ok := v.(map[string]interface{})
if !ok {
return false, "no field " + field
}
if v, ok = obj[k]; !ok {
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
}
body, err := setupGateProbeGet(it.probe.URL)
if err != nil {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] %s: setup probe unreadable (%v) — gate stays closed", it.name, err)
}
continue
}
done, got := probeSaysDone(body, it.probe.Field, it.probe.Done)
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)
}
}
}
}
// 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)
return nil
}
@@ -0,0 +1,332 @@
package stacks
import (
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// v0.280.0 (`09` §3 decision 46) — the setup gate. Nothing here reaches Docker: the deploy's compose call is
// the composeExecFn seam, every other docker call hits a stub on PATH, and the probe is setupGateProbeGet.
const gateCompose = "services:\n" +
" gapp:\n image: busybox\n labels:\n" +
" - \"traefik.enable=true\"\n" +
" - \"traefik.http.routers.gapp.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)\"\n" +
" - \"traefik.http.routers.gapp.tls.certresolver=letsencrypt\"\n" +
" - \"traefik.http.services.gapp.loadbalancer.server.port=80\"\n" +
" - \"traefik.http.routers.gapp-api.rule=Host(`${SUBDOMAIN}.${DOMAIN}`) && PathPrefix(`/api`)\"\n" +
" gapp-db:\n image: busybox\n"
func gateManager(t *testing.T, felhomYml string) *Manager {
t.Helper()
dir := t.TempDir()
// A docker STUB on PATH (R-650's sanctioned seam): every docker call answers "nothing", none reaches this host.
bin := filepath.Join(dir, "bin")
must(t, os.MkdirAll(bin, 0o755))
must(t, os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755))
t.Setenv("PATH", bin)
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.SystemDataPath = filepath.Join(dir, "system")
cfg.Stacks.ComposeCommand = "docker compose"
cfg.Customer.Domain = "example.hu"
app := filepath.Join(cfg.Paths.StacksDir, "gapp")
must(t, os.MkdirAll(app, 0o755))
must(t, os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte(gateCompose), 0o644))
must(t, os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte(felhomYml), 0o644))
m, err := NewManager(cfg, log.New(io.Discard, "", 0))
must(t, err)
must(t, m.ScanStacks())
return m
}
const gatedYml = "display_name: Gated App\nsetup_gate: true\n" +
"setup_done_probe:\n url: http://gapp:80/api/status\n field: data.initialized\n done: \"true\"\n" +
"deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n"
// The gate stands BEFORE the app's first start (spike F2: written after, the app is open until it lands).
// COMPANION RED-PROOF: move the prepareSetupGate block in DeployStack below the compose call (or drop it) →
// "the gate file did not exist when the app was first started" fails.
func TestSetupGate_WrittenBeforeTheFirstStartAndRecordedClosed(t *testing.T) {
m := gateManager(t, gatedYml)
gatePath := m.setupGatePath("gapp")
var atUp string
existedAtUp := false
m.composeExecFn = func(_ string, _ map[string]string, args ...string) (string, error) {
if len(args) > 0 && args[0] == "up" {
b, err := os.ReadFile(gatePath)
existedAtUp, 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 !existedAtUp {
t.Fatal("the gate file did not exist when the app was first started — a stranger could reach its first-setup screen")
}
for _, want := range []string{
"Host(`gapp.example.hu`)", `service: "gapp@docker"`, "http://felhom-controller:8080/__felhom_gate/auth",
"certResolver: letsencrypt", "PathPrefix(`/api`)", "felhom-setup-gate-gapp@file",
} {
if !strings.Contains(atUp, want) {
t.Errorf("the gate file lacks %q:\n%s", want, atUp)
}
}
// The path router must still win over the host-only one, as it does without the gate.
hostRule, apiRule := "Host(`gapp.example.hu`)", "Host(`gapp.example.hu`) && PathPrefix(`/api`)"
if !strings.Contains(atUp, fmt.Sprintf("priority: %d", setupGatePriority+len(apiRule))) ||
!strings.Contains(atUp, fmt.Sprintf("priority: %d", setupGatePriority+len(hostRule))) || len(apiRule) <= len(hostRule) {
t.Errorf("priorities do not keep the path router above the host router:\n%s", atUp)
}
cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp"))
if cfg == nil || !cfg.SetupGate.Closed() || strings.Join(cfg.SetupGate.Hosts, ",") != "gapp.example.hu" {
t.Fatalf("app.yaml gate record: %+v", cfg)
}
}
// A gate that cannot be written refuses the install: never published open.
// COMPANION RED-PROOF: ignore prepareSetupGate's error in DeployStack → the deploy is accepted and this fails.
func TestSetupGate_AnUnwritableGateRefusesTheInstall(t *testing.T) {
m := gateManager(t, gatedYml)
// a FILE where the dynamic directory must be
must(t, os.MkdirAll(filepath.Join(m.cfg.Paths.StacksDir, "traefik"), 0o755))
must(t, os.WriteFile(m.setupGateDir(), []byte("x"), 0o644))
called := false
m.composeExecFn = func(string, map[string]string, ...string) (string, error) { called = true; return "", nil }
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err == nil {
t.Fatal("an install whose gate could not be written was accepted")
}
time.Sleep(50 * time.Millisecond)
if called {
t.Fatal("compose ran for a refused install")
}
if st, _ := m.GetStack("gapp"); st.Deployed || st.Deploying {
t.Fatalf("left Deployed=%v Deploying=%v", st.Deployed, st.Deploying)
}
}
// An ungated template is untouched: no file, no record.
func TestSetupGate_AnUngatedTemplateGetsNoGate(t *testing.T) {
m := gateManager(t, strings.Replace(gatedYml, "setup_gate: true\n", "", 1))
m.composeExecFn = func(string, map[string]string, ...string) (string, error) { return "", nil }
done := make(chan bool, 1)
m.SetDeployDoneHook(func(string, bool, string) { done <- true })
_, err := m.DeployStack(DeployRequest{StackName: "gapp"})
must(t, err)
<-done
if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) {
t.Fatal("an ungated template got a gate file")
}
if c := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp")); c.SetupGate != nil {
t.Fatalf("an ungated template got a gate record: %+v", c.SetupGate)
}
}
// closedGate puts gapp in the state a gated install leaves: deployed, running, record closed, file written.
func closedGate(t *testing.T, m *Manager) string {
t.Helper()
dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp")
env := map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}
rec, err := m.prepareSetupGate("gapp", filepath.Join(dir, "docker-compose.yml"), env)
must(t, err)
cfg := &AppConfig{Deployed: true, Env: env, SetupGate: rec}
must(t, SaveAppConfig(dir, cfg, m.encKey, nil))
m.mu.Lock()
m.stacks["gapp"].Deployed, m.stacks["gapp"].State, m.stacks["gapp"].AppConfig = true, StateRunning, cfg
m.mu.Unlock()
return dir
}
// The probe opens the gate — the record first, then the file — and only when the app says it is set up.
// COMPANION RED-PROOF: make probeSaysDone return true for any readable body → "not yet set up" opens the gate
// and this fails; drop the removeSetupGateFile call in OpenSetupGate → "file still there" fails.
func TestSetupGate_TheProbeOpensItOnlyWhenTheAppSaysSetUp(t *testing.T) {
m := gateManager(t, gatedYml)
dir := closedGate(t, m)
answer := `{"data":{"initialized":false}}`
var probeErr error
old := setupGateProbeGet
setupGateProbeGet = func(url string) ([]byte, error) {
if url != "http://gapp:80/api/status" {
t.Errorf("probed %q", url)
}
return []byte(answer), probeErr
}
t.Cleanup(func() { setupGateProbeGet = old })
m.SetupGateTick()
if c := LoadAppConfig(dir); !c.SetupGate.Closed() {
t.Fatal("not yet set up, but the gate opened")
}
probeErr = errors.New("connection refused")
answer = `{"data":{"initialized":true}}`
m.SetupGateTick()
if c := LoadAppConfig(dir); !c.SetupGate.Closed() {
t.Fatal("an unreadable probe opened the gate (must fail closed)")
}
probeErr = nil
m.SetupGateTick()
c := LoadAppConfig(dir)
if c.SetupGate.Closed() || c.SetupGate.OpenedBy != SetupGateByProbe || c.SetupGate.OpenedAt == "" {
t.Fatalf("the app says it is set up, but the gate record is %+v", c.SetupGate)
}
if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) {
t.Fatal("the gate opened but its traefik file is still there — the app is still gated")
}
// A later tick does not re-gate it.
m.SetupGateTick()
if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) {
t.Fatal("a tick re-gated an opened app")
}
}
// The household's button opens it; a second press says it is already open.
// COMPANION RED-PROOF: skip the Closed() check at the top of OpenSetupGate → the second press succeeds.
func TestSetupGate_TheButtonOpensItOnce(t *testing.T) {
m := gateManager(t, strings.Replace(gatedYml, "setup_done_probe:\n url: http://gapp:80/api/status\n field: data.initialized\n done: \"true\"\n", "", 1))
dir := closedGate(t, m)
must(t, m.OpenSetupGate("gapp", SetupGateByHousehold))
if c := LoadAppConfig(dir); c.SetupGate.Closed() || c.SetupGate.OpenedBy != SetupGateByHousehold {
t.Fatalf("record %+v", c.SetupGate)
}
if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) {
t.Fatal("file still there after the press")
}
if err := m.OpenSetupGate("gapp", SetupGateByHousehold); !errors.Is(err, ErrSetupGateNotClosed) {
t.Fatalf("second press: %v", err)
}
}
// A controller restart keeps the gate: the record is on disk, and the loop rewrites a missing file. A file
// whose app is not gated any more (removed app) is removed.
// COMPANION RED-PROOF: drop the writeSetupGate call in SetupGateTick → "the restart left the app open" fails.
func TestSetupGate_ARestartKeepsItAndStaleFilesGo(t *testing.T) {
m := gateManager(t, gatedYml)
closedGate(t, m)
must(t, os.Remove(m.setupGatePath("gapp"))) // e.g. lost with the traefik dir
must(t, os.WriteFile(filepath.Join(m.setupGateDir(), "setup-gate-gone.yml"), []byte("x"), 0o644)) // a removed app's
// "restart": a fresh manager reads the same disk
m2, err := NewManager(m.cfg, log.New(io.Discard, "", 0))
must(t, err)
must(t, m2.ScanStacks())
m2.mu.Lock()
m2.stacks["gapp"].State = StateStopped // not running: no probe, but the file must still come back
m2.mu.Unlock()
m2.SetupGateTick()
if _, err := os.Stat(m2.setupGatePath("gapp")); err != nil {
t.Fatal("the restart left the app open: the gate file was not rewritten from the record")
}
if _, err := os.Stat(filepath.Join(m2.setupGateDir(), "setup-gate-gone.yml")); !os.IsNotExist(err) {
t.Fatal("a gate file nobody owns was kept")
}
if _, closed, found := m2.SetupGateHost("GAPP.example.hu"); !found || !closed {
t.Fatalf("after the restart the host is found=%v closed=%v", found, closed)
}
}
// A restore keeps the gate as it was (never re-gates an app that is set up); no prior record = no gate.
// COMPANION RED-PROOF: drop `cfg.SetupGate = prior.SetupGate` from carryLifeRecords → the closed case fails.
func TestSetupGate_ARestoreKeepsTheRecord(t *testing.T) {
lg := log.New(io.Discard, "", 0)
for _, c := range []struct {
name string
prior *AppConfig
want string
}{
{"opened before", &AppConfig{SetupGate: &SetupGateRecord{State: SetupGateOpen}}, SetupGateOpen},
{"still closed", &AppConfig{SetupGate: &SetupGateRecord{State: SetupGateClosed}}, SetupGateClosed},
{"never gated / kept data / removed app", nil, ""},
} {
cfg := &AppConfig{}
carryLifeRecords(lg, "gapp", c.prior, cfg)
got := ""
if cfg.SetupGate != nil {
got = cfg.SetupGate.State
}
if got != c.want {
t.Errorf("%s: after the restore %q, want %q", c.name, got, c.want)
}
}
}
// The probe reads a dotted path and compares its text; anything unreadable is "not done".
func TestSetupGate_ProbeSaysDone(t *testing.T) {
for _, c := range []struct {
body, field, done string
want bool
}{
{`{"data":{"userManagement":{"showSetupOnFirstLoad":false}}}`, "data.userManagement.showSetupOnFirstLoad", "false", true},
{`{"data":{"userManagement":{"showSetupOnFirstLoad":true}}}`, "data.userManagement.showSetupOnFirstLoad", "false", false},
{`{"isInitialized":true}`, "isInitialized", "true", true},
{`{"isInitialized":false}`, "isInitialized", "true", false},
{`{}`, "isInitialized", "true", false},
{`not json`, "isInitialized", "true", false},
{`{"a":"x"}`, "a.b", "x", false},
} {
if got, _ := probeSaysDone([]byte(c.body), c.field, c.done); got != c.want {
t.Errorf("%s @ %s: %v, want %v", c.body, c.field, got, c.want)
}
}
}
// A router with no service label and a container with two services is refused (the gate would not know where
// to send the household).
func TestSetupGate_RoutersNeedAKnownService(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "c.yml")
must(t, os.WriteFile(p, []byte("services:\n a:\n labels:\n traefik.enable: \"true\"\n"+
" traefik.http.routers.a.rule: Host(`a.x.hu`)\n"+
" traefik.http.services.s1.loadbalancer.server.port: \"1\"\n"+
" traefik.http.services.s2.loadbalancer.server.port: \"2\"\n"), 0o644))
if _, err := gateRoutersFromCompose(p, nil); err == nil {
t.Fatal("an ambiguous service was accepted")
}
}
// "password:N:special" (v0.280.0) meets a policy that demands a special character (calibre-web): a lower, an
// upper, a digit, one of passwordSpecials, a letter or digit first, and nothing that breaks a compose value, a
// shell's double quotes or "user:password". COMPANION RED-PROOF: return randomAlphanumeric for the special form
// → "no special character" fails.
func TestGenerateValue_PasswordWithASpecialCharacter(t *testing.T) {
for i := 0; i < 300; i++ {
p, err := generateValue("password:24:special")
must(t, err)
if len(p) != 24 {
t.Fatalf("length %d", len(p))
}
if !strings.ContainsAny(p, passwordSpecials) {
t.Fatal("no special character")
}
if !strings.ContainsAny(p, "abcdefghijklmnopqrstuvwxyz") || !strings.ContainsAny(p, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") || !strings.ContainsAny(p, "0123456789") {
t.Fatal("a character class is missing")
}
if strings.ContainsAny(p, "'\"$\\: `") || strings.ContainsAny(p[:1], passwordSpecials) {
t.Fatal("an unsafe character, or a special first")
}
}
if p, err := generateValue("password:16"); err != nil || strings.ContainsAny(p, passwordSpecials) || len(p) != 16 {
t.Fatalf("the plain form changed: %q %v", p, err)
}
for _, bad := range []string{"password:24:weird", "password:4:special"} {
if _, err := generateValue(bad); err == nil {
t.Fatalf("%q accepted", bad)
}
}
}