diff --git a/CHANGELOG.md b/CHANGELOG.md index abd759f..9040e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +## v0.281.0 — "Done" asks the app first; open sign-up closed once the first admin exists; a password is never read as code (2026-09-29) + +**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: `signup_closed.page_title`, +`signup_closed.page_body`, `app_info.setup_gate_confirm`, `app_info.signup_title`, `app_info.signup_closed`, +`app_info.signup_add_how`, `app_info.signup_open_until`, `app_info.signup_window_btn`, `err.setup_gate.probe_not_done`, +`err.setup_gate.no_signup_block` (hu + en). Evidence: `felhom.eu/documentation/audits/gate-rollout-2026-09-29/`. + +- **„Kész, beállítottam" asks the app first.** For an app with a `setup_done_probe:` the press reads it and REFUSES + while it says "not done", and when it cannot be read (409, "The app says its first setup is not done yet. Create your + account, then try again."). Measured 2026-09-29: uptime-kuma pressed before its setup answered anyone. A probe app + now also shows the press. An app without a probe asks in the page first (`felhomConfirm`: "Press this only after + you have created your own account. After this, anyone can reach the app's address."). +- **Sign-up closed once the first admin exists (`09` §3 decision 47).** `.felhom.yml` `signup_block:` (a traefik + matcher for the app's own sign-up address). When the setup gate opens, the box writes + `/traefik/dynamic/signup-block-.yml` FIRST (a failed write keeps the gate closed), then removes the + gate: that address alone answers "sign-up is closed" (`/__felhom_gate/signup-closed`, a page or 403 JSON). The app + page's new sign-up card says how to add a family member (`app_info.add_people`, per app, hu + en) and has + „Regisztráció megnyitása 15 percre" (`POST /apps//signup-window`); the loop closes it again. Only an app + whose gate this box opened gets a block — an installed app is never touched. +- **R-713:** `after_install` refuses a value that would land inside code (text with spaces, quotes or brackets around + the placeholder) when it holds a quote, a backslash, `$`, `{`, `}`, a backtick or a line break. Its own argument and + plain arguments (`--password=${X}`, `admin:${X}`) take any value. New `${NAME|base64}`. +- Tests: `TestSetupGateButton_*`, `TestSignupBlock_*`, `TestSignupClosed_*`, `TestSetupGatePage_ConfirmAndSignupCard`, + `TestR713_*`. Red-proofs RP17–RP24, each seen failing on an assertion. + ## v0.280.0 — the setup gate: a new app is closed to strangers until its household set it up; "I changed it"; an installed app's password leaves the page HTML; a generator with a special character (2026-09-29) **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: `setup_gate.page_title`, diff --git a/REUSE.md b/REUSE.md index 3a13c6a..0b6492e 100644 --- a/REUSE.md +++ b/REUSE.md @@ -27,6 +27,7 @@ | `stacks.OldAppDataPaths` / `Manager.ListKept` / `KeepAside` / `DeleteKept` / `FindKept` | controller/internal/stacks/kept.go | `(composePath, hdd)` / `(drives)` / … | Kept data (`09` §3 decision 36): what counts as an app's old data (ONLY `/appdata/…` binds), the list, start-fresh, the household's delete | **An action names a kept item by path only through `FindKept`** — `DeleteKept` refuses anything not listed. `KeepAside` is a rename on one drive; never copy, never `RemoveAll` in a rollback (`removeEmptyDirs`) | | `stacks.RunAfterInstall` / `expandAfterInstall` / `web.defaultLoginInEffect` (v0.279.0, decision 45) | controller/internal/stacks/after_install.go · controller/internal/web/known_login.go | `(name, wait)` / `(cmd, allowed, env)` / `(meta, cfg, installed)` | A fresh install replaces a known default login; the page says when a default is still in effect | **Only from the deploy-done hook** — never after a restore/kept load (R-694). A `success:` marker is required (exit 0 lies). Never log the expanded command | | `stacks.OpenSetupGate` / `SetupGateTick` / `SetupGateHost` · `web.ServeGateAuth` / `ServeGateStart` (v0.280.0, decision 46) | controller/internal/stacks/setup_gate.go · controller/internal/web/setup_gate.go | `(name, by)` / `()` / `(host)` · handlers | The setup gate: a `setup_gate: true` install is closed to everyone but the household until its probe or the household's press opens it | **Write the gate BEFORE the first start** (spike F2). Open = record first, then remove the file. Never widen the dashboard cookie — the handshake mints a host-bound one-use token | +| `stacks.OpenSignupWindow` / `SignupBlocked` / `SetupGateProbe` · `web.ServeSignupClosed` (v0.281.0, decision 47) | controller/internal/stacks/signup_block.go · controller/internal/web/setup_gate.go | `(name)` | Sign-up closed at the app's own address once the gate opens; the household's 15-minute window; the press asks the probe | **The block goes up BEFORE the gate comes down** (a failed write keeps the gate closed). Never on an app this box did not gate | | `backup.judgeCopy` / `HollowCopies` / `SetHollowCopyNotify` (Part D, v0.279.0) | controller/internal/backup/hollow_watch.go | `(app, tier, unitDir)` | A RUNNING app whose newest copy holds no data → operator digest once/day + page sentence | Uses `unitCarriesData` (the manifest, never size); a stopped held app is never flagged | | `web.nightChain` (R-705, v0.279.0) | controller/internal/web/night_chain.go | `POST /api/debug/backup/night-chain` | The night's four legs now, in order | Refuses while any op/update/chain runs; the leg uses `RunUpdateLegNow` | | `Router.dropLeftoverHold` + `settings.ClearUpdateHold` (R-704, v0.278.0) | controller/internal/api/router.go · controller/internal/settings/settings.go | `(name, why)` / `(stack) (bool, error)` | A new install (plain or "use my kept data") and a removal clear the update / crash-loop hold of the app's install | **A hold belongs to an INSTALL; the name is all the next install shares with it.** Never clears an R-379 restore hold (operator-only) | diff --git a/controller/README.md b/controller/README.md index 4511c36..cbe3766 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1933,6 +1933,15 @@ that folder is never a dead end, and an install never runs into it silently (R-6 owns, and opens a gate whose probe says done; the household's „Kész, beállítottam" (`POST /apps//setup-gate/open`) opens one without a probe. Opening writes the record, then removes the file. A restore keeps the record; a kept-data load never gates. Code: `internal/stacks/setup_gate.go`, `internal/web/setup_gate.go`. + **v0.281.0:** the household's press asks the probe first and refuses while it says "not done" or cannot be read; an + app without a probe confirms in the page first. +- **Sign-up closed after the first admin (v0.281.0, decision 47)** — `.felhom.yml` `signup_block:` (traefik matcher). + When the gate opens, `signup-block-.yml` goes up first (replacePath → `/__felhom_gate/signup-closed`, the + controller's refusal page / 403 JSON), then the gate comes down. The app page's sign-up card shows + `app_info.add_people` and „Regisztráció megnyitása 15 percre" (`POST /apps//signup-window`, 15 min; the loop + restores the block). Never on an app this box did not gate. Code: `internal/stacks/signup_block.go`. +- **R-713 (v0.281.0).** `after_install` refuses a code-bound value holding a quote, backslash, `$`, `{`, `}`, backtick + or line break; `${NAME|base64}` passes any value safely. - **R-709 (v0.280.0).** An installed app's `type: password` value is not in its settings page; the eye fetches it. - **`generate: password:N:special` (v0.280.0)** — a lower, an upper, a digit and one of `-_.!@#%+=` (calibre-web's policy). - **R-704 (v0.278.0).** A new install (plain or "use my kept data") drops an update or crash-loop hold left by an EARLIER diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 3cb2de4..2e6be75 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2479,5 +2479,15 @@ "app_info.default_login_changed_btn": "I changed it", "err.stacks.setup_gate_failed": "The app's protection could not be prepared, so it was not installed: %s", "err.setup_gate.not_closed": "This app is already open.", - "err.setup_gate.open_failed": "It could not be saved. Try again." + "err.setup_gate.open_failed": "It could not be saved. Try again.", + "signup_closed.page_title": "Sign-up closed", + "signup_closed.page_body": "You cannot sign up on this app. The app's admin creates new accounts.", + "app_info.setup_gate_confirm": "Press this only after you have created your own account. After this, anyone can reach the app's address.", + "app_info.signup_title": "Sign-up", + "app_info.signup_closed": "Since the first admin account, sign-up is closed: a stranger cannot make an account.", + "app_info.signup_open_until": "Sign-up is open now, until:", + "app_info.signup_window_btn": "Open sign-up for 15 minutes", + "err.setup_gate.probe_not_done": "The app says its first setup is not done yet. Create your account, then try again.", + "err.setup_gate.no_signup_block": "Sign-up is not closed on this app.", + "app_info.signup_add_how": "To add a family member:" } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index d735e09..9ec0bf3 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2467,5 +2467,15 @@ "app_info.default_login_changed_btn": "Megváltoztattam", "err.stacks.setup_gate_failed": "Az alkalmazás védelmét nem sikerült előkészíteni, ezért nem telepítettük: %s", "err.setup_gate.not_closed": "Ez az alkalmazás már nyitva van.", - "err.setup_gate.open_failed": "Nem sikerült elmenteni. Próbáld újra." + "err.setup_gate.open_failed": "Nem sikerült elmenteni. Próbáld újra.", + "signup_closed.page_title": "A regisztráció zárva", + "signup_closed.page_body": "Ezen az alkalmazáson nem lehet regisztrálni. Új fiókot az alkalmazás adminja hoz létre.", + "app_info.setup_gate_confirm": "Csak akkor nyomd meg, ha már létrehoztad a saját fiókodat. Utána bárki elérheti az alkalmazás címét.", + "app_info.signup_title": "Regisztráció", + "app_info.signup_closed": "Az első admin fiók után a regisztráció zárva: idegen nem hozhat létre fiókot.", + "app_info.signup_open_until": "A regisztráció most nyitva, eddig:", + "app_info.signup_window_btn": "Regisztráció megnyitása 15 percre", + "err.setup_gate.probe_not_done": "Az alkalmazás szerint még nincs kész az első beállítás. Hozd létre a fiókodat, aztán próbáld újra.", + "err.setup_gate.no_signup_block": "Ennél az alkalmazásnál a regisztráció nincs lezárva.", + "app_info.signup_add_how": "Családtagot így adhatsz hozzá:" } diff --git a/controller/internal/stacks/after_install.go b/controller/internal/stacks/after_install.go index ad073bc..ebbf936 100644 --- a/controller/internal/stacks/after_install.go +++ b/controller/internal/stacks/after_install.go @@ -1,6 +1,7 @@ package stacks import ( + "encoding/base64" "fmt" "os" "path/filepath" @@ -60,23 +61,63 @@ func expandAfterInstall(cmd []string, allowed []string, env map[string]string) ( for _, n := range allowed { ok[n] = true } - var missing []string + 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 { - if !ok[k] || env[k] == "" { + 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 "" } - return env[k] + 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) { diff --git a/controller/internal/stacks/after_install_test.go b/controller/internal/stacks/after_install_test.go index c5b5a87..83bb66a 100644 --- a/controller/internal/stacks/after_install_test.go +++ b/controller/internal/stacks/after_install_test.go @@ -37,7 +37,10 @@ func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) { // 1. Success: the value is filled in, the command runs ONCE, the record says ok, the log never carries it. var calls [][]string - m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "... FELHOM_OK", nil } + m.afterLoadFn = func(_ string, args ...string) (string, error) { + calls = append(calls, args) + return "... FELHOM_OK", nil + } cmd, err := expandAfterInstall(ai.Command, ai.Env, map[string]string{"ADMIN_PASSWORD": "Gen3r4tedValue"}) must(t, err) if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err != nil { @@ -55,7 +58,10 @@ func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) { // 2. No success marker: retried, then recorded as FAILED — never recorded ok. calls = nil - m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "boom", errors.New("exit 1") } + m.afterLoadFn = func(_ string, args ...string) (string, error) { + calls = append(calls, args) + return "boom", errors.New("exit 1") + } if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err == nil { t.Fatal("a failing command reported success") } @@ -89,3 +95,34 @@ func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) { t.Fatal("an undeclared name was filled in") } } + +// R-713 (v0.281.0): a value pasted into CODE must not be able to end a string or start an interpolation; the +// same value as its own argument, a plain argument, or ${NAME|base64} is fine. +// COMPANION RED-PROOF: make argumentShaped return true always → the claper-shaped case runs with the quote. +func TestR713_AValueThatWouldBeReadAsCodeIsRefused(t *testing.T) { + typed := map[string]string{"ADMIN_PASSWORD": `My"pass#{System.halt()}`} + allowed := []string{"ADMIN_PASSWORD"} + claperShaped := []string{"/app/bin/claper", "rpc", `Claper.x(u, %{password: "${ADMIN_PASSWORD}"})`} + if _, err := expandAfterInstall(claperShaped, allowed, typed); err == nil || !strings.Contains(err.Error(), "read as code") { + t.Fatalf("a quote and #{ went into Elixir code: %v", err) + } + for _, cmd := range [][]string{ + {"python3", "-c", "import sys; f(sys.argv[1])", "${ADMIN_PASSWORD}"}, // its own argument (mealie, wger) + {"php", "artisan", "x", "--password=${ADMIN_PASSWORD}"}, // a plain argument (bookstack) + {"python3", "cps.py", "-s", "admin:${ADMIN_PASSWORD}"}, // calibre-web + {"/app/bin/claper", "rpc", `x(Base.decode64!("${ADMIN_PASSWORD|base64}"))`}, // claper, fixed + } { + out, err := expandAfterInstall(cmd, allowed, typed) + if err != nil { + t.Fatalf("%v: refused a safe placement: %v", cmd, err) + } + if strings.Contains(cmd[len(cmd)-1], "|base64") { + if strings.ContainsAny(out[len(out)-1][len(`x(Base.decode64!("`):], `'\{}$`+"`") || strings.Contains(out[len(out)-1], `My"pass`) { + t.Fatalf("base64 form leaked the raw value: %q", out[len(out)-1]) + } + } + } + if _, err := expandAfterInstall([]string{"x", "${ADMIN_PASSWORD|rot13}"}, allowed, typed); err == nil { + t.Fatal("an unknown encoding was accepted") + } +} diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index c55ff0a..2732679 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -60,8 +60,11 @@ type Metadata struct { // 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"` + SetupDoneProbe *SetupDoneProbe `yaml:"setup_done_probe,omitempty" json:"setup_done_probe,omitempty"` + // SignupBlock (v0.281.0, `09` §3 decision 47): a traefik matcher for the app's own sign-up address, closed once the + // setup gate opens. See signup_block.go. + SignupBlock string `yaml:"signup_block,omitempty" json:"signup_block,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. @@ -146,6 +149,8 @@ type AppInfo struct { Prerequisites []string `yaml:"prerequisites" json:"prerequisites"` DefaultCreds string `yaml:"default_creds" json:"default_creds"` DocsURL string `yaml:"docs_url" json:"docs_url"` + // AddPeople (v0.281.0, decision 47): how the household adds a family member once sign-up is closed. + AddPeople string `yaml:"add_people,omitempty" json:"add_people,omitempty"` } // OptionalConfigGroup defines a group of optional config fields (e.g., "Metadata providers"). diff --git a/controller/internal/stacks/metadata_i18n.go b/controller/internal/stacks/metadata_i18n.go index fd32491..6af7367 100644 --- a/controller/internal/stacks/metadata_i18n.go +++ b/controller/internal/stacks/metadata_i18n.go @@ -69,6 +69,7 @@ type AppInfoOverlay struct { FirstSteps []string `yaml:"first_steps,omitempty"` Prerequisites []string `yaml:"prerequisites,omitempty"` DefaultCreds *string `yaml:"default_creds,omitempty"` + AddPeople *string `yaml:"add_people,omitempty"` } // DeployFieldOverlay translates one deploy field, found by EnvVar. @@ -178,6 +179,7 @@ func (m Metadata) For(lang string) Metadata { if ov.AppInfo != nil { out.AppInfo.Tagline = overlayStr(ov.AppInfo.Tagline, out.AppInfo.Tagline) out.AppInfo.DefaultCreds = overlayStr(ov.AppInfo.DefaultCreds, out.AppInfo.DefaultCreds) + out.AppInfo.AddPeople = overlayStr(ov.AppInfo.AddPeople, out.AppInfo.AddPeople) out.AppInfo.UseCases = overlayList(ov.AppInfo.UseCases, out.AppInfo.UseCases) out.AppInfo.FirstSteps = overlayList(ov.AppInfo.FirstSteps, out.AppInfo.FirstSteps) out.AppInfo.Prerequisites = overlayList(ov.AppInfo.Prerequisites, out.AppInfo.Prerequisites) diff --git a/controller/internal/stacks/setup_gate.go b/controller/internal/stacks/setup_gate.go index 16c4ce2..65546aa 100644 --- a/controller/internal/stacks/setup_gate.go +++ b/controller/internal/stacks/setup_gate.go @@ -65,6 +65,8 @@ type SetupGateRecord struct { 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"` } // Closed reports whether the gate stands. @@ -296,6 +298,14 @@ func (m *Manager) OpenSetupGate(name, by string) error { } 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() { @@ -306,6 +316,9 @@ func (m *Manager) OpenSetupGate(name, by string) error { 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 { @@ -435,6 +448,7 @@ func (m *Manager) SetupGateTick() { } } } + m.reconcileSignupBlocks() } // RunSetupGateLoop runs SetupGateTick every interval until ctx ends. diff --git a/controller/internal/stacks/signup_block.go b/controller/internal/stacks/signup_block.go new file mode 100644 index 0000000..feedc24 --- /dev/null +++ b/controller/internal/stacks/signup_block.go @@ -0,0 +1,208 @@ +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 + } + 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) + } + } +} + +// 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 + } + body, err := setupGateProbeGet(p.URL) + if err != nil { + return true, false, "", err + } + done, got = probeSaysDone(body, p.Field, p.Done) + return true, done, got, nil +} + +// SetSetupGateProbeGetForTest swaps the probe's HTTP read (a test seam for other packages); returns the restore. +func SetSetupGateProbeGetForTest(f func(url string) ([]byte, error)) func() { + old := setupGateProbeGet + setupGateProbeGet = f + return func() { setupGateProbeGet = old } +} diff --git a/controller/internal/stacks/signup_block_test.go b/controller/internal/stacks/signup_block_test.go new file mode 100644 index 0000000..1339bba --- /dev/null +++ b/controller/internal/stacks/signup_block_test.go @@ -0,0 +1,118 @@ +package stacks + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// v0.281.0 (`09` §3 decision 47) — sign-up closed once the first admin exists, and the household's 15 minutes. + +const signupYml = "display_name: Gated App\nsetup_gate: true\nsignup_block: \"PathPrefix(`/signup`)\"\n" + + "deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n" + +// Opening the gate puts the sign-up block up FIRST; then the gate comes down. +// COMPANION RED-PROOF: drop the writeSignupBlock call in OpenSetupGate → "no sign-up block after the gate opened" +// fails (between the open and the next tick a stranger could sign up). +func TestSignupBlock_TheGateOpensOnlyWithTheBlockUp(t *testing.T) { + m := gateManager(t, signupYml) + closedGate(t, m) + must(t, m.OpenSetupGate("gapp", SetupGateByHousehold)) + b, err := os.ReadFile(m.signupBlockPath("gapp")) + if err != nil { + t.Fatal("no sign-up block after the gate opened") + } + for _, want := range []string{"Host(`gapp.example.hu`)", "PathPrefix(`/signup`)", `path: "/__felhom_gate/signup-closed"`, "http://felhom-controller:8080"} { + if !strings.Contains(string(b), want) { + t.Errorf("block lacks %q:\n%s", want, b) + } + } + if blocked, _ := m.SignupBlocked("gapp"); !blocked { + t.Fatal("SignupBlocked says open") + } + // Still gated → no block (the household may need the sign-up address for the first admin). + m2 := gateManager(t, signupYml) + closedGate(t, m2) + m2.SetupGateTick() + if _, err := os.Stat(m2.signupBlockPath("gapp")); !os.IsNotExist(err) { + t.Fatal("a block stands while the gate is still closed — the household could not sign up as the first admin") + } +} + +// A block that cannot be written keeps the gate CLOSED. +// COMPANION RED-PROOF: ignore writeSignupBlock's error in OpenSetupGate → the gate opens with sign-up wide open. +func TestSignupBlock_UnwritableBlockKeepsTheGateClosed(t *testing.T) { + m := gateManager(t, signupYml) + dir := closedGate(t, m) + must(t, os.MkdirAll(m.signupBlockPath("gapp"), 0o755)) // a DIRECTORY where the file must go: rename fails + must(t, os.WriteFile(filepath.Join(m.signupBlockPath("gapp"), "x"), []byte("x"), 0o644)) + if err := m.OpenSetupGate("gapp", SetupGateByHousehold); err == nil { + t.Fatal("the gate opened although the sign-up block could not be written") + } + if c := LoadAppConfig(dir); !c.SetupGate.Closed() { + t.Fatal("record says open") + } + if _, err := os.Stat(m.setupGatePath("gapp")); err != nil { + t.Fatal("the gate file was removed") + } +} + +// The household's window lifts the block for 15 minutes; the loop puts it back after. +// COMPANION RED-PROOF: make signupWindowOpen always true → "the block did not come back" fails. +func TestSignupBlock_TheWindowOpensAndCloses(t *testing.T) { + m := gateManager(t, signupYml) + closedGate(t, m) + must(t, m.OpenSetupGate("gapp", SetupGateByProbe)) + until, err := m.OpenSignupWindow("gapp") + must(t, err) + if until == "" { + t.Fatal("no end time") + } + if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) { + t.Fatal("the window opened but the block is still up") + } + m.SetupGateTick() + if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) { + t.Fatal("a tick inside the window put the block back") + } + if blocked, u := m.SignupBlocked("gapp"); blocked || u != until { + t.Fatalf("inside the window: blocked=%v until=%q", blocked, u) + } + later := time.Now().Add(signupWindow + time.Minute) + m.updateNowFn = func() time.Time { return later } + m.SetupGateTick() + if _, err := os.Stat(m.signupBlockPath("gapp")); err != nil { + t.Fatal("the window passed but the block did not come back") + } +} + +// An app with no gate record (installed before, or on a box that never gated it) never gets a block, even when its +// template has signup_block — Part 0's rule. A removed app's block goes. +// COMPANION RED-PROOF: drop the `g == nil ||` / State check in reconcileSignupBlocks → a block appears on an +// app nobody gated. +func TestSignupBlock_NeverOnAnAppThisBoxDidNotGate(t *testing.T) { + m := gateManager(t, signupYml) + dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp") + cfg := &AppConfig{Deployed: true, Env: map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}} + 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() + must(t, os.MkdirAll(m.setupGateDir(), 0o755)) + must(t, os.WriteFile(filepath.Join(m.setupGateDir(), "signup-block-gone.yml"), []byte("x"), 0o644)) + m.SetupGateTick() + if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) { + t.Fatal("a sign-up block appeared on an app this box never gated") + } + if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) { + t.Fatal("a catalog setup_gate closed an app that was already installed") + } + if _, err := os.Stat(filepath.Join(m.setupGateDir(), "signup-block-gone.yml")); !os.IsNotExist(err) { + t.Fatal("a block nobody owns was kept") + } + if _, err := m.OpenSignupWindow("gapp"); err != ErrNoSignupBlock { + t.Fatalf("window on an ungated app: %v", err) + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 2056a36..ca81785 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -774,6 +774,16 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s // v0.280.0 (decision 46): the setup gate's card, while the gate stands. data["SetupGateClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.SetupGate.Closed() data["SetupGateHasProbe"] = found.Meta.SetupDoneProbe != nil && found.Meta.SetupDoneProbe.URL != "" + // v0.281.0 (decision 47): the sign-up card — closed, or open for the household's 15 minutes. + if s.stackMgr != nil { + blocked, until := s.stackMgr.SignupBlocked(found.Name) + data["SignupClosed"] = blocked + if until != "" { + if t, err := time.Parse(time.RFC3339, until); err == nil { + data["SignupOpenUntil"] = t.In(getTimezone()).Format("15:04") + } + } + } data["HasAppInfo"] = found.Meta.HasAppInfo() data["EffectiveSubdomain"] = effectiveSubdomain diff --git a/controller/internal/web/i18n_cases_c_test.go b/controller/internal/web/i18n_cases_c_test.go index 1f29521..06731a0 100644 --- a/controller/internal/web/i18n_cases_c_test.go +++ b/controller/internal/web/i18n_cases_c_test.go @@ -152,6 +152,9 @@ func i18nCasesC() []i18nCase { return m{"AppName": "Private Bin", "AppSlug": "privatebin", "ControllerURL": "https://felhom.example.hu", "Status": "stopped", "StatusText": "Az alkalmazás jelenleg le van állítva", "Host": "paste.example.hu"} }}, + {"signupclosed", "signupclosed", func() map[string]interface{} { + return m{"AppName": "Opengist", "Host": "gist.example.hu"} + }}, {"setupgate", "setupgate", func() map[string]interface{} { return m{"AppName": "Immich", "Host": "photos.example.hu", "LoginURL": "/login?next=%2F__gate%2Fstart%3Frd%3Dhttps%253A%252F%252Fphotos.example.hu%252F"} }}, diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 36f9a33..412ee1c 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -329,6 +329,23 @@ func i18nCases() []i18nCase { }} } base = append(base, gateCase("app_info_setup_gate_button", false), gateCase("app_info_setup_gate_probe", true)) + // v0.281.0 (decision 47): the sign-up card, closed (with the app's how-to) and open for the household's window. + signupCase := func(name string, closed bool, until string) i18nCase { + return i18nCase{name, "app_info", func() map[string]interface{} { + d := i18nLayoutData("stacks", "Opengist") + st := stacks.Stack{Name: "opengist", Deployed: true, State: stacks.StateRunning} + st.Meta = stacks.Metadata{DisplayName: "Opengist", Slug: "opengist", AppInfo: stacks.AppInfo{AddPeople: "Admin panel → Users."}} + d["Stack"] = st + d["Meta"] = st.Meta + d["AppInfo"] = st.Meta.AppInfo + d["SignupClosed"] = closed + if until != "" { + d["SignupOpenUntil"] = until + } + return d + }} + } + base = append(base, signupCase("app_info_signup_closed", true, ""), signupCase("app_info_signup_window", false, "14:05")) base = append(base, i18nCase{"app_info_known_login_changed", "app_info", func() map[string]interface{} { d := i18nLayoutData("stacks", "Calibre-Web") st := stacks.Stack{Name: "calibre-web", Deployed: true, State: stacks.StateRunning} @@ -413,7 +430,8 @@ var i18nSessionTemplates = map[string]bool{"recovery": true} var i18nDirectTemplates = map[string]bool{ "login": true, "claim": true, "recovery": true, "launcher_shared": true, "launcher_share_password": true, "catchall": true, - "setupgate": true, // v0.280.0 (decision 46): the gate page a stranger meets + "setupgate": true, // v0.280.0 (decision 46): the gate page a stranger meets + "signupclosed": true, // v0.281.0 (decision 47): an app's sign-up address once closed } func i18nTestServer(t *testing.T) *Server { diff --git a/controller/internal/web/i18n_wiring_test.go b/controller/internal/web/i18n_wiring_test.go index c0bd5de..caaa564 100644 --- a/controller/internal/web/i18n_wiring_test.go +++ b/controller/internal/web/i18n_wiring_test.go @@ -314,6 +314,7 @@ var i18nDirectPages = []struct{ tmpl, caseName, enProbe string }{ {"launcher_share_password", "launcher_share_password", "This page is protected by a password."}, {"catchall", "catchall_app", "Manage app"}, {"setupgate", "setupgate", "waiting for its first setup"}, + {"signupclosed", "signupclosed", "You cannot sign up on this app"}, } // TestI18nDirectRenderPagesFollowLanguage — with the household language saved as English the page is diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index a605a3f..b90b3fc 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -818,6 +818,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // it" under a known default login (R-710). POST, so CsrfProtect covers them. case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/setup-gate/open") && r.Method == http.MethodPost: s.appSetupGateOpenHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/setup-gate/open")) + case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/signup-window") && r.Method == http.MethodPost: + s.appSignupWindowHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/signup-window")) case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/default-login/changed") && r.Method == http.MethodPost: s.appDefaultLoginChangedHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/default-login/changed")) case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/initial-credentials/reveal") && r.Method == http.MethodPost: @@ -854,6 +856,10 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler { s.ServeGateAuth(w, r) return } + if r.URL.Path == signupClosedPath { // v0.281.0 (decision 47): an app's own sign-up address while closed + s.ServeSignupClosed(w, r) + return + } if r.URL.Path == gateStartPath && strings.EqualFold(host, controllerHost) && r.Method == http.MethodGet { s.ServeGateStart(w, r) return diff --git a/controller/internal/web/setup_gate.go b/controller/internal/web/setup_gate.go index 97767cc..61e8766 100644 --- a/controller/internal/web/setup_gate.go +++ b/controller/internal/web/setup_gate.go @@ -48,6 +48,8 @@ const ( gateAuthPath = "/__felhom_gate/auth" gateCallbackURI = "/__felhom_gate/cb" gateStartPath = "/__gate/start" + // signupClosedPath: the sign-up block's replacePath target (internal/stacks/signup_block.go, decision 47). + signupClosedPath = "/__felhom_gate/signup-closed" ) type gateState struct { @@ -285,6 +287,14 @@ func (s *Server) appSetupGateOpenHandler(w http.ResponseWriter, r *http.Request, escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app")) return } + // v0.281.0: an app that can say whether its setup is done is ASKED first — the press never opens an app that + // still says "not done" (measured 2026-09-29: uptime-kuma pressed before its setup answered anyone). An + // unreadable status refuses too (fail closed). Without a probe the page's confirm says what the press does. + if has, done, got, perr := s.stackMgr.SetupGateProbe(found.Name); has && (perr != nil || !done) { + s.logger.Printf("[INFO] [web] setup gate %s: the household's press refused — the app's own status says not done (value %q, err %v)", found.Name, got, perr) + escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.probe_not_done")) + return + } if err := s.stackMgr.OpenSetupGate(found.Name, stacks.SetupGateByHousehold); err != nil { if errors.Is(err, stacks.ErrSetupGateNotClosed) { escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.not_closed")) @@ -326,3 +336,52 @@ func (s *Server) stackBySlug(slug string) *stacks.Stack { } return nil } + +// appSignupWindowHandler is the household's "open sign-up for 15 minutes" (POST /apps//signup-window, decision 47). +func (s *Server) appSignupWindowHandler(w http.ResponseWriter, r *http.Request, slug string) { + found := s.stackBySlug(slug) + if found == nil { + escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app")) + return + } + until, err := s.stackMgr.OpenSignupWindow(found.Name) + if err != nil { + if errors.Is(err, stacks.ErrNoSignupBlock) { + escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.no_signup_block")) + return + } + s.logger.Printf("[ERROR] [web] signup window %s: %v", found.Name, err) + escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "err.setup_gate.open_failed")) + return + } + escrowJSON(w, http.StatusOK, map[string]any{"open_until": until}, "") +} + +// ServeSignupClosed answers the app's own sign-up address while it is closed (the sign-up block's replacePath +// sends it here). A browser gets a page, anything else a 403 JSON. It holds no secret and changes nothing. +func (s *Server) ServeSignupClosed(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !strings.Contains(r.Header.Get("Accept"), "text/html") || r.Method != http.MethodGet { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"sign-up is closed on this app; its admin adds new accounts"}`)) + return + } + host := strings.ToLower(r.Host) + if i := strings.LastIndex(host, ":"); i != -1 { + host = host[:i] + } + data := map[string]interface{}{"Host": host} + if s.stackMgr != nil { + if app, _, found := s.stackMgr.SetupGateHost(host); found { + if st, ok := s.stackMgr.GetStack(app); ok { + data["AppName"] = st.Meta.DisplayName + } + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + if err := s.executeTemplateLang(w, r, "signupclosed", data); err != nil { + s.logger.Printf("[ERROR] [web] signup-closed page: %v", err) + } +} diff --git a/controller/internal/web/setup_gate_page_test.go b/controller/internal/web/setup_gate_page_test.go index b8d384b..aab9be1 100644 --- a/controller/internal/web/setup_gate_page_test.go +++ b/controller/internal/web/setup_gate_page_test.go @@ -37,8 +37,9 @@ func TestSetupGatePage_TheCardAndItsButton(t *testing.T) { t.Fatal("closed, no probe: the card or its button is missing") } closedProbe := renderAppInfoWith(t, map[string]interface{}{"SetupGateClosed": true, "SetupGateHasProbe": true}) - if !strings.Contains(closedProbe, `id="setup-gate-card"`) || strings.Contains(closedProbe, "/apps/gapp/setup-gate/open") || !strings.Contains(closedProbe, "magától észreveszi") { - t.Fatal("closed with a probe: want the card and the 'notices it by itself' line, no button") + // v0.281.0: a probe app has the press too — the server asks the probe before it opens (TestSetupGateButton_*). + if !strings.Contains(closedProbe, `id="setup-gate-card"`) || !strings.Contains(closedProbe, "/apps/gapp/setup-gate/open") || !strings.Contains(closedProbe, "magától észreveszi") { + t.Fatal("closed with a probe: want the card, the 'notices it by itself' line and the press") } if open := renderAppInfoWith(t, nil); strings.Contains(open, `id="setup-gate-card"`) { t.Fatal("an app with no closed gate shows the gate card") diff --git a/controller/internal/web/signup_block_test.go b/controller/internal/web/signup_block_test.go new file mode 100644 index 0000000..6adac5c --- /dev/null +++ b/controller/internal/web/signup_block_test.go @@ -0,0 +1,105 @@ +package web + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// v0.281.0 — the household's "Done" press asks the app first (Part A), and the sign-up block's answer (decision 47). + +func pressDone(s *Server) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/apps/gapp/setup-gate/open", nil) + w := httptest.NewRecorder() + s.appSetupGateOpenHandler(w, r, "gapp") + return w +} + +// The uptime-kuma shape of 2026-09-29: a press BEFORE the setup. With a probe it is refused while the app says +// "not done", and when the probe cannot be read; it opens once the app says "done". +// COMPANION RED-PROOF: remove the SetupGateProbe check in appSetupGateOpenHandler → the first press opens the app. +func TestSetupGateButton_RefusedWhileTheAppSaysNotDone(t *testing.T) { + s := gateHarness(t) + app := filepath.Join(s.cfg.Paths.StacksDir, "gapp") + if err := os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Gated App\nslug: gapp\nsetup_gate: true\nsetup_done_probe:\n url: http://gapp:80/status\n field: isInit\n done: \"true\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := s.stackMgr.ScanStacks(); err != nil { + t.Fatal(err) + } + answer, probeErr := `{"isInit":false}`, error(nil) + defer stacks.SetSetupGateProbeGetForTest(func(string) ([]byte, error) { return []byte(answer), probeErr })() + if w := pressDone(s); w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "még nincs kész") { + t.Fatalf("press before the setup: %d %s — want 409 and the sentence", w.Code, w.Body.String()) + } + if _, closed, _ := s.stackMgr.SetupGateHost("gapp.example.hu"); !closed { + t.Fatal("the press before the setup opened the gate") + } + answer, probeErr = `{"isInit":true}`, errors.New("connection refused") + if w := pressDone(s); w.Code != http.StatusConflict { + t.Fatalf("an unreadable status: %d — want 409 (fail closed)", w.Code) + } + probeErr = nil + if w := pressDone(s); w.Code != http.StatusOK { + t.Fatalf("the app says done: %d %s", w.Code, w.Body.String()) + } + if _, closed, _ := s.stackMgr.SetupGateHost("gapp.example.hu"); closed { + t.Fatal("still closed after a press the app agreed with") + } +} + +// The sign-up address answers "closed": a page for a browser, 403 JSON for anything else. +func TestSignupClosed_TheAnswer(t *testing.T) { + s := gateHarness(t) + r := httptest.NewRequest(http.MethodGet, "https://gapp.example.hu"+signupClosedPath, nil) + r.Host = "gapp.example.hu" + r.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + s.ServeSignupClosed(w, r) + if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "nem lehet regisztr") { + t.Fatalf("browser: %d\n%s", w.Code, w.Body.String()) + } + r = httptest.NewRequest(http.MethodPost, signupClosedPath, strings.NewReader(`{"username":"x"}`)) + w = httptest.NewRecorder() + s.ServeSignupClosed(w, r) + if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "sign-up is closed") { + t.Fatalf("API: %d %s", w.Code, w.Body.String()) + } + // No block on this app (it is still gated): the window press says so. + req := httptest.NewRequest(http.MethodPost, "/apps/gapp/signup-window", nil) + rec := httptest.NewRecorder() + s.appSignupWindowHandler(rec, req, "gapp") + if rec.Code != http.StatusConflict { + t.Fatalf("window on a gated app: %d", rec.Code) + } +} + +// The page: a probe app now has the press too; an app without one asks first, in the page (felhomConfirm); the +// sign-up card with the app's own "how to add a family member" and the window button. +func TestSetupGatePage_ConfirmAndSignupCard(t *testing.T) { + noProbe := renderAppInfoWith(t, map[string]interface{}{"SetupGateClosed": true}) + if !strings.Contains(noProbe, "felhomConfirm(b, 'Csak akkor nyomd meg") { + t.Fatal("no-probe press without the in-page confirm") + } + probe := renderAppInfoWith(t, map[string]interface{}{"SetupGateClosed": true, "SetupGateHasProbe": true}) + if !strings.Contains(probe, "/apps/gapp/setup-gate/open") || strings.Contains(probe, "felhomConfirm(b, 'Csak akkor") { + t.Fatal("probe app: want the press, without the confirm") + } + closed := renderAppInfoWith(t, map[string]interface{}{"SignupClosed": true, "AppInfo": stacks.AppInfo{Tagline: "t", AddPeople: "Beállítások → Felhasználók → Meghívás."}}) + if !strings.Contains(closed, `id="signup-card"`) || !strings.Contains(closed, "Meghívás") || !strings.Contains(closed, "/apps/gapp/signup-window") { + t.Fatal("sign-up card: text, how-to or window button missing") + } + open := renderAppInfoWith(t, map[string]interface{}{"SignupOpenUntil": "14:05"}) + if !strings.Contains(open, "14:05") || strings.Contains(open, "/apps/gapp/signup-window") { + t.Fatal("open window: want the end time, no button") + } + if none := renderAppInfoWith(t, nil); strings.Contains(none, `id="signup-card"`) { + t.Fatal("a sign-up card on an app with no block") + } +} diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 6fd77e3..47b89c7 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -91,9 +91,27 @@

