4110da50e9
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
233 lines
9.2 KiB
Go
233 lines
9.2 KiB
Go
package stacks
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ── Sign-up closed once the first admin exists (v0.281.0, `09` §3 decision 47) ─────────────────────────
|
|
//
|
|
// Operator ruling 2026-09-29 (R-711, option A): after an app's first admin exists, a stranger can no longer make an
|
|
// account. Measured on 9202 the same day: opengist and wishlist keep "sign-up on/off" ONLY in their own admin
|
|
// settings (no env, no CLI), and vikunja reads it at start but then offers no way to add a user but its CLI. So the
|
|
// box does not reach into each app: when an app's setup gate OPENS, the box keeps a small traefik router in front of
|
|
// the app's own sign-up address only (`.felhom.yml` `signup_block:`, a traefik matcher), answered by the controller
|
|
// with "sign-up is closed on this app" (internal/web/setup_gate.go ServeSignupClosed). Everything else of the app is reached as
|
|
// without a gate. The household lets a family member join by opening sign-up for 15 minutes from the app page
|
|
// (OpenSignupWindow); the loop closes it again. Decided by CC unattended 2026-09-29 — the operator may reverse.
|
|
//
|
|
// signup_block: "PathPrefix(`/-/register`)" # opengist
|
|
//
|
|
// Only an app whose setup gate this box opened carries a block: an app installed before (no gate record) is never
|
|
// touched — the same rule as the gate itself (Part 0, 2026-09-29).
|
|
// Pinned by internal/stacks/signup_block_test.go.
|
|
|
|
// signupWindow is how long the household's "open sign-up" press lasts.
|
|
var signupWindow = 15 * time.Minute
|
|
|
|
// signupClosedPath is where the block sends a request (replacePath), answered by the controller.
|
|
const signupClosedPath = "/__felhom_gate/signup-closed"
|
|
|
|
func (m *Manager) signupBlockPath(name string) string {
|
|
return filepath.Join(m.setupGateDir(), "signup-block-"+name+".yml")
|
|
}
|
|
|
|
func renderSignupBlock(name string, hosts []string, fragment string) string {
|
|
hs := make([]string, 0, len(hosts))
|
|
for _, h := range hosts {
|
|
hs = append(hs, "Host(`"+h+"`)")
|
|
}
|
|
rule := "(" + strings.Join(hs, " || ") + ") && (" + fragment + ")"
|
|
r := "felhom-signup-block-" + name
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "# Sign-up block for %s — managed by felhom-controller (`09` §3 decision 47).\n", name)
|
|
b.WriteString("# The app's own sign-up address answers \"sign-up is closed\"; the household opens it for 15 minutes from the app page.\n")
|
|
b.WriteString("http:\n middlewares:\n")
|
|
fmt.Fprintf(&b, " %s:\n replacePath:\n path: %q\n", r, signupClosedPath)
|
|
b.WriteString(" routers:\n")
|
|
fmt.Fprintf(&b, " %s:\n rule: %q\n priority: %d\n", r, rule, 2*setupGatePriority+len(rule))
|
|
b.WriteString(" entryPoints:\n - websecure\n tls: {}\n")
|
|
fmt.Fprintf(&b, " middlewares:\n - %s@file\n service: %s\n", r, r)
|
|
fmt.Fprintf(&b, " services:\n %s:\n loadBalancer:\n servers:\n - url: \"http://felhom-controller:8080\"\n", r)
|
|
return b.String()
|
|
}
|
|
|
|
func (m *Manager) writeSignupBlock(name string, hosts []string, fragment string) error {
|
|
if len(hosts) == 0 || strings.TrimSpace(fragment) == "" {
|
|
return fmt.Errorf("signup block %s: no host or no rule", name)
|
|
}
|
|
if err := os.MkdirAll(m.setupGateDir(), 0o755); err != nil {
|
|
return err
|
|
}
|
|
want := renderSignupBlock(name, hosts, fragment)
|
|
p := m.signupBlockPath(name)
|
|
if cur, err := os.ReadFile(p); err == nil && string(cur) == want {
|
|
return nil
|
|
}
|
|
tmp := p + ".tmp"
|
|
if err := os.WriteFile(tmp, []byte(want), 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, p)
|
|
}
|
|
|
|
func (m *Manager) removeSignupBlockFile(name string) error {
|
|
err := os.Remove(m.signupBlockPath(name))
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// signupWindowOpen: the household's 15 minutes are running.
|
|
func (r *SetupGateRecord) signupWindowOpen(now time.Time) bool {
|
|
if r == nil || r.SignupOpenUntil == "" {
|
|
return false
|
|
}
|
|
t, err := time.Parse(time.RFC3339, r.SignupOpenUntil)
|
|
return err == nil && now.Before(t)
|
|
}
|
|
|
|
// SignupBlocked reports whether an app's sign-up is closed now, and until when a household's window runs ("" = none).
|
|
func (m *Manager) SignupBlocked(name string) (blocked bool, windowUntil string) {
|
|
st, ok := m.GetStack(name)
|
|
if !ok || !st.Deployed || st.AppConfig == nil || st.AppConfig.SetupGate == nil || strings.TrimSpace(st.Meta.SignupBlock) == "" {
|
|
return false, ""
|
|
}
|
|
g := st.AppConfig.SetupGate
|
|
if g.State != SetupGateOpen {
|
|
return false, ""
|
|
}
|
|
if g.signupWindowOpen(m.now()) {
|
|
return false, g.SignupOpenUntil
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
// ErrNoSignupBlock: the app has no sign-up block to open (never gated here, not open yet, or no signup_block).
|
|
var ErrNoSignupBlock = fmt.Errorf("the app has no closed sign-up")
|
|
|
|
// OpenSignupWindow is the household's "open sign-up for 15 minutes": the record, then the file goes. The loop puts
|
|
// the block back once the window has passed.
|
|
func (m *Manager) OpenSignupWindow(name string) (string, error) {
|
|
st, ok := m.GetStack(name)
|
|
if !ok || !st.Deployed || st.AppConfig == nil || st.AppConfig.SetupGate == nil ||
|
|
st.AppConfig.SetupGate.State != SetupGateOpen || strings.TrimSpace(st.Meta.SignupBlock) == "" {
|
|
return "", ErrNoSignupBlock
|
|
}
|
|
until := m.now().Add(signupWindow).UTC().Format(time.RFC3339)
|
|
done := false
|
|
m.mutateAppConfig(name, filepath.Dir(st.ComposePath), "signup_window", func(cfg *AppConfig) bool {
|
|
if cfg.SetupGate == nil || cfg.SetupGate.State != SetupGateOpen {
|
|
return false
|
|
}
|
|
cfg.SetupGate.SignupOpenUntil = until
|
|
done = true
|
|
return true
|
|
})
|
|
if !done {
|
|
return "", fmt.Errorf("signup window %s: the record could not be written", name)
|
|
}
|
|
if err := m.removeSignupBlockFile(name); err != nil {
|
|
return "", err
|
|
}
|
|
if st.Meta.AfterSetup != nil && len(st.Meta.AfterSetup.Env) > 0 { // v0.282.0: the app's own switch opens too (a restart)
|
|
m.goNativeLock(name, false, "the household's window")
|
|
}
|
|
m.logger.Printf("[INFO] [stacks] %s: the household opened sign-up until %s — the loop closes it again", name, until)
|
|
return until, nil
|
|
}
|
|
|
|
// reconcileSignupBlocks: every app whose sign-up should be closed has its block file; every other block file goes.
|
|
func (m *Manager) reconcileSignupBlocks() {
|
|
type want struct {
|
|
hosts []string
|
|
fragment string
|
|
}
|
|
wants := map[string]want{}
|
|
m.mu.RLock()
|
|
now := m.now()
|
|
for n, st := range m.stacks {
|
|
g := func() *SetupGateRecord {
|
|
if st.AppConfig == nil {
|
|
return nil
|
|
}
|
|
return st.AppConfig.SetupGate
|
|
}()
|
|
if !st.Deployed || g == nil || g.State != SetupGateOpen || strings.TrimSpace(st.Meta.SignupBlock) == "" || g.signupWindowOpen(now) {
|
|
continue
|
|
}
|
|
wants[n] = want{hosts: append([]string(nil), g.Hosts...), fragment: st.Meta.SignupBlock}
|
|
}
|
|
m.mu.RUnlock()
|
|
if ents, err := os.ReadDir(m.setupGateDir()); err == nil {
|
|
for _, e := range ents {
|
|
n := e.Name()
|
|
if !strings.HasPrefix(n, "signup-block-") || !strings.HasSuffix(n, ".yml") {
|
|
continue
|
|
}
|
|
app := strings.TrimSuffix(strings.TrimPrefix(n, "signup-block-"), ".yml")
|
|
if _, ok := wants[app]; !ok {
|
|
if err := m.removeSignupBlockFile(app); err == nil {
|
|
m.logger.Printf("[INFO] [stacks] %s: sign-up block removed (window open, app removed, or no block wanted)", app)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for n, w := range wants {
|
|
if err := m.writeSignupBlock(n, w.hosts, w.fragment); err != nil {
|
|
m.logger.Printf("[ERROR] [stacks] %s: the sign-up block could not be written: %v — sign-up is OPEN until it is", n, err)
|
|
}
|
|
// v0.282.0: the app's own switch back on (after the household's window), or a retry of a failed attempt —
|
|
// at most every nativeLockRetry, so a switch that cannot be set does not restart the app every tick.
|
|
if st, ok := m.GetStack(n); ok && st.Meta.AfterSetup != nil && st.AppConfig != nil && st.AppConfig.SetupGate != nil &&
|
|
st.AppConfig.SetupGate.NativeLock != NativeLockApplied {
|
|
last := st.AppConfig.AfterSetup
|
|
due := last == nil || last.OK
|
|
if !due {
|
|
if t, err := time.Parse(time.RFC3339, last.At); err != nil || m.now().Sub(t) > nativeLockRetry {
|
|
due = true
|
|
}
|
|
}
|
|
if due {
|
|
m.goNativeLock(n, true, "the loop (window ended or retry)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// nativeLockRetry: how often the loop retries an app's own switch that could not be set.
|
|
var nativeLockRetry = 30 * time.Minute
|
|
|
|
// SetupGateProbe asks the app's own "setup done" status once (the household's button asks it first, Part A of the
|
|
// 2026-09-29 afternoon brief). has=false: the template declares no probe.
|
|
func (m *Manager) SetupGateProbe(name string) (has bool, done bool, got string, err error) {
|
|
st, ok := m.GetStack(name)
|
|
if !ok {
|
|
return false, false, "", fmt.Errorf("stack %q not found", name)
|
|
}
|
|
p := st.Meta.SetupDoneProbe
|
|
if p == nil || p.URL == "" {
|
|
return false, false, "", nil
|
|
}
|
|
done, got, err = probeOnce(p)
|
|
return true, done, got, err
|
|
}
|
|
|
|
// SetSetupGateProbeGetForTest swaps the probe's HTTP read (a test seam for other packages); returns the restore.
|
|
// f's error means "unreadable"; a nil error is a 200 with that body.
|
|
func SetSetupGateProbeGetForTest(f func(url string) ([]byte, error)) func() {
|
|
old := setupGateProbeFetch
|
|
setupGateProbeFetch = func(url string) (int, []byte, error) {
|
|
b, err := f(url)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return 200, b, nil
|
|
}
|
|
return func() { setupGateProbeFetch = old }
|
|
}
|