package stacks import ( "encoding/base64" "fmt" "os" "path/filepath" "strings" "time" ) // ── after_install (v0.279.0, `09` §3 decision 45): no app is published with a login a stranger knows ── // // Some apps start with a known, shared admin password (claper seeds admin@claper.co / claper at every first // start — measured on 9202 2026-09-28, R-702). The box publishes every app on the household's domain. So // where the app's OWN CLI or API can change it, the template declares ONE command the box runs once, in the // app's own container, right after a FRESH install: // // after_install: // service: claper // env: [ADMIN_PASSWORD] # deploy values filled into the command; nothing else is // command: ["/app/bin/claper", "rpc", "... ${ADMIN_PASSWORD} ..."] // success: FELHOM_AFTER_INSTALL_OK # the command's output must carry this, or it failed // // The value is a generated `type: password` field, so the household sees it on the app page as the first // password (the grafana/code-server pattern). // // ONLY after a fresh install (the deploy-done hook, ok=true). NEVER after a restore or "use my kept data": // there the login comes back with the data, and changing it would lock the household out (R-694). A failed // command is retried while the app boots, then recorded (`after_install` in app.yaml) and shown on the app // page; the app stays installed and running — never half-installed. The expanded command is never logged // (it carries the password); the log names the template. // Pinned by internal/stacks/after_install_test.go. // AfterInstallCommand is `.felhom.yml`'s `after_install:`. type AfterInstallCommand struct { Service string `yaml:"service" json:"service"` User string `yaml:"user,omitempty" json:"user,omitempty"` Env []string `yaml:"env,omitempty" json:"env,omitempty"` Command []string `yaml:"command" json:"command"` Success string `yaml:"success" json:"success"` } // AfterInstallRecord is app.yaml's `after_install:` — what the one-time command did. type AfterInstallRecord struct { At string `yaml:"at" json:"at"` OK bool `yaml:"ok" json:"ok"` Detail string `yaml:"detail,omitempty" json:"detail,omitempty"` } // afterInstallTries / afterInstallGap: the app may still be booting when the deploy reports done. var ( afterInstallTries = 6 afterInstallGap = 20 * time.Second ) // expandAfterInstall fills ${NAME} for the declared env names only, from the app's env. An undeclared or // empty name refuses — a command with a hole must never run (it could set an EMPTY password). func expandAfterInstall(cmd []string, allowed []string, env map[string]string) ([]string, error) { ok := map[string]bool{} for _, n := range allowed { ok[n] = true } var missing, unsafe []string out := make([]string, len(cmd)) for i, a := range cmd { // R-713 (v0.281.0): where does the value land? Its OWN argument ("${X}") or a plain argument // ("--password=${X}", "admin:${X}") cannot be read as code — any value. Text with spaces, quotes or // brackets around it ('… "${X}" …' in an Elixir or Python string) can: there the raw value must not hold a // quote, a backslash, $, {, }, a backtick or a line break. `${X|base64}` is always safe (letters, digits, // +, /, =): the template decodes it in its own code (claper). codeShaped := !argumentShaped(a) out[i] = os.Expand(a, func(k string) string { name, enc, _ := strings.Cut(k, "|") if !ok[name] || env[name] == "" { missing = append(missing, name) return "" } v := env[name] switch enc { case "": case "base64": return base64.StdEncoding.EncodeToString([]byte(v)) default: missing = append(missing, k) return "" } if codeShaped && strings.ContainsAny(v, codeUnsafeChars) { unsafe = append(unsafe, name) } return v }) } if len(missing) > 0 { return nil, fmt.Errorf("after_install: %v not declared in env or has no value — not run", missing) } if len(unsafe) > 0 { return nil, fmt.Errorf("after_install: the value of %v would be read as code (it holds a quote, a backslash, $, {, }, a backtick or a line break) — not run; the template should pass it as its own argument or as ${NAME|base64}", unsafe) } return out, nil } // codeUnsafeChars can end a string or start an interpolation in the code a command carries. const codeUnsafeChars = "'\"\\$`{}\n\r\x00" // argumentShaped: with every ${…} removed, the element is empty or plain argument text (a flag, a name, a "user:" // prefix) — nothing that can open or close a string in code. func argumentShaped(a string) bool { rest := os.Expand(a, func(string) string { return "" }) for _, r := range rest { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': case strings.ContainsRune("-_.:=/@+,", r): default: return false } } return true } // RunAfterInstall runs the app's after_install once, after a FRESH install (main.go's deploy-done hook). // Returns (ran, error). Records the outcome in app.yaml either way. func (m *Manager) RunAfterInstall(name string, wait time.Duration) (bool, error) { st, ok := m.GetStack(name) if !ok { return false, fmt.Errorf("stack %q not found", name) } ai := st.Meta.AfterInstall if ai == nil || ai.Service == "" || len(ai.Command) == 0 || ai.Success == "" { return false, nil } dir := filepath.Dir(st.ComposePath) record := func(ok bool, detail string) { rec := &AfterInstallRecord{At: m.now().UTC().Format(time.RFC3339), OK: ok, Detail: truncateStr(detail, 300)} m.mutateAppConfig(name, dir, "after_install", func(cfg *AppConfig) bool { cfg.AfterInstall = rec; return true }) } cfg := LoadAppConfigDecrypted(dir, m.encKey) if cfg == nil { record(false, "the app's settings could not be read") return true, fmt.Errorf("after_install %s: app.yaml unreadable", name) } cmd, err := expandAfterInstall(ai.Command, ai.Env, cfg.Env) if err != nil { m.logger.Printf("[ERROR] [stacks] %s: %v", name, err) record(false, err.Error()) return true, err } deadline := time.Now().Add(wait) for { _ = m.RefreshStatus() if s, ok := m.GetStack(name); ok && (s.State == StateRunning || s.State == StateUnhealthy) { break } if time.Now().After(deadline) { record(false, "the app did not start in time") return true, fmt.Errorf("after_install %s: the app did not start within %s — not run", name, wait) } time.Sleep(5 * time.Second) } return true, m.runAfterInstallNow(name, ai, cmd, record) } // runAfterInstallNow runs the expanded command (RunAfterInstall has waited for the app): retries while the // output lacks the success marker, then records the outcome. func (m *Manager) runAfterInstallNow(name string, ai *AfterInstallCommand, cmd []string, record func(ok bool, detail string)) error { dir := "" if st, ok := m.GetStack(name); ok { dir = filepath.Dir(st.ComposePath) } args := []string{"exec", "-T"} if ai.User != "" { args = append(args, "-u", ai.User) } args = append(args, ai.Service) args = append(args, cmd...) var last string for try := 1; try <= afterInstallTries; try++ { t0 := time.Now() out, err := m.afterLoadExec(dir, args...) if err == nil && strings.Contains(out, ai.Success) { m.logger.Printf("[INFO] [stacks] after_install %s: %s %v done in %s (try %d) — the app's known first login is replaced by its generated one", name, ai.Service, ai.Command, time.Since(t0).Round(time.Millisecond), try) record(true, "") return nil } last = fmt.Sprintf("try %d: err=%v, no %q in the output", try, err, ai.Success) m.logger.Printf("[WARN] [stacks] after_install %s: %s %v %s", name, ai.Service, ai.Command, last) if try < afterInstallTries { time.Sleep(afterInstallGap) } } m.logger.Printf("[ERROR] [stacks] after_install %s FAILED after %d tries — the app runs with its KNOWN default login; the app page says so", name, afterInstallTries) record(false, last) return fmt.Errorf("after_install %s failed: %s", name, last) }