diff --git a/CHANGELOG.md b/CHANGELOG.md index 1084374..abd759f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## 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`, +`setup_gate.page_body`, `setup_gate.sign_in`, `app_info.setup_gate_title`, `app_info.setup_gate_closed`, +`app_info.setup_gate_probe`, `app_info.setup_gate_button_hint`, `app_info.setup_gate_done_btn`, +`app_info.default_login_changed_btn`, `err.stacks.setup_gate_failed`, `err.setup_gate.not_closed`, +`err.setup_gate.open_failed` (hu + en). Evidence: `felhom.eu/documentation/audits/login-gate-2026-09-29/`. + +- **The setup gate (`09` §3 decision 46, built after the spike PASSED — `audits/login-gate-2026-09-29/B/B-VERDICT.md`).** + A template with `setup_gate: true` is installed CLOSED: before the app's first start the controller writes a traefik + file-provider router per app router (same rule, priority 100000 + rule length, `forwardAuth` → + `http://felhom-controller:8080/__felhom_gate/auth`, then the app's own `@docker`). The answerer lets a request + through only with a host-only gate cookie; a browser without one goes to `https://felhom./__gate/start`, + where a valid DASHBOARD session gets a 60-second one-use token bound to the app host, swapped on the app host for + the cookie. The dashboard cookie is never widened. A script or a phone app gets 401. The gate OPENS (record first, + then the file) when the app's own `setup_done_probe:` says so (read on the docker network every 20 s), or when the + household presses „Kész, beállítottam". The record (`setup_gate:` in app.yaml) is a life record: a restart rewrites a + missing file, a restore keeps it, a kept-data load never gates. A gate that cannot be written REFUSES the install. + The key is persisted (`/setup-gate.key`, 0600): a restart does not re-gate a browser that passed. +- **R-710:** an app installed before its template gained an `after_install` was never warned about its live default + login (an absent record read as "not run yet" for ever — measured on demo-hp's bookstack). An absent record now means + "not run yet" only for 30 minutes after the install. And the default-login card has „Megváltoztattam" / "I changed it": + the household's word, recorded as `default_login: {changed_at, by}`; the card then goes. +- **R-709:** an installed app's `type: password` value is no longer written into its settings page; the eye fetches it + (`/stacks//auto-field/reveal`, now also for password fields of an installed app, never for a restore-generated one). +- **`generate: password:N:special`:** a lower, an upper, a digit and one of `-_.!@#%+=`, a letter or digit first — for + an app whose own policy demands it (calibre-web). The install page's „Generálás" button makes the same shape. +- Tests: `TestSetupGate_*` (stacks + web), `TestSetupGatePage_*`, `TestKnownLogin_*`, `TestR709_*`, + `TestGenerateValue_PasswordWithASpecialCharacter`; a `composeExecFn` seam so a deploy test never reaches Docker. + Red-proofs RP1–RP16, each seen failing on an assertion (`audits/login-gate-2026-09-29/C/redproofs/`). + ## v0.279.0 — no app goes live with a login a stranger knows (after_install); an empty backup of a running app is an alarm; the night's chain on a button; R-706 (2026-09-28) **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged; Part D rides the existing operator-only diff --git a/REUSE.md b/REUSE.md index f72e65b..3a13c6a 100644 --- a/REUSE.md +++ b/REUSE.md @@ -26,6 +26,7 @@ | `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, kept, legacy felhom-data) | Consult before ANY recursive delete under a drive | | `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 | | `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) | @@ -312,6 +313,7 @@ | `Manager.sambaUpFn` / `sambaPasswdFn` / `sambaRunFn` / `sambaAddrFn` (func seams) | controller/internal/stacks/manager.go (fields) + samba.go | nil → `composeUp` / `docker exec smbpasswd` (STDIN) / `containerRunning("felhom-samba")` / `docker exec felhom-samba ip -4 -o addr show eth0` | injected in controller/internal/stacks/samba_test.go — the idempotency test asserts the up-seam is called **zero** times when config is unchanged; the passwd seam means no unit test ever handles a real secret or touches docker. **`sambaRunFn` has an EXPORTED setter (`SetSambaRunProbe`)** — internal/web's status-contract tests need a live-container world from another package. `sambaAddrFn` backs `SambaLANAddress()` (v0.151.0); its parse is separately pinned in samba_lanaddr_test.go and it returns "" on any failure — the page omits a line rather than printing a wrong address | | `volumeCopier` + `Manager.undoCopier` / `updateUndoHealthFn` (v0.263.0) | controller/internal/stacks/undo.go | nil → `dockerVolumeCopier` (alpine helper: `cp -a` named volume → `.pre-update-`, finished-marker LAST; restore re-checks the marker in the same shell) / nil → `waitUpdateHealthyMeta` with the OLD `.felhom.yml` | `fakeCopier` in controller/internal/stacks/undo_test.go — volume CONTENT as strings, so "the data came back" is a compare; a cut-off copy is a copy without its marker. **Judge a copy by the helper's own exit + the marker, never by the client** (killing `docker run` leaves the container copying — measured) | | `pgConverter` + `Manager.pgConv` / `convertFreeFn` (v0.273.0) | controller/internal/stacks/pgconvert.go | nil → `dockerPGConverter` (docker exec psql/pg_dumpall over 127.0.0.1 — the entrypoint's temporary init server listens on the socket only; `Empty` re-checks the undo copy's marker in the same helper) / nil → statfs of the stack dir | `fakePG` in controller/internal/stacks/pgconvert_test.go — works on `fakeCopier`'s volume strings. **Never convert without the ladder's `engine_conversion` mark** (`planEngineConversion` refuses a PostgreSQL major move without it); `isPostgresImage` must match `appbackup.dbTypeForImage` (pinned by a source-reading test) | +| `Manager.composeExecFn` / `stacks.setupGateProbeGet` / `Server.gateClock` (v0.280.0) | controller/internal/stacks/manager.go + setup_gate.go · controller/internal/web/server.go | nil → the real compose call / an HTTP GET (5 s) / `time.Now` | controller/internal/stacks/setup_gate_test.go (with a docker STUB on PATH — R-650) · controller/internal/web/setup_gate_test.go | | `Manager.SambaLANAddress()` | controller/internal/stacks/samba.go | `() string` — the guest's LAN IPv4 for the Megosztás connect card (v0.151.0, S-2) | Read from the SAMBA container's netns (`network_mode: host`), never `net.InterfaceAddrs()` — the controller is on a docker BRIDGE and would answer 172.x (the same trap `setup.DetectLocalIPs` needs `HOST_IP` for). **NEVER cache/persist it** — the guest holds it by DHCP (S-5); callers re-derive per render. `""` = omit the line | | `Server.sambaAddrFn` (func seam) | controller/internal/web/server.go (field) + sharing_handlers.go `sambaLANAddress()` | nil → `stackMgr.SambaLANAddress()` | The web-side half of the connect card. Tests inject a COUNTED fn — the fresh-per-render assertion is what stops anyone memoizing a DHCP lease | | `Manager.guestNetExecFn` (func seam) + `GuestGateway()` / `GuestNetSnapshot()` | controller/internal/stacks/manager.go (field) + guestnet.go | nil → `docker exec felhom-samba ` — ONE seam for all R-66 guest-netns reads (route/link/addr/resolv.conf); tests script canned outputs per argv | guestnet_test.go. **The netns door rule:** the controller's OWN netns is the docker bridge, so any in-process read (`net.Interfaces`, `/proc/net/route`, its own `/etc/resolv.conf` = 127.0.0.11) is the S-2 wrong answer — guest-net reads MUST go through the samba (`network_mode: host`) exec door. Megosztás off ⇒ door closed ⇒ "" / per-item error strings; NEVER substitute an in-process value. Same S-5 law as SambaLANAddress: live per render, never cached/persisted. Parsers (`parseDefaultRoute`, `parseGuestInterfaces`, `parseResolvConf`) are pure + separately pinned | diff --git a/controller/README.md b/controller/README.md index 134b5e7..4511c36 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1920,6 +1920,21 @@ that folder is never a dead end, and an install never runs into it silently (R-6 command in the app's own container after a FRESH install (never after a restore or a kept-data load), with the named deploy values filled into `${NAME}`; the output must carry `success`. Recorded in `app.yaml` `after_install`; the app page hides the default-login card once it succeeded and warns while a default login is in effect. + **v0.280.0 (R-710):** an absent record means "not run yet" only for 30 minutes after the install (an app installed + before its template gained the command is warned), and the card has "I changed it" (`POST /apps//default-login/changed` + → `app.yaml` `default_login`), after which the card goes. +- **The setup gate (v0.280.0, decision 46)** — `.felhom.yml` `setup_gate: true` + optional `setup_done_probe: {url, field, + done}`. A FRESH install is closed to everyone but the household: the traefik file + `/traefik/dynamic/setup-gate-.yml` is written BEFORE the first start (a failed write refuses the install) and + puts `forwardAuth` (`/__felhom_gate/auth`, answered before the host check in `CatchAllMiddleware`) in front of every + router the app's labels publish. A browser without a gate cookie goes to `felhom./__gate/start`; a valid + dashboard session gets a 60 s one-use token for that app host, swapped on the app host for a host-only cookie + (HMAC key `/setup-gate.key`). The loop (`RunSetupGateLoop`, 20 s) rewrites a missing file, removes files nobody + 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`. +- **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 install of the app, and a removal clears both kinds; a restore hold (R-379) stays operator-cleared. - **R-690 (fixed here).** The removed-app restore (R-487) never found a unit on a DATA drive — it asked diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 7465ae2..a52cc9c 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1684,6 +1684,10 @@ func main() { go waiter.Run(ctx) } + // v0.280.0 (`09` §3 decision 46): the setup gate's loop — every closed gate's traefik file exists, a probe + // that says "set up" opens its gate, and a gate file nobody owns is removed. + go stackMgr.RunSetupGateLoop(ctx, 20*time.Second) + // --- Initialize API router --- apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, updater, notifier, logger) if reportTrigger != nil { diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 9977980..3cb2de4 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2467,5 +2467,17 @@ "note.restore.older_version_no_step": "%s is back from the backup of %s, at version %s. No tested update step leads on from this version, so the box does not update it by itself.", "err.backup.unit_versions_mixed": "%s: the data in this backup was written by different versions, so the restore did not start. The app is untouched; the copy on the second drive or off-site can restore it.", "err.backup.unit_version_mismatch": "%s: this backup's definition (%s) does not belong to the version that wrote its data (%s), so the restore did not start. The app is untouched.", - "deploy.login_from_backup": "Restored from a backup: log in with the password that was valid when the backup was taken. The value stored here is not it, so it is not shown." + "deploy.login_from_backup": "Restored from a backup: log in with the password that was valid when the backup was taken. The value stored here is not it, so it is not shown.", + "setup_gate.page_title": "Waiting for setup", + "setup_gate.page_body": "This app is waiting for its first setup. Sign in to the Felhom dashboard.", + "setup_gate.sign_in": "Sign in", + "app_info.setup_gate_title": "First setup", + "app_info.setup_gate_closed": "Right now only you can reach this app, while you are signed in to the dashboard. So nobody else can create its first admin account. Open it and finish the first setup.", + "app_info.setup_gate_probe": "When you are done, the box notices it by itself and opens the app for everyone.", + "app_info.setup_gate_button_hint": "When you are done, press this button. Until then, phone apps and the rest of your family cannot reach it.", + "app_info.setup_gate_done_btn": "Done, I set it up", + "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." } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index ac7ca92..d735e09 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2455,5 +2455,17 @@ "note.restore.older_version_no_step": "A(z) %s visszaállt a(z) %s-i mentésből, a(z) %s verzióra. Ettől a verziótól nem vezet kipróbált frissítési lépés, ezért a doboz magától nem frissíti.", "err.backup.unit_versions_mixed": "A(z) %s mentésében az adatokat különböző verziók írták, ezért a visszaállítás nem indult el. Az alkalmazás érintetlen; a második meghajtón vagy a távoli helyen lévő másolatból visszaállítható.", "err.backup.unit_version_mismatch": "A(z) %s mentésében a beállítás (%s) nem ahhoz a verzióhoz tartozik, amelyik az adatokat írta (%s), ezért a visszaállítás nem indult el. Az alkalmazás érintetlen.", - "deploy.login_from_backup": "Mentésből töltötted vissza: a belépéshez a mentés idején érvényes jelszavad kell. Az itt tárolt érték nem az, ezért nem mutatjuk." + "deploy.login_from_backup": "Mentésből töltötted vissza: a belépéshez a mentés idején érvényes jelszavad kell. Az itt tárolt érték nem az, ezért nem mutatjuk.", + "setup_gate.page_title": "Beállításra vár", + "setup_gate.page_body": "Ez az alkalmazás még beállításra vár. Jelentkezz be a Felhom vezérlőpultba.", + "setup_gate.sign_in": "Bejelentkezés", + "app_info.setup_gate_title": "Első beállítás", + "app_info.setup_gate_closed": "Most csak te éred el ezt az alkalmazást, amíg be vagy jelentkezve a vezérlőpultba. Így más nem hozhatja létre az első admin fiókot. Nyisd meg, és végezd el az első beállítást.", + "app_info.setup_gate_probe": "Ha kész, a doboz magától észreveszi, és mindenkinek megnyitja az alkalmazást.", + "app_info.setup_gate_button_hint": "Ha kész, nyomd meg ezt a gombot. Addig a telefonos alkalmazások és a család többi tagja nem éri el.", + "app_info.setup_gate_done_btn": "Kész, beállítottam", + "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." } diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 62b83da..4c711ba 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -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 { diff --git a/controller/internal/stacks/life_records.go b/controller/internal/stacks/life_records.go index 61f91e3..d4e4f1f 100644 --- a/controller/internal/stacks/life_records.go +++ b/controller/internal/stacks/life_records.go @@ -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 { diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 96ff529..1f05280 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -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 diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index b2706c6..c55ff0a 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -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. diff --git a/controller/internal/stacks/setup_gate.go b/controller/internal/stacks/setup_gate.go new file mode 100644 index 0000000..16c4ce2 --- /dev/null +++ b/controller/internal/stacks/setup_gate.go @@ -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 +} diff --git a/controller/internal/stacks/setup_gate_test.go b/controller/internal/stacks/setup_gate_test.go new file mode 100644 index 0000000..a68ff32 --- /dev/null +++ b/controller/internal/stacks/setup_gate_test.go @@ -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) + } + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 7e39a23..2056a36 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "sort" "strings" "time" @@ -463,7 +464,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri // v0.279.0 (decision 45): the default login, before the install when nothing will replace it, after it // while it is still in effect. data["KnownLoginLine"] = s.knownLoginLine(lang, meta, appCfg, alreadyDeployed) - data["DefaultLoginReplaced"] = meta.AfterInstall != nil && !defaultLoginInEffect(meta, appCfg, alreadyDeployed) + data["DefaultLoginReplaced"] = defaultLoginReplaced(meta, appCfg, alreadyDeployed) data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug) data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug) data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug) @@ -500,6 +501,13 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri delete(decryptedEnv, n) } } + // R-709 (v0.280.0): a `type: password` value is never written into this page's HTML either — like a + // `type: secret` (R-254) it is fetched on demand (/stacks//auto-field/reveal). + for _, f := range meta.DeployFields { + if f.Type == "password" { + delete(decryptedEnv, f.EnvVar) + } + } data["DeployedFieldValues"] = decryptedEnv data["RestoredLogins"] = restored } @@ -762,7 +770,10 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s data["AppInfo"] = found.Meta.AppInfo // v0.279.0 (decision 45): the default-login card only while that login is in effect. data["KnownLoginLine"] = s.knownLoginLine(s.langFor(r), &found.Meta, found.AppConfig, found.Deployed) - data["DefaultLoginReplaced"] = found.Meta.AfterInstall != nil && !defaultLoginInEffect(&found.Meta, found.AppConfig, found.Deployed) + data["DefaultLoginReplaced"] = defaultLoginReplaced(&found.Meta, found.AppConfig, found.Deployed) + // 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 != "" data["HasAppInfo"] = found.Meta.HasAppInfo() data["EffectiveSubdomain"] = effectiveSubdomain @@ -1129,8 +1140,8 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { // 1./2./3. tier rows. func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) { data := s.backupsCommonData("backups-apps", "Biztonsági mentés — Alkalmazások", r) - data["TitleKey"] = "page.title.backups_apps" // i18n: the Hungarian title above is what hu renders - s.backupsOffboxData(data, s.langFor(r)) // the tier-3 rows render $.Offbox status + data["TitleKey"] = "page.title.backups_apps" // i18n: the Hungarian title above is what hu renders + s.backupsOffboxData(data, s.langFor(r)) // the tier-3 rows render $.Offbox status data["HollowCopyLines"] = s.hollowCopyLines(s.langFor(r)) // Part D: running apps whose copy holds no data if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil { @@ -2477,7 +2488,9 @@ func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Reques escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "escrow.missing_field")) return } - // AUTHORISATION: the field must be an auto-generated SECRET of this stack's catalog metadata. + // AUTHORISATION: the field must be an auto-generated SECRET of this stack's catalog metadata — or, since + // v0.280.0 (R-709), a `type: password` field of an INSTALLED app, unless a restore generated it (R-694: + // that value is not the app's login and is never shown). allowed := false for _, f := range stack.Meta.AutoGeneratedFields() { if f.EnvVar == envVar && f.Type == "secret" { @@ -2485,6 +2498,18 @@ func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Reques break } } + if !allowed && stack.Deployed { + restored := false + if stack.AppConfig != nil { + restored = slices.Contains(stack.AppConfig.RestoredLogins, envVar) + } + for _, f := range stack.Meta.UserFacingFields() { + if f.EnvVar == envVar && f.Type == "password" && !restored { + allowed = true + break + } + } + } if !allowed { s.logger.Printf("[WARN] [web] auto-field reveal refused for %s/%s: not an auto-generated secret field", stackName, envVar) escrowJSON(w, http.StatusForbidden, nil, s.msg(r, "escrow.field_not_revealable")) diff --git a/controller/internal/web/i18n_cases_c_test.go b/controller/internal/web/i18n_cases_c_test.go index 4249fab..1f29521 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"} }}, + {"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"} + }}, {"catchall_unknown", "catchall", func() map[string]interface{} { return m{"ControllerURL": "https://felhom.example.hu", "Status": "unknown", "StatusText": "Ez a cím nem tartozik alkalmazáshoz", "Host": "x.example.hu"} }}, diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 65a0dc4..36f9a33 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -313,6 +313,33 @@ func i18nCases() []i18nCase { d["StorageLabels"] = map[string]string{} return d }}) + // v0.280.0 (`09` §3 decision 46, R-710): the setup gate's card (button / probe) and "I changed it" — captured + // when born. + gateCase := func(name string, probe bool) i18nCase { + return i18nCase{name, "app_info", func() map[string]interface{} { + d := i18nLayoutData("stacks", "Immich") + st := stacks.Stack{Name: "immich", Deployed: true, State: stacks.StateRunning} + st.Meta = stacks.Metadata{DisplayName: "Immich", Slug: "immich"} + d["Stack"] = st + d["Meta"] = st.Meta + d["AppInfo"] = st.Meta.AppInfo + d["SetupGateClosed"] = true + d["SetupGateHasProbe"] = probe + return d + }} + } + base = append(base, gateCase("app_info_setup_gate_button", false), gateCase("app_info_setup_gate_probe", true)) + 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} + st.Meta = stacks.Metadata{DisplayName: "Calibre-Web", Slug: "calibre-web", AppInfo: stacks.AppInfo{DefaultCreds: "admin / admin123"}} + d["Stack"] = st + d["Meta"] = st.Meta + d["AppInfo"] = st.Meta.AppInfo + d["HasAppInfo"] = true + d["KnownLoginLine"] = "Ez az alkalmazás egy ismert, közös jelszóval indul: admin / admin123. Telepítés után azonnal változtasd meg." + return d + }}) // v0.268.0 (`09` §3 decision 14): the ladder's "steps remaining" line, captured when it was born. base = append(base, i18nCase{"app_info_ladder_steps", "app_info", func() map[string]interface{} { d := i18nLayoutData("stacks", "RomM") @@ -386,6 +413,7 @@ 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 } 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 467e583..c0bd5de 100644 --- a/controller/internal/web/i18n_wiring_test.go +++ b/controller/internal/web/i18n_wiring_test.go @@ -313,6 +313,7 @@ var i18nDirectPages = []struct{ tmpl, caseName, enProbe string }{ {"launcher_shared", "launcher_shared_apps", "Launcher"}, {"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"}, } // TestI18nDirectRenderPagesFollowLanguage — with the household language saved as English the page is diff --git a/controller/internal/web/known_login.go b/controller/internal/web/known_login.go index 9d7d580..829d1ef 100644 --- a/controller/internal/web/known_login.go +++ b/controller/internal/web/known_login.go @@ -2,6 +2,7 @@ package web import ( "strings" + "time" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) @@ -14,21 +15,48 @@ import ( // after_install) or the command failed, the page and the install dialog say plainly what the default is and // to change it at once. Pinned by internal/web/known_login_test.go. +// afterInstallWindow is how long after an install an ABSENT after_install record still means "not run yet": +// the deploy-done hook waits up to 10 minutes for the app, then tries 6 × 20 s (after_install.go). +const afterInstallWindow = 30 * time.Minute + +// knownLoginNow is the page's clock (a test seam). +var knownLoginNow = time.Now + // defaultLoginInEffect: the template has a default login and nothing has replaced it on this install. // installed=false is the install dialog, before the install: a declared after_install WILL replace it. func defaultLoginInEffect(meta *stacks.Metadata, cfg *stacks.AppConfig, installed bool) bool { if meta == nil || strings.TrimSpace(meta.AppInfo.DefaultCreds) == "" { return false } + // R-710: the household said it changed the login by hand (the app page's "I changed it"). + if installed && cfg != nil && cfg.DefaultLogin != nil { + return false + } if meta.AfterInstall == nil { return true } if !installed { return false } - // Installed with an after_install: in effect only when the command FAILED (absent = not run yet — the - // deploy-done hook runs it within minutes; the page does not warn about a default it is replacing). - return cfg != nil && cfg.AfterInstall != nil && !cfg.AfterInstall.OK + if cfg == nil { + return true // the app's record is unreadable: say what the template's default is + } + if cfg.AfterInstall != nil { + return !cfg.AfterInstall.OK + } + // R-710 (measured on demo-hp 2026-09-29): an ABSENT record means "not run yet" only inside the command's + // window after the install. An app installed before its template gained an after_install never runs it — + // read as "not run yet" for ever, its live default login went unannounced. + at, err := time.Parse(time.RFC3339, cfg.DeployedAt) + return err != nil || knownLoginNow().Sub(at) > afterInstallWindow +} + +// defaultLoginReplaced: installed, and something replaced the default (after_install, or the household). +func defaultLoginReplaced(meta *stacks.Metadata, cfg *stacks.AppConfig, installed bool) bool { + if meta == nil || !installed || defaultLoginInEffect(meta, cfg, installed) { + return false + } + return meta.AfterInstall != nil || (cfg != nil && cfg.DefaultLogin != nil) } // knownLoginLine is the sentence, in lang, or "" when no default login is in effect. diff --git a/controller/internal/web/known_login_test.go b/controller/internal/web/known_login_test.go index dd8f69b..2ab84ba 100644 --- a/controller/internal/web/known_login_test.go +++ b/controller/internal/web/known_login_test.go @@ -2,6 +2,7 @@ package web import ( "testing" + "time" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) @@ -9,12 +10,18 @@ import ( // v0.279.0 (decision 45) — when the default login is IN EFFECT, and the sentence the household reads. // COMPANION RED-PROOF: make defaultLoginInEffect ignore after_install (the pre-0.279.0 page: the default // card always shown) → the "replaced" rows fail — the page would send the household to a dead login. +// R-710 RED-PROOF (v0.280.0): return false for an absent record again (the 0.279.0 shape) → the two "R-710: +// installed long before / no install time" rows fail — measured live on demo-hp's bookstack. func TestKnownLogin_InEffectOnlyUntilReplaced(t *testing.T) { withCreds := &stacks.Metadata{AppInfo: stacks.AppInfo{DefaultCreds: "admin / admin"}} withFix := &stacks.Metadata{AppInfo: stacks.AppInfo{DefaultCreds: "admin@claper.co / claper"}, AfterInstall: &stacks.AfterInstallCommand{Service: "claper", Command: []string{"x"}, Success: "OK"}} ok := &stacks.AppConfig{AfterInstall: &stacks.AfterInstallRecord{OK: true}} failed := &stacks.AppConfig{AfterInstall: &stacks.AfterInstallRecord{OK: false}} + now := time.Date(2026, 9, 29, 8, 0, 0, 0, time.UTC) + knownLoginNow = func() time.Time { return now } + t.Cleanup(func() { knownLoginNow = time.Now }) + recent, old := now.Add(-5*time.Minute).Format(time.RFC3339), now.Add(-48*time.Hour).Format(time.RFC3339) for _, c := range []struct { name string meta *stacks.Metadata @@ -27,8 +34,13 @@ func TestKnownLogin_InEffectOnlyUntilReplaced(t *testing.T) { {"default, nothing replaces it: installed", withCreds, &stacks.AppConfig{}, true, true}, {"after_install declared: before install", withFix, nil, false, false}, {"after_install succeeded", withFix, ok, true, false}, - {"after_install not run yet", withFix, &stacks.AppConfig{}, true, false}, + {"after_install not run yet (installed minutes ago)", withFix, &stacks.AppConfig{DeployedAt: recent}, true, false}, {"after_install FAILED", withFix, failed, true, true}, + // R-710: installed BEFORE the template gained its after_install — the command never runs for it. + {"R-710: installed long before the after_install existed", withFix, &stacks.AppConfig{DeployedAt: old}, true, true}, + {"R-710: no install time on record", withFix, &stacks.AppConfig{}, true, true}, + {"R-710: the household changed it by hand", withCreds, &stacks.AppConfig{DeployedAt: old, DefaultLogin: &stacks.DefaultLoginRecord{ChangedAt: "t", By: "household"}}, true, false}, + {"R-710: ...and on an app with an after_install", withFix, &stacks.AppConfig{DeployedAt: old, DefaultLogin: &stacks.DefaultLoginRecord{ChangedAt: "t", By: "household"}}, true, false}, } { if got := defaultLoginInEffect(c.meta, c.cfg, c.installed); got != c.want { t.Errorf("%s: in effect = %v, want %v", c.name, got, c.want) @@ -42,3 +54,18 @@ func TestKnownLogin_InEffectOnlyUntilReplaced(t *testing.T) { t.Fatalf("en sentence %q, want %q", got, want) } } + +// The card's "I changed it" press is on the installed app's page while the default is named, and the card goes +// once the household pressed it (R-710). defaultLoginReplaced is what hides it. +func TestKnownLogin_ReplacedByTheHouseholdHidesTheCard(t *testing.T) { + meta := &stacks.Metadata{AppInfo: stacks.AppInfo{DefaultCreds: "admin / admin123"}} + if defaultLoginReplaced(meta, &stacks.AppConfig{DeployedAt: "2020-01-01T00:00:00Z"}, true) { + t.Fatal("nothing replaced the default, but the card would hide") + } + if !defaultLoginReplaced(meta, &stacks.AppConfig{DefaultLogin: &stacks.DefaultLoginRecord{ChangedAt: "t", By: "household"}}, true) { + t.Fatal("the household changed it, but the card stays") + } + if defaultLoginReplaced(meta, &stacks.AppConfig{DefaultLogin: &stacks.DefaultLoginRecord{}}, false) { + t.Fatal("the install dialog is never 'replaced'") + } +} diff --git a/controller/internal/web/r709_password_field_test.go b/controller/internal/web/r709_password_field_test.go new file mode 100644 index 0000000..eee1fce --- /dev/null +++ b/controller/internal/web/r709_password_field_test.go @@ -0,0 +1,94 @@ +package web + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-709 (v0.280.0) — an installed app's `type: password` value is never in its settings page's HTML; it is +// fetched on demand, like a `type: secret` (R-254). The pre-deploy form keeps its generator. + +const testAdminPassword = "TESTONLY-admin-pw-Qz72" + +func renderDeployWithPassword(t *testing.T, alreadyDeployed bool, restored bool) string { + t.Helper() + s := securityHarness(t) + s.loadTemplates() + data := map[string]interface{}{ + "Page": "stacks", "Title": "Telepítés", "Domain": "example.hu", + "Stack": stacks.Stack{Name: "grafana", Deployed: alreadyDeployed}, + "Meta": stacks.Metadata{DisplayName: "Grafana", Slug: "grafana"}, + "AlreadyDeployed": alreadyDeployed, + "UserFields": []stacks.DeployField{ + {EnvVar: "ADMIN_PASSWORD", Label: "Admin jelszó", Type: "password", Generate: "password:24:special"}, + }, + // The PRE-FIX handler shape: the stored value in DeployedFieldValues. The template must not print it + // even then (the handler now also drops it — TestR709_TheRevealEndpointServesAnInstalledPassword). + "DeployedFieldValues": map[string]string{"ADMIN_PASSWORD": testAdminPassword}, + "RestoredLogins": map[string]bool{"ADMIN_PASSWORD": restored}, + } + var buf bytes.Buffer + if err := s.tmpl.ExecuteTemplate(&buf, "deploy", data); err != nil { + t.Fatalf("render deploy: %v", err) + } + return buf.String() +} + +// COMPANION RED-PROOF: put `value="{{index $.DeployedFieldValues .EnvVar}}"` back on the deployed password input +// → the first assertion fails with the password in the body. +func TestR709_AnInstalledAppsPasswordIsNotInThePage(t *testing.T) { + html := renderDeployWithPassword(t, true, false) + if strings.Contains(html, testAdminPassword) { + t.Fatal("R-709: the installed app's admin password is in the HTML of its settings page") + } + if !strings.Contains(html, `onclick="revealPasswordField('grafana', 'ADMIN_PASSWORD', this)"`) { + t.Fatal("no reveal control: the household cannot see its own password") + } + // A login a restore generated is not the app's login (R-694): no reveal for it. + if html := renderDeployWithPassword(t, true, true); strings.Contains(html, `revealPasswordField('grafana'`) || strings.Contains(html, testAdminPassword) { + t.Fatal("a restore-generated login is offered for reveal") + } + // The pre-deploy form keeps its generator, with the field's own spec. + if html := renderDeployWithPassword(t, false, false); !strings.Contains(html, `generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', 'password:24:special')`) { + t.Fatal("the install form lost its generator (or its spec)") + } +} + +// The reveal endpoint serves an installed app's password field, refuses a restore-generated one and an unknown +// field. COMPANION RED-PROOF: drop the new password branch in appAutoFieldRevealHandler → the first case 403s. +func TestR709_TheRevealEndpointServesAnInstalledPassword(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: G\nslug: gapp\ndeploy_fields:\n - env_var: ADMIN_PASSWORD\n type: password\n - env_var: OLD_PW\n type: password\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := stacks.SaveAppConfig(app, &stacks.AppConfig{Deployed: true, Env: map[string]string{"ADMIN_PASSWORD": testAdminPassword, "OLD_PW": "x"}, RestoredLogins: []string{"OLD_PW"}}, nil, nil); err != nil { + t.Fatal(err) + } + if err := s.stackMgr.ScanStacks(); err != nil { + t.Fatal(err) + } + ask := func(field string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/stacks/gapp/auto-field/reveal", strings.NewReader("env_var="+field)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.appAutoFieldRevealHandler(w, r, "gapp") + return w + } + if w := ask("ADMIN_PASSWORD"); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), testAdminPassword) { + t.Fatalf("installed password: %d %s", w.Code, w.Body.String()) + } + if w := ask("OLD_PW"); w.Code != http.StatusForbidden { + t.Fatalf("a restore-generated login was revealed: %d", w.Code) + } + if w := ask("NOPE"); w.Code != http.StatusForbidden { + t.Fatalf("an unknown field: %d", w.Code) + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 1433057..a605a3f 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -46,10 +46,13 @@ type Server struct { updater *selfupdate.Updater logger *log.Logger version string - encKey []byte // AES-256 key for decrypting app.yaml values - tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this - tmplByLang map[string]*template.Template - i18n *i18n.Bundle + encKey []byte // AES-256 key for decrypting app.yaml values + // gate / gateClock (v0.280.0, decision 46): the setup gate's key + used tokens; the clock is a test seam. + gate gateState + gateClock func() time.Time + tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this + tmplByLang map[string]*template.Template + i18n *i18n.Bundle // versionPosition (v0.275.0) — where a just-restored app stands against the catalog; nil → the stack // manager's RestoredVersionPosition. A seam so the restore sentence is testable without a catalog. @@ -811,6 +814,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // R-254: the app's generated first-login password is fetched by an explicit authenticated act, // never templated into the info page. Placed BEFORE the /apps/ catch-all so the more specific // path wins. POST (not GET) so CsrfProtect covers it and it is not cacheable — see the handler. + // v0.280.0: the household's two presses on the app page — "Done, I set it up" (decision 46) and "I changed + // 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, "/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: slug := strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/initial-credentials/reveal") s.appInitialCredsRevealHandler(w, r, slug) @@ -839,6 +848,16 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler { if idx := strings.LastIndex(host, ":"); idx != -1 { host = host[:idx] } + // v0.280.0 (decision 46): traefik's forwardAuth call for a gated app arrives with the controller's own + // container name as Host, so it is answered before the host check. The start page is the dashboard's. + if r.URL.Path == gateAuthPath { + s.ServeGateAuth(w, r) + return + } + if r.URL.Path == gateStartPath && strings.EqualFold(host, controllerHost) && r.Method == http.MethodGet { + s.ServeGateStart(w, r) + return + } // Pass through: controller host, localhost (healthcheck/internal), or empty if strings.EqualFold(host, controllerHost) || host == "" || host == "localhost" || host == "127.0.0.1" { diff --git a/controller/internal/web/setup_gate.go b/controller/internal/web/setup_gate.go new file mode 100644 index 0000000..97767cc --- /dev/null +++ b/controller/internal/web/setup_gate.go @@ -0,0 +1,328 @@ +package web + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// ── The setup gate's answerer (v0.280.0, `09` §3 decision 46) ───────────────────────────────────────── +// +// traefik asks GET /__felhom_gate/auth (forwardAuth) for every request to an app whose gate is closed +// (internal/stacks/setup_gate.go writes that route). The answer: +// +// - a valid GATE COOKIE for that app host → 200, the request goes to the app; +// - /__felhom_gate/cb?t= → the token (minted below, 60 s, one use, bound to the host) is swapped for a +// host-only gate cookie, and the browser goes back where it was going; +// - a browser GET without one → 302 to https://felhom./__gate/start?rd=; +// - anything else → 401 {"error": …}: a script or a phone app gets a plain refusal. +// +// GET /__gate/start is on the DASHBOARD host, where the household's own session cookie already is. With a +// valid session: a token and a 302 to the app's callback. Without: a page that says the app is waiting for its +// first setup, with a sign-in link that comes straight back here after the sign-in. +// +// The dashboard cookie is NEVER widened to the app hosts (it is host-only, auth.go) — the spike measured that +// widening it would send the household's session to every app's backend. The gate cookie reaches only its +// own app host and opens only that app's gate (HMAC over the host). The key is persisted, so a controller +// restart does not re-gate a browser that already passed. +// Pinned by internal/web/setup_gate_test.go. + +const ( + gateCookieName = "felhom_gate" + gateCookieLife = 7 * 24 * time.Hour + gateTokenLife = 60 * time.Second + gateAuthPath = "/__felhom_gate/auth" + gateCallbackURI = "/__felhom_gate/cb" + gateStartPath = "/__gate/start" +) + +type gateState struct { + once sync.Once + key []byte + mu sync.Mutex + used map[string]time.Time // token nonces already swapped, until they expire +} + +// gateKey loads the gate's HMAC key from the data dir, or makes one. No data dir → a key for this run only. +func (s *Server) gateKey() []byte { + s.gate.once.Do(func() { + s.gate.used = map[string]time.Time{} + p := "" + if s.cfg != nil && s.cfg.Paths.DataDir != "" { + p = filepath.Join(s.cfg.Paths.DataDir, "setup-gate.key") + if b, err := os.ReadFile(p); err == nil { + if k, err := hex.DecodeString(strings.TrimSpace(string(b))); err == nil && len(k) == 32 { + s.gate.key = k + return + } + } + } + k := make([]byte, 32) + _, _ = rand.Read(k) + s.gate.key = k + if p != "" { + if err := os.WriteFile(p, []byte(hex.EncodeToString(k)), 0o600); err != nil { + s.logger.Printf("[WARN] [web] setup gate: the key could not be saved (%v) — a restart will ask browsers to sign in again", err) + } + } + }) + return s.gate.key +} + +func (s *Server) gateMAC(parts ...string) string { + m := hmac.New(sha256.New, s.gateKey()) + m.Write([]byte(strings.Join(parts, "\x00"))) + return hex.EncodeToString(m.Sum(nil)) +} + +func (s *Server) gateNow() time.Time { + if s.gateClock != nil { + return s.gateClock() + } + return time.Now() +} + +// gateCookieValid: ".". +func (s *Server) gateCookieValid(r *http.Request, host string) bool { + c, err := r.Cookie(gateCookieName) + if err != nil { + return false + } + exp, mac, ok := strings.Cut(c.Value, ".") + n, err := strconv.ParseInt(exp, 10, 64) + if !ok || err != nil || s.gateNow().Unix() > n { + return false + } + return hmac.Equal([]byte(mac), []byte(s.gateMAC("cookie", host, exp))) +} + +type gateToken struct { + Host string `json:"h"` + Exp int64 `json:"e"` + Nonce string `json:"n"` + RD string `json:"r"` + MAC string `json:"m"` +} + +func (s *Server) mintGateToken(host, rd string) string { + nb := make([]byte, 12) + _, _ = rand.Read(nb) + t := gateToken{Host: host, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd} + t.MAC = s.gateMAC("token", t.Host, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD) + b, _ := json.Marshal(t) + return base64.RawURLEncoding.EncodeToString(b) +} + +// takeGateToken checks a token for host and uses it up. Returns where the browser was going. +func (s *Server) takeGateToken(raw, host string) (string, error) { + b, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return "", errors.New("malformed") + } + var t gateToken + if json.Unmarshal(b, &t) != nil { + return "", errors.New("malformed") + } + if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("token", t.Host, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) { + return "", errors.New("bad signature") + } + if t.Host != host { + return "", errors.New("for another app") + } + now := s.gateNow() + if now.Unix() > t.Exp { + return "", errors.New("expired") + } + s.gateKey() + s.gate.mu.Lock() + defer s.gate.mu.Unlock() + for n, until := range s.gate.used { + if now.After(until) { + delete(s.gate.used, n) + } + } + if _, seen := s.gate.used[t.Nonce]; seen { + return "", errors.New("already used") + } + s.gate.used[t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second) + return t.RD, nil +} + +// gateRDHost: the host of a return address that may be used — https, on this household's domain, and an +// app whose gate is closed. Anything else is refused (no open redirect through the dashboard). +func (s *Server) gateRDHost(rd string) (string, bool) { + u, err := url.Parse(rd) + if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || s.stackMgr == nil { + return "", false + } + host := strings.ToLower(u.Hostname()) + if u.Port() != "" || !strings.HasSuffix(host, "."+strings.ToLower(s.cfg.Customer.Domain)) { + return "", false + } + if _, closed, found := s.stackMgr.SetupGateHost(host); !found || !closed { + return "", false + } + return host, true +} + +func gateRefuse(w http.ResponseWriter, code int) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + _, _ = w.Write([]byte(`{"error":"this app is waiting for its first setup"}`)) +} + +// ServeGateAuth is traefik's forwardAuth answer. It trusts X-Forwarded-Host/-Uri/-Method, which traefik sets +// from the request it is forwarding (the entrypoints trust no client's own X-Forwarded-* headers). Anyone who +// calls it directly on the docker network learns only yes or no about a cookie they already hold. +func (s *Server) ServeGateAuth(w http.ResponseWriter, r *http.Request) { + host := strings.ToLower(r.Header.Get("X-Forwarded-Host")) + if i := strings.LastIndex(host, ":"); i != -1 { + host = host[:i] + } + uri := r.Header.Get("X-Forwarded-Uri") + if uri == "" { + uri = "/" + } + method := r.Header.Get("X-Forwarded-Method") + if s.stackMgr == nil { + gateRefuse(w, http.StatusForbidden) + return + } + app, closed, found := s.stackMgr.SetupGateHost(host) + if !found { + // A host no app claims: fail closed. traefik only asks for hosts a gate file names. + s.logger.Printf("[WARN] [web] setup gate: asked about %q, which no gated app owns — refused", host) + gateRefuse(w, http.StatusForbidden) + return + } + if !closed { + // Opened; traefik has not dropped the file yet (it is removed right after the record is written). + w.WriteHeader(http.StatusOK) + return + } + if u, err := url.Parse(uri); err == nil && u.Path == gateCallbackURI { + rd, err := s.takeGateToken(u.Query().Get("t"), host) + if err != nil { + s.logger.Printf("[WARN] [web] setup gate %s: a sign-in token was refused (%v)", app, err) + gateRefuse(w, http.StatusForbidden) + return + } + exp := strconv.FormatInt(s.gateNow().Add(gateCookieLife).Unix(), 10) + http.SetCookie(w, &http.Cookie{ + Name: gateCookieName, Value: exp + "." + s.gateMAC("cookie", host, exp), Path: "/", + MaxAge: int(gateCookieLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, + }) + s.logger.Printf("[INFO] [web] setup gate %s: the household passed (a dashboard session vouched for this browser)", app) + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, rd, http.StatusFound) + return + } + if s.gateCookieValid(r, host) { + w.WriteHeader(http.StatusOK) + return + } + if (method == "" || method == http.MethodGet) && strings.Contains(r.Header.Get("Accept"), "text/html") { + rd := "https://" + host + uri + w.Header().Set("Cache-Control", "no-store") + http.Redirect(w, r, "https://felhom."+s.cfg.Customer.Domain+gateStartPath+"?"+url.Values{"rd": {rd}}.Encode(), http.StatusFound) + return + } + if s.isDebug() { + s.logger.Printf("[DEBUG] [web] setup gate %s: %s %s without a pass — 401", app, method, uri) + } + gateRefuse(w, http.StatusUnauthorized) +} + +// ServeGateStart is /__gate/start on the dashboard host. +func (s *Server) ServeGateStart(w http.ResponseWriter, r *http.Request) { + rd := r.URL.Query().Get("rd") + host, ok := s.gateRDHost(rd) + if !ok { + http.Redirect(w, r, "/", http.StatusFound) + return + } + w.Header().Set("Cache-Control", "no-store") + if s.hasSession(r) { + http.Redirect(w, r, "https://"+host+gateCallbackURI+"?"+url.Values{"t": {s.mintGateToken(host, rd)}}.Encode(), http.StatusFound) + return + } + next := gateStartPath + "?" + url.Values{"rd": {rd}}.Encode() + data := map[string]interface{}{ + "LoginURL": "/login?" + url.Values{"next": {next}}.Encode(), + "Host": host, + } + 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") + if err := s.executeTemplateLang(w, r, "setupgate", data); err != nil { + s.logger.Printf("[ERROR] [web] setup gate page: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + } +} + +// appSetupGateOpenHandler is the household's "Done, I set it up" (POST /apps//setup-gate/open). +func (s *Server) appSetupGateOpenHandler(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 + } + 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")) + return + } + s.logger.Printf("[ERROR] [web] setup gate %s: the household's open failed: %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{"opened": true}, "") +} + +// appDefaultLoginChangedHandler is the household's "I changed it" under a known default login +// (POST /apps//default-login/changed, R-710). +func (s *Server) appDefaultLoginChangedHandler(w http.ResponseWriter, r *http.Request, slug string) { + found := s.stackBySlug(slug) + if found == nil || !found.Deployed { + escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app")) + return + } + if err := s.stackMgr.MarkDefaultLoginChanged(found.Name, "household"); err != nil { + s.logger.Printf("[ERROR] [web] default login %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{"recorded": true}, "") +} + +// stackBySlug resolves a page slug exactly as appDetailHandler does. +func (s *Server) stackBySlug(slug string) *stacks.Stack { + if s.stackMgr == nil { + return nil + } + for _, st := range s.stackMgr.GetStacks() { + if st.Meta.Slug == slug { + st := st + return &st + } + } + return nil +} diff --git a/controller/internal/web/setup_gate_page_test.go b/controller/internal/web/setup_gate_page_test.go new file mode 100644 index 0000000..b8d384b --- /dev/null +++ b/controller/internal/web/setup_gate_page_test.go @@ -0,0 +1,59 @@ +package web + +import ( + "bytes" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// v0.280.0 — the app page's two new presses render only where they belong (one render per branch: a seam built +// and never wired is this project's commonest silent defect). ASCII fragments of the Hungarian copy. +func renderAppInfoWith(t *testing.T, extra map[string]interface{}) string { + t.Helper() + s := securityHarness(t) + s.loadTemplates() + data := map[string]interface{}{ + "Page": "stacks", "Title": "T", "Domain": "example.hu", + "Stack": stacks.Stack{Name: "gapp", Deployed: true, State: "running"}, + "Meta": stacks.Metadata{DisplayName: "G", Slug: "gapp"}, + "AppInfo": stacks.AppInfo{Tagline: "t", DefaultCreds: "admin / admin123"}, "HasAppInfo": true, + "CSRFToken": "tok", + } + for k, v := range extra { + data[k] = v + } + var buf bytes.Buffer + if err := s.tmpl.ExecuteTemplate(&buf, "app_info", data); err != nil { + t.Fatalf("render: %v", err) + } + return buf.String() +} + +func TestSetupGatePage_TheCardAndItsButton(t *testing.T) { + closedNoProbe := renderAppInfoWith(t, map[string]interface{}{"SetupGateClosed": true}) + if !strings.Contains(closedNoProbe, `id="setup-gate-card"`) || !strings.Contains(closedNoProbe, "/apps/gapp/setup-gate/open") || !strings.Contains(closedNoProbe, "Kész, beállítottam") { + 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") + } + if open := renderAppInfoWith(t, nil); strings.Contains(open, `id="setup-gate-card"`) { + t.Fatal("an app with no closed gate shows the gate card") + } +} + +func TestKnownLoginPage_TheChangedItButton(t *testing.T) { + warn := renderAppInfoWith(t, map[string]interface{}{"KnownLoginLine": "This app starts with a known, shared password: admin / admin123."}) + if !strings.Contains(warn, "/apps/gapp/default-login/changed") || !strings.Contains(warn, "Megv") { + t.Fatal("the default is named, but there is no 'I changed it' press") + } + if plain := renderAppInfoWith(t, nil); strings.Contains(plain, "/apps/gapp/default-login/changed") { + t.Fatal("'I changed it' shown with no default named") + } + if replaced := renderAppInfoWith(t, map[string]interface{}{"DefaultLoginReplaced": true, "KnownLoginLine": ""}); strings.Contains(replaced, "admin / admin123") { + t.Fatal("the default card is still shown after it was replaced") + } +} diff --git a/controller/internal/web/setup_gate_test.go b/controller/internal/web/setup_gate_test.go new file mode 100644 index 0000000..992ed95 --- /dev/null +++ b/controller/internal/web/setup_gate_test.go @@ -0,0 +1,217 @@ +package web + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// v0.280.0 (`09` §3 decision 46) — the setup gate's answerer: a stranger is refused, the household passes with +// its dashboard session, a pass survives a controller restart, and an opened gate stops asking. Driven through +// the same handlers traefik and the browser reach (ServeGateAuth, ServeGateStart). Docker is a stub on PATH. + +func gateHarness(t *testing.T) *Server { + t.Helper() + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + for _, d := range []string{bin, filepath.Join(dir, "data"), filepath.Join(dir, "stacks", "gapp")} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + app := filepath.Join(dir, "stacks", "gapp") + write := func(name, body string) { + if err := os.WriteFile(filepath.Join(app, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("docker-compose.yml", "services:\n gapp:\n image: busybox\n") + write(".felhom.yml", "display_name: Gated App\nslug: gapp\nsetup_gate: true\n") + write("app.yaml", "deployed: true\nsetup_gate:\n state: closed\n since: \"2026-09-29T00:00:00Z\"\n hosts: [gapp.example.hu, gapp-db.example.hu]\n") + lg := log.New(io.Discard, "", 0) + cfg := config.Default() + cfg.Customer.Domain = "example.hu" + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Paths.DataDir = filepath.Join(dir, "data") + sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + mgr, err := stacks.NewManager(cfg, lg) + if err != nil { + t.Fatal(err) + } + if err := mgr.ScanStacks(); err != nil { + t.Fatal(err) + } + s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test", sessions: map[string]*session{}} + s.loadTemplates() + return s +} + +// traefik's forwardAuth request for host+uri. +func gateAsk(s *Server, host, method, uri, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+gateAuthPath, nil) + r.Header.Set("X-Forwarded-Host", host) + r.Header.Set("X-Forwarded-Method", method) + r.Header.Set("X-Forwarded-Uri", uri) + r.Header.Set("Accept", accept) + for _, c := range cookies { + r.AddCookie(c) + } + w := httptest.NewRecorder() + s.ServeGateAuth(w, r) + return w +} + +func gateStart(s *Server, rd string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+gateStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil) + for _, c := range cookies { + r.AddCookie(c) + } + w := httptest.NewRecorder() + s.ServeGateStart(w, r) + return w +} + +// A stranger never reaches the app: a browser is sent to the dashboard's gate page, a script gets 401, a host no +// app owns gets 403, a forged pass is refused, and the gate page is no open redirect. +// COMPANION RED-PROOF: make ServeGateAuth answer 200 when there is no gate cookie (the pre-gate behaviour: the +// app answers everyone) → the first two assertions fail. +func TestSetupGate_AStrangerIsRefused(t *testing.T) { + s := gateHarness(t) + w := gateAsk(s, "gapp.example.hu", "GET", "/setup", "text/html,application/xhtml+xml") + if w.Code != http.StatusFound || !strings.HasPrefix(w.Header().Get("Location"), "https://felhom.example.hu/__gate/start?rd=https%3A%2F%2Fgapp.example.hu%2Fsetup") { + t.Fatalf("a stranger's browser: %d %q — want 302 to the dashboard's gate page", w.Code, w.Header().Get("Location")) + } + for _, c := range []struct{ method, accept string }{{"POST", "text/html"}, {"GET", "application/json"}, {"PUT", "*/*"}} { + if w := gateAsk(s, "gapp.example.hu", c.method, "/api/auth/admin-sign-up", c.accept); w.Code != http.StatusUnauthorized || + !strings.Contains(w.Body.String(), "waiting for its first setup") { + t.Fatalf("a stranger's %s %s: %d %q — want 401", c.method, c.accept, w.Code, w.Body.String()) + } + } + if w := gateAsk(s, "nobody.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden { + t.Fatalf("a host no gated app owns: %d, want 403 (fail closed)", w.Code) + } + if w := gateAsk(s, "gapp.example.hu", "GET", gateCallbackURI+"?t=eyJoIjoiZ2FwcC5leGFtcGxlLmh1In0", "text/html"); w.Code != http.StatusForbidden || len(w.Result().Cookies()) != 0 { + t.Fatalf("a forged pass: %d cookies=%d, want 403 and no cookie", w.Code, len(w.Result().Cookies())) + } + // The gate page, without a dashboard session: the sentence and a sign-in link, no pass. + w = gateStart(s, "https://gapp.example.hu/setup") + body := w.Body.String() + if w.Code != http.StatusOK || !strings.Contains(body, "Jelentkezz be a Felhom") || !strings.Contains(body, "/login?next=%2F__gate%2Fstart") { + t.Fatalf("gate page: %d, sentence/link missing:\n%s", w.Code, body) + } + if strings.Contains(body, gateCallbackURI) || len(w.Result().Cookies()) != 0 { + t.Fatal("the gate page handed a stranger a pass") + } + for _, rd := range []string{"https://evil.example/", "https://other.example.hu/", "http://gapp.example.hu/", "https://gapp.example.hu:8443/"} { + if w := gateStart(s, rd); w.Code != http.StatusFound || w.Header().Get("Location") != "/" { + t.Fatalf("rd %q: %d %q — want a plain 302 to / (no open redirect)", rd, w.Code, w.Header().Get("Location")) + } + } +} + +// The household passes with its dashboard session: a one-use token, swapped for a host-only gate cookie that +// opens that app's gate and no other. The dashboard cookie never reaches the app host. +// COMPANION RED-PROOF: drop the nonce check in takeGateToken → "a token worked twice" fails; drop the host from +// the cookie's MAC → "the pass for gapp opened gapp-db" fails. +func TestSetupGate_TheHouseholdPassesWithItsSession(t *testing.T) { + s := gateHarness(t) + sess := &http.Cookie{Name: sessionCookieName, Value: s.createSession()} + w := gateStart(s, "https://gapp.example.hu/setup", sess) + loc := w.Header().Get("Location") + if w.Code != http.StatusFound || !strings.HasPrefix(loc, "https://gapp.example.hu"+gateCallbackURI+"?t=") { + t.Fatalf("with a session: %d %q — want 302 to the app's callback", w.Code, loc) + } + cb, _ := url.Parse(loc) + w = gateAsk(s, "gapp.example.hu", "GET", cb.RequestURI(), "text/html") + if w.Code != http.StatusFound || w.Header().Get("Location") != "https://gapp.example.hu/setup" { + t.Fatalf("callback: %d %q", w.Code, w.Header().Get("Location")) + } + var pass *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == sessionCookieName { + t.Fatal("the dashboard session cookie was set on the app host") + } + if c.Name == gateCookieName { + pass = c + } + } + if pass == nil || !pass.HttpOnly || !pass.Secure || pass.Domain != "" { + t.Fatalf("gate cookie %+v — want HttpOnly, Secure, host-only", pass) + } + if w := gateAsk(s, "gapp.example.hu", "POST", "/api/auth/admin-sign-up", "application/json", pass); w.Code != http.StatusOK { + t.Fatalf("the household's pass: %d, want 200", w.Code) + } + if w := gateAsk(s, "gapp.example.hu", "GET", cb.RequestURI(), "text/html"); w.Code != http.StatusForbidden { + t.Fatalf("a token worked twice: %d", w.Code) + } + if w := gateAsk(s, "gapp-db.example.hu", "GET", "/", "application/json", pass); w.Code != http.StatusUnauthorized { + t.Fatalf("the pass for gapp opened gapp-db: %d", w.Code) + } + // A token for one host is refused on another, and an expired one is refused. + w = gateStart(s, "https://gapp.example.hu/", sess) + cb2, _ := url.Parse(w.Header().Get("Location")) + if w := gateAsk(s, "gapp-db.example.hu", "GET", cb2.RequestURI(), "text/html"); w.Code != http.StatusForbidden { + t.Fatalf("a token for gapp worked on gapp-db: %d", w.Code) + } + s.gateClock = func() time.Time { return time.Now().Add(gateTokenLife + time.Minute) } + if w := gateAsk(s, "gapp.example.hu", "GET", cb2.RequestURI(), "text/html"); w.Code != http.StatusForbidden { + t.Fatalf("an expired token: %d", w.Code) + } +} + +// A controller restart does not re-gate a browser that already passed: the key is persisted. +// COMPANION RED-PROOF: skip the os.WriteFile of the key in gateKey → the second server rejects the pass. +func TestSetupGate_ARestartKeepsTheHouseholdsPass(t *testing.T) { + s := gateHarness(t) + sess := &http.Cookie{Name: sessionCookieName, Value: s.createSession()} + cb, _ := url.Parse(gateStart(s, "https://gapp.example.hu/", sess).Header().Get("Location")) + var pass *http.Cookie + for _, c := range gateAsk(s, "gapp.example.hu", "GET", cb.RequestURI(), "text/html").Result().Cookies() { + if c.Name == gateCookieName { + pass = c + } + } + if pass == nil { + t.Fatal("no pass") + } + s2 := &Server{cfg: s.cfg, settings: s.settings, stackMgr: s.stackMgr, logger: s.logger, sessions: map[string]*session{}} + if w := gateAsk(s2, "gapp.example.hu", "GET", "/", "application/json", pass); w.Code != http.StatusOK { + t.Fatalf("after a restart the household's pass was refused: %d", w.Code) + } + if fi, err := os.Stat(filepath.Join(s.cfg.Paths.DataDir, "setup-gate.key")); err != nil || fi.Mode().Perm() != 0o600 { + t.Fatalf("key file: %v %v", fi, err) + } +} + +// Once the gate is open, the answerer lets everything through (traefik may still hold the file for a moment). +// COMPANION RED-PROOF: drop the `if !closed` branch in ServeGateAuth → the opened app still refuses. +func TestSetupGate_AnOpenedGateLetsEverythingThrough(t *testing.T) { + s := gateHarness(t) + if err := s.stackMgr.OpenSetupGate("gapp", stacks.SetupGateByHousehold); err != nil { + t.Fatal(err) + } + if w := gateAsk(s, "gapp.example.hu", "POST", "/api/x", "application/json"); w.Code != http.StatusOK { + t.Fatalf("an opened gate: %d, want 200", w.Code) + } + if w := gateStart(s, "https://gapp.example.hu/"); w.Header().Get("Location") != "/" { + t.Fatalf("the gate page served for an opened app: %d %q", w.Code, w.Header().Get("Location")) + } +} diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 0f5a5f0..6fd77e3 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -85,6 +85,20 @@ onerror="this.style.display='none'"> +{{- if .SetupGateClosed}} +
+