{{T "app_info.setup_gate_closed"}}

{{- if .SetupGateHasProbe}}

{{T "app_info.setup_gate_probe"}}

+ {{- else}}

{{T "app_info.setup_gate_button_hint"}}

- + + {{- end}} + + +{{- end}} +{{- if or .SignupClosed .SignupOpenUntil}} +
+

{{T "app_info.signup_title"}}

+ {{- if .SignupOpenUntil}} +

{{T "app_info.signup_open_until"}} {{.SignupOpenUntil}}

+ {{- else}} +

{{T "app_info.signup_closed"}}

+ {{- end}} + {{- if .AppInfo.AddPeople}} +

{{T "app_info.signup_add_how"}} {{.AppInfo.AddPeople}}

+ {{- end}} + {{- if .SignupClosed}} + {{- end}}
@@ -306,7 +324,7 @@ function icCopyPw(btn) { {{end}} {{template "layout_end" .}} -{{- if or .SetupGateClosed (and .Stack.Deployed .KnownLoginLine)}} +{{- if or .SetupGateClosed .SignupClosed (and .Stack.Deployed .KnownLoginLine)}} + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
+ +
+ +

+ +
+ ~ RAM + + + Csak x86 + +
+ +
+
+ + +
+ + + +
+
+

Regisztráció

+

Az első admin fiók után a regisztráció zárva: idegen nem hozhat létre fiókot.

+

Családtagot így adhatsz hozzá: Admin panel → Users.

+ + +
+ + + + + + + + + + +
+ + + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_info_signup_window.html b/controller/internal/web/testdata/i18n_parity/app_info_signup_window.html new file mode 100644 index 0000000..459cb79 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_info_signup_window.html @@ -0,0 +1,571 @@ + + + + + + + + Opengist — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
+ +
+ +

+ +
+ ~ RAM + + + Csak x86 + +
+ +
+
+ + +
+ + + +
+
+

Regisztráció

+

A regisztráció most nyitva, eddig: 14:05

+

Családtagot így adhatsz hozzá: Admin panel → Users.

+
+ + + + + + + + + + +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/signupclosed.html b/controller/internal/web/testdata/i18n_parity/signupclosed.html new file mode 100644 index 0000000..78ee1e4 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/signupclosed.html @@ -0,0 +1,26 @@ + + + + + + + + Opengist — A regisztráció zárva + + + + + + diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index bdc5258..5c832f3 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -95,6 +95,8 @@ "err.setup_gate.not_closed": "BORN AS A KEY, v0.280.0 (09 decision 46 / R-710) -- a NEW sentence, never a Go literal. Pinned by internal/web/setup_gate_test.go / internal/stacks/setup_gate_test.go.", "err.setup_gate.open_failed": "BORN AS A KEY, v0.280.0 (09 decision 46 / R-710) -- a NEW sentence, never a Go literal. Pinned by internal/web/setup_gate_test.go / internal/stacks/setup_gate_test.go.", "err.stacks.setup_gate_failed": "BORN AS A KEY, v0.280.0 (09 decision 46 / R-710) -- a NEW sentence, never a Go literal. Pinned by internal/web/setup_gate_test.go / internal/stacks/setup_gate_test.go.", + "err.setup_gate.probe_not_done": "BORN AS A KEY, v0.281.0 (09 decision 46/47) -- a NEW sentence, never a Go literal. Pinned by internal/web/signup_block_test.go.", + "err.setup_gate.no_signup_block": "BORN AS A KEY, v0.281.0 (09 decision 46/47) -- a NEW sentence, never a Go literal. Pinned by internal/web/signup_block_test.go.", "backups_apps.hollow_local": "BORN AS A KEY, v0.279.0 (09 decision 45 / Part D) -- a NEW sentence, never a Go literal. Pinned by internal/web/known_login_test.go / internal/web/r_partd_hollow_page_test.go.", "backups_apps.hollow_offsite": "BORN AS A KEY, v0.279.0 (09 decision 45 / Part D) -- a NEW sentence, never a Go literal. Pinned by internal/web/known_login_test.go / internal/web/r_partd_hollow_page_test.go.", "err.kept.offsite_not_usable": "BORN AS A KEY, R-691 (2) (v0.277.0) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go TestR691_InstallChoiceNamesTheOffsiteCopy / internal/backup/r691_kept_offsite_test.go.",