v0.308.0 (R-554, held-back-able): recovery-info.txt points at the dashboard's Restore page and the recovery code; the first-boot setup wizard is deleted

Operator ruling 2026-10-12 (S27, 09 §3 decision 222). The operator approves the Hungarian text before
release. A box whose first ingestion failed now serves one bilingual waiting page and retries every
minute instead of the wizard. Own commit so the lead can hold it back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-12 15:38:28 +02:00
parent fd28d70bff
commit faef8f8a91
36 changed files with 421 additions and 1916 deletions
+11
View File
@@ -41,6 +41,17 @@
- **R-521 (S32, controller half) — the lost-drive option.** The household's „Meghajtó leválasztva" option already exists, - **R-521 (S32, controller half) — the lost-drive option.** The household's „Meghajtó leválasztva" option already exists,
saves and is sent in `enabled_events`; `TestR521_StorageDisconnectedOptionRoundTrips` now pins it both ways (RED with saves and is sent in `enabled_events`; `TestR521_StorageDisconnectedOptionRoundTrips` now pins it both ways (RED with
the option dropped from the handler). It has been in `DefaultEnabledEvents` since v0.17.0 — left as it is (see REPORT). the option dropped from the handler). It has been in `DefaultEnabledEvents` since v0.17.0 — left as it is (see REPORT).
- **R-554 (S27) — recovery-info.txt points at the dashboard; the first-boot setup wizard is deleted.** (Own commit, so it
can be held back; **the operator approves the Hungarian text before release.**) (a) `recovery-info.txt` now says:
open the dashboard, restore an app on Biztonsági mentés → Visszaállítás, and after a reinstall reopen the earlier
off-site backups on Adatok visszaszerzése with the ten-word recovery code — in the box's language (new bundle key
`recovery_info.body`); no more docker-setup.sh / port 8081 / „Visszaállítás mentésből". `TestRecoveryInfo_PointsAtDashboardNotWizard`
(RED with the old text: „hu: the file still carries "docker-setup.sh""). (b) `internal/setup/` (wizard, templates,
its CSRF, `.needs-setup`) and `runSetupMode` are deleted, with the debug page's „DR / Telepítő varázsló" trigger and
its copy. A box whose first ingestion failed now serves ONE bilingual waiting page, keeps `/api/health` green, and
retries every minute (`bootstrap.WaitForConfig`); once configured it starts normally. `TestWaitForConfig_ServesWaitingPageThenStarts`
(RED with the page answering 404: „GET / = 404 — an unconfigured box must show the waiting page, not a 404").
`web.setup_listen` is still read (an old controller.yaml loads) and unused.
## v0.307.0 — after a restart the Databases table lists each database once, not every undo copy (2026-10-10) ## v0.307.0 — after a restart the Databases table lists each database once, not every undo copy (2026-10-10)
+4 -5
View File
@@ -113,7 +113,6 @@
| `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback | | `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback |
| `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) | | `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) |
| `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) | | `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) |
| `atomicWriteFile` | controller/internal/setup/handlers.go | `(path, data, perm) error` | setup-wizard writes | Same bind-mount fallback caveat |
| `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand | | `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand |
| `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-<ts>` + safe defaults; never crash-loops | | `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-<ts>` + safe defaults; never crash-loops |
| `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup | | `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup |
@@ -281,10 +280,11 @@
| `i18n.Bundle` (`Shared` / `Msg` / `Plural` / `Expand`) | controller/internal/i18n/i18n.go | `Msg(lang, key) string` | ALL customer copy that has been converted (v0.247.0) | hu.json is authoritative; a key missing in en shows Hungarian (counted), never blank; `Expand` runs BEFORE template parse — never add a runtime `T` func (escaping would change Hungarian bytes). A template edit to converted copy goes in hu.json + en.json, then `TestI18nParity` must still pass | | `i18n.Bundle` (`Shared` / `Msg` / `Plural` / `Expand`) | controller/internal/i18n/i18n.go | `Msg(lang, key) string` | ALL customer copy that has been converted (v0.247.0) | hu.json is authoritative; a key missing in en shows Hungarian (counted), never blank; `Expand` runs BEFORE template parse — never add a runtime `T` func (escaping would change Hungarian bytes). A template edit to converted copy goes in hu.json + en.json, then `TestI18nParity` must still pass |
| `huTemplateSource` (test) | controller/internal/web/i18n_wiring_test.go | `(name) ([]byte, error)` | A test that asserts a template's Hungarian WORDING | Reads the expansion, not the raw file — since slice 1 the wording lives in hu.json. Never use `templateFS.ReadFile` for a wording assertion | | `huTemplateSource` (test) | controller/internal/web/i18n_wiring_test.go | `(name) ([]byte, error)` | A test that asserts a template's Hungarian WORDING | Reads the expansion, not the raw file — since slice 1 the wording lives in hu.json. Never use `templateFS.ReadFile` for a wording assertion |
| `Server.langFor` / `templatesFor` / `addLanguageData` | controller/internal/web/server.go + i18n_web.go | request → lang → template set | Rendering a page in the household's language | Only `executeTemplate` is language-aware; `s.tmpl` is the Hungarian set; Go-side titles via `data["TitleKey"]` | | `Server.langFor` / `templatesFor` / `addLanguageData` | controller/internal/web/server.go + i18n_web.go | request → lang → template set | Rendering a page in the household's language | Only `executeTemplate` is language-aware; `s.tmpl` is the Hungarian set; Go-side titles via `data["TitleKey"]` |
| `bootstrap.Unconfigured` / `WaitForConfig` / `WaitingHandler` (v0.308.0, R-554) | controller/internal/bootstrap/waiting.go | `WaitForConfig(ctx, ln, configPath, cfg, logger, pull, every, version) *config.Config` | The ONLY unconfigured-box path: the bilingual waiting page + a retry of `MaybeIngest` every minute; returns the configured config (nil on stop) | Replaces the deleted setup wizard. `/api/health` stays 200 while waiting; a nil listener still retries |
| `i18n.FormatDateTime` / `FormatDate` / `FormatMonthDayTime` / `FormatDecimal` / `LocalizeDecimals` | controller/internal/i18n/format.go | `(lang, …) string` | EVERY date or decimal number shown on the dashboard (R-562, v0.308.0): hu „2026. 10. 10. 15:04" + decimal comma, en „2026-10-10 15:04" + point | Pass the time already in Budapest; `LocalizeDecimals` is only for a size another package already formatted („1.5 GB") — new code calls `FormatDecimal`. Templates use the bound names in `formatFuncs(lang)` (funcmap.go: `fmtTime`, `fmtTimeStr`, `fmtGB`, `fmtBytes`, `numText`, …); Go page data uses `fmtLocalTime(lang, t)` / `fmtRFC3339Local(lang, s)` / `fmtSizeBytes(lang, n)`; page scripts use `fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` (layout.html). Never a `"2006-01-02 15:04"` literal for display. CSS widths keep `printf "%.1f"` | | `i18n.FormatDateTime` / `FormatDate` / `FormatMonthDayTime` / `FormatDecimal` / `LocalizeDecimals` | controller/internal/i18n/format.go | `(lang, …) string` | EVERY date or decimal number shown on the dashboard (R-562, v0.308.0): hu „2026. 10. 10. 15:04" + decimal comma, en „2026-10-10 15:04" + point | Pass the time already in Budapest; `LocalizeDecimals` is only for a size another package already formatted („1.5 GB") — new code calls `FormatDecimal`. Templates use the bound names in `formatFuncs(lang)` (funcmap.go: `fmtTime`, `fmtTimeStr`, `fmtGB`, `fmtBytes`, `numText`, …); Go page data uses `fmtLocalTime(lang, t)` / `fmtRFC3339Local(lang, s)` / `fmtSizeBytes(lang, n)`; page scripts use `fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` (layout.html). Never a `"2006-01-02 15:04"` literal for display. CSS widths keep `printf "%.1f"` |
| `timeAgoStr` | controller/internal/web/funcmap.go | `(s RFC3339 string) string` | Ago-format for STRING timestamps | Exists because `timeAgo(time.Time)` 500'd on strings (v0.93 bug) | | `timeAgoStr` | controller/internal/web/funcmap.go | `(s RFC3339 string) string` | Ago-format for STRING timestamps | Exists because `timeAgo(time.Time)` 500'd on strings (v0.93 bug) |
| `Server.baseData` / `executeTemplate` | controller/internal/web/handlers.go + server.go | page-data plumbing | New pages | baseData injects nav/alerts/version; templates must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` | | `Server.baseData` / `executeTemplate` | controller/internal/web/handlers.go + server.go | page-data plumbing | New pages | baseData injects nav/alerts/version; templates must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` |
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input; setup wizard has its OWN csrf (§6) | | `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input |
| `LogBuffer` + `Lines(maxBytes)` | controller/internal/web/logbuffer.go | ring buffer io.Writer | In-memory log capture for the debug UI + the report `controller_log_tail` source | v0.116.0: ALWAYS constructed (any logging.level) — the logger is `MultiWriter(LevelFilterWriter(stdout, level), ring)`; `Lines` drops OLDEST to honor the byte budget | | `LogBuffer` + `Lines(maxBytes)` | controller/internal/web/logbuffer.go | ring buffer io.Writer | In-memory log capture for the debug UI + the report `controller_log_tail` source | v0.116.0: ALWAYS constructed (any logging.level) — the logger is `MultiWriter(LevelFilterWriter(stdout, level), ring)`; `Lines` drops OLDEST to honor the byte budget |
| `logx.Debugf/Infof/Warnf/Errorf` | controller/internal/logx/logx.go | `(l *log.Logger, format, args…)` | ALL NEW leveled log lines (the v0.116.0 sweep standard) | routing is the WRITER's job — Debugf always reaches the ring, stdout filters; nil logger = no-op; caller-attributed (Output calldepth 3) | | `logx.Debugf/Infof/Warnf/Errorf` | controller/internal/logx/logx.go | `(l *log.Logger, format, args…)` | ALL NEW leveled log lines (the v0.116.0 sweep standard) | routing is the WRITER's job — Debugf always reaches the ring, stdout filters; nil logger = no-op; caller-attributed (Output calldepth 3) |
| `web.LevelFilterWriter` | controller/internal/web/levelfilter.go | `NewLevelFilterWriter(w, minLevel)` | stdout leveling under the always-on ring | untagged lines parse INFO; always reports full length written | | `web.LevelFilterWriter` | controller/internal/web/levelfilter.go | `NewLevelFilterWriter(w, minLevel)` | stdout leveling under the always-on ring | untagged lines parse INFO; always reports full length written |
@@ -319,7 +319,7 @@
| `timeAgo` on an RFC3339 string field | Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) | `timeAgoStr` | | `timeAgo` on an RFC3339 string field | Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) | `timeAgoStr` |
| `backup.Manager.DumpAppVolumes` on a running DB app | Inconsistent tar of live DB volume | `DumpAppVolumesSafe` (stop → dump → restart, both errors surfaced) | | `backup.Manager.DumpAppVolumes` on a running DB app | Inconsistent tar of live DB volume | `DumpAppVolumesSafe` (stop → dump → restart, both errors surfaced) |
| `stacks.Manager.execCommand` / `composeExecCustomEnv` for NEW long-running calls | No context/timeout — a hung docker CLI blocks forever | `exec.CommandContext` + explicit timeout (copy `rsyncCopy` or appexport `composeExecEnv`) | | `stacks.Manager.execCommand` / `composeExecCustomEnv` for NEW long-running calls | No context/timeout — a hung docker CLI blocks forever | `exec.CommandContext` + explicit timeout (copy `rsyncCopy` or appexport `composeExecEnv`) |
| `config.LoadPermissive` | Skips validation — setup-mode only (customer.id/domain may be unset) | `config.Load` everywhere else | | `config.LoadPermissive` | Skips validation — first boot only (customer.id/domain may be unset until `bootstrap.WaitForConfig` gets the configuration, R-554) | `config.Load` everywhere else |
| `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) | | `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) |
| any settings getter/setter or helper that ends in one (`m.note`, `boxLang`) INSIDE a `settings.UpdateOffboxStatus`/`UpdateCrossDriveStatus` callback (R-578) | The callback runs under the settings WRITE lock and `sync.RWMutex` is not reentrant — it DEADLOCKS holding the lock and wedges settings.json for the box | Resolve the value BEFORE the callback (`lang := m.boxLang()`); `TestR578NothingTakesTheSettingsLockInsideASettingsCallback` (controller/internal/settings/r578_callback_lock_gate_test.go) convicts it in every package | | any settings getter/setter or helper that ends in one (`m.note`, `boxLang`) INSIDE a `settings.UpdateOffboxStatus`/`UpdateCrossDriveStatus` callback (R-578) | The callback runs under the settings WRITE lock and `sync.RWMutex` is not reentrant — it DEADLOCKS holding the lock and wedges settings.json for the box | Resolve the value BEFORE the callback (`lang := m.boxLang()`); `TestR578NothingTakesTheSettingsLockInsideASettingsCallback` (controller/internal/settings/r578_callback_lock_gate_test.go) convicts it in every package |
| `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` | | `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` |
@@ -411,13 +411,12 @@ Cross-repo edges:
| Duplication | Locations | | Duplication | Locations |
|---|---| |---|---|
| Atomic write ×4 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/setup/handlers.go `atomicWriteFile`; controller/internal/api/router.go `writeConfig0600` | | Atomic write ×3 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/api/router.go `writeConfig0600` (the setup wizard's `atomicWriteFile` went with it, R-554) |
| String truncation ×4 | controller/internal/util/strings.go `TruncateStr` (rune-safe, 1 caller); controller/internal/stacks/manager.go `truncateStr` (byte-based HEAD, widely used) and `tailStr` (rune-safe TAIL, v0.294.0 R-864 — use it for a command's stderr: the error is printed last); controller/internal/backup/offbox.go `truncate` | | String truncation ×4 | controller/internal/util/strings.go `TruncateStr` (rune-safe, 1 caller); controller/internal/stacks/manager.go `truncateStr` (byte-based HEAD, widely used) and `tailStr` (rune-safe TAIL, v0.294.0 R-864 — use it for a command's stderr: the error is printed last); controller/internal/backup/offbox.go `truncate` |
| humanizeBytes ×3 | controller/internal/appbackup/appdata.go `HumanizeBytes` (canonical) + private twin; controller/internal/appexport/estimate.go `humanizeBytes`; controller/internal/backup/appbackup_bridge.go wrapper (deliberate bridge) | | humanizeBytes ×3 | controller/internal/appbackup/appdata.go `HumanizeBytes` (canonical) + private twin; controller/internal/appexport/estimate.go `humanizeBytes`; controller/internal/backup/appbackup_bridge.go wrapper (deliberate bridge) |
| copyFile ×2 | controller/internal/stacks/migrate.go (returns bytes) vs controller/internal/appexport/export.go | | copyFile ×2 | controller/internal/stacks/migrate.go (returns bytes) vs controller/internal/appexport/export.go |
| dir-size ×6 | controller/internal/stacks/delete.go `getDirSizeBytes`/`getDirSizeHuman`; controller/internal/backup/tier2.go `dirSizeBytes` (du -sb); controller/internal/appexport/estimate.go `dirSize`+`duBytes`; controller/internal/appexport/export.go `calcDirSize`; controller/internal/web/handlers.go `dirSizeHuman` | | dir-size ×6 | controller/internal/stacks/delete.go `getDirSizeBytes`/`getDirSizeHuman`; controller/internal/backup/tier2.go `dirSizeBytes` (du -sb); controller/internal/appexport/estimate.go `dirSize`+`duBytes`; controller/internal/appexport/export.go `calcDirSize`; controller/internal/web/handlers.go `dirSizeHuman` |
| timeAgo switch body ×2 | controller/internal/web/funcmap.go `timeAgo` vs `timeAgoStr` (identical formatting logic) | | timeAgo switch body ×2 | controller/internal/web/funcmap.go `timeAgo` vs `timeAgoStr` (identical formatting logic) |
| CSRF ×2 | controller/internal/web/csrf.go (session HMAC) vs controller/internal/setup/csrf.go (cookie double-submit) — intentional (pre-auth wizard) but unlabeled |
| Budapest timezone loader ×4 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` vs controller/internal/api/kept_install.go `budapest` (v0.277.0, the kept choice's dates) (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) | | Budapest timezone loader ×4 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` vs controller/internal/api/kept_install.go `budapest` (v0.277.0, the kept choice's dates) (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) |
| JSON writers ×5, 3 envelope shapes | api `writeJSON`; web `writeDiskJSON`, `jsonResponse`/`jsonError`, `writeDebugJSON` | | JSON writers ×5, 3 envelope shapes | api `writeJSON`; web `writeDiskJSON`, `jsonResponse`/`jsonError`, `writeDebugJSON` |
| Safe-name validators ×4 | controller/internal/web/validate.go `validStackName`; controller/internal/api/router.go `validStackParam` (same body — api↔web import cycle); controller/internal/backup/offbox.go `isSafeStackName`; controller/internal/appexport/validate.go `ValidateSegment` (strictest) | | Safe-name validators ×4 | controller/internal/web/validate.go `validStackName`; controller/internal/api/router.go `validStackParam` (same body — api↔web import cycle); controller/internal/backup/offbox.go `isSafeStackName`; controller/internal/appexport/validate.go `ValidateSegment` (strictest) |
+24 -104
View File
@@ -112,7 +112,7 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated
component; state chips are `.tag`; informational pills are `.metarow`. **Fonts (Plus Jakarta Sans + component; state chips are `.tag`; informational pills are `.metarow`. **Fonts (Plus Jakarta Sans +
JetBrains Mono, variable woff2, latin+latin-ext) and a 30-icon Lucide sprite are vendored in the JetBrains Mono, variable woff2, latin+latin-ext) and a 30-icon Lucide sprite are vendored in the
binary** (`internal/web/static/fonts/` served at `/static/fonts/`; `templates/icons.html`) — no CDN, binary** (`internal/web/static/fonts/` served at `/static/fonts/`; `templates/icons.html`) — no CDN,
no emoji. The setup wizard serves the same embedded stylesheet via `web.StyleCSS()`. no emoji.
- **Guest RAM resize (v0.143.0, R-24; MinAgent 0.90.0)** — the Rendszer page's "Szerver memória (RAM)" - **Guest RAM resize (v0.143.0, R-24; MinAgent 0.90.0)** — the Rendszer page's "Szerver memória (RAM)"
card shows the guest's current/used memory + the allowed range and lets the customer resize it. The card shows the guest's current/used memory + the allowed range and lets the customer resize it. The
controller only proxies + maps the agent's machine `code` to Hungarian; **the agent (felhom-agent controller only proxies + maps the agent's machine `code` to Hungarian; **the agent (felhom-agent
@@ -3364,77 +3364,18 @@ The hub service (separate Go app in the `felhom.eu` repo) provides:
- Color coding: green (<30min), yellow (30-60min), red (>60min since last report) - Color coding: green (<30min), yellow (30-60min), red (>60min since last report)
- 90-day report + event retention with daily prune at 04:30 Budapest time - 90-day report + event retention with daily prune at 04:30 Budapest time
### 10. First-Run Setup Wizard ### 10. Not configured yet — the waiting page (the setup wizard is deleted, v0.308.0, R-554)
When the controller starts with no valid customer configuration (`customer.id` empty), it enters **setup mode** — a web-based wizard that handles all initial configuration. This replaces the old interactive shell wizard in `docker-setup.sh`. The host agent provisions every box: at first boot the controller ingests its configuration from the hub
(`internal/bootstrap`, `MaybeIngest`). The first-boot **setup wizard** (`internal/setup/`, the docker-setup.sh era,
#### Setup Mode Detection (`internal/setup/setup.go`) port 8081) was obsolete by design (`02-controller-module-map.md`) and is **deleted** (`09` §3 decision 222). A box whose
ingestion failed (no or bad `bootstrap.json`, a hub it cannot reach, a failed write) — `bootstrap.Unconfigured(cfg)`,
`NeedsSetup(cfg)` returns true when `customer.id` is empty or a `.needs-setup` marker file exists. In setup mode, the controller skips normal startup (no scheduler, no backup, no stacks) and serves only the wizard UI on two listeners: i.e. `customer.id` empty — now serves ONE page on its listener, in both languages („A Felhom még nem érte el a
- `:8080` — behind Traefik (accessible via domain, e.g. `https://felhom.example.com`) központot" / "Felhom has not reached the central service yet"), answers `/api/health` with
- `:8081` — direct HTTP (accessible via LAN IP, e.g. `http://192.168.0.100:8081`) `waiting_for_hub: true`, and retries the ingestion every minute (`bootstrap.WaitForConfig`). Once configured it closes
the page and starts normally on the same listener. The `.needs-setup` marker and the debug page's „DR / Telepítő
#### Wizard Flow varázsló" trigger are gone; `web.setup_listen` is still read so an old `controller.yaml` loads, and is unused.
Pinned by `TestWaitForConfig_ServesWaitingPageThenStarts`.
```
┌──────────────────────────────────┐
│ 1. Welcome │
│ Choose: Restore / Fresh install │
└─────────┬───────────┬────────────┘
│ │
┌─────▼─────┐ ┌──▼───────────────┐
│ 2a. Scan │ │ 2b. Hub download │
│ drives for│ │ (customer ID + │
│ local │ │ password) │
│ backups │ │ │
└─────┬─────┘ └──────┬────────────┘
│ │
┌─────▼─────┐ │
│ 2a.2 Hub │ │
│ recovery │ │
│ (fallback)│ │
└─────┬─────┘ │
│ │
┌─────▼─────┐ ┌──────▼───────────┐
│ Execute │ │ Execute fresh │
│ restore │ │ install │
└─────┬─────┘ └──────┬───────────┘
│ │
└───────┬───────┘
▼
os.Exit(0) → Docker restarts
→ normal mode
```
#### Hub Pre-Seeding
When `docker-setup.sh` is run with `--hub-customer` / `--hub-password`, the controller receives
pre-seeded credentials via environment variables:
| Env var | Purpose |
|---------|---------|
| `FELHOM_SETUP_CUSTOMER_ID` | Pre-fills customer ID in wizard forms |
| `FELHOM_SETUP_PASSWORD` | Pre-fills retrieval password for auto-processing |
In hub mode, the welcome page shows three cards instead of two:
1. **"Visszaállítás a Hub-ról"** — auto-calls `PullRecovery()`, shows infra backup details
2. **"Visszaállítás helyi meghajtóról"** — standard drive scan
3. **"Friss telepítés"** — auto-calls `PullConfig()`, downloads config only
Both hub paths auto-process when credentials are pre-seeded (no form entry needed).
On error, the wizard falls back to the manual form with the error displayed.
#### Key Components
| File | Purpose |
|------|---------|
| `setup/setup.go` | `NeedsSetup()` detection, `SetupState` persistence to `setup-state.json` |
| `setup/handlers.go` | HTTP handlers for each wizard step (welcome, scan, hub-restore, fresh, manual) |
| `setup/scanner.go` | Scans all block devices for `.felhom-infra-backup/` directories (current + `history/`) via `lsblk` + temp mounts; returns rich info (app names, disk count) |
| `setup/hub.go` | Hub recovery pull (`GET /api/v1/recovery/{id}`) and config download |
| `setup/csrf.go` | Lightweight CSRF protection (cookie + hidden field, `SameSite=Strict`) |
| `setup/network.go` | Detects local IPs for LAN access URL display |
| `setup/templates/` | 8 embedded HTML templates (Hungarian, dark theme matching main UI) — includes `setup_hub_versions.html` for Hub backup version picker |
#### Local Infra Backup (`internal/backup/local_infra.go`) #### Local Infra Backup (`internal/backup/local_infra.go`)
@@ -3445,11 +3386,15 @@ The controller writes infrastructure snapshots to **every connected drive** afte
- `{timestamp}-backup.json` + `{timestamp}-metadata.json` pairs (timestamp format: `20060102T150405Z`) - `{timestamp}-backup.json` + `{timestamp}-metadata.json` pairs (timestamp format: `20060102T150405Z`)
- Oldest entries pruned when count exceeds 5 - Oldest entries pruned when count exceeds 5
During setup wizard drive scan, both current and historical backups are discovered, integrity-verified, and offered for one-click restore. The scan results table shows app names/count, disk count, and a "korábbi" badge for historical versions. (The setup wizard's drive scan that offered these for one-click restore is deleted, v0.308.0, R-554.)
#### Recovery Info (`internal/recovery/info.go`) #### Recovery Info (`internal/recovery/info.go`)
Generates `recovery-info.txt` on the system data partition with customer ID, Hub URL, retrieval password, and recovery instructions in Hungarian. Updated on startup and after config changes. Also displayed on the Settings page in a "Vészhelyzeti információk" section. Generates `recovery-info.txt` (0600) in the data directory at every start when a retrieval password is set: customer
ID, Hub URL, retrieval password, support contacts, and — since v0.308.0 (R-554) — what to do: the dashboard's
**Biztonsági mentés → Visszaállítás** page, and after a reinstall the **Adatok visszaszerzése** page with the
household's recovery code (ten words). Written in the box's language from the bundle key `recovery_info.body`; it no
longer points at the deleted setup wizard. Pinned by `TestRecoveryInfo_PointsAtDashboardNotWizard`.
**No secret is rendered into a page's response body (v0.207.0 + v0.208.0, R-249/R-254).** Three endpoints implement one rule — the page carries a BOOLEAN, the value comes from an explicit authenticated POST with `Cache-Control: no-store`, and the reveal is LOGGED (reading a value off markup left no trace at all): **No secret is rendered into a page's response body (v0.207.0 + v0.208.0, R-249/R-254).** Three endpoints implement one rule — the page carries a BOOLEAN, the value comes from an explicit authenticated POST with `Cache-Control: no-store`, and the reveal is LOGGED (reading a value off markup left no trace at all):
@@ -3465,29 +3410,11 @@ They are deliberately **per-secret**, not one generic "reveal any named secret"
### 11. Disaster Recovery ### 11. Disaster Recovery
When a system drive fails and is replaced, the recovery flow uses the setup wizard: Whole-guest recovery is the host agent's and the hub's (PBS restore, provisioning a replacement guest); the controller
has no recovery wizard any more (R-554, v0.308.0). On a box that is running, an app is restored on the dashboard's
``` **Biztonsági mentés → Visszaállítás** page. After a reinstall the earlier off-site backups are reopened on the
1. docker-setup.sh deploys fresh controller with minimal config **Adatok visszaszerzése** page (`/recovery`) with the household's recovery code. A box that cannot reach the hub at its
- With --hub-customer: credentials pre-seeded via env vars first boot waits on the page described in §10.
- Without: user enters credentials manually in wizard
2. Controller detects empty customer.id → enters setup mode
3. User opens wizard at http://<LAN-IP>:8081
4. Hub mode: welcome page shows Hub restore / local scan / fresh install
Non-hub mode: welcome page shows restore / fresh install
5. Hub restore: auto-connects to Hub, shows version picker if multiple versions
Local restore: scans all drives for .felhom-infra-backup/ directories (current + history/)
6. User selects backup version → restore: config, settings, passwords, disk layout
7. Controller restarts into normal mode with full config
8. Controller auto-mounts surviving drives by UUID from disk layout
9. Dashboard shows "Visszaállítás" (Restore) page for app-level recovery
10. User confirms → sequential restore: rsync first, restic fallback, DB import
```
**Backup sources (priority order):**
1. **Local infra backup** (`.felhom-infra-backup/` on surviving drives) — fastest, no network needed
2. **Hub recovery endpoint** (`GET /api/v1/recovery/{id}`) — requires retrieval password, supports `?version=ID` for specific versions; Hub retains ~14 versions via GFS pruning (7 daily / 4 weekly / 3 monthly)
3. **Manual config** (wizard form) — enter all details manually as last resort
**Hub verification:** After setup, the controller periodically verifies customer standing via the Hub report push response (`customer_blocked` field). If blocked or Hub unreachable for >7 days, the controller enters limited mode (no new deployments). **Hub verification:** After setup, the controller periodically verifies customer standing via the Hub report push response (`customer_blocked` field). If blocked or Hub unreachable for >7 days, the controller enters limited mode (no new deployments).
@@ -4259,13 +4186,6 @@ controller/
│ │ ├── builder.go # Hub report builder (all subsystems → JSON) │ │ ├── builder.go # Hub report builder (all subsystems → JSON)
│ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth, parses customer_blocked) │ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth, parses customer_blocked)
│ │ └── infra_pull.go # DR: pull recovery/config from Hub (retrieval password auth) │ │ └── infra_pull.go # DR: pull recovery/config from Hub (retrieval password auth)
│ ├── setup/ # First-run setup wizard (web-based, replaces docker-setup.sh wizard)
│ │ ├── setup.go # NeedsSetup() detection, state persistence
│ │ ├── handlers.go # HTTP handlers for all wizard steps
│ │ ├── scanner.go # Drive scanner for local infra backups
│ │ ├── csrf.go # Lightweight CSRF (cookie + hidden field)
│ │ ├── network.go # Local IP detection for LAN access URLs
│ │ └── templates/ # 7 wizard HTML templates (Hungarian)
│ ├── recovery/info.go # Recovery info file generator (recovery-info.txt) │ ├── recovery/info.go # Recovery info file generator (recovery-info.txt)
│ └── web/ │ └── web/
│ ├── server.go # HTTP server, routing, static files, catch-all middleware, executeTemplate wrapper │ ├── server.go # HTTP server, routing, static files, catch-all middleware, executeTemplate wrapper
@@ -4363,7 +4283,7 @@ monitoring:
web: web:
listen: ":8080" listen: ":8080"
setup_listen: ":8081" # Plain HTTP for setup wizard LAN access setup_listen: ":8081" # UNUSED since v0.308.0 (the setup wizard is deleted, R-554)
hub: hub:
enabled: true enabled: true
+22 -72
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -10,6 +9,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/sockheal" "gitea.dooplex.hu/admin/felhom-controller/internal/sockheal"
"io" "io"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -54,7 +54,6 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/selftest" "gitea.dooplex.hu/admin/felhom-controller/internal/selftest"
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate" "gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/setup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync" catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/system" "gitea.dooplex.hu/admin/felhom-controller/internal/system"
@@ -325,12 +324,26 @@ func main() {
system.DebugLogger = logger system.DebugLogger = logger
} }
// --- Setup mode: if no customer ID configured, run setup wizard --- // --- Not configured yet: wait for the hub (R-554, v0.308.0) ---
if setup.NeedsSetup(cfg) { // The first-boot setup wizard is deleted. A box whose first ingestion failed shows ONE waiting page
logger.Printf("[INFO] felhom-controller %s — setup mode", Version) // (both languages) and retries every minute; once configured it falls through to the normal start
runSetupMode(cfg, logger) // below, on the same listener. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.
if bootstrap.Unconfigured(cfg) {
logger.Printf("[INFO] felhom-controller %s — not configured yet: waiting for the hub", Version)
waitCtx, waitStop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
ln, lerr := net.Listen("tcp", cfg.Web.Listen)
if lerr != nil {
logger.Printf("[ERROR] waiting page: cannot listen on %s: %v — retrying the configuration without it", cfg.Web.Listen, lerr)
ln = nil
}
next := bootstrap.WaitForConfig(waitCtx, ln, *configPath, cfg, logger, pull, bootstrap.WaitRetryInterval, Version)
waitStop()
if next == nil {
logger.Printf("[INFO] Received a stop signal while waiting for the configuration — exiting")
return return
} }
cfg = next
}
// --- Local API connectivity probe (slice 8A) --- // --- Local API connectivity probe (slice 8A) ---
// When seeded with a local-API endpoint, prove the controller↔agent channel at startup and // When seeded with a local-API endpoint, prove the controller↔agent channel at startup and
@@ -388,7 +401,7 @@ func main() {
stackMgr.MigrateEncryption() stackMgr.MigrateEncryption()
// --- First-boot base-infrastructure bring-up --- // --- First-boot base-infrastructure bring-up ---
// We are guaranteed configured here (setup.NeedsSetup returned false above), so deploy the base // We are guaranteed configured here (bootstrap.Unconfigured returned false above), so deploy the base
// stack (traefik-public network → traefik → cloudflared → filebrowser) the controller needs for // stack (traefik-public network → traefik → cloudflared → filebrowser) the controller needs for
// routing + external access. Runs in a goroutine so a slow first-boot image pull never delays the // routing + external access. Runs in a goroutine so a slow first-boot image pull never delays the
// web server; non-fatal (idempotent + single-flight, the health loop re-attempts each tick). // web server; non-fatal (idempotent + single-flight, the health loop re-attempts each tick).
@@ -1713,6 +1726,8 @@ func main() {
HubURL: cfg.Hub.URL, HubURL: cfg.Hub.URL,
SupportEmail: "support@felhom.eu", SupportEmail: "support@felhom.eu",
SupportURL: "https://felhom.eu/kapcsolat", SupportURL: "https://felhom.eu/kapcsolat",
DashboardURL: "https://felhom." + cfg.Customer.Domain, // R-554: the Restore page, not the wizard
Lang: sett.GetLanguage(),
} }
if err := recovery.GenerateRecoveryFile(info, Version, cfg.Paths.DataDir); err != nil { if err := recovery.GenerateRecoveryFile(info, Version, cfg.Paths.DataDir); err != nil {
logger.Printf("[WARN] Failed to generate recovery-info.txt: %v", err) logger.Printf("[WARN] Failed to generate recovery-info.txt: %v", err)
@@ -3696,71 +3711,6 @@ func probeLocalAPI(cfg *config.Config, logger *log.Logger) {
} }
} }
// runSetupMode starts the setup wizard on dual listeners and blocks until signal.
func runSetupMode(cfg *config.Config, logger *log.Logger) {
ips := setup.DetectLocalIPs()
setup.LogSetupMode(cfg.Customer.Domain, ips, cfg.Web.SetupListen, logger)
setupSrv := setup.NewServer(cfg, cfg.Paths.DataDir, logger, Version)
handler := setupSrv.Handler()
// Health endpoint wrapper (returns setup_mode: true)
healthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true, "message": "felhom-controller is healthy",
"setup_mode": true, "version": Version,
})
})
// Mux for both listeners
mux := http.NewServeMux()
mux.HandleFunc("/api/health", healthHandler)
mux.Handle("/", handler)
// Start main listener (:8080, behind Traefik for domain access)
mainServer := &http.Server{
Addr: cfg.Web.Listen,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
logger.Printf("[INFO] Setup wizard (main) listening on %s", cfg.Web.Listen)
if err := mainServer.ListenAndServe(); err != http.ErrServerClosed {
logger.Printf("[ERROR] Main HTTP server error: %v", err)
}
}()
// Start setup-only listener (:8081, direct HTTP for LAN access)
setupServer := &http.Server{
Addr: cfg.Web.SetupListen,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
logger.Printf("[INFO] Setup wizard (LAN) listening on %s", cfg.Web.SetupListen)
if err := setupServer.ListenAndServe(); err != http.ErrServerClosed {
logger.Printf("[ERROR] Setup HTTP server error: %v", err)
}
}()
// Wait for signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
logger.Printf("[INFO] Received signal %v, shutting down setup wizard...", sig)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
mainServer.Shutdown(shutdownCtx)
setupServer.Shutdown(shutdownCtx)
logger.Println("[INFO] Setup wizard stopped")
}
// discoverHDDPaths scans deployed apps' app.yaml for HDD_PATH env values. // discoverHDDPaths scans deployed apps' app.yaml for HDD_PATH env values.
func discoverHDDPaths(stacksDir string, logger *log.Logger) []string { func discoverHDDPaths(stacksDir string, logger *log.Logger) []string {
entries, err := os.ReadDir(stacksDir) entries, err := os.ReadDir(stacksDir)
+14 -14
View File
@@ -5,7 +5,7 @@
// uses the passphrase to PULL its full controller.yaml from the hub (which mints the customer-scoped // uses the passphrase to PULL its full controller.yaml from the hub (which mints the customer-scoped
// hub api_key + identity + assets + backup + CF config), MERGES in the per-guest local_api block (the // hub api_key + identity + assets + backup + CF config), MERGES in the per-guest local_api block (the
// only thing the hub yaml lacks, because the hub must not know per-guest Proxmox internals), writes // only thing the hub yaml lacks, because the hub must not know per-guest Proxmox internals), writes
// it, and comes up CONFIGURED — skipping the setup wizard. // it, and comes up CONFIGURED (an unconfigured box waits and retries — the setup wizard is deleted, R-554).
// //
// This replaces the old "seed a configured yaml from the agent's HOST key" path, which made the // This replaces the old "seed a configured yaml from the agent's HOST key" path, which made the
// controller's hub reports 401 (the hub's /report needs the customer-scoped key, not the host key). // controller's hub reports 401 (the hub's /report needs the customer-scoped key, not the host key).
@@ -33,7 +33,7 @@ const DefaultMountPath = "/etc/felhom-bootstrap/bootstrap.json"
// SchemaV2 is the stable contract version the agent emits and the controller ingests. v2 changed the // SchemaV2 is the stable contract version the agent emits and the controller ingests. v2 changed the
// contract's MEANING (the controller pulls its config from the hub rather than seeding it from the // contract's MEANING (the controller pulls its config from the hub rather than seeding it from the
// agent's host key) and its field set, so it is a clean version bump with no v1 back-compat // agent's host key) and its field set, so it is a clean version bump with no v1 back-compat
// (pre-launch; zero v1 guests deployed). A non-v2 schema is rejected → setup mode. // (pre-launch; zero v1 guests deployed). A non-v2 schema is rejected → the box waits (R-554).
const SchemaV2 = "felhom.bootstrap/v2" const SchemaV2 = "felhom.bootstrap/v2"
// ErrPullTransient marks a pull failure as retryable (a boot-time network race reaching the hub). // ErrPullTransient marks a pull failure as retryable (a boot-time network race reaching the hub).
@@ -95,7 +95,7 @@ func Path() string {
// - Idempotent: if cfg is already configured (customer.id set), the existing controller.yaml is // - Idempotent: if cfg is already configured (customer.id set), the existing controller.yaml is
// NEVER clobbered and the hub is NEVER pulled — returns cfg unchanged. // NEVER clobbered and the hub is NEVER pulled — returns cfg unchanged.
// - Fail-safe: an absent/malformed bootstrap, a non-v2 schema, a missing required field, or a hub // - Fail-safe: an absent/malformed bootstrap, a non-v2 schema, a missing required field, or a hub
// pull that ultimately fails leaves cfg unchanged (the caller proceeds to setup mode). It logs // pull that ultimately fails leaves cfg unchanged (the caller serves the waiting page and retries, R-554). It logs
// and NEVER crashes — a hub outage at first boot must not brick the guest. // and NEVER crashes — a hub outage at first boot must not brick the guest.
// - On success: writes controller.yaml (0600, atomic), reloads it, and returns the reloaded cfg. // - On success: writes controller.yaml (0600, atomic), reloads it, and returns the reloaded cfg.
func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull PullFunc) *config.Config { func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull PullFunc) *config.Config {
@@ -111,37 +111,37 @@ func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull
data, err := os.ReadFile(bpath) data, err := os.ReadFile(bpath)
if err != nil { if err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
logger.Printf("[WARN] bootstrap: cannot read %s: %v — staying in setup", bpath, err) logger.Printf("[WARN] bootstrap: cannot read %s: %v — waiting for the configuration", bpath, err)
} }
return cfg // no bootstrap → normal setup return cfg // no bootstrap → the caller waits (WaitForConfig)
} }
var b Bootstrap var b Bootstrap
if err := json.Unmarshal(data, &b); err != nil { if err := json.Unmarshal(data, &b); err != nil {
logger.Printf("[WARN] bootstrap: %s is not valid JSON: %v — staying in setup", bpath, err) logger.Printf("[WARN] bootstrap: %s is not valid JSON: %v — waiting for the configuration", bpath, err)
return cfg return cfg
} }
if b.Schema != SchemaV2 { if b.Schema != SchemaV2 {
logger.Printf("[WARN] bootstrap: unsupported schema %q (want %q) — staying in setup", b.Schema, SchemaV2) logger.Printf("[WARN] bootstrap: unsupported schema %q (want %q) — waiting for the configuration", b.Schema, SchemaV2)
return cfg return cfg
} }
if b.Customer.ID == "" || b.Hub.URL == "" || b.Hub.RetrievalPassword == "" { if b.Customer.ID == "" || b.Hub.URL == "" || b.Hub.RetrievalPassword == "" {
logger.Printf("[WARN] bootstrap: %s missing customer.id / hub.url / hub.retrieval_password — staying in setup", bpath) logger.Printf("[WARN] bootstrap: %s missing customer.id / hub.url / hub.retrieval_password — waiting for the configuration", bpath)
return cfg return cfg
} }
if b.LocalAPI.Endpoint == "" || b.LocalAPI.Fingerprint == "" || b.LocalAPI.Token == "" { if b.LocalAPI.Endpoint == "" || b.LocalAPI.Fingerprint == "" || b.LocalAPI.Token == "" {
logger.Printf("[WARN] bootstrap: %s missing local_api.{endpoint,fingerprint,token} — staying in setup", bpath) logger.Printf("[WARN] bootstrap: %s missing local_api.{endpoint,fingerprint,token} — waiting for the configuration", bpath)
return cfg return cfg
} }
if pull == nil { if pull == nil {
logger.Printf("[WARN] bootstrap: no pull function wired — staying in setup") logger.Printf("[WARN] bootstrap: no pull function wired — waiting for the configuration")
return cfg return cfg
} }
// --- Pull the full controller.yaml from the hub, with bounded retry on transient errors only. --- // --- Pull the full controller.yaml from the hub, with bounded retry on transient errors only. ---
pulled, err := pullWithRetry(pull, b.Hub.URL, b.Customer.ID, b.Hub.RetrievalPassword, logger) pulled, err := pullWithRetry(pull, b.Hub.URL, b.Customer.ID, b.Hub.RetrievalPassword, logger)
if err != nil { if err != nil {
logger.Printf("[WARN] bootstrap: hub config pull failed for customer %s from %s: %v — staying in setup (manual setup wizard remains the fallback)", logger.Printf("[WARN] bootstrap: hub config pull failed for customer %s from %s: %v — waiting for the configuration (the waiting page retries every minute)",
b.Customer.ID, b.Hub.URL, err) b.Customer.ID, b.Hub.URL, err)
return cfg return cfg
} }
@@ -150,16 +150,16 @@ func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull
// every field the hub emits is preserved (forward-compat with hub template changes). --- // every field the hub emits is preserved (forward-compat with hub template changes). ---
merged, err := mergeLocalAPI(pulled, b.LocalAPI) merged, err := mergeLocalAPI(pulled, b.LocalAPI)
if err != nil { if err != nil {
logger.Printf("[WARN] bootstrap: merging local_api into pulled config failed: %v — staying in setup", err) logger.Printf("[WARN] bootstrap: merging local_api into pulled config failed: %v — waiting for the configuration", err)
return cfg return cfg
} }
if err := writeFileAtomic(configPath, merged); err != nil { if err := writeFileAtomic(configPath, merged); err != nil {
logger.Printf("[WARN] bootstrap: could not write %s: %v — staying in setup", configPath, err) logger.Printf("[WARN] bootstrap: could not write %s: %v — waiting for the configuration", configPath, err)
return cfg return cfg
} }
reloaded, err := config.LoadPermissive(configPath) reloaded, err := config.LoadPermissive(configPath)
if err != nil { if err != nil {
logger.Printf("[WARN] bootstrap: wrote %s but reload failed: %v — staying in setup", configPath, err) logger.Printf("[WARN] bootstrap: wrote %s but reload failed: %v — waiting for the configuration", configPath, err)
return cfg return cfg
} }
logger.Printf("[INFO] bootstrap: pulled config from %s for %s, merged local_api (%s) — coming up configured", logger.Printf("[INFO] bootstrap: pulled config from %s for %s, merged local_api (%s) — coming up configured",
+112
View File
@@ -0,0 +1,112 @@
package bootstrap
import (
"context"
"encoding/json"
"html"
"log"
"net"
"net/http"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// ── Not configured yet: wait for the hub (R-554, v0.308.0, `09` §3 decision 222) ───────────────
//
// Until v0.308.0 a box whose first boot could not ingest its configuration (no or bad bootstrap.json, a
// hub it could not reach, a failed write) entered the first-boot SETUP WIZARD — eight pages built for the
// retired docker-setup.sh install. The agent provisions every box now; the wizard is deleted. Such a box
// shows ONE page instead — „Felhom has not reached the central service yet", in both languages — and
// retries the ingestion every minute. When it succeeds the page goes away and the controller starts
// normally on the same listener. Pinned by TestWaitForConfig_ServesWaitingPageThenStarts.
// WaitRetryInterval is how often an unconfigured box retries the ingestion.
const WaitRetryInterval = time.Minute
// Unconfigured reports whether this controller has no customer id — the only state that waits.
func Unconfigured(cfg *config.Config) bool {
return cfg == nil || cfg.Customer.ID == ""
}
// WaitingHandler serves the waiting page on every path, and a health answer on /api/health (the
// container's health check must keep passing while the box waits — it is not broken, it is early).
func WaitingHandler(version string) http.Handler {
page := waitingPage()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/health" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true, "message": "felhom-controller is waiting for its configuration",
"waiting_for_hub": true, "setup_mode": true, "version": version,
})
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(page))
})
}
// waitingPage is the page in BOTH languages: an unconfigured box has no household setting to choose by.
func waitingPage() string {
var b strings.Builder
b.WriteString("<!DOCTYPE html>\n<html lang=\"hu\">\n<head>\n<meta charset=\"UTF-8\">\n")
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n")
b.WriteString("<meta http-equiv=\"refresh\" content=\"60\">\n<title>Felhom</title>\n")
b.WriteString("<style>body{font-family:system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem;line-height:1.5}section{margin-bottom:2rem}</style>\n")
b.WriteString("</head>\n<body>\n")
bundle, err := i18n.Shared()
for _, lang := range i18n.Supported {
title, body := "Felhom", ""
if err == nil {
title, body = bundle.Msg(lang, "waiting.title"), bundle.Msg(lang, "waiting.body")
}
b.WriteString("<section lang=\"" + lang + "\">\n<h1>" + html.EscapeString(title) + "</h1>\n<p>" + html.EscapeString(body) + "</p>\n</section>\n")
}
b.WriteString("</body>\n</html>\n")
return b.String()
}
// WaitForConfig serves the waiting page on ln and retries MaybeIngest every `every` until the box is
// configured, then closes the page and returns the configured config. It returns nil when ctx ends first
// (the process is stopping). ln may be nil (the port could not be bound): the retries still run.
func WaitForConfig(ctx context.Context, ln net.Listener, configPath string, cfg *config.Config, logger *log.Logger, pull PullFunc, every time.Duration, version string) *config.Config {
var srv *http.Server
if ln != nil {
srv = &http.Server{Handler: WaitingHandler(version), ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second}
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
logger.Printf("[ERROR] bootstrap: waiting page server: %v", err)
}
}()
logger.Printf("[INFO] bootstrap: not configured yet — serving the waiting page on %s, retrying every %s", ln.Addr(), every)
}
stop := func() {
if srv == nil {
return
}
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(sctx)
}
t := time.NewTicker(every)
defer t.Stop()
for attempt := 1; ; attempt++ {
select {
case <-ctx.Done():
stop()
return nil
case <-t.C:
}
next := MaybeIngest(configPath, cfg, logger, pull)
if !Unconfigured(next) {
logger.Printf("[INFO] bootstrap: configured after %d retries — closing the waiting page and starting", attempt)
stop()
return next
}
cfg = next
}
}
@@ -0,0 +1,100 @@
package bootstrap
import (
"context"
"errors"
"io"
"net"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// R-554 (`09` §3 decision 222): the setup wizard is deleted. A box whose first ingestion FAILED serves the
// waiting page — in both languages, on every path, with a passing health answer — and not a 404 or a
// wizard; it retries, and once the hub answers it returns the configured config and closes the page.
//
// RED-PROOF (REPORT): make WaitingHandler answer http.NotFound for "/" → "GET / = 404"; make the loop
// return the unchanged cfg on the first tick → "the box did not come up configured".
func TestWaitForConfig_ServesWaitingPageThenStarts(t *testing.T) {
dir := t.TempDir()
_, cfgPath := writeBootstrap(t, dir, goodBootstrapV2)
var calls atomic.Int32
pull := func(_, _, _ string) (string, error) {
if calls.Add(1) <= 5 {
return "", errors.New("hub answered 503") // permanent: no in-call retry, the loop retries
}
return hubYAML, nil
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := "http://" + ln.Addr().String()
// The first ingestion failed: the caller holds an unconfigured config.
start := MaybeIngest(cfgPath, config.Default(), testLogger(), pull)
if !Unconfigured(start) {
t.Fatal("setup: the first ingestion should have failed")
}
done := make(chan *config.Config, 1)
go func() {
done <- WaitForConfig(context.Background(), ln, cfgPath, start, testLogger(), pull, 150*time.Millisecond, "0.308.0")
}()
get := func(path string) (int, string) {
t.Helper()
resp, err := http.Get(addr + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
code, body := get("/")
if code != http.StatusOK {
t.Fatalf("GET / = %d — an unconfigured box must show the waiting page, not a 404", code)
}
for _, want := range []string{`lang="hu"`, "nem érte el a központot", `lang="en"`, "has not reached the central service yet", "support@felhom.eu"} {
if !strings.Contains(body, want) {
t.Errorf("the waiting page does not carry %q", want)
}
}
if strings.Contains(strings.ToLower(body), "varázsló") || strings.Contains(body, "8081") {
t.Error("the waiting page still points at the setup wizard")
}
if code, _ := get("/backups/restore"); code != http.StatusOK {
t.Errorf("GET /backups/restore = %d — every path shows the waiting page", code)
}
if code, body := get("/api/health"); code != http.StatusOK || !strings.Contains(body, `"waiting_for_hub":true`) {
t.Errorf("health while waiting: %d %s", code, body)
}
select {
case got := <-done:
if got == nil || Unconfigured(got) || got.Customer.ID != "cust-8200" {
t.Fatalf("the box did not come up configured: %+v", got)
}
case <-time.After(10 * time.Second):
t.Fatal("WaitForConfig never returned although the hub answered")
}
if _, err := http.Get(addr + "/"); err == nil {
t.Error("the waiting page is still served after the box was configured")
}
}
// A stop signal while waiting returns nil (the process exits); it never pretends to be configured.
func TestWaitForConfig_StopReturnsNil(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if got := WaitForConfig(ctx, nil, "", config.Default(), testLogger(), nil, time.Hour, "x"); got != nil {
t.Fatalf("a stopped wait returned a config: %+v", got)
}
}
+1 -1
View File
@@ -168,7 +168,7 @@ type PathsConfig struct {
type WebConfig struct { type WebConfig struct {
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
SetupListen string `yaml:"setup_listen"` // Plain HTTP listener for setup wizard (only active during setup mode) SetupListen string `yaml:"setup_listen"` // UNUSED since v0.308.0 (R-554: the setup wizard is deleted); kept so an existing controller.yaml still loads
PasswordHash string `yaml:"password_hash"` PasswordHash string `yaml:"password_hash"`
SessionSecret string `yaml:"session_secret"` SessionSecret string `yaml:"session_secret"`
+4 -12
View File
@@ -928,7 +928,6 @@
"debug.bejegyzes": "entries", "debug.bejegyzes": "entries",
"debug.betoltes": "Loading...", "debug.betoltes": "Loading...",
"debug.cimke": "Label", "debug.cimke": "Label",
"debug.controller_ujraindulasa": "Controller restarting...",
"debug.cpu_atlag": "CPU (avg)", "debug.cpu_atlag": "CPU (avg)",
"debug.csak_cross_drive": "Cross-drive only", "debug.csak_cross_drive": "Cross-drive only",
"debug.csak_db_dump": "DB dump only", "debug.csak_db_dump": "DB dump only",
@@ -938,7 +937,6 @@
"debug.db": "items", "debug.db": "items",
"debug.debug_mod": "Debug mode", "debug.debug_mod": "Debug mode",
"debug.debug_mod_aktiv_ez_az": "Debug mode on — this page is only for development and troubleshooting", "debug.debug_mod_aktiv_ez_az": "Debug mode on — this page is only for development and troubleshooting",
"debug.dr_telepito_varazslo": "DR / Setup wizard",
"debug.dry_run_frissites": "Dry-run update", "debug.dry_run_frissites": "Dry-run update",
"debug.elavult_temp_fajlok": "Stale temp files", "debug.elavult_temp_fajlok": "Stale temp files",
"debug.eleres": "Path", "debug.eleres": "Path",
@@ -947,8 +945,6 @@
"debug.ertesites_teszt": "Notification test", "debug.ertesites_teszt": "Notification test",
"debug.esemeny_tipus": "Event type:", "debug.esemeny_tipus": "Event type:",
"debug.export_konyvtarak": "Export folders", "debug.export_konyvtarak": "Export folders",
"debug.ez_torli_az_ugyfel_azonositot": "This deletes the customer ID and restarts the controller in setup mode. The configuration files stay on the drives.",
"debug.figyelem_ez_ujrainditja_a_controllert": "WARNING! This restarts the controller in setup mode. Continue?",
"debug.figyelmeztetesek": "Warnings", "debug.figyelmeztetesek": "Warnings",
"debug.folyamatban": "In progress...", "debug.folyamatban": "In progress...",
"debug.frissites": "Refresh", "debug.frissites": "Refresh",
@@ -972,8 +968,6 @@
"debug.igen": "Yes", "debug.igen": "Yes",
"debug.igen_2": "yes", "debug.igen_2": "yes",
"debug.inaktiv": "Inactive", "debug.inaktiv": "Inactive",
"debug.irja_be_reset": "Type: RESET",
"debug.irja_be_reset_a_megerositeshez": "Type \"RESET\" to confirm:",
"debug.jelenlegi": "Current:", "debug.jelenlegi": "Current:",
"debug.katalogus_limit": "Catalog limit", "debug.katalogus_limit": "Catalog limit",
"debug.kattintson_a_gombra_a_telemetria": "Click the button to run telemetry.", "debug.kattintson_a_gombra_a_telemetria": "Click the button to run telemetry.",
@@ -1028,7 +1022,6 @@
"debug.titkositott": "encrypted", "debug.titkositott": "encrypted",
"debug.torles": "Clear", "debug.torles": "Clear",
"debug.ugynok": "Agent", "debug.ugynok": "Agent",
"debug.ujrainditas_setup_modban": "Restart in setup mode",
"debug.utemezo": "Scheduler", "debug.utemezo": "Scheduler",
"debug.utolso_db_dump": "Last DB dump:", "debug.utolso_db_dump": "Last DB dump:",
"debug.utolso_esemenyek": "Recent events", "debug.utolso_esemenyek": "Recent events",
@@ -1039,7 +1032,6 @@
"debug.utvonal": "Path", "debug.utvonal": "Path",
"debug.varakozik": "Waiting", "debug.varakozik": "Waiting",
"debug.verzio": "Version", "debug.verzio": "Version",
"debug.veszhelyzet_szimulacio": "Emergency simulation",
"debug.vezerlo": "Controller", "debug.vezerlo": "Controller",
"debug.api.invalid_request": "Invalid request", "debug.api.invalid_request": "Invalid request",
"debug.api.notifier_unconfigured": "Notifier is not configured", "debug.api.notifier_unconfigured": "Notifier is not configured",
@@ -1063,9 +1055,6 @@
"debug.api.gitea_reachable": "Gitea reachable (HTTP %d, %dms)", "debug.api.gitea_reachable": "Gitea reachable (HTTP %d, %dms)",
"debug.api.telemetry_collected": "Telemetry collected: apps %d, errors %d, warnings %d (%dms)", "debug.api.telemetry_collected": "Telemetry collected: apps %d, errors %d, warnings %d (%dms)",
"debug.api.selfupdate_unconfigured": "Self-update is not configured", "debug.api.selfupdate_unconfigured": "Self-update is not configured",
"debug.api.reset_confirm_invalid": "Invalid confirmation — type: RESET",
"debug.api.marker_write_failed": "Marker file write error: %v",
"debug.api.restarting_to_setup": "Restarting the controller into setup mode...",
"debug.api.agent_unconfigured": "The agent is not configured on this system.", "debug.api.agent_unconfigured": "The agent is not configured on this system.",
"debug.api.agent_logs_unsupported": "The agent’s log view becomes available after the agent’s next update.", "debug.api.agent_logs_unsupported": "The agent’s log view becomes available after the agent’s next update.",
"debug.api.bundles_found": "Bundles found: %d", "debug.api.bundles_found": "Bundles found: %d",
@@ -1912,6 +1901,7 @@
"recovery.ugyhogy_tartsd_keznel": "so keep it at hand.", "recovery.ugyhogy_tartsd_keznel": "so keep it at hand.",
"recovery.vissza_a_kezdolapra": "Back to the home page", "recovery.vissza_a_kezdolapra": "Back to the home page",
"recovery.visszaszerzese": "your data", "recovery.visszaszerzese": "your data",
"recovery_info.body": "Felhom — Emergency information\n==============================\n\nCustomer ID: %s\nHub URL: %s\nRetrieval password: %s\n\nSupport:\n E-mail: %s\n Web: %s\n\nIf you need to restore something:\n 1. Open your dashboard in your browser: %s\n 2. Restore the data of an app on the Backup → Restore page:\n %s\n 3. If your machine was reinstalled, the dashboard offers to bring back\n your earlier off-site backups (Retrieve data: %s).\n For this you need your recovery code: the ten words you received\n at installation. Nobody can replace this code, so keep it in a\n safe place.\n 4. If you get stuck, write to us at the address above.\n\nGenerated: %s\nController version: %s\n",
"restore.refuse.files.head": "This backup does not hold the files of the app, so we do not restore the database over them — the files stay where they are. ", "restore.refuse.files.head": "This backup does not hold the files of the app, so we do not restore the database over them — the files stay where they are. ",
"restore.refuse.files.none": "There is no copy of these files at the moment — turn on the remote backup, or connect a second drive.", "restore.refuse.files.none": "There is no copy of these files at the moment — turn on the remote backup, or connect a second drive.",
"restore.refuse.files.offsite": "The files can be restored from the remote copy: Backup → Restore, “Full restore (files + database)”.", "restore.refuse.files.offsite": "The files can be restored from the remote copy: Backup → Restore, “Full restore (files + database)”.",
@@ -2661,5 +2651,7 @@
"family_gate.pw_once": "The password shows only now. Write it down, or give it to the family member:", "family_gate.pw_once": "The password shows only now. Write it down, or give it to the family member:",
"family_gate.confirm_remove": "Remove them? Their sign-ins end at once.", "family_gate.confirm_remove": "Remove them? Their sign-ins end at once.",
"family_gate.confirm_reset": "Give a new password? The old one stops at once.", "family_gate.confirm_reset": "Give a new password? The old one stops at once.",
"family_gate.error": "That did not work. Try again." "family_gate.error": "That did not work. Try again.",
"waiting.body": "This machine has just started for the first time, but it could not download its settings from the Felhom central service yet. It tries again every minute; once it succeeds, this page turns into your dashboard by itself. If you still see this in an hour, write to us: support@felhom.eu",
"waiting.title": "Felhom has not reached the central service yet"
} }
+4 -12
View File
@@ -923,7 +923,6 @@
"debug.bejegyzes": "bejegyzés", "debug.bejegyzes": "bejegyzés",
"debug.betoltes": "Betöltés...", "debug.betoltes": "Betöltés...",
"debug.cimke": "Cimke", "debug.cimke": "Cimke",
"debug.controller_ujraindulasa": "Controller újraindulása...",
"debug.cpu_atlag": "CPU (átlag)", "debug.cpu_atlag": "CPU (átlag)",
"debug.csak_cross_drive": "Csak cross-drive", "debug.csak_cross_drive": "Csak cross-drive",
"debug.csak_db_dump": "Csak DB dump", "debug.csak_db_dump": "Csak DB dump",
@@ -933,7 +932,6 @@
"debug.db": "db", "debug.db": "db",
"debug.debug_mod": "Debug mód", "debug.debug_mod": "Debug mód",
"debug.debug_mod_aktiv_ez_az": "Debug mód aktív — ez az oldal csak fejlesztéshez és hibaelhárításhoz", "debug.debug_mod_aktiv_ez_az": "Debug mód aktív — ez az oldal csak fejlesztéshez és hibaelhárításhoz",
"debug.dr_telepito_varazslo": "DR / Telepítő varázsló",
"debug.dry_run_frissites": "Dry-run frissítés", "debug.dry_run_frissites": "Dry-run frissítés",
"debug.elavult_temp_fajlok": "Elavult temp fájlok", "debug.elavult_temp_fajlok": "Elavult temp fájlok",
"debug.eleres": "Elérés", "debug.eleres": "Elérés",
@@ -942,8 +940,6 @@
"debug.ertesites_teszt": "Értesítés teszt", "debug.ertesites_teszt": "Értesítés teszt",
"debug.esemeny_tipus": "Esemény típus:", "debug.esemeny_tipus": "Esemény típus:",
"debug.export_konyvtarak": "Export könyvtárak", "debug.export_konyvtarak": "Export könyvtárak",
"debug.ez_torli_az_ugyfel_azonositot": "Ez törli az ügyfél azonosítót és újraindítja a controllert setup módba. A konfigurációs fájlok megmaradnak a meghajtókon.",
"debug.figyelem_ez_ujrainditja_a_controllert": "FIGYELEM! Ez újraindítja a controllert setup módba. Biztosan folytatod?",
"debug.figyelmeztetesek": "Figyelmeztetések", "debug.figyelmeztetesek": "Figyelmeztetések",
"debug.folyamatban": "Folyamatban...", "debug.folyamatban": "Folyamatban...",
"debug.frissites": "Frissítés", "debug.frissites": "Frissítés",
@@ -967,8 +963,6 @@
"debug.igen": "Igen", "debug.igen": "Igen",
"debug.igen_2": "igen", "debug.igen_2": "igen",
"debug.inaktiv": "Inaktív", "debug.inaktiv": "Inaktív",
"debug.irja_be_reset": "Írd be: RESET",
"debug.irja_be_reset_a_megerositeshez": "Írd be \"RESET\" a megerősítéshez:",
"debug.jelenlegi": "Jelenlegi:", "debug.jelenlegi": "Jelenlegi:",
"debug.katalogus_limit": "Katalógus limit", "debug.katalogus_limit": "Katalógus limit",
"debug.kattintson_a_gombra_a_telemetria": "Kattints a gombra a telemetria futtatásához.", "debug.kattintson_a_gombra_a_telemetria": "Kattints a gombra a telemetria futtatásához.",
@@ -1023,7 +1017,6 @@
"debug.titkositott": "titkosított", "debug.titkositott": "titkosított",
"debug.torles": "Törlés", "debug.torles": "Törlés",
"debug.ugynok": "Ügynök", "debug.ugynok": "Ügynök",
"debug.ujrainditas_setup_modban": "Újraindítás setup módban",
"debug.utemezo": "Ütemező", "debug.utemezo": "Ütemező",
"debug.utolso_db_dump": "Utolsó DB dump:", "debug.utolso_db_dump": "Utolsó DB dump:",
"debug.utolso_esemenyek": "Utolsó események", "debug.utolso_esemenyek": "Utolsó események",
@@ -1034,7 +1027,6 @@
"debug.utvonal": "Útvonal", "debug.utvonal": "Útvonal",
"debug.varakozik": "Várakozik", "debug.varakozik": "Várakozik",
"debug.verzio": "Verzió", "debug.verzio": "Verzió",
"debug.veszhelyzet_szimulacio": "Vészhelyzet szimuláció",
"debug.vezerlo": "Vezérlő", "debug.vezerlo": "Vezérlő",
"debug.api.invalid_request": "Érvénytelen kérés", "debug.api.invalid_request": "Érvénytelen kérés",
"debug.api.notifier_unconfigured": "Notifier nincs konfigurálva", "debug.api.notifier_unconfigured": "Notifier nincs konfigurálva",
@@ -1058,9 +1050,6 @@
"debug.api.gitea_reachable": "Gitea elérhető (HTTP %d, %dms)", "debug.api.gitea_reachable": "Gitea elérhető (HTTP %d, %dms)",
"debug.api.telemetry_collected": "Telemetria összegyűjtve: %d app, %d hiba, %d figyelmeztetés (%dms)", "debug.api.telemetry_collected": "Telemetria összegyűjtve: %d app, %d hiba, %d figyelmeztetés (%dms)",
"debug.api.selfupdate_unconfigured": "Self-update nincs konfigurálva", "debug.api.selfupdate_unconfigured": "Self-update nincs konfigurálva",
"debug.api.reset_confirm_invalid": "Érvénytelen megerősítés — írd be: RESET",
"debug.api.marker_write_failed": "Marker fájl írási hiba: %v",
"debug.api.restarting_to_setup": "Controller újraindítása setup módba...",
"debug.api.agent_unconfigured": "Az ügynök nincs konfigurálva ezen a rendszeren.", "debug.api.agent_unconfigured": "Az ügynök nincs konfigurálva ezen a rendszeren.",
"debug.api.agent_logs_unsupported": "Az ügynök naplónézete az ügynök következő frissítése után érhető el.", "debug.api.agent_logs_unsupported": "Az ügynök naplónézete az ügynök következő frissítése után érhető el.",
"debug.api.bundles_found": "%d csomag található", "debug.api.bundles_found": "%d csomag található",
@@ -1900,6 +1889,7 @@
"recovery.ugyhogy_tartsd_keznel": "úgyhogy tartsd kéznél.", "recovery.ugyhogy_tartsd_keznel": "úgyhogy tartsd kéznél.",
"recovery.vissza_a_kezdolapra": "Vissza a kezdőlapra", "recovery.vissza_a_kezdolapra": "Vissza a kezdőlapra",
"recovery.visszaszerzese": "visszaszerzése", "recovery.visszaszerzese": "visszaszerzése",
"recovery_info.body": "Felhom — Vészhelyzeti információk\n=================================\n\nÜgyfél azonosító: %s\nHub URL: %s\nVisszaállítási jelszó: %s\n\nTámogatás:\n E-mail: %s\n Web: %s\n\nHa vissza kell állítanod valamit:\n 1. Nyisd meg a vezérlőpultot a böngésződben: %s\n 2. Egy alkalmazás adatait a Biztonsági mentés → Visszaállítás oldalon\n állíthatod vissza: %s\n 3. Ha a gépedet újratelepítettük, a vezérlőpult felajánlja a korábbi,\n házon kívüli mentéseid visszaszerzését (Adatok visszaszerzése: %s).\n Ehhez a helyreállítási kódodra lesz szükséged: a tíz szóra, amelyet\n a telepítéskor kaptál. Ezt a kódot senki nem tudja pótolni, ezért\n őrizd meg biztonságos helyen.\n 4. Ha elakadsz, írj nekünk a fenti címre.\n\nGenerálva: %s\nController verzió: %s\n",
"restore.refuse.files.head": "Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. ", "restore.refuse.files.head": "Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. ",
"restore.refuse.files.none": "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót.", "restore.refuse.files.none": "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót.",
"restore.refuse.files.offsite": "A fájlok a távoli másolatból állíthatók vissza: Biztonsági mentés → Visszaállítás, „Teljes visszaállítás (fájlok + adatbázis)”.", "restore.refuse.files.offsite": "A fájlok a távoli másolatból állíthatók vissza: Biztonsági mentés → Visszaállítás, „Teljes visszaállítás (fájlok + adatbázis)”.",
@@ -2649,5 +2639,7 @@
"family_gate.pw_once": "A jelszó csak most látszik. Írd fel, vagy add át a családtagnak:", "family_gate.pw_once": "A jelszó csak most látszik. Írd fel, vagy add át a családtagnak:",
"family_gate.confirm_remove": "Eltávolítod? A belépései azonnal megszűnnek.", "family_gate.confirm_remove": "Eltávolítod? A belépései azonnal megszűnnek.",
"family_gate.confirm_reset": "Új jelszót adsz? A régi azonnal megszűnik.", "family_gate.confirm_reset": "Új jelszót adsz? A régi azonnal megszűnik.",
"family_gate.error": "Nem sikerült. Próbáld újra." "family_gate.error": "Nem sikerült. Próbáld újra.",
"waiting.body": "Ez a gép most indult el először, de még nem tudta letölteni a beállításait a Felhom központi rendszeréből. Percenként újrapróbálkozik; amint sikerül, ez az oldal magától a vezérlőpultra vált. Ha egy óra múlva is ezt látod, írj nekünk: support@felhom.eu",
"waiting.title": "A Felhom még nem érte el a központot"
} }
+40 -31
View File
@@ -4,55 +4,64 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
) )
// Info holds the data needed for the recovery info file and settings UI. // Info holds the data needed for the recovery info file.
type Info struct { type Info struct {
CustomerID string CustomerID string
RetrievalPassword string RetrievalPassword string
HubURL string HubURL string
SupportEmail string SupportEmail string
SupportURL string SupportURL string
// DashboardURL is the household's dashboard, e.g. https://felhom.<domain> (R-554, v0.308.0): the file
// points at its Restore page and its „Adatok visszaszerzése" page, not at the deleted setup wizard.
DashboardURL string
// Lang is the box's language; the file is written in it (Hungarian when empty or unknown).
Lang string
} }
// GenerateRecoveryFile writes a plain text recovery-info.txt to the given directory. // recoveryInfoKey is the file's text in the bundle (hu.json / en.json), with ten %s, in order:
func GenerateRecoveryFile(info Info, version, outputDir string) error { // customer id, hub URL, retrieval password, support e-mail, support web, dashboard, restore page,
if err := os.MkdirAll(outputDir, 0755); err != nil { // recovery page, generated-at, controller version.
return fmt.Errorf("creating recovery info directory: %w", err) const recoveryInfoKey = "recovery_info.body"
// Render returns the text of recovery-info.txt (R-554, `09` §3 decision 222): what to do when something must
// be restored — the dashboard's Backup → Restore page, and after a reinstall the recovery code on the
// „Adatok visszaszerzése" page. Pinned by TestRecoveryInfo_PointsAtDashboardNotWizard.
func Render(info Info, version string, now time.Time) (string, error) {
b, err := i18n.Shared()
if err != nil {
return "", fmt.Errorf("loading message bundle: %w", err)
} }
dash := strings.TrimRight(info.DashboardURL, "/")
content := fmt.Sprintf(`Felhom Controller — Vészhelyzeti információk return b.Msgf(i18n.Normalize(info.Lang), recoveryInfoKey,
=============================================
Ügyfél azonosító: %s
Hub URL: %s
Visszaállítási jelszó: %s
Támogatás:
Email: %s
Web: %s
Visszaállítási útmutató:
1. Telepítse az operációs rendszert (Debian 13)
2. Futtassa a docker-setup.sh szkriptet:
sudo ./docker-setup.sh --domain <domain>
3. Nyissa meg a böngészőben: http://<ip>:8081
4. Válassza a "Visszaállítás mentésből" opciót
5. Adja meg az ügyfél azonosítót és a visszaállítási jelszót
6. Kövesse a varázsló utasításait
Generálva: %s
Controller verzió: %s
`,
info.CustomerID, info.CustomerID,
info.HubURL, info.HubURL,
info.RetrievalPassword, info.RetrievalPassword,
info.SupportEmail, info.SupportEmail,
info.SupportURL, info.SupportURL,
time.Now().Format("2006-01-02 15:04:05"), dash,
dash+"/backups/restore",
dash+"/recovery",
now.Format("2006-01-02 15:04:05"),
version, version,
) ), nil
}
// GenerateRecoveryFile writes a plain text recovery-info.txt to the given directory (0600: it holds the
// retrieval password).
func GenerateRecoveryFile(info Info, version, outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("creating recovery info directory: %w", err)
}
content, err := Render(info, version, time.Now())
if err != nil {
return err
}
path := filepath.Join(outputDir, "recovery-info.txt") path := filepath.Join(outputDir, "recovery-info.txt")
tmp := path + ".tmp" tmp := path + ".tmp"
+68
View File
@@ -0,0 +1,68 @@
package recovery
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// R-554 (`09` §3 decision 222): recovery-info.txt points at the dashboard's Restore page and the recovery
// code — never at the deleted first-boot wizard (docker-setup.sh, :8081, „Visszaállítás mentésből").
//
// RED-PROOF (REPORT): the old text (docker-setup.sh … http://<ip>:8081 … varázsló) fails every
// "must carry" line and every "must not carry" line.
func TestRecoveryInfo_PointsAtDashboardNotWizard(t *testing.T) {
info := Info{
CustomerID: "c-123", RetrievalPassword: "TESTONLY-retrieval-pw", HubURL: "https://hub.example",
SupportEmail: "support@felhom.eu", SupportURL: "https://felhom.eu/kapcsolat",
DashboardURL: "https://felhom.example.hu/",
}
at := time.Date(2026, 10, 12, 15, 4, 5, 0, time.UTC)
for _, tc := range []struct {
lang string
must []string
}{
{"hu", []string{"Biztonsági mentés → Visszaállítás", "helyreállítási kód", "Adatok visszaszerzése"}},
{"en", []string{"Backup → Restore", "recovery code", "Retrieve data"}},
} {
info.Lang = tc.lang
got, err := Render(info, "0.308.0", at)
if err != nil {
t.Fatal(err)
}
for _, want := range append(tc.must,
"https://felhom.example.hu/backups/restore", "https://felhom.example.hu/recovery",
"c-123", "https://hub.example", "TESTONLY-retrieval-pw", "2026-10-12 15:04:05", "0.308.0") {
if !strings.Contains(got, want) {
t.Errorf("%s: the file does not carry %q", tc.lang, want)
}
}
for _, gone := range []string{"docker-setup.sh", ":8081", "Visszaállítás mentésből", "varázsló", "wizard", "%!"} {
if strings.Contains(got, gone) {
t.Errorf("%s: the file still carries %q", tc.lang, gone)
}
}
}
}
// The file is written 0600 (it holds the retrieval password) and in the box's language.
func TestGenerateRecoveryFile_WritesPrivateFile(t *testing.T) {
dir := t.TempDir()
if err := GenerateRecoveryFile(Info{CustomerID: "c-1", DashboardURL: "https://felhom.x.hu", Lang: "en"}, "0.308.0", dir); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, "recovery-info.txt")
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Errorf("mode = %v, want 0600", fi.Mode().Perm())
}
b, _ := os.ReadFile(p)
if !strings.Contains(string(b), "Emergency information") {
t.Errorf("an English box's file is not in English")
}
}
-53
View File
@@ -1,53 +0,0 @@
package setup
import (
"crypto/rand"
"encoding/hex"
"net/http"
)
const csrfCookieName = "felhom_csrf"
const csrfFormField = "_csrf"
// generateCSRFToken creates a random 32-byte hex token.
func generateCSRFToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand.Read failed: " + err.Error())
}
return hex.EncodeToString(b)
}
// setCSRFCookie sets the CSRF cookie on the response.
func setCSRFCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
SameSite: http.SameSiteStrictMode,
HttpOnly: false, // JavaScript needs to read it for AJAX if needed
})
}
// validateCSRF checks that the form field matches the cookie.
func validateCSRF(r *http.Request) bool {
cookie, err := r.Cookie(csrfCookieName)
if err != nil || cookie.Value == "" {
return false
}
formToken := r.FormValue(csrfFormField)
if formToken == "" {
return false
}
return cookie.Value == formToken
}
// ensureCSRFToken returns the existing CSRF token from the cookie, or generates a new one.
func ensureCSRFToken(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(csrfCookieName); err == nil && cookie.Value != "" {
return cookie.Value
}
token := generateCSRFToken()
setCSRFCookie(w, token)
return token
}
-51
View File
@@ -1,51 +0,0 @@
package setup
import (
"bytes"
"io"
"log"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
)
// TestSetupCSSServesEmbedded (Scenario E, TASK-D0): the setup wizard must serve
// the web package's EMBEDDED stylesheet. The pre-fix handleCSS read a filesystem
// path derived from dataDir, which does not exist inside the container image, so
// production setup mode silently served the minimalCSS fallback.
func TestSetupCSSServesEmbedded(t *testing.T) {
// dataDir whose derived legacy path (internal/web/templates/style.css
// relative to its grandparent) cannot exist.
dataDir := filepath.Join(t.TempDir(), "nonexistent-nested", "data")
logger := log.New(io.Discard, "", 0)
s := NewServer(&config.Config{}, dataDir, logger, "test")
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/static/style.css", nil)
s.Handler().ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.Bytes()
if len(body) <= 10000 {
t.Fatalf("body length = %d, want > 10000 (minimalCSS fallback served?)", len(body))
}
if !strings.Contains(string(body), "--bg-0: #0A1220") {
t.Error("served CSS is missing the v2 token block (--bg-0)")
}
embedded, err := web.StyleCSS()
if err != nil {
t.Fatalf("web.StyleCSS() error: %v", err)
}
if !bytes.Equal(body, embedded) {
t.Error("served CSS differs from the embedded web stylesheet")
}
if strings.Contains(string(body), ".setup-container") {
t.Error("minimalCSS fallback was served instead of the embedded stylesheet")
}
}
-603
View File
@@ -1,603 +0,0 @@
package setup
import (
crand "crypto/rand"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
"golang.org/x/crypto/bcrypt"
)
//go:embed templates/*.html
var templateFS embed.FS
// Server handles the setup wizard HTTP routes.
type Server struct {
cfg *config.Config
dataDir string
logger *log.Logger
tmpl *template.Template
state *SetupState
version string
}
// NewServer creates a new setup wizard server.
func NewServer(cfg *config.Config, dataDir string, logger *log.Logger, version string) *Server {
s := &Server{
cfg: cfg,
dataDir: dataDir,
logger: logger,
state: LoadState(dataDir),
version: version,
}
s.loadTemplates()
// Pre-seed setup state from env vars (set by docker-setup.sh --hub-customer)
if cid := os.Getenv("FELHOM_SETUP_CUSTOMER_ID"); cid != "" {
s.state.SetFormField("customer_id", cid)
logger.Printf("[INFO] Setup: pre-seeded customer_id from env: %s", cid)
}
if pw := os.Getenv("FELHOM_SETUP_PASSWORD"); pw != "" {
s.state.SetFormField("retrieval_password", pw)
logger.Printf("[INFO] Setup: pre-seeded retrieval_password from env")
}
if s.isHubPreseeded() {
s.state.Save()
}
return s
}
// isDebug returns true if logging level is "debug".
func (s *Server) isDebug() bool {
return s.cfg != nil && s.cfg.Logging.Level == "debug"
}
// isHubPreseeded returns true if both customer_id and retrieval_password are
// pre-seeded in setup state (set by docker-setup.sh --hub-customer).
func (s *Server) isHubPreseeded() bool {
return s.state.GetFormField("customer_id") != "" && s.state.GetFormField("retrieval_password") != ""
}
func (s *Server) loadTemplates() {
s.tmpl = template.Must(
template.New("").Funcs(template.FuncMap{
"timeNow": func() string { return time.Now().Format("2006-01-02 15:04") },
}).ParseFS(templateFS, "templates/*.html"),
)
}
// Handler returns the HTTP handler for the setup wizard.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Disk-recovery setup paths (drive scan, infra-backup restore) have moved to the
// host agent (slice 8C). The wizard now offers fresh install (from Hub) + manual.
mux.HandleFunc("/", s.handleRoot)
mux.HandleFunc("/setup", s.handleWelcome)
mux.HandleFunc("/setup/fresh", s.handleFreshHub)
mux.HandleFunc("/setup/manual", s.handleManual)
mux.HandleFunc("/setup/failed", s.handleFailed)
mux.HandleFunc("/static/style.css", s.handleCSS)
mux.HandleFunc("/static/felhom-logo.svg", s.handleLogo)
return mux
}
// --- Route Handlers ---
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.Redirect(w, r, "/setup", http.StatusFound)
return
}
http.Redirect(w, r, "/setup", http.StatusFound)
}
func (s *Server) handleWelcome(w http.ResponseWriter, r *http.Request) {
csrf := ensureCSRFToken(w, r)
domain := s.cfg.Customer.Domain
ips := DetectLocalIPs()
var accessURLs []string
if domain != "" {
accessURLs = append(accessURLs, fmt.Sprintf("https://felhom.%s", domain))
}
for _, ip := range ips {
accessURLs = append(accessURLs, fmt.Sprintf("http://%s%s", ip, s.cfg.Web.SetupListen))
}
data := map[string]interface{}{
"CSRF": csrf,
"AccessURLs": accessURLs,
"Version": s.version,
"HubMode": s.isHubPreseeded(),
"HubCustomerID": s.state.GetFormField("customer_id"),
}
s.render(w, "setup_welcome", data)
}
func (s *Server) handleFreshHub(w http.ResponseWriter, r *http.Request) {
csrf := ensureCSRFToken(w, r)
if r.Method == http.MethodPost {
if !validateCSRF(r) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
s.processFreshHub(w, r)
return
}
// Auto-process if credentials are pre-seeded (hub mode from docker-setup.sh)
if s.isHubPreseeded() {
customerID := s.state.GetFormField("customer_id")
password := s.state.GetFormField("retrieval_password")
s.autoProcessFreshHub(w, r, customerID, password)
return
}
data := map[string]interface{}{
"CSRF": csrf,
"CustomerID": s.state.GetFormField("customer_id"),
}
s.render(w, "setup_fresh_hub", data)
}
func (s *Server) handleManual(w http.ResponseWriter, r *http.Request) {
csrf := ensureCSRFToken(w, r)
if r.Method == http.MethodPost {
if !validateCSRF(r) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
s.processManual(w, r)
return
}
data := map[string]interface{}{
"CSRF": csrf,
"FormData": s.state.FormData,
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
}
s.render(w, "setup_manual", data)
}
func (s *Server) handleFailed(w http.ResponseWriter, r *http.Request) {
csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{
"CSRF": csrf,
}
s.render(w, "setup_failed", data)
}
// --- Static Assets (reuse from web package embed) ---
func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) {
// Serve the web package's embedded stylesheet. The previous implementation
// read a filesystem path derived from dataDir, which does not exist inside
// the container image — setup mode silently fell back to minimalCSS.
data, err := web.StyleCSS()
if err != nil {
s.logger.Printf("[WARN] Setup: embedded style.css unavailable (%v) — serving minimal fallback CSS", err)
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write([]byte(minimalCSS))
return
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(data)
}
func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
fmt.Fprint(w, web.FelhomLogoSVG)
}
// --- Auto-Processing (Hub pre-seeded mode) ---
// autoProcessHubRestore calls PullRecovery with pre-seeded credentials and
// renders the confirmation page directly, skipping the manual form.
// Falls back to the form with an error message on failure.
// autoProcessFreshHub calls PullConfig with pre-seeded credentials and
// proceeds with fresh install, skipping the manual form.
func (s *Server) autoProcessFreshHub(w http.ResponseWriter, r *http.Request, customerID, password string) {
hubURL := DefaultHubURL
s.logger.Printf("[INFO] Setup: auto-processing fresh hub install for %s (pre-seeded credentials)", customerID)
configYAML, err := report.PullConfig(hubURL, customerID, password)
if err != nil {
s.logger.Printf("[WARN] Setup: auto fresh hub failed: %v — falling back to form", err)
// Clear pre-seeded password so form is shown on next attempt
s.state.SetFormField("retrieval_password", "")
s.state.Save()
var msg string
switch {
case isError(err, report.ErrHubUnreachable):
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
case isError(err, report.ErrAuthFailed):
msg = "Helytelen ügyfél-azonosító vagy jelszó."
case isError(err, report.ErrNotFound):
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
default:
msg = fmt.Sprintf("Hiba történt: %v", err)
}
s.renderError(w, r, "setup_fresh_hub", msg, customerID)
return
}
s.logger.Printf("[INFO] Setup: config downloaded (%d bytes), writing config...", len(configYAML))
if err := s.writeFreshConfig(configYAML, password); err != nil {
s.logger.Printf("[ERROR] Setup: writeFreshConfig failed: %v", err)
s.renderError(w, r, "setup_fresh_hub", fmt.Sprintf("Konfigurációs hiba: %v", err), customerID)
return
}
s.logger.Printf("[INFO] Setup: fresh install from Hub completed for %s", customerID)
s.finishSetup()
}
// --- Processing Logic ---
func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
customerID := strings.TrimSpace(r.FormValue("customer_id"))
password := r.FormValue("password")
hubURL := DefaultHubURL
s.state.SetFormField("customer_id", customerID)
if customerID == "" || password == "" {
s.renderError(w, r, "setup_fresh_hub", "Kérem töltse ki mindkét mezőt.", customerID)
return
}
s.logger.Printf("[INFO] Setup: downloading config from Hub (%s) for customer %s", hubURL, customerID)
configYAML, err := report.PullConfig(hubURL, customerID, password)
if err != nil {
s.logger.Printf("[ERROR] Setup: Hub config download failed: %v", err)
var msg string
switch {
case isError(err, report.ErrHubUnreachable):
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
case isError(err, report.ErrAuthFailed):
msg = "Helytelen ügyfél-azonosító vagy jelszó."
case isError(err, report.ErrNotFound):
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
default:
msg = fmt.Sprintf("Hiba történt: %v", err)
}
s.renderError(w, r, "setup_fresh_hub", msg, customerID)
return
}
s.logger.Printf("[INFO] Setup: config downloaded (%d bytes), writing config...", len(configYAML))
// Write config and finish setup
if s.isDebug() {
s.logger.Printf("[DEBUG] Setup: writing fresh config (%d bytes)", len(configYAML))
}
s.state.SetFormField("retrieval_password", password)
if err := s.writeFreshConfig(configYAML, password); err != nil {
s.logger.Printf("[ERROR] Setup: writeFreshConfig failed: %v", err)
s.renderError(w, r, "setup_fresh_hub", fmt.Sprintf("Konfigurációs hiba: %v", err), customerID)
return
}
s.logger.Printf("[INFO] Setup: fresh install from Hub completed for %s", customerID)
s.finishSetup()
}
func (s *Server) processManual(w http.ResponseWriter, r *http.Request) {
// Save all form fields
fields := []string{"customer_id", "display_name", "domain", "email",
"cf_tunnel_token", "cf_api_token", "system_data_path",
"password", "password_confirm",
"git_repo_url", "git_username", "git_token"}
for _, f := range fields {
s.state.SetFormField(f, r.FormValue(f))
}
// Validate
customerID := strings.TrimSpace(r.FormValue("customer_id"))
domain := strings.TrimSpace(r.FormValue("domain"))
password := r.FormValue("password")
passwordConfirm := r.FormValue("password_confirm")
var errs []string
if customerID == "" {
errs = append(errs, "Ügyfél-azonosító kötelező")
}
if domain == "" || domain == "homeserver.local" {
errs = append(errs, "Érvényes domain szükséges")
}
if password != "" && len(password) < 8 {
errs = append(errs, "A jelszó legalább 8 karakter legyen")
}
if password != passwordConfirm {
errs = append(errs, "A jelszavak nem egyeznek")
}
if len(errs) > 0 {
csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{
"CSRF": csrf,
"FormData": s.state.FormData,
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
"Errors": errs,
}
s.render(w, "setup_manual", data)
return
}
// Generate controller.yaml
configYAML := s.generateManualConfig()
if s.isDebug() {
s.logger.Printf("[DEBUG] Setup: generated manual config (%d bytes) for customer %s", len(configYAML), customerID)
}
if err := s.writeFreshConfig(configYAML, ""); err != nil {
csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{
"CSRF": csrf,
"FormData": s.state.FormData,
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
"Errors": []string{fmt.Sprintf("Konfigurációs hiba: %v", err)},
}
s.render(w, "setup_manual", data)
return
}
s.logger.Printf("[INFO] Setup: manual configuration completed for %s", customerID)
s.finishSetup()
}
// --- Restore Execution ---
func (s *Server) writeFreshConfig(configYAML, retrievalPassword string) error {
configPath := "/opt/docker/felhom-controller/controller.yaml"
if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil {
return fmt.Errorf("writing controller.yaml: %w", err)
}
// Create initial settings with password hash and retrieval password
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
if err != nil {
sett = &settings.Settings{}
}
// Hash the dashboard password if provided in form
if pw := s.state.GetFormField("password"); pw != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
if err == nil {
sett.SetPasswordHash(string(hash))
}
}
if retrievalPassword != "" {
sett.SetRetrievalPassword(retrievalPassword)
}
return nil
}
func (s *Server) generateManualConfig() string {
fd := s.state.FormData
customerID := fd["customer_id"]
displayName := fd["display_name"]
if displayName == "" {
displayName = customerID
}
domain := fd["domain"]
email := fd["email"]
cfTunnelToken := fd["cf_tunnel_token"]
cfAPIToken := fd["cf_api_token"]
systemDataPath := fd["system_data_path"]
if systemDataPath == "" {
systemDataPath = "/mnt/sys_drive"
}
// Generate session secret
secretBytes := make([]byte, 32)
crand.Read(secretBytes)
sessionSecret := hex.EncodeToString(secretBytes)
// Generate password hash
passwordHash := ""
if pw := fd["password"]; pw != "" {
if hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost); err == nil {
passwordHash = string(hash)
}
}
gitRepoURL := fd["git_repo_url"]
if gitRepoURL == "" {
gitRepoURL = "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
}
gitUsername := fd["git_username"]
gitToken := fd["git_token"]
// Build YAML manually (simple key-value, no templates needed)
var b strings.Builder
b.WriteString("# Generated by felhom-controller setup wizard\n")
b.WriteString("customer:\n")
fmt.Fprintf(&b, " id: %q\n", customerID)
fmt.Fprintf(&b, " name: %q\n", displayName)
fmt.Fprintf(&b, " domain: %q\n", domain)
if email != "" {
fmt.Fprintf(&b, " email: %q\n", email)
}
b.WriteString("\ninfrastructure:\n")
if cfTunnelToken != "" {
fmt.Fprintf(&b, " cf_tunnel_token: %q\n", cfTunnelToken)
}
if cfAPIToken != "" {
fmt.Fprintf(&b, " cf_api_token: %q\n", cfAPIToken)
}
b.WriteString("\npaths:\n")
b.WriteString(" stacks_dir: \"/opt/docker/stacks\"\n")
b.WriteString(" data_dir: \"/opt/docker/felhom-controller/data\"\n")
fmt.Fprintf(&b, " system_data_path: %q\n", systemDataPath)
b.WriteString("\nsystem:\n")
b.WriteString(" reserved_memory_mb: 384\n")
b.WriteString("\nweb:\n")
b.WriteString(" listen: \":8080\"\n")
b.WriteString(" setup_listen: \":8081\"\n")
if passwordHash != "" {
fmt.Fprintf(&b, " password_hash: %q\n", passwordHash)
}
fmt.Fprintf(&b, " session_secret: %q\n", sessionSecret)
b.WriteString("\ngit:\n")
fmt.Fprintf(&b, " repo_url: %q\n", gitRepoURL)
b.WriteString(" branch: \"main\"\n")
b.WriteString(" sync_interval: \"15m\"\n")
if gitUsername != "" {
fmt.Fprintf(&b, " username: %q\n", gitUsername)
}
if gitToken != "" {
fmt.Fprintf(&b, " token: %q\n", gitToken)
}
b.WriteString("\nstacks:\n")
b.WriteString(" protected:\n")
b.WriteString(" - \"traefik\"\n")
b.WriteString(" - \"cloudflared\"\n")
b.WriteString(" - \"felhom-controller\"\n")
b.WriteString(" - \"filebrowser\"\n")
b.WriteString("\nbackup:\n")
b.WriteString(" enabled: true\n")
b.WriteString(" restic_password_file: \"/opt/docker/felhom-controller/data/restic-password\"\n")
b.WriteString(" db_dump_schedule: \"02:30\"\n")
b.WriteString(" restic_schedule: \"03:00\"\n")
b.WriteString(" retention:\n")
b.WriteString(" keep_daily: 7\n")
b.WriteString(" keep_weekly: 4\n")
b.WriteString(" keep_monthly: 6\n")
b.WriteString(" prune_schedule: \"weekly\"\n")
b.WriteString("\nmonitoring:\n")
b.WriteString(" enabled: true\n")
b.WriteString(" healthchecks_base: \"https://status.felhom.eu\"\n")
b.WriteString(" system_health_interval: \"5m\"\n")
b.WriteString(" health_check_schedule: \"06:00\"\n")
b.WriteString("\nhub:\n")
b.WriteString(" enabled: true\n")
b.WriteString(" url: \"https://hub.felhom.eu\"\n")
// Generate a Hub API key from customer ID
apiKeyHash := sha256.Sum256([]byte(customerID + "-" + sessionSecret))
fmt.Fprintf(&b, " api_key: %q\n", hex.EncodeToString(apiKeyHash[:]))
b.WriteString(" push_interval: \"15m\"\n")
b.WriteString("\nself_update:\n")
b.WriteString(" enabled: true\n")
b.WriteString(" check_interval: \"6h\"\n")
b.WriteString(" image: \"gitea.dooplex.hu/admin/felhom-controller\"\n")
b.WriteString(" auto_update: false\n")
b.WriteString(" health_timeout_seconds: 60\n")
b.WriteString("\nlogging:\n")
b.WriteString(" level: \"info\"\n")
return b.String()
}
// --- Helpers ---
func (s *Server) finishSetup() {
s.state.Remove()
s.logger.Printf("[INFO] Setup complete — restarting controller")
os.Exit(0) // Docker restart policy will restart us
}
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.logger.Printf("[ERROR] Template %s render error: %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, tmpl, msg, customerID string) {
csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{
"CSRF": csrf,
"Error": msg,
"CustomerID": customerID,
}
s.render(w, tmpl, data)
}
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, perm); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
// Rename fails on bind-mounted files (EBUSY). Fall back to direct write.
return os.WriteFile(path, data, perm)
}
return nil
}
func isError(err, target error) bool {
return err != nil && strings.Contains(err.Error(), target.Error())
}
// Minimal CSS for when the main stylesheet can't be loaded (v2 tokens)
const minimalCSS = `
:root { --bg-primary: #0A1220; --bg-card: #0F1B2E; --text-primary: #EDF2F9; --text-secondary: #94A6BF; --accent-blue: #0083D8; --border: #22344F; --green: #0083D8; --red: #E5534B; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.setup-container { max-width: 700px; margin: 0 auto; padding: 2rem 1.5rem; }
.setup-header { text-align: center; margin-bottom: 2rem; }
.setup-header img { width: 120px; margin-bottom: 1rem; }
.setup-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.setup-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 2px; padding: 1.5rem; margin-bottom: 1rem; cursor: pointer; transition: border-color 0.2s; }
.setup-card:hover { border-color: var(--accent-blue); }
.setup-card h3 { margin-bottom: 0.5rem; }
.setup-card p { color: var(--text-secondary); font-size: 0.9rem; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; color: var(--text-secondary); }
.form-control { width: 100%; padding: 0.5rem 0.75rem; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 2px; color: var(--text-primary); font-size: 0.9rem; }
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.6rem 1.5rem; border-radius: 2px; border: none; font-size: 0.9rem; font-weight: 500; cursor: pointer; text-decoration: none; }
.btn-primary { background: var(--green); color: #fff; }
.btn-primary:hover { background: #2EA8F5; }
.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border); }
.alert { padding: 0.75rem 1rem; border-radius: 2px; margin-bottom: 1rem; font-size: 0.9rem; }
.alert-error { background: rgba(229,83,75,.12); color: #E5534B; border: 1px solid rgba(229,83,75,.3); }
.alert-info { background: rgba(0,131,216,.13); color: #2EA8F5; border: 1px solid rgba(0,131,216,.3); }
.info-box { background: rgba(0,131,216,.13); border: 1px solid rgba(0,131,216,.2); border-radius: 2px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: var(--text-secondary); }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 0.5rem 0.75rem; color: var(--text-secondary); font-size: 0.85rem; border-bottom: 1px solid var(--border); }
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; }
.badge-ok { background: rgba(63,185,80,0.15); color: var(--green); }
.badge-error { background: rgba(229,83,75,.12); color: var(--red); }
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.section { margin-bottom: 1.5rem; }
.section-header { cursor: pointer; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 2px; display: flex; justify-content: space-between; align-items: center; }
.section-body { padding: 1rem; border: 1px solid var(--border); border-top: none; border-radius: 0 0 6px 6px; }
.step-list { list-style: none; }
.step-list li { padding: 0.5rem 0; display: flex; align-items: center; gap: 0.75rem; }
.step-done { color: var(--green); }
.step-running { color: var(--accent-blue); }
.step-failed { color: var(--red); }
`
-62
View File
@@ -1,62 +0,0 @@
package setup
import (
"net"
"os"
"strings"
)
// DetectLocalIPs returns the host's LAN IP addresses.
// Inside a Docker container, the network interfaces only show the bridge IP
// (e.g. 172.18.0.4), which is useless for users. Instead, we:
// 1. Check HOST_IP env var (set by docker-compose.yml)
// 2. Fall back to interface enumeration as last resort
func DetectLocalIPs() []string {
// Option 1: explicit HOST_IP from environment
if hostIP := os.Getenv("HOST_IP"); hostIP != "" {
return []string{hostIP}
}
// Option 2: fallback to interface enumeration (works on bare metal)
return detectInterfaceIPs()
}
func detectInterfaceIPs() []string {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
var ips []string
for _, iface := range ifaces {
// Skip down, loopback, and Docker/container interfaces
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
name := strings.ToLower(iface.Name)
if strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "br-") ||
strings.HasPrefix(name, "veth") || strings.HasPrefix(name, "lo") {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLoopback() || ip.To4() == nil {
continue // skip non-IPv4
}
ips = append(ips, ip.String())
}
}
return ips
}
-145
View File
@@ -1,145 +0,0 @@
package setup
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// NeedsSetup checks whether the controller should enter setup mode.
// Setup is needed when no customer ID has been configured (empty string)
// or when a debug-triggered setup marker file exists.
func NeedsSetup(cfg *config.Config) bool {
if cfg.Customer.ID == "" {
return true
}
if _, err := os.Stat(filepath.Join(cfg.Paths.DataDir, ".needs-setup")); err == nil {
return true
}
return false
}
// ClearSetupMarker removes the debug-triggered setup marker file.
func ClearSetupMarker(dataDir string) {
os.Remove(filepath.Join(dataDir, ".needs-setup"))
}
// SetupState persists wizard progress to survive browser crashes.
type SetupState struct {
mu sync.Mutex `json:"-"`
path string `json:"-"`
Step string `json:"step"` // "welcome", "scan", "hub-restore", "restore-exec", "fresh-hub", "fresh-manual", "done"
Mode string `json:"mode"` // "restore" or "fresh"
FormData map[string]string `json:"form_data"` // partially filled form fields
SelectedBackup *SelectedBackup `json:"selected_backup,omitempty"`
}
// SelectedBackup tracks which backup the user chose.
type SelectedBackup struct {
Source string `json:"source"` // "local" or "hub"
DrivePath string `json:"drive_path"` // for local
CustomerID string `json:"customer_id"`
Timestamp string `json:"timestamp"`
}
// LoadState loads or creates setup state from the data directory.
func LoadState(dataDir string) *SetupState {
path := filepath.Join(dataDir, "setup-state.json")
s := &SetupState{path: path, Step: "welcome"}
data, err := os.ReadFile(path)
if err != nil {
return s // fresh state
}
if err := json.Unmarshal(data, s); err != nil {
return &SetupState{path: path, Step: "welcome"}
}
s.path = path
return s
}
// Save persists the setup state atomically.
func (s *SetupState) Save() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.FormData == nil {
s.FormData = make(map[string]string)
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("marshaling setup state: %w", err)
}
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, s.path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// SetStep updates the current step and saves.
func (s *SetupState) SetStep(step string) {
s.mu.Lock()
s.Step = step
s.mu.Unlock()
if err := s.Save(); err != nil {
log.Printf("[WARN] [setup] Failed to save setup step %q: %v", step, err)
}
}
// SetFormField saves a form field for state persistence.
func (s *SetupState) SetFormField(key, value string) {
s.mu.Lock()
if s.FormData == nil {
s.FormData = make(map[string]string)
}
s.FormData[key] = value
s.mu.Unlock()
}
// GetFormField retrieves a saved form field.
func (s *SetupState) GetFormField(key string) string {
s.mu.Lock()
defer s.mu.Unlock()
if s.FormData == nil {
return ""
}
return s.FormData[key]
}
// Remove deletes the setup state file.
func (s *SetupState) Remove() {
os.Remove(s.path)
}
// DefaultHubURL is the default Hub URL.
const DefaultHubURL = "https://hub.felhom.eu"
// LogSetupMode logs the setup mode startup message.
func LogSetupMode(domain string, ips []string, setupListen string, logger *log.Logger) {
logger.Printf("[INFO] Controller in setup mode — waiting for configuration via web UI")
if domain != "" {
logger.Printf("[INFO] Setup wizard available at: https://felhom.%s", domain)
}
for _, ip := range ips {
logger.Printf("[INFO] Setup wizard available at: http://%s%s", ip, setupListen)
}
}
@@ -1,32 +0,0 @@
{{define "setup_failed"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visszaállítás sikertelen — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>A visszaállítás nem sikerült</h1>
</div>
<div class="setup-card">
<p>Kérjük, vegye fel a kapcsolatot a támogatással:</p>
<div style="margin-top: 1rem;">
<p><strong>Email:</strong> <a href="mailto:support@felhom.eu" style="color: var(--accent-blue, #0088cc);">support@felhom.eu</a></p>
<p><strong>Web:</strong> <a href="https://felhom.eu/kapcsolat" target="_blank" style="color: var(--accent-blue, #0088cc);">felhom.eu/kapcsolat</a></p>
</div>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1.5rem;">
<a href="/setup/fresh" class="btn btn-primary">Új telepítés</a>
<a href="/setup" class="btn btn-outline">Vissza a kezdőlapra</a>
</div>
</div>
</body>
</html>
{{end}}
@@ -1,46 +0,0 @@
{{define "setup_fresh_hub"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Új telepítés — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Új telepítés</h1>
<p style="color: var(--text-secondary, #8b949e);">Konfiguráció letöltése a Hub-ról.</p>
</div>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<div class="setup-card">
<form method="POST" action="/setup/fresh">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="form-group">
<label for="customer_id">Ügyfél-azonosító</label>
<input type="text" id="customer_id" name="customer_id" class="form-control"
value="{{.CustomerID}}" required autofocus placeholder="pl. kiscsalad-bp">
</div>
<div class="form-group">
<label for="password">Visszaállítási jelszó</label>
<input type="password" id="password" name="password" class="form-control"
required placeholder="A Hub-on beállított jelszó">
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary">Letöltés</button>
<a href="/setup" class="btn btn-outline">Vissza</a>
</div>
</form>
</div>
<p style="text-align: center; margin-top: 1rem;">
<a href="/setup/manual" style="color: var(--text-secondary, #8b949e); font-size: 0.85rem;">Nincs Hub hozzáférés? Kézi beállítás &rarr;</a>
</p>
</div>
</body>
</html>
{{end}}
@@ -1,42 +0,0 @@
{{define "setup_hub_restore"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hub visszaállítás — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Visszaállítás a Hub-ról</h1>
<p style="color: var(--text-secondary, #8b949e);">Adja meg az ügyfél-azonosítót és jelszót a mentés letöltéséhez.</p>
</div>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<div class="setup-card">
<form method="POST" action="/setup/hub-restore">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="form-group">
<label for="customer_id">Ügyfél-azonosító</label>
<input type="text" id="customer_id" name="customer_id" class="form-control"
value="{{.CustomerID}}" required autofocus placeholder="pl. kiscsalad-bp">
</div>
<div class="form-group">
<label for="password">Visszaállítási jelszó</label>
<input type="password" id="password" name="password" class="form-control"
required placeholder="A Hub-on beállított jelszó">
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary">Kapcsolódás</button>
<a href="/setup" class="btn btn-outline">Vissza</a>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}
@@ -1,56 +0,0 @@
{{define "setup_hub_versions"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mentés kiválasztása — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Visszaállítás a Hub-ról</h1>
<p style="color: var(--text-secondary, #8b949e);">Válasszon a mentés-verziók közül.</p>
</div>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<div class="setup-card">
<form method="POST" action="/setup/hub-restore/select" id="version-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="width: 2rem;"></th>
<th style="text-align: left; padding: 0.5rem;">Dátum</th>
<th style="text-align: left; padding: 0.5rem;">Alkalmazások</th>
<th style="text-align: right; padding: 0.5rem;">Lemezek</th>
</tr>
</thead>
<tbody>
{{range $i, $v := .Versions}}
<tr style="border-top: 1px solid var(--line);">
<td style="padding: 0.5rem;">
<input type="radio" name="version_id" value="{{$v.ID}}" {{if eq $i 0}}checked{{end}}>
</td>
<td style="padding: 0.5rem;">{{$v.CreatedAt}}{{if eq $i 0}} <span class="badge badge-ok" style="font-size: 0.75em;">legújabb</span>{{end}}</td>
<td style="padding: 0.5rem;">
{{$v.StackCount}}{{if $v.StackNames}}: {{range $j, $n := $v.StackNames}}{{if $j}}, {{end}}{{$n}}{{end}}{{end}}
</td>
<td style="text-align: right; padding: 0.5rem;">{{$v.DiskCount}}</td>
</tr>
{{end}}
</tbody>
</table>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary">Visszaállítás</button>
<a href="/setup" class="btn btn-outline">Vissza</a>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}
@@ -1,111 +0,0 @@
{{define "setup_manual"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kézi beállítás — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Kézi beállítás</h1>
</div>
{{if .Errors}}
<div class="alert alert-error">
{{range .Errors}}<div>{{.}}</div>{{end}}
</div>
{{end}}
<form method="POST" action="/setup/manual">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="setup-card">
<h3>Ügyfél azonosítás</h3>
<div class="form-group">
<label for="customer_id">Ügyfél-azonosító *</label>
<input type="text" id="customer_id" name="customer_id" class="form-control"
value="{{index .FormData "customer_id"}}" required placeholder="pl. kiscsalad-bp"
pattern="[a-zA-Z0-9_-]+" title="Csak betűk, számok, kötőjel és aláhúzás">
</div>
<div class="form-group">
<label for="display_name">Megjelenítési név</label>
<input type="text" id="display_name" name="display_name" class="form-control"
value="{{index .FormData "display_name"}}" placeholder="pl. Kis Család">
</div>
<div class="form-group">
<label for="domain">Domain *</label>
<input type="text" id="domain" name="domain" class="form-control"
value="{{index .FormData "domain"}}" required placeholder="pl. kiscsalad.hu">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" class="form-control"
value="{{index .FormData "email"}}" placeholder="Opcionális">
</div>
</div>
<div class="setup-card">
<h3>Infrastruktúra</h3>
<div class="form-group">
<label for="cf_tunnel_token">Cloudflare Tunnel token</label>
<input type="password" id="cf_tunnel_token" name="cf_tunnel_token" class="form-control"
value="{{index .FormData "cf_tunnel_token"}}" placeholder="Opcionális">
</div>
<div class="form-group">
<label for="cf_api_token">Cloudflare API token</label>
<input type="password" id="cf_api_token" name="cf_api_token" class="form-control"
value="{{index .FormData "cf_api_token"}}" placeholder="Opcionális — DNS-01 TLS-hez">
</div>
<div class="form-group">
<label for="system_data_path">Rendszer adatpartíció útvonala</label>
<input type="text" id="system_data_path" name="system_data_path" class="form-control"
value="{{index .FormData "system_data_path"}}" placeholder="Alapértelmezett: /mnt/sys_drive">
</div>
</div>
<div class="setup-card">
<h3>Dashboard jelszó</h3>
<div class="form-group">
<label for="password">Jelszó (min. 8 karakter)</label>
<input type="password" id="password" name="password" class="form-control"
placeholder="Hagyja üresen, ha később szeretné beállítani" minlength="8">
</div>
<div class="form-group">
<label for="password_confirm">Jelszó megerősítés</label>
<input type="password" id="password_confirm" name="password_confirm" class="form-control"
placeholder="Adja meg újra a jelszót">
</div>
</div>
<div class="setup-card">
<h3>Alkalmazás-katalógus</h3>
<div class="form-group">
<label for="git_repo_url">Git repo URL</label>
<input type="text" id="git_repo_url" name="git_repo_url" class="form-control"
value="{{index .FormData "git_repo_url"}}" placeholder="{{.DefaultGit}}">
</div>
<div class="form-group">
<label for="git_username">Git felhasználónév</label>
<input type="text" id="git_username" name="git_username" class="form-control"
value="{{index .FormData "git_username"}}" placeholder="Opcionális">
</div>
<div class="form-group">
<label for="git_token">Git token</label>
<input type="password" id="git_token" name="git_token" class="form-control"
value="{{index .FormData "git_token"}}" placeholder="Opcionális">
</div>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
<button type="submit" class="btn btn-primary">Mentés és indítás</button>
<a href="/setup/fresh" class="btn btn-outline">Vissza</a>
</div>
</form>
</div>
</body>
</html>
{{end}}
@@ -1,90 +0,0 @@
{{define "setup_restore_exec"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visszaállítás folyamatban — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Visszaállítás</h1>
</div>
<div class="setup-card">
<ul class="step-list" id="steps">
<li><span class="spinner"></span> Indítás...</li>
</ul>
</div>
<div id="done-msg" style="display: none;">
<div class="alert alert-info">Visszaállítás sikeres! A vezérlőpult újraindul...</div>
</div>
<div id="error-msg" style="display: none;">
<div class="alert alert-error" id="error-text"></div>
<div style="display: flex; gap: 0.75rem; margin-top: 1rem;">
<a href="/setup/failed" class="btn btn-outline">Tovább</a>
</div>
</div>
</div>
<script>
(function() {
// After restore, the setup server (port 8081) exits and the main
// controller restarts on port 8080. Poll port 8080 until it responds.
var mainURL = window.location.protocol + '//' + window.location.hostname + ':8080/';
function waitForRestart() {
fetch(mainURL, {redirect: 'follow', mode: 'no-cors'})
.then(function() {
window.location.href = mainURL;
})
.catch(function() {
setTimeout(waitForRestart, 2000);
});
}
function poll() {
fetch('/setup/restore/status')
.then(function(r) { return r.json(); })
.then(function(data) {
var list = document.getElementById('steps');
if (data.steps && data.steps.length > 0) {
list.innerHTML = '';
data.steps.forEach(function(step) {
var li = document.createElement('li');
var icon = '';
if (step.status === 'done') icon = '<span class="step-done">&#10003;</span>';
else if (step.status === 'running') icon = '<span class="spinner"></span>';
else if (step.status === 'failed') icon = '<span class="step-failed">&#10007;</span>';
else icon = '<span style="color: var(--text-2);">&#9675;</span>';
li.innerHTML = icon + ' ' + step.label;
if (step.error) li.innerHTML += '<br><small style="color: var(--red, #f85149);">' + step.error + '</small>';
list.appendChild(li);
});
}
if (data.error) {
document.getElementById('error-msg').style.display = 'block';
document.getElementById('error-text').textContent = data.error;
return;
}
if (data.done) {
document.getElementById('done-msg').style.display = 'block';
setTimeout(waitForRestart, 3000);
return;
}
setTimeout(poll, 1500);
})
.catch(function() {
// Connection lost — controller is restarting
document.getElementById('done-msg').style.display = 'block';
setTimeout(waitForRestart, 3000);
});
}
poll();
})();
</script>
</body>
</html>
{{end}}
@@ -1,145 +0,0 @@
{{define "setup_scan"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meghajtók keresése — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Visszaállítás mentésből</h1>
</div>
<div class="setup-card" id="scan-status">
<h3>Külső meghajtók keresése...</h3>
<p style="color: var(--text-secondary, #8b949e);">Ha vannak külső meghajtók csatlakoztatva a szerverhez, győződjön meg róla, hogy most csatlakoztatva vannak.</p>
<div style="margin-top: 1rem; text-align: center;">
<div class="spinner"></div>
</div>
</div>
<div id="results" style="display: none;">
<div class="setup-card">
<h3>Találatok</h3>
<table id="results-table">
<thead>
<tr>
<th></th>
<th>Meghajtó</th>
<th>Ügyfél</th>
<th>Dátum</th>
<th>Verzió</th>
<th>Alkalmazások</th>
<th>Lemezek</th>
<th>Állapot</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
<form method="POST" action="/setup/restore" id="restore-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="source" value="local">
<input type="hidden" name="drive_path" id="selected-drive" value="">
<input type="hidden" name="history_file" id="selected-history" value="">
<button type="submit" class="btn btn-primary" id="restore-btn" disabled>Visszaállítás</button>
</form>
<a href="/setup/hub-restore" class="btn btn-outline">Tovább a Hub-hoz</a>
</div>
</div>
<div id="no-results" style="display: none;">
<div class="setup-card">
<h3>Nem található helyi mentés.</h3>
<p style="color: var(--text-secondary, #8b949e);">A csatlakoztatott meghajtókon nem található Felhom infra mentés.</p>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
<a href="/setup/hub-restore" class="btn btn-primary">Tovább a Hub-hoz</a>
<a href="/setup" class="btn btn-outline">Vissza</a>
</div>
</div>
<div id="scan-error" style="display: none;">
<div class="alert alert-error" id="scan-error-msg"></div>
<a href="/setup" class="btn btn-outline">Vissza</a>
</div>
</div>
<script>
(function() {
function poll() {
fetch('/setup/scan/status')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
document.getElementById('scan-status').style.display = 'none';
document.getElementById('scan-error').style.display = 'block';
document.getElementById('scan-error-msg').textContent = data.error;
return;
}
if (!data.done) {
setTimeout(poll, 1000);
return;
}
document.getElementById('scan-status').style.display = 'none';
if (!data.results || data.results.length === 0) {
document.getElementById('no-results').style.display = 'block';
return;
}
document.getElementById('results').style.display = 'block';
var tbody = document.querySelector('#results-table tbody');
tbody.innerHTML = '';
var validCount = 0;
data.results.forEach(function(r, i) {
var tr = document.createElement('tr');
var driveVal = r.mount_point + '|' + (r.history_file || '');
var radio = r.integrity_ok ? '<input type="radio" name="backup" value="' + driveVal + '" onclick="selectDrive(this)">' : '';
var apps = '-';
if (r.stack_count > 0) {
apps = r.stack_count.toString();
if (r.stack_names && r.stack_names.length > 0) {
var names = r.stack_names.slice(0, 3).join(', ');
if (r.stack_names.length > 3) names += ', ...';
apps += ': ' + names;
}
}
var disks = r.disk_count > 0 ? r.disk_count.toString() : '-';
var dateBadge = '';
if (r.is_history) dateBadge = ' <span class="badge" style="font-size:0.7em;background:#6e4000;color:#ffd080;">korábbi</span>';
var statusCol = r.integrity_ok
? '<span class="badge badge-ok">OK</span>'
: '<span class="badge badge-error">' + (r.error || 'Hiba') + '</span>';
tr.innerHTML = '<td>' + radio + '</td>' +
'<td>' + (r.device || '') + (r.label ? ' (' + r.label + ')' : '') + '</td>' +
'<td>' + (r.customer_id || '-') + '</td>' +
'<td>' + (r.timestamp ? r.timestamp.substring(0, 10) : '-') + dateBadge + '</td>' +
'<td>' + (r.controller_version || '-') + '</td>' +
'<td>' + apps + '</td>' +
'<td>' + disks + '</td>' +
'<td>' + statusCol + '</td>';
tbody.appendChild(tr);
if (r.integrity_ok) validCount++;
});
if (validCount === 1) {
var radio = tbody.querySelector('input[type="radio"]');
if (radio) { radio.checked = true; selectDrive(radio); }
}
});
}
window.selectDrive = function(el) {
var parts = el.value.split('|');
document.getElementById('selected-drive').value = parts[0];
document.getElementById('selected-history').value = parts[1] || '';
document.getElementById('restore-btn').disabled = false;
};
poll();
})();
</script>
</body>
</html>
{{end}}
@@ -1,58 +0,0 @@
{{define "setup_welcome"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Felhom Szerver Beállítás</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Felhom Szerver Beállítás</h1>
<p style="color: var(--text-secondary, #8b949e); font-size: 0.85rem;">{{.Version}}</p>
</div>
{{if .AccessURLs}}
<div class="info-box">
Ez az oldal elérhető:
{{range .AccessURLs}}<br>{{.}}{{end}}
</div>
{{end}}
{{if .HubMode}}
<div class="alert alert-info" style="margin-bottom: 1.5rem;">
Hub-mód: <strong>{{.HubCustomerID}}</strong> &mdash; az azonosító és jelszó automatikusan betöltődik.
</div>
<a href="/setup/hub-restore" class="setup-card" style="display: block; text-decoration: none; color: inherit; border-color: var(--accent-blue, #0088cc);">
<h3>Visszaállítás a Hub-ról</h3>
<p>A Hub-on tárolt infrastruktúra mentés visszaállítása (beállítások, titkosítási kulcsok, lemez-kiosztás).</p>
</a>
<a href="/setup/scan" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
<h3>Visszaállítás helyi meghajtóról</h3>
<p>Csatlakoztatott meghajtók keresése infrastruktúra mentésért.</p>
</a>
<a href="/setup/fresh" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
<h3>Friss telepítés</h3>
<p>Új konfiguráció letöltése a Hub-ról. Korábbi beállítások nem állítódnak vissza.</p>
</a>
{{else}}
<a href="/setup/scan" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
<h3>Visszaállítás mentésből</h3>
<p>Rendszerhiba utáni visszaállítás helyi meghajtóról vagy a Hub-ról. Válassza ezt, ha az operációs rendszert újratelepítette.</p>
</a>
<a href="/setup/fresh" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
<h3>Új telepítés</h3>
<p>Új ügyfél beállítása. Konfiguráció letöltése a Hub-ról vagy kézi beállítás.</p>
</a>
{{end}}
</div>
</body>
</html>
{{end}}
-36
View File
@@ -108,10 +108,6 @@ func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) {
case subpath == "selfupdate/dry-run" && r.Method == http.MethodPost: case subpath == "selfupdate/dry-run" && r.Method == http.MethodPost:
s.debugSelfUpdateDryRun(w, r) s.debugSelfUpdateDryRun(w, r)
// Section 7: DR / Setup
case subpath == "dr/trigger-setup" && r.Method == http.MethodPost:
s.debugTriggerSetupWizard(w, r)
// Section 8: Log viewer // Section 8: Log viewer
case subpath == "logs" && r.Method == http.MethodGet: case subpath == "logs" && r.Method == http.MethodGet:
s.debugLogBuffer(w, r) s.debugLogBuffer(w, r)
@@ -686,38 +682,6 @@ func (s *Server) debugSelfUpdateDryRun(w http.ResponseWriter, r *http.Request) {
writeDebugJSON(w, http.StatusOK, true, "", result) writeDebugJSON(w, http.StatusOK, true, "", result)
} }
// ── Section 7: DR / Setup ───────────────────────────────────────────
func (s *Server) debugTriggerSetupWizard(w http.ResponseWriter, r *http.Request) {
var req struct {
Confirm string `json:"confirm"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.invalid_request"), nil)
return
}
if req.Confirm != "RESET" {
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.reset_confirm_invalid"), nil)
return
}
// Write marker file
markerPath := filepath.Join(s.cfg.Paths.DataDir, ".needs-setup")
if err := os.WriteFile(markerPath, []byte("debug-triggered\n"), 0644); err != nil {
writeDebugJSON(w, http.StatusInternalServerError, false,
s.msg(r, "debug.api.marker_write_failed", err), nil)
return
}
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.restarting_to_setup"), nil)
// Exit after response is sent so the container restarts into setup mode
go func() {
time.Sleep(500 * time.Millisecond)
os.Exit(0)
}()
}
// ── Section 8: Log viewer ─────────────────────────────────────────── // ── Section 8: Log viewer ───────────────────────────────────────────
func (s *Server) debugLogBuffer(w http.ResponseWriter, r *http.Request) { func (s *Server) debugLogBuffer(w http.ResponseWriter, r *http.Request) {
@@ -51,6 +51,8 @@ func TestR400_DeletedControlsAreGoneFromTheTemplate(t *testing.T) {
{"/api/debug/storage/simulate-disconnect", {"/api/debug/storage/simulate-disconnect",
"no backing capability, and it WRITES storage state — a button that fakes a drive disconnect on a customer's machine is a foot-gun"}, "no backing capability, and it WRITES storage state — a button that fakes a drive disconnect on a customer's machine is a foot-gun"},
{"/api/debug/storage/simulate-reconnect", "same as simulate-disconnect"}, {"/api/debug/storage/simulate-reconnect", "same as simulate-disconnect"},
{"/api/debug/dr/trigger-setup",
"R-554 (v0.308.0): it wrote .needs-setup and restarted the box into the first-boot setup wizard, which is deleted"},
} }
for _, d := range deleted { for _, d := range deleted {
if strings.Contains(src, d.ref) { if strings.Contains(src, d.ref) {
@@ -60,7 +62,7 @@ func TestR400_DeletedControlsAreGoneFromTheTemplate(t *testing.T) {
// POSITIVE CONTROL. Without it this test passes against a template it failed to read, or one that // POSITIVE CONTROL. Without it this test passes against a template it failed to read, or one that
// was emptied — the exact shape of an assertion that proves nothing. // was emptied — the exact shape of an assertion that proves nothing.
for _, kept := range []string{"/api/debug/backup/integrity", "/api/debug/dr/trigger-setup"} { for _, kept := range []string{"/api/debug/backup/integrity", "/api/debug/hub/push"} {
if !strings.Contains(src, kept) { if !strings.Contains(src, kept) {
t.Fatalf("positive control failed: %s is missing too, so the assertions above prove nothing "+ t.Fatalf("positive control failed: %s is missing too, so the assertions above prove nothing "+
"about what was deliberately deleted", kept) "about what was deliberately deleted", kept)
@@ -85,6 +87,9 @@ func TestR400_DeletedControlsLeftNoPanelOrScript(t *testing.T) {
"btn-infra-backup", "btn-infra-backup",
"btn-hub-infra", "btn-hub-infra",
"section-storage", "section-storage",
"btn-dr-trigger", // R-554: the setup-wizard trigger and its panel
"triggerDR",
"section-dr",
} { } {
if strings.Contains(src, orphan) { if strings.Contains(src, orphan) {
t.Errorf("%q survived the deletion — an orphaned panel or handler renders nothing forever "+ t.Errorf("%q survived the deletion — an orphaned panel or handler renders nothing forever "+
@@ -92,7 +97,7 @@ func TestR400_DeletedControlsLeftNoPanelOrScript(t *testing.T) {
} }
} }
// POSITIVE CONTROL: the neighbours that must stay. // POSITIVE CONTROL: the neighbours that must stay.
for _, kept := range []string{"btn-dr-trigger", "triggerDR", "section-dr", "btn-crossdrive"} { for _, kept := range []string{"section-appexport", "btn-crossdrive"} {
if !strings.Contains(src, kept) { if !strings.Contains(src, kept) {
t.Fatalf("positive control failed: %q is gone too — the deletion took a neighbour with it", kept) t.Fatalf("positive control failed: %q is gone too — the deletion took a neighbour with it", kept)
} }
@@ -163,8 +163,6 @@ func TestR516_WidenedFormalFormsAreGone(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
want := map[string]string{ want := map[string]string{
"debug.irja_be_reset": "Írd be: RESET",
"debug.api.reset_confirm_invalid": "Érvénytelen megerősítés — írd be: RESET",
"settings_security.adja_meg_a_jelenlegi_jelszavat": "Add meg a jelenlegi jelszavad", "settings_security.adja_meg_a_jelenlegi_jelszavat": "Add meg a jelenlegi jelszavad",
"storage.biztosan_eltavolitja_adattarolot": "Biztosan eltávolítod a(z) {{.Path}} adattárolót?", "storage.biztosan_eltavolitja_adattarolot": "Biztosan eltávolítod a(z) {{.Path}} adattárolót?",
"storage_network.engedelyezze_a_felhom_gep_ip": "Engedélyezd a Felhom gép IP-címét a megosztáson. Proxmox-fürt esetén minden csomópont IP-címét.", "storage_network.engedelyezze_a_felhom_gep_ip": "Engedélyezd a Felhom gép IP-címét a megosztáson. Proxmox-fürt esetén minden csomópont IP-címét.",
@@ -149,28 +149,6 @@
</div> </div>
</div> </div>
<!-- Section 7: DR / Setup Wizard -->
<div class="card debug-section" id="section-dr">
<div class="card-header debug-section-header" onclick="toggleSection('dr')">
<h3>{{T "debug.dr_telepito_varazslo"}}</h3>
<span class="section-toggle">▶</span>
</div>
<div class="card-body debug-section-body" style="display:none">
<div class="debug-actions" style="margin-top:1rem">
<div class="debug-dr-danger">
<p style="color:var(--crit);font-weight:600">{{T "debug.veszhelyzet_szimulacio"}}</p>
<p class="text-muted" style="font-size:.85rem;margin-bottom:.5rem">{{T "debug.ez_torli_az_ugyfel_azonositot"}}</p>
<div class="debug-form-row">
<label>{{T "debug.irja_be_reset_a_megerositeshez"}}</label>
<input type="text" id="dr-confirm-input" class="form-control debug-confirm-input" placeholder="RESET" autocomplete="off">
<button class="btn btn-danger btn-sm" id="btn-dr-trigger" data-label="{{T "debug.ujrainditas_setup_modban"}}" onclick="triggerDR()">{{T "debug.ujrainditas_setup_modban"}}</button>
<span class="debug-result" id="btn-dr-trigger-result"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Section 9: App Export/Import --> <!-- Section 9: App Export/Import -->
<div class="card debug-section" id="section-appexport"> <div class="card debug-section" id="section-appexport">
<div class="card-header debug-section-header" onclick="toggleSection('appexport')"> <div class="card-header debug-section-header" onclick="toggleSection('appexport')">
@@ -452,43 +430,6 @@ function loadSelfUpdateStatus() {
}); });
} }
// ── Section 7: DR ──
function triggerDR() {
var input = document.getElementById('dr-confirm-input');
if (input.value !== 'RESET') {
var result = document.getElementById('btn-dr-trigger-result');
result.className = 'debug-result debug-result-error';
result.textContent = '{{T "debug.irja_be_reset"}}';
return;
}
var btn = document.getElementById('btn-dr-trigger');
felhomConfirm(btn, '{{T "debug.figyelem_ez_ujrainditja_a_controllert"}}', function () {
var result = document.getElementById('btn-dr-trigger-result');
btn.disabled = true;
btn.textContent = '{{T "debug.folyamatban"}}';
fetch('/api/debug/dr/trigger-setup', {
method: 'POST',
headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()),
body: JSON.stringify({confirm: 'RESET'})
}).then(function(r){return r.json()}).then(function(data) {
if (data.ok) {
result.className = 'debug-result debug-result-ok';
result.textContent = '{{T "debug.controller_ujraindulasa"}}';
} else {
result.className = 'debug-result debug-result-error';
result.textContent = data.error || '{{T "debug.hiba"}}';
btn.disabled = false;
btn.textContent = btn.dataset.label;
}
}).catch(function(e) {
result.className = 'debug-result debug-result-error';
result.textContent = '{{T "debug.hiba_2"}} ' + e.message;
btn.disabled = false;
btn.textContent = btn.dataset.label;
});
});
}
// ── Section 8: Log viewer (Vezérlő | Ügynök tabs — v0.116.0) ── // ── Section 8: Log viewer (Vezérlő | Ügynök tabs — v0.116.0) ──
var currentLogLevel = 'DEBUG'; var currentLogLevel = 'DEBUG';
var currentLogSource = 'controller'; var currentLogSource = 'controller';
-59
View File
@@ -321,28 +321,6 @@
</div> </div>
<div class="card debug-section" id="section-dr">
<div class="card-header debug-section-header" onclick="toggleSection('dr')">
<h3>DR / Telepítő varázsló</h3>
<span class="section-toggle">▶</span>
</div>
<div class="card-body debug-section-body" style="display:none">
<div class="debug-actions" style="margin-top:1rem">
<div class="debug-dr-danger">
<p style="color:var(--crit);font-weight:600">Vészhelyzet szimuláció</p>
<p class="text-muted" style="font-size:.85rem;margin-bottom:.5rem">Ez törli az ügyfél azonosítót és újraindítja a controllert setup módba. A konfigurációs fájlok megmaradnak a meghajtókon.</p>
<div class="debug-form-row">
<label>Írd be "RESET" a megerősítéshez:</label>
<input type="text" id="dr-confirm-input" class="form-control debug-confirm-input" placeholder="RESET" autocomplete="off">
<button class="btn btn-danger btn-sm" id="btn-dr-trigger" data-label="Újraindítás setup módban" onclick="triggerDR()">Újraindítás setup módban</button>
<span class="debug-result" id="btn-dr-trigger-result"></span>
</div>
</div>
</div>
</div>
</div>
<div class="card debug-section" id="section-appexport"> <div class="card debug-section" id="section-appexport">
<div class="card-header debug-section-header" onclick="toggleSection('appexport')"> <div class="card-header debug-section-header" onclick="toggleSection('appexport')">
<h3>Alkalmazás Export/Import</h3> <h3>Alkalmazás Export/Import</h3>
@@ -624,43 +602,6 @@ function loadSelfUpdateStatus() {
} }
function triggerDR() {
var input = document.getElementById('dr-confirm-input');
if (input.value !== 'RESET') {
var result = document.getElementById('btn-dr-trigger-result');
result.className = 'debug-result debug-result-error';
result.textContent = 'Írd be: RESET';
return;
}
var btn = document.getElementById('btn-dr-trigger');
felhomConfirm(btn, 'FIGYELEM! Ez újraindítja a controllert setup módba. Biztosan folytatod?', function () {
var result = document.getElementById('btn-dr-trigger-result');
btn.disabled = true;
btn.textContent = 'Folyamatban...';
fetch('/api/debug/dr/trigger-setup', {
method: 'POST',
headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()),
body: JSON.stringify({confirm: 'RESET'})
}).then(function(r){return r.json()}).then(function(data) {
if (data.ok) {
result.className = 'debug-result debug-result-ok';
result.textContent = 'Controller újraindulása...';
} else {
result.className = 'debug-result debug-result-error';
result.textContent = data.error || 'Hiba';
btn.disabled = false;
btn.textContent = btn.dataset.label;
}
}).catch(function(e) {
result.className = 'debug-result debug-result-error';
result.textContent = 'Hiba: ' + e.message;
btn.disabled = false;
btn.textContent = btn.dataset.label;
});
});
}
var currentLogLevel = 'DEBUG'; var currentLogLevel = 'DEBUG';
var currentLogSource = 'controller'; var currentLogSource = 'controller';
var lastLogTimestamp = ''; var lastLogTimestamp = '';
+2 -3
View File
@@ -3,7 +3,7 @@
grep-gate zero was a false negative). This scans templates by Unicode codepoint. grep-gate zero was a false negative). This scans templates by Unicode codepoint.
Run from controller/: python scripts/emoji_gate.py Run from controller/: python scripts/emoji_gate.py
Exit 1 if any emoji/pictograph remains in web+setup templates. Exit 1 if any emoji/pictograph remains in the web templates.
""" """
import io, os, sys, unicodedata import io, os, sys, unicodedata
@@ -12,7 +12,6 @@ import i18n_bundle # noqa: E402
ROOTS = [ ROOTS = [
os.path.join("internal", "web", "templates"), os.path.join("internal", "web", "templates"),
os.path.join("internal", "setup", "templates"),
] ]
# Codepoint ranges that count as "emoji / pictographs / dingbats" for UI-copy purposes. # Codepoint ranges that count as "emoji / pictographs / dingbats" for UI-copy purposes.
@@ -88,7 +87,7 @@ def main():
if total: if total:
print("EMOJI GATE FAILED: %d emoji found" % total) print("EMOJI GATE FAILED: %d emoji found" % total)
sys.exit(1) sys.exit(1)
print("emoji gate OK — no emoji in web/setup templates") print("emoji gate OK — no emoji in web templates")
if __name__ == "__main__": if __name__ == "__main__":
+3 -3
View File
@@ -1,6 +1,9 @@
{ {
"_comment": "Localisation slice 2 (R-557). key -> the base-commit Go literal it replaced, or the ORDERED list of literals a concatenation joined. Checked by scripts/i18n_go_parity.py against scripts/i18n_go_base.json, which is frozen at 736f54b49610 (the base commit of release v0.252.0). A key whose Hungarian text is not byte-identical to what the Go code said fails the gate.", "_comment": "Localisation slice 2 (R-557). key -> the base-commit Go literal it replaced, or the ORDERED list of literals a concatenation joined. Checked by scripts/i18n_go_parity.py against scripts/i18n_go_base.json, which is frozen at 736f54b49610 (the base commit of release v0.252.0). A key whose Hungarian text is not byte-identical to what the Go code said fails the gate.",
"_preexisting": { "_preexisting": {
"waiting.title": "BORN AS A KEY (R-554, 2026-10-12) -- the title of the page an unconfigured box shows while it retries its configuration, replacing the deleted setup wizard. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.",
"waiting.body": "BORN AS A KEY (R-554, 2026-10-12) -- the body of that waiting page. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.",
"recovery_info.body": "BORN AS A KEY (R-554, 2026-10-12, `09` §3 decision 222) -- the NEW text of recovery-info.txt, pointing at the dashboard's Restore page and the recovery code instead of the deleted setup wizard. The operator approves the Hungarian before release. Pinned by internal/recovery TestRecoveryInfo_PointsAtDashboardNotWizard.",
"err.backup.db_restore_failed_held_mixed": "BORN AS A KEY (R-893, 2026-10-08) -- a NEW sentence for a failed off-site replay whose database rollback worked but whose files/volumes/version had already moved; the app is held. Never a Go literal; pinned by internal/backup/r893_mixed_restore_test.go.", "err.backup.db_restore_failed_held_mixed": "BORN AS A KEY (R-893, 2026-10-08) -- a NEW sentence for a failed off-site replay whose database rollback worked but whose files/volumes/version had already moved; the app is held. Never a Go literal; pinned by internal/backup/r893_mixed_restore_test.go.",
"err.backup.restore_failed_held_mixed": "BORN AS A KEY (R-893, 2026-10-08, security review) -- a restore that stopped half-way (placement, volume replay or DB start failed) after files/volumes/version had moved; the app is HELD. Pinned by internal/backup/r893_mixed_restore_test.go.", "err.backup.restore_failed_held_mixed": "BORN AS A KEY (R-893, 2026-10-08, security review) -- a restore that stopped half-way (placement, volume replay or DB start failed) after files/volumes/version had moved; the app is HELD. Pinned by internal/backup/r893_mixed_restore_test.go.",
"note.reconstitute.held_mixed": "BORN AS A KEY (R-893, 2026-10-08) -- the hold sentence for that held app (HoldReasonRestoreMixed). Never a Go literal; pinned by internal/backup/r893_mixed_restore_test.go.", "note.reconstitute.held_mixed": "BORN AS A KEY (R-893, 2026-10-08) -- the hold sentence for that held app (HoldReasonRestoreMixed). Never a Go literal; pinned by internal/backup/r893_mixed_restore_test.go.",
@@ -170,7 +173,6 @@
"flash.backup.window_invalid_time": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.", "flash.backup.window_invalid_time": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
"api.deploy.netstorage_unreachable": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.", "api.deploy.netstorage_unreachable": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
"api.deploy.no_space": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.", "api.deploy.no_space": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
"debug.api.reset_confirm_invalid": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
"err.web.formazas_kesz_de_az_uj_fajlrendszer": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.", "err.web.formazas_kesz_de_az_uj_fajlrendszer": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
"err.selfupdate.mentes_fut_probalja_kesobb": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.", "err.selfupdate.mentes_fut_probalja_kesobb": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
"err.stacks.field_required_password": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.", "err.stacks.field_required_password": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
@@ -676,8 +678,6 @@
"debug.api.gitea_reachable": "Gitea elérhető (HTTP %d, %dms)", "debug.api.gitea_reachable": "Gitea elérhető (HTTP %d, %dms)",
"debug.api.telemetry_collected": "Telemetria összegyűjtve: %d app, %d hiba, %d figyelmeztetés (%dms)", "debug.api.telemetry_collected": "Telemetria összegyűjtve: %d app, %d hiba, %d figyelmeztetés (%dms)",
"debug.api.selfupdate_unconfigured": "Self-update nincs konfigurálva", "debug.api.selfupdate_unconfigured": "Self-update nincs konfigurálva",
"debug.api.marker_write_failed": "Marker fájl írási hiba: %v",
"debug.api.restarting_to_setup": "Controller újraindítása setup módba...",
"debug.api.agent_unconfigured": "Az ügynök nincs konfigurálva ezen a rendszeren.", "debug.api.agent_unconfigured": "Az ügynök nincs konfigurálva ezen a rendszeren.",
"debug.api.agent_logs_unsupported": "Az ügynök naplónézete az ügynök következő frissítése után érhető el.", "debug.api.agent_logs_unsupported": "Az ügynök naplónézete az ügynök következő frissítése után érhető el.",
"debug.api.bundles_found": "%d csomag található", "debug.api.bundles_found": "%d csomag található",
+2 -1
View File
@@ -63,7 +63,8 @@ BASE_FILE = os.path.join(HERE, "i18n_go_base.json")
KEYS_FILE = os.path.join(HERE, "i18n_go_keys.json") KEYS_FILE = os.path.join(HERE, "i18n_go_keys.json")
# Directories whose Go literals are IN SCOPE for the capture. cmd/ and internal/ minus the # Directories whose Go literals are IN SCOPE for the capture. cmd/ and internal/ minus the
# first-boot wizard, which is out of scope and slated for deletion (R-554, 10-localisation.md s6). # first-boot wizard, which was out of scope and is DELETED in v0.308.0 (R-554). The skip stays: the frozen
# base capture was taken with it, so a rescan must skip the same path to measure the same thing.
SCOPE_ROOTS = ["internal", "cmd"] SCOPE_ROOTS = ["internal", "cmd"]
SCOPE_SKIP = [os.path.join("internal", "setup")] SCOPE_SKIP = [os.path.join("internal", "setup")]
+1 -2
View File
@@ -7,7 +7,7 @@ to the signature characters below, so any hit is a defect. The allowlist is ZERO
design — fix the text, don't allowlist it. (Windows grep false-negatives multibyte — design — fix the text, don't allowlist it. (Windows grep false-negatives multibyte —
this gate is Python by the multibyte rule, like emoji_gate.) this gate is Python by the multibyte rule, like emoji_gate.)
Scans every template (web + setup) and every Go source file: Scans every template (web) and every Go source file:
- the file must decode as strict UTF-8 (undecodable bytes = broken already), and - the file must decode as strict UTF-8 (undecodable bytes = broken already), and
- the decoded text must contain none of the double-encoding signature characters: - the decoded text must contain none of the double-encoding signature characters:
à (U+00C3)  (U+00C2) Ă (U+0102) ă (U+0103) ˘ (U+02D8) ˇ (U+02C7) à (U+00C3)  (U+00C2) Ă (U+0102) ă (U+0103) ˘ (U+02D8) ˇ (U+02C7)
@@ -19,7 +19,6 @@ import io, os, sys
ROOTS = [ ROOTS = [
os.path.join("internal", "web", "templates"), os.path.join("internal", "web", "templates"),
os.path.join("internal", "setup", "templates"),
"internal", # every .go string literal / comment "internal", # every .go string literal / comment
"cmd", "cmd",
] ]
@@ -16,7 +16,6 @@ import i18n_bundle # noqa: E402
ROOTS = [ ROOTS = [
os.path.join("internal", "web", "templates"), os.path.join("internal", "web", "templates"),
os.path.join("internal", "setup", "templates"),
] ]
# A bare confirm(/prompt(/alert( CALL with an argument: not preceded by an identifier character, # A bare confirm(/prompt(/alert( CALL with an argument: not preceded by an identifier character,
+1 -2
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""D1 §10 JS element-ID integrity gate. """D1 §10 JS element-ID integrity gate.
For every template under internal/web/templates and internal/setup/templates, extract each For every template under internal/web/templates (the setup wizard templates are deleted, R-554), extract each
getElementById('X') / querySelector('#X') literal used by the file's inline JS and assert an getElementById('X') / querySelector('#X') literal used by the file's inline JS and assert an
id="X" exists in the SAME file — or that the ID is created by that file's own JS (innerHTML / id="X" exists in the SAME file — or that the ID is created by that file's own JS (innerHTML /
insertAdjacentHTML string containing id="X" / id='X'), or is explicitly allowlisted below with insertAdjacentHTML string containing id="X" / id='X'), or is explicitly allowlisted below with
@@ -14,7 +14,6 @@ import io, os, re, sys
ROOTS = [ ROOTS = [
os.path.join("internal", "web", "templates"), os.path.join("internal", "web", "templates"),
os.path.join("internal", "setup", "templates"),
] ]
# Dynamic-ID exceptions: (template, id-prefix-or-name) -> justification. # Dynamic-ID exceptions: (template, id-prefix-or-name) -> justification.