{{T "app_info.setup_gate_title"}}

+

{{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 .DataPathCards}}

{{T "app_info.hova_tegyem_a_fajlokat"}}

@@ -222,6 +236,10 @@ function appMigrate(btn,app,label){

{{T "app_info.alapertelmezett_belepes"}}

{{.AppInfo.DefaultCreds}}

{{if .KnownLoginLine}}

{{.KnownLoginLine}}

{{else}}

{{T "app_info.az_elso_bejelentkezes_utan_azonnal_2"}}

{{end}} + {{- if and .Stack.Deployed .KnownLoginLine}} + + + {{- end}}
{{end}} @@ -288,4 +306,21 @@ function icCopyPw(btn) { {{end}} {{template "layout_end" .}} +{{- if or .SetupGateClosed (and .Stack.Deployed .KnownLoginLine)}} + +{{- end}} {{end}} diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index 9a62806..ea1ab89 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -563,19 +563,26 @@ {{else if eq .Type "password"}}
+ {{- if $.AlreadyDeployed}} + {{- /* R-709 (v0.280.0): an installed app's password is never in this page; the eye fetches it. */}} + + {{- if not (and $.RestoredLogins (index $.RestoredLogins .EnvVar))}} + + {{- end}} + {{- else}} + required> - {{if not $.AlreadyDeployed}} - {{end}} + onclick="generatePassword('field-{{.EnvVar}}', 'field-confirm-{{.EnvVar}}', '{{.Generate}}')">{{T "deploy.generalas"}} + {{- end}}
{{if $.AlreadyDeployed}} {{if and $.RestoredLogins (index $.RestoredLogins .EnvVar)}}{{T "deploy.login_from_backup"}}{{else}}{{T "deploy.telepiteskor_beallitott_kezdeti_jelszo_h"}}{{end}} @@ -808,6 +815,30 @@ document.addEventListener('DOMContentLoaded', function() { // R-254 site two: on an already-deployed app the value is NOT in this page — it is fetched on // demand, and the server records the act. Nothing caches it between presses. +// R-709 (v0.280.0): an installed app's `type: password` value, fetched on demand like a secret. +function revealPasswordField(stackName, envVar, btn) { + var el = document.getElementById('field-' + envVar); + if (!el) return; + if (btn.dataset.shown === '1') { + el.value = ''; + el.type = 'password'; + btn.dataset.shown = ''; + return; + } + btn.disabled = true; + fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()), + credentials: 'same-origin', + body: 'env_var=' + encodeURIComponent(envVar) + }).then(function (r) { return r.json(); }).then(function (j) { + btn.disabled = false; + if (!j.ok) { showAlert(j.error || '{{T "deploy.a_lekeres_nem_sikerult"}}'); return; } + el.value = j.data.value; + el.type = 'text'; + btn.dataset.shown = '1'; + }).catch(function () { btn.disabled = false; showAlert('{{T "deploy.a_lekeres_nem_sikerult"}}'); }); +} function revealAutoField(stackName, envVar, btn) { var el = document.getElementById('auto-field-' + envVar); if (!el) return; @@ -839,13 +870,28 @@ function toggleAutoField(fieldId, btn) { el.type = el.type === 'password' ? 'text' : 'password'; btn.textContent = el.type === 'password' ? '{{T "deploy.megjelenites"}}' : '{{T "deploy.elrejtes"}}'; } -function generatePassword(fieldId, confirmFieldId) { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; +// spec is the field's `generate:` (e.g. "password:24:special"); empty = 16 letters and digits. With +// ":special" (v0.280.0) the password carries a lower, an upper, a digit and one of SPECIAL — for an app whose +// own policy demands it (calibre-web). SPECIAL matches internal/stacks generateValue. +function generatePassword(fieldId, confirmFieldId, spec) { + const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const SPECIAL = '-_.!@#%+='; + var parts = (spec || '').split(':'); + var n = parseInt(parts[1], 10); + if (parts[0] !== 'password' || !(n >= 12 && n <= 64)) { n = 16; } + var special = parts[2] === 'special'; + var chars = special ? letters + SPECIAL : letters; let pass = ''; - const arr = new Uint8Array(16); - crypto.getRandomValues(arr); - for (let i = 0; i < 16; i++) { - pass += chars[arr[i] % chars.length]; + for (;;) { + pass = ''; + const arr = new Uint32Array(n); + crypto.getRandomValues(arr); + for (let i = 0; i < n; i++) { + pass += chars[arr[i] % chars.length]; + } + if (!special || (/[a-z]/.test(pass) && /[A-Z]/.test(pass) && /[0-9]/.test(pass) && /[-_.!@#%+=]/.test(pass) && /^[A-Za-z0-9]/.test(pass))) { + break; + } } document.getElementById(fieldId).value = pass; if (confirmFieldId) { diff --git a/controller/internal/web/templates/setupgate.html b/controller/internal/web/templates/setupgate.html new file mode 100644 index 0000000..8f5c19d --- /dev/null +++ b/controller/internal/web/templates/setupgate.html @@ -0,0 +1,24 @@ +{{define "setupgate"}} + + + + + + + {{if .AppName}}{{.AppName}} — {{end}}{{T "setup_gate.page_title"}} + + + + + + +{{end}} diff --git a/controller/internal/web/testdata/i18n_parity/app_info_known_login_changed.html b/controller/internal/web/testdata/i18n_parity/app_info_known_login_changed.html new file mode 100644 index 0000000..a2a5fc2 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_info_known_login_changed.html @@ -0,0 +1,603 @@ + + + + + + + + Calibre-Web — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
+ +
+ +

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

Alapértelmezett belépés

+

admin / admin123

+

Ez az alkalmazás egy ismert, közös jelszóval indul: admin / admin123. Telepítés után azonnal változtasd meg.

+ + +
+ + + +
+ + + + + +
+ + + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_info_setup_gate_button.html b/controller/internal/web/testdata/i18n_parity/app_info_setup_gate_button.html new file mode 100644 index 0000000..b001f50 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_info_setup_gate_button.html @@ -0,0 +1,588 @@ + + + + + + + + Immich — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
+ +
+ +

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

Első beállítás

+

Most csak te éred el ezt az alkalmazást, amíg be vagy jelentkezve a vezérlőpultba. Így más nem hozhatja létre az első admin fiókot. Nyisd meg, és végezd el az első beállítást.

+

Ha kész, nyomd meg ezt a gombot. Addig a telefonos alkalmazások és a család többi tagja nem éri el.

+ + +
+ + + + + + + + + + +
+ + + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_info_setup_gate_probe.html b/controller/internal/web/testdata/i18n_parity/app_info_setup_gate_probe.html new file mode 100644 index 0000000..b9348db --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_info_setup_gate_probe.html @@ -0,0 +1,586 @@ + + + + + + + + Immich — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
+ +
+ +

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

Első beállítás

+

Most csak te éred el ezt az alkalmazást, amíg be vagy jelentkezve a vezérlőpultba. Így más nem hozhatja létre az első admin fiókot. Nyisd meg, és végezd el az első beállítást.

+

Ha kész, a doboz magától észreveszi, és mindenkinek megnyitja az alkalmazást.

+
+ + + + + + + + + + +
+ + + + + diff --git a/controller/internal/web/testdata/i18n_parity/deploy_deployed_restored_login.html b/controller/internal/web/testdata/i18n_parity/deploy_deployed_restored_login.html index 649399f..ac16501 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_deployed_restored_login.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_deployed_restored_login.html @@ -776,16 +776,7 @@
- - - +
Mentésből töltötted vissza: a belépéshez a mentés idején érvényes jelszavad kell. Az itt tárolt érték nem az, ezért nem mutatjuk. @@ -1046,6 +1037,30 @@ document.addEventListener('DOMContentLoaded', function() { + +function revealPasswordField(stackName, envVar, btn) { + var el = document.getElementById('field-' + envVar); + if (!el) return; + if (btn.dataset.shown === '1') { + el.value = ''; + el.type = 'password'; + btn.dataset.shown = ''; + return; + } + btn.disabled = true; + fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()), + credentials: 'same-origin', + body: 'env_var=' + encodeURIComponent(envVar) + }).then(function (r) { return r.json(); }).then(function (j) { + btn.disabled = false; + if (!j.ok) { showAlert(j.error || 'A lekérés nem sikerült.'); return; } + el.value = j.data.value; + el.type = 'text'; + btn.dataset.shown = '1'; + }).catch(function () { btn.disabled = false; showAlert('A lekérés nem sikerült.'); }); +} function revealAutoField(stackName, envVar, btn) { var el = document.getElementById('auto-field-' + envVar); if (!el) return; @@ -1077,13 +1092,28 @@ function toggleAutoField(fieldId, btn) { el.type = el.type === 'password' ? 'text' : 'password'; btn.textContent = el.type === 'password' ? 'Megjelenítés' : 'Elrejtés'; } -function generatePassword(fieldId, confirmFieldId) { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + + +function generatePassword(fieldId, confirmFieldId, spec) { + const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const SPECIAL = '-_.!@#%+='; + var parts = (spec || '').split(':'); + var n = parseInt(parts[1], 10); + if (parts[0] !== 'password' || !(n >= 12 && n <= 64)) { n = 16; } + var special = parts[2] === 'special'; + var chars = special ? letters + SPECIAL : letters; let pass = ''; - const arr = new Uint8Array(16); - crypto.getRandomValues(arr); - for (let i = 0; i < 16; i++) { - pass += chars[arr[i] % chars.length]; + for (;;) { + pass = ''; + const arr = new Uint32Array(n); + crypto.getRandomValues(arr); + for (let i = 0; i < n; i++) { + pass += chars[arr[i] % chars.length]; + } + if (!special || (/[a-z]/.test(pass) && /[A-Z]/.test(pass) && /[0-9]/.test(pass) && /[-_.!@#%+=]/.test(pass) && /^[A-Za-z0-9]/.test(pass))) { + break; + } } document.getElementById(fieldId).value = pass; if (confirmFieldId) { diff --git a/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html b/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html index ab43d72..3ae8abc 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html @@ -776,16 +776,10 @@
- + -
Telepítéskor beállított kezdeti jelszó — ha az alkalmazásban megváltoztattad, az itt nem frissül. @@ -1046,6 +1040,30 @@ document.addEventListener('DOMContentLoaded', function() { + +function revealPasswordField(stackName, envVar, btn) { + var el = document.getElementById('field-' + envVar); + if (!el) return; + if (btn.dataset.shown === '1') { + el.value = ''; + el.type = 'password'; + btn.dataset.shown = ''; + return; + } + btn.disabled = true; + fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()), + credentials: 'same-origin', + body: 'env_var=' + encodeURIComponent(envVar) + }).then(function (r) { return r.json(); }).then(function (j) { + btn.disabled = false; + if (!j.ok) { showAlert(j.error || 'A lekérés nem sikerült.'); return; } + el.value = j.data.value; + el.type = 'text'; + btn.dataset.shown = '1'; + }).catch(function () { btn.disabled = false; showAlert('A lekérés nem sikerült.'); }); +} function revealAutoField(stackName, envVar, btn) { var el = document.getElementById('auto-field-' + envVar); if (!el) return; @@ -1077,13 +1095,28 @@ function toggleAutoField(fieldId, btn) { el.type = el.type === 'password' ? 'text' : 'password'; btn.textContent = el.type === 'password' ? 'Megjelenítés' : 'Elrejtés'; } -function generatePassword(fieldId, confirmFieldId) { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + + +function generatePassword(fieldId, confirmFieldId, spec) { + const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const SPECIAL = '-_.!@#%+='; + var parts = (spec || '').split(':'); + var n = parseInt(parts[1], 10); + if (parts[0] !== 'password' || !(n >= 12 && n <= 64)) { n = 16; } + var special = parts[2] === 'special'; + var chars = special ? letters + SPECIAL : letters; let pass = ''; - const arr = new Uint8Array(16); - crypto.getRandomValues(arr); - for (let i = 0; i < 16; i++) { - pass += chars[arr[i] % chars.length]; + for (;;) { + pass = ''; + const arr = new Uint32Array(n); + crypto.getRandomValues(arr); + for (let i = 0; i < n; i++) { + pass += chars[arr[i] % chars.length]; + } + if (!special || (/[a-z]/.test(pass) && /[A-Z]/.test(pass) && /[0-9]/.test(pass) && /[-_.!@#%+=]/.test(pass) && /^[A-Za-z0-9]/.test(pass))) { + break; + } } document.getElementById(fieldId).value = pass; if (confirmFieldId) { diff --git a/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html b/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html index 61e1c52..effd9f8 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html @@ -731,16 +731,10 @@
- + -
Telepítéskor beállított kezdeti jelszó — ha az alkalmazásban megváltoztattad, az itt nem frissül. @@ -973,6 +967,30 @@ document.addEventListener('DOMContentLoaded', function() { + +function revealPasswordField(stackName, envVar, btn) { + var el = document.getElementById('field-' + envVar); + if (!el) return; + if (btn.dataset.shown === '1') { + el.value = ''; + el.type = 'password'; + btn.dataset.shown = ''; + return; + } + btn.disabled = true; + fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()), + credentials: 'same-origin', + body: 'env_var=' + encodeURIComponent(envVar) + }).then(function (r) { return r.json(); }).then(function (j) { + btn.disabled = false; + if (!j.ok) { showAlert(j.error || 'A lekérés nem sikerült.'); return; } + el.value = j.data.value; + el.type = 'text'; + btn.dataset.shown = '1'; + }).catch(function () { btn.disabled = false; showAlert('A lekérés nem sikerült.'); }); +} function revealAutoField(stackName, envVar, btn) { var el = document.getElementById('auto-field-' + envVar); if (!el) return; @@ -1004,13 +1022,28 @@ function toggleAutoField(fieldId, btn) { el.type = el.type === 'password' ? 'text' : 'password'; btn.textContent = el.type === 'password' ? 'Megjelenítés' : 'Elrejtés'; } -function generatePassword(fieldId, confirmFieldId) { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + + +function generatePassword(fieldId, confirmFieldId, spec) { + const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const SPECIAL = '-_.!@#%+='; + var parts = (spec || '').split(':'); + var n = parseInt(parts[1], 10); + if (parts[0] !== 'password' || !(n >= 12 && n <= 64)) { n = 16; } + var special = parts[2] === 'special'; + var chars = special ? letters + SPECIAL : letters; let pass = ''; - const arr = new Uint8Array(16); - crypto.getRandomValues(arr); - for (let i = 0; i < 16; i++) { - pass += chars[arr[i] % chars.length]; + for (;;) { + pass = ''; + const arr = new Uint32Array(n); + crypto.getRandomValues(arr); + for (let i = 0; i < n; i++) { + pass += chars[arr[i] % chars.length]; + } + if (!special || (/[a-z]/.test(pass) && /[A-Z]/.test(pass) && /[0-9]/.test(pass) && /[-_.!@#%+=]/.test(pass) && /^[A-Za-z0-9]/.test(pass))) { + break; + } } document.getElementById(fieldId).value = pass; if (confirmFieldId) { diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new.html b/controller/internal/web/testdata/i18n_parity/deploy_new.html index 5bd84bf..3705d89 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new.html @@ -744,15 +744,12 @@ class="form-control" value="" placeholder="Legalább 12 karakter" data-field-type="password" - required - > + required> - - + onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás
@@ -1022,6 +1019,30 @@ document.addEventListener('DOMContentLoaded', function() { + +function revealPasswordField(stackName, envVar, btn) { + var el = document.getElementById('field-' + envVar); + if (!el) return; + if (btn.dataset.shown === '1') { + el.value = ''; + el.type = 'password'; + btn.dataset.shown = ''; + return; + } + btn.disabled = true; + fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()), + credentials: 'same-origin', + body: 'env_var=' + encodeURIComponent(envVar) + }).then(function (r) { return r.json(); }).then(function (j) { + btn.disabled = false; + if (!j.ok) { showAlert(j.error || 'A lekérés nem sikerült.'); return; } + el.value = j.data.value; + el.type = 'text'; + btn.dataset.shown = '1'; + }).catch(function () { btn.disabled = false; showAlert('A lekérés nem sikerült.'); }); +} function revealAutoField(stackName, envVar, btn) { var el = document.getElementById('auto-field-' + envVar); if (!el) return; @@ -1053,13 +1074,28 @@ function toggleAutoField(fieldId, btn) { el.type = el.type === 'password' ? 'text' : 'password'; btn.textContent = el.type === 'password' ? 'Megjelenítés' : 'Elrejtés'; } -function generatePassword(fieldId, confirmFieldId) { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + + +function generatePassword(fieldId, confirmFieldId, spec) { + const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const SPECIAL = '-_.!@#%+='; + var parts = (spec || '').split(':'); + var n = parseInt(parts[1], 10); + if (parts[0] !== 'password' || !(n >= 12 && n <= 64)) { n = 16; } + var special = parts[2] === 'special'; + var chars = special ? letters + SPECIAL : letters; let pass = ''; - const arr = new Uint8Array(16); - crypto.getRandomValues(arr); - for (let i = 0; i < 16; i++) { - pass += chars[arr[i] % chars.length]; + for (;;) { + pass = ''; + const arr = new Uint32Array(n); + crypto.getRandomValues(arr); + for (let i = 0; i < n; i++) { + pass += chars[arr[i] % chars.length]; + } + if (!special || (/[a-z]/.test(pass) && /[A-Z]/.test(pass) && /[0-9]/.test(pass) && /[-_.!@#%+=]/.test(pass) && /^[A-Za-z0-9]/.test(pass))) { + break; + } } document.getElementById(fieldId).value = pass; if (confirmFieldId) { diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html b/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html index d73d4ab..0245f5b 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html @@ -721,15 +721,12 @@ class="form-control" value="" placeholder="Legalább 12 karakter" data-field-type="password" - required - > + required> - - + onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás
@@ -971,6 +968,30 @@ document.addEventListener('DOMContentLoaded', function() { + +function revealPasswordField(stackName, envVar, btn) { + var el = document.getElementById('field-' + envVar); + if (!el) return; + if (btn.dataset.shown === '1') { + el.value = ''; + el.type = 'password'; + btn.dataset.shown = ''; + return; + } + btn.disabled = true; + fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()), + credentials: 'same-origin', + body: 'env_var=' + encodeURIComponent(envVar) + }).then(function (r) { return r.json(); }).then(function (j) { + btn.disabled = false; + if (!j.ok) { showAlert(j.error || 'A lekérés nem sikerült.'); return; } + el.value = j.data.value; + el.type = 'text'; + btn.dataset.shown = '1'; + }).catch(function () { btn.disabled = false; showAlert('A lekérés nem sikerült.'); }); +} function revealAutoField(stackName, envVar, btn) { var el = document.getElementById('auto-field-' + envVar); if (!el) return; @@ -1002,13 +1023,28 @@ function toggleAutoField(fieldId, btn) { el.type = el.type === 'password' ? 'text' : 'password'; btn.textContent = el.type === 'password' ? 'Megjelenítés' : 'Elrejtés'; } -function generatePassword(fieldId, confirmFieldId) { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + + +function generatePassword(fieldId, confirmFieldId, spec) { + const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const SPECIAL = '-_.!@#%+='; + var parts = (spec || '').split(':'); + var n = parseInt(parts[1], 10); + if (parts[0] !== 'password' || !(n >= 12 && n <= 64)) { n = 16; } + var special = parts[2] === 'special'; + var chars = special ? letters + SPECIAL : letters; let pass = ''; - const arr = new Uint8Array(16); - crypto.getRandomValues(arr); - for (let i = 0; i < 16; i++) { - pass += chars[arr[i] % chars.length]; + for (;;) { + pass = ''; + const arr = new Uint32Array(n); + crypto.getRandomValues(arr); + for (let i = 0; i < n; i++) { + pass += chars[arr[i] % chars.length]; + } + if (!special || (/[a-z]/.test(pass) && /[A-Z]/.test(pass) && /[0-9]/.test(pass) && /[-_.!@#%+=]/.test(pass) && /^[A-Za-z0-9]/.test(pass))) { + break; + } } document.getElementById(fieldId).value = pass; if (confirmFieldId) { diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html b/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html index 94ee87f..04af78e 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html @@ -721,15 +721,12 @@ class="form-control" value="" placeholder="Legalább 12 karakter" data-field-type="password" - required - > + required> - - + onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás
@@ -1001,6 +998,30 @@ document.addEventListener('DOMContentLoaded', function() { + +function revealPasswordField(stackName, envVar, btn) { + var el = document.getElementById('field-' + envVar); + if (!el) return; + if (btn.dataset.shown === '1') { + el.value = ''; + el.type = 'password'; + btn.dataset.shown = ''; + return; + } + btn.disabled = true; + fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()), + credentials: 'same-origin', + body: 'env_var=' + encodeURIComponent(envVar) + }).then(function (r) { return r.json(); }).then(function (j) { + btn.disabled = false; + if (!j.ok) { showAlert(j.error || 'A lekérés nem sikerült.'); return; } + el.value = j.data.value; + el.type = 'text'; + btn.dataset.shown = '1'; + }).catch(function () { btn.disabled = false; showAlert('A lekérés nem sikerült.'); }); +} function revealAutoField(stackName, envVar, btn) { var el = document.getElementById('auto-field-' + envVar); if (!el) return; @@ -1032,13 +1053,28 @@ function toggleAutoField(fieldId, btn) { el.type = el.type === 'password' ? 'text' : 'password'; btn.textContent = el.type === 'password' ? 'Megjelenítés' : 'Elrejtés'; } -function generatePassword(fieldId, confirmFieldId) { - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + + +function generatePassword(fieldId, confirmFieldId, spec) { + const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const SPECIAL = '-_.!@#%+='; + var parts = (spec || '').split(':'); + var n = parseInt(parts[1], 10); + if (parts[0] !== 'password' || !(n >= 12 && n <= 64)) { n = 16; } + var special = parts[2] === 'special'; + var chars = special ? letters + SPECIAL : letters; let pass = ''; - const arr = new Uint8Array(16); - crypto.getRandomValues(arr); - for (let i = 0; i < 16; i++) { - pass += chars[arr[i] % chars.length]; + for (;;) { + pass = ''; + const arr = new Uint32Array(n); + crypto.getRandomValues(arr); + for (let i = 0; i < n; i++) { + pass += chars[arr[i] % chars.length]; + } + if (!special || (/[a-z]/.test(pass) && /[A-Z]/.test(pass) && /[0-9]/.test(pass) && /[-_.!@#%+=]/.test(pass) && /^[A-Za-z0-9]/.test(pass))) { + break; + } } document.getElementById(fieldId).value = pass; if (confirmFieldId) { diff --git a/controller/internal/web/testdata/i18n_parity/setupgate.html b/controller/internal/web/testdata/i18n_parity/setupgate.html new file mode 100644 index 0000000..1fed080 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/setupgate.html @@ -0,0 +1,27 @@ + + + + + + + + Immich — Beállításra vár + + + + + + diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 3558596..bdc5258 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -92,6 +92,9 @@ "kept.backup.own": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", "kept.backup.second": "BORN AS A KEY, kept-data release (09 3 decision 36, Part E) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go / internal/stacks/kept_test.go / internal/web/kept_fb_test.go.", "app_info.known_login": "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.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.", "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.",