From 6eb75204b6fb37969c54bd63179392ca343b706f Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 21 Feb 2026 12:33:17 +0100 Subject: [PATCH] v0.22.0: First-run setup wizard, local infra backup, hub verification New controller features: - Web-based setup wizard replaces docker-setup.sh interactive config - Dual listener: :8080 (Traefik) + :8081 (direct HTTP for LAN) - Drive scanner finds .felhom-infra-backup/ on all block devices - Hub recovery pull (GET /api/v1/recovery/{id}) with retrieval password - Fresh install: Hub config download or manual wizard - CSRF protection, state persistence, Hungarian UI - Local infra backup written to all connected drives after each backup cycle - .felhom-infra-backup/backup.json + metadata.json with SHA256 checksum - Hub verification: parse customer_blocked from report push response - Limited mode after 7 days without verification - Recovery info page on Settings + recovery-info.txt file generation - Pending events queue: DR events sent to Hub on next report push - docker-setup.sh v6.0.0: removed interactive wizard, minimal controller.yaml only Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 45 + controller/README.md | 124 ++- controller/cmd/controller/main.go | 264 ++--- controller/docker-compose.yml | 1 + controller/internal/backup/local_infra.go | 136 +++ .../internal/backup/local_infra_test.go | 163 ++++ controller/internal/backup/paths.go | 5 + controller/internal/config/config.go | 31 +- controller/internal/recovery/info.go | 68 ++ controller/internal/report/infra_pull.go | 98 ++ controller/internal/report/pusher.go | 22 +- controller/internal/settings/settings.go | 119 +++ controller/internal/setup/csrf.go | 54 + controller/internal/setup/handlers.go | 922 ++++++++++++++++++ controller/internal/setup/network.go | 49 + controller/internal/setup/scanner.go | 271 +++++ controller/internal/setup/setup.go | 132 +++ .../setup/templates/setup_failed.html | 32 + .../setup/templates/setup_fresh_hub.html | 46 + .../setup/templates/setup_hub_restore.html | 42 + .../setup/templates/setup_manual.html | 111 +++ .../setup/templates/setup_restore_exec.html | 78 ++ .../internal/setup/templates/setup_scan.html | 123 +++ .../setup/templates/setup_welcome.html | 37 + controller/internal/web/handlers.go | 6 + .../internal/web/templates/settings.html | 40 + scripts/CHANGELOG.md | 10 + scripts/docker-setup.sh | 446 ++------- 28 files changed, 2970 insertions(+), 505 deletions(-) create mode 100644 controller/internal/backup/local_infra.go create mode 100644 controller/internal/backup/local_infra_test.go create mode 100644 controller/internal/recovery/info.go create mode 100644 controller/internal/setup/csrf.go create mode 100644 controller/internal/setup/handlers.go create mode 100644 controller/internal/setup/network.go create mode 100644 controller/internal/setup/scanner.go create mode 100644 controller/internal/setup/setup.go create mode 100644 controller/internal/setup/templates/setup_failed.html create mode 100644 controller/internal/setup/templates/setup_fresh_hub.html create mode 100644 controller/internal/setup/templates/setup_hub_restore.html create mode 100644 controller/internal/setup/templates/setup_manual.html create mode 100644 controller/internal/setup/templates/setup_restore_exec.html create mode 100644 controller/internal/setup/templates/setup_scan.html create mode 100644 controller/internal/setup/templates/setup_welcome.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc14f0..b5bdb78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ ## Changelog +### v0.22.0 — First-Run Setup Wizard & Local Infra Backup (2026-02-21) + +Major feature release: moves ALL initial configuration and disaster recovery setup from `docker-setup.sh` into the controller itself as a web-based wizard. + +**Setup Wizard (`internal/setup/`):** +- New web-based setup wizard replaces interactive CLI wizard from `docker-setup.sh` +- Dual listener: `:8080` (behind Traefik) + `:8081` (direct HTTP for LAN access before DNS is configured) +- Setup mode detection: controller enters wizard when `customer.id` is empty or `"demo-felhom"` +- Two paths: "Restore from backup" (local drive scan + Hub recovery) and "Fresh install" (Hub download or manual config) +- Drive scanner: detects `.felhom-infra-backup/` on all connected drives, validates checksums +- Hub recovery: `GET /api/v1/recovery/{id}` with retrieval password auth — returns combined config + infra backup +- CSRF protection (cookie + hidden field) for all wizard POST endpoints +- State persistence (`setup-state.json`) survives browser crashes +- All UI text in Hungarian, uses existing dark theme CSS +- After setup: writes `controller.yaml`, creates `settings.json`, `os.Exit(0)` → Docker restart into normal mode + +**Local Infra Backup (`internal/backup/local_infra.go`):** +- Writes infrastructure backup to all connected drives as `.felhom-infra-backup/backup.json` + `metadata.json` +- Schema-versioned with SHA256 checksum validation +- Runs on startup and after each nightly backup cycle +- Enables disaster recovery without Hub connectivity — any drive can bootstrap a new controller + +**Hub Verification:** +- Pusher parses Hub report response for `customer_blocked` field +- Updates `hub_verified` / `hub_verified_at` in settings on each successful push +- `IsLimitedMode()` checks verification state + 7-day grace period + +**Recovery Info:** +- New `internal/recovery/` package generates `recovery-info.txt` in data directory +- Settings page shows recovery info section (customer ID, Hub URL, masked retrieval password) +- Recovery file auto-regenerated on each startup when retrieval password is set + +**Pending Events:** +- New `PendingEvent` type in settings with `AddPendingEvent()` / `DrainPendingEvents()` +- Events queued during setup (e.g., DR completed) are drained and pushed to Hub on first successful report push + +**Config & Settings Schema:** +- `config.go`: Added `SetupListen` field (default `:8081`), `LoadPermissive()`, `Default()` +- `settings.go`: Added `hub_verified`, `hub_verified_at`, `retrieval_password`, `pending_events` fields with RWMutex accessors + +**Infrastructure:** +- `docker-compose.yml`: Added port `8081:8081` mapping for setup wizard +- Removed old fresh-deployment auto-restore code from `main.go` (lines 70-141) +- Removed `restoreSettingsFromHub()` and `restorePasswordsFromHub()` helpers + ### v0.21.3 — Config Apply Infra Push + Fixes (2026-02-20) - **Push infra backup after config apply**: After a successful `POST /api/config/apply`, the controller immediately pushes an infra backup to the Hub so the config sync status updates right away. - **Fix double "v" prefix in startup event**: "Controller elindult (vv0.21.2)" → "Controller elindult (v0.21.3)". diff --git a/controller/README.md b/controller/README.md index b6c5997..f881c86 100644 --- a/controller/README.md +++ b/controller/README.md @@ -4,7 +4,7 @@ A single, lightweight Go container that replaces Portainer + scattered systemd scripts with a unified, Hungarian-language web dashboard for managing Docker Compose stacks, backups, storage, monitoring, and notifications on customer hardware. -**Current version: v0.21.0** +**Current version: v0.22.0** --- @@ -20,6 +20,8 @@ A single, lightweight Go container that replaces Portainer + scattered systemd s - [Update Management](#6-update-management) - [Authentication & Settings](#7-authentication--settings) - [Central Hub](#8-central-hub-reporting) + - [Setup Wizard](#9-first-run-setup-wizard) + - [Disaster Recovery](#10-disaster-recovery) - [Repository Layout](#repository-layout) - [Configuration](#configuration) - [REST API](#rest-api) @@ -812,28 +814,95 @@ The hub service (separate Go app in the `felhom.eu` repo) provides: - 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 -### 9. Disaster Recovery +### 9. First-Run Setup Wizard -When a system drive fails and is replaced, the controller can automatically restore the full deployment: +When the controller starts with no valid customer configuration (`customer.id` empty or `"demo-felhom"`), it enters **setup mode** — a web-based wizard that handles all initial configuration. This replaces the old interactive shell wizard in `docker-setup.sh`. + +#### Setup Mode Detection (`internal/setup/setup.go`) + +`NeedsSetup(cfg)` returns true when `customer.id` is empty or `"demo-felhom"`. In setup mode, the controller skips normal startup (no scheduler, no backup, no stacks) and serves only the wizard UI on two listeners: +- `:8080` — behind Traefik (accessible via domain, e.g. `https://felhom.example.com`) +- `:8081` — direct HTTP (accessible via LAN IP, e.g. `http://192.168.0.100:8081`) + +#### Wizard Flow ``` -1. docker-setup.sh deploys fresh controller (Hub enabled, customer_id configured) -2. Controller detects empty data dir → fresh deployment -3. Controller pulls infra backup from Hub → gets disk layout, passwords, configs -4. Controller scans block devices for UUIDs matching stored disk layout -5. Controller mounts surviving drives (e.g., HDD with backups) -6. Controller scans mounted drives for local backup data (_infra/ + rsync copies) -7. Controller auto-restores stack configs → apps appear in dashboard -8. User opens dashboard → "Visszaállítás" (Restore) wizard -9. User confirms → sequential restore: rsync first, restic fallback, DB import -10. Apps restored and running +┌──────────────────────────────────┐ +│ 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 +``` + +#### 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 via `lsblk` + temp mounts | +| `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/` | 7 embedded HTML templates (Hungarian, dark theme matching main UI) | + +#### Local Infra Backup (`internal/backup/local_infra.go`) + +The controller writes infrastructure snapshots to **every connected drive** after each backup cycle and on startup. Location: `/.felhom-infra-backup/`. Files: +- `backup.json` — full infra backup (config, settings, disk layout, passwords, stacks) +- `metadata.json` — schema version, timestamp, customer ID, controller version, SHA256 checksum + +During setup wizard drive scan, these backups are discovered, integrity-verified, and offered for one-click restore. + +#### 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. + +### 10. Disaster Recovery + +When a system drive fails and is replaced, the recovery flow uses the setup wizard: + +``` +1. docker-setup.sh deploys fresh controller with minimal config (domain + paths only) +2. Controller detects empty customer.id → enters setup mode +3. User opens wizard at http://:8081 +4. Wizard scans all drives for .felhom-infra-backup/ directories +5. If found: one-click restore (config, settings, passwords, disk layout) +6. If not found: Hub recovery via customer ID + retrieval password +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. **Rsync copies** (cross-drive, plain files, no password needed) — fastest, most reliable -2. **Restic snapshots** (encrypted, needs password from Hub) — comprehensive but slower +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 +3. **Manual config** (wizard form) — enter all details manually as last resort -**Fallback:** If the Hub is unreachable, the controller can still detect backups on already-mounted drives (manual mount or pre-existing fstab entries). +**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). --- @@ -841,7 +910,7 @@ When a system drive fails and is replaced, the controller can automatically rest ``` controller/ -├── cmd/controller/main.go # Entry point, wires all 14 modules +├── cmd/controller/main.go # Entry point, wires all 15 modules (setup mode branch + normal startup) ├── internal/ │ ├── config/config.go # YAML loader, validation, env overrides │ ├── settings/settings.go # Runtime settings (JSON, atomic writes, RWMutex) @@ -860,7 +929,8 @@ controller/ │ │ └── *_other.go # Non-Linux stubs for cross-compilation │ ├── backup/ │ │ ├── backup.go # Orchestrator (per-drive dumps + restic + cross-drive chain) -│ │ ├── paths.go # Per-drive path helpers (PrimaryResticRepoPath, AppDBDumpPath, etc.) +│ │ ├── paths.go # Per-drive path helpers (PrimaryResticRepoPath, InfraBackupDir, etc.) +│ │ ├── local_infra.go # Local infra backup to all drives (.felhom-infra-backup/) │ │ ├── dbdump.go # DB auto-discovery + dump (pg_dump, mariadb-dump) │ │ ├── restic.go # Restic operations (init, snapshot, prune, check) — repoPath as param │ │ ├── appdata.go # StackDataProvider interface, app data discovery @@ -890,8 +960,16 @@ controller/ │ ├── notify/notifier.go # Email relay to hub, preference sync, cooldowns │ ├── report/ │ │ ├── builder.go # Hub report builder (all subsystems → JSON) -│ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth) -│ │ └── infra_pull.go # DR: pull infra backup from Hub for fresh deployment +│ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth, parses customer_blocked) +│ │ └── 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) │ └── web/ │ ├── server.go # HTTP server, routing, static files │ ├── auth.go # Session auth, login/logout, session cleanup @@ -953,6 +1031,10 @@ monitoring: backup: "uuid-here" backup_integrity: "uuid-here" +web: + listen: ":8080" + setup_listen: ":8081" # Plain HTTP for setup wizard LAN access + hub: enabled: true url: "https://hub.felhom.eu" @@ -966,7 +1048,7 @@ Environment variable overrides: `FELHOM_LOGGING_LEVEL=debug`, `FELHOM_HUB_ENABLE ### Runtime settings (`settings.json`) -Auto-managed by the controller. Contains password hash overrides, notification preferences, per-app backup configs, storage path registry, DB validation cache. All writes are atomic. +Auto-managed by the controller. Contains password hash overrides, notification preferences, per-app backup configs, storage path registry, DB validation cache, Hub verification state (`hub_verified`, `hub_verified_at`), retrieval password for disaster recovery, and pending event queue. All writes are atomic (write `.tmp`, rename). ### Per-app config (`app.yaml`) diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 9aacf11..9b77141 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/base64" "encoding/json" "flag" "fmt" @@ -22,14 +21,16 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/metrics" "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" "gitea.dooplex.hu/admin/felhom-controller/internal/notify" + "gitea.dooplex.hu/admin/felhom-controller/internal/recovery" "gitea.dooplex.hu/admin/felhom-controller/internal/report" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate" "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/storage" catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync" "gitea.dooplex.hu/admin/felhom-controller/internal/system" - "gitea.dooplex.hu/admin/felhom-controller/internal/storage" "gitea.dooplex.hu/admin/felhom-controller/internal/web" ) @@ -51,12 +52,23 @@ func main() { } // --- Load configuration --- - cfg, err := config.Load(*configPath) + // Use LoadPermissive to tolerate minimal configs (e.g. only domain set by docker-setup.sh). + // If even that fails (file missing/unreadable), fall back to defaults. + cfg, err := config.LoadPermissive(*configPath) if err != nil { - log.Fatalf("[FATAL] Failed to load config from %s: %v", *configPath, err) + cfg = config.Default() + log.Printf("[WARN] Config load failed (%s), using defaults: %v", *configPath, err) } logger := setupLogger(cfg) + + // --- Setup mode: if no customer ID configured, run setup wizard --- + if setup.NeedsSetup(cfg) { + logger.Printf("[INFO] felhom-controller %s — setup mode", Version) + runSetupMode(cfg, logger) + return + } + logger.Printf("[INFO] felhom-controller %s starting (customer: %s, domain: %s)", Version, cfg.Customer.ID, cfg.Customer.Domain) @@ -67,79 +79,6 @@ func main() { logger.Fatalf("[FATAL] Failed to load settings from %s: %v", settingsPath, err) } - // --- Detect fresh deployment (Phase 2+3: DR restore from Hub) --- - var restorePlan *backup.RestorePlan - isFreshDeployment := !fileExists(settingsPath) - if isFreshDeployment && cfg.Hub.Enabled && cfg.Hub.URL != "" { - logger.Println("[INFO] Fresh deployment detected — checking Hub for infra backup") - - ib, pullErr := report.PullInfraBackup(cfg.Hub.URL, cfg.Hub.APIKey, cfg.Customer.ID) - if pullErr != nil { - logger.Printf("[WARN] Could not reach Hub for infra backup: %v", pullErr) - } else if ib != nil { - logger.Printf("[INFO] Found infra backup on Hub: %s (%s), %d stacks, synced %s", - ib.Domain, ib.CustomerID, len(ib.DeployedStacks), ib.Timestamp) - - // Restore settings.json from Hub backup first - restoreSettingsFromHub(ib, cfg, logger) - - // Re-load settings (now from restored file) - if restoredSett, loadErr := settings.Load(settingsPath, logger); loadErr == nil { - sett = restoredSett - logger.Println("[INFO] Settings reloaded after Hub restore") - } - - // Restore restic passwords AFTER settings reload so cross-drive password persists - restorePasswordsFromHub(ib, cfg, sett, logger) - - // Mount drives using stored disk layout - mountCtx, mountCancel := context.WithTimeout(context.Background(), 2*time.Minute) - mountedPaths, mountErr := backup.MountDrivesFromLayout(mountCtx, ib.DiskLayout, logger) - mountCancel() - if mountErr != nil { - logger.Printf("[WARN] Drive mounting error: %v", mountErr) - } else if len(mountedPaths) > 0 { - logger.Printf("[INFO] Mounted %d drives from Hub disk layout: %v", len(mountedPaths), mountedPaths) - } else { - logger.Println("[INFO] No matching drives found to mount from Hub disk layout") - } - - // Phase 3: Scan mounted drives for backup data and build restore plan - if len(ib.DeployedStacks) > 0 { - // Collect mount paths from disk layout - var drivePaths []string - for _, dm := range ib.DiskLayout.Mounts { - if dm.MountPoint != "" { - drivePaths = append(drivePaths, dm.MountPoint) - } - } - - // Convert report stacks to backup scan format - var infraStacks []backup.InfraStackInfo - for _, s := range ib.DeployedStacks { - infraStacks = append(infraStacks, backup.InfraStackInfo{ - Name: s.Name, - DisplayName: s.DisplayName, - HDDPath: s.HDDPath, - NeedsHDD: s.NeedsHDD, - }) - } - - restorePlan = backup.ScanDrivesForBackups(drivePaths, infraStacks, logger) - if restorePlan != nil { - restorePlan.CustomerID = ib.CustomerID - restorePlan.Domain = ib.Domain - restorePlan.Timestamp = ib.Timestamp - logger.Printf("[INFO] DR restore plan ready: %d apps to restore", len(restorePlan.Apps)) - } else { - logger.Println("[WARN] ScanDrivesForBackups returned nil — no restore plan created") - } - } - } else { - logger.Println("[INFO] No infra backup found on Hub for this customer") - } - } - // --- Auto-discover storage paths from deployed apps --- discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger) sett.AutoDiscoverStoragePaths(discoveredPaths, cfg.Paths.HDDPath, logger) @@ -304,6 +243,15 @@ func main() { var hubPusher *report.Pusher if cfg.Hub.URL != "" && cfg.Hub.APIKey != "" { hubPusher = report.NewPusher(&cfg.Hub, logger) + // Wire hub verification: update settings when hub reports customer status + hubPusher.OnPushResponse = func(resp *report.PushResponse) { + if resp.CustomerBlocked { + sett.SetHubVerified(false, time.Now()) + logger.Printf("[WARN] Customer blocked on Hub — new deployments may be restricted") + } else { + sett.SetHubVerified(true, time.Now()) + } + } // Wire hub push status into alert manager for dashboard alerts alertMgr.SetHubPushStatus(func() web.HubPushStatusData { s := hubPusher.GetStatus() @@ -350,6 +298,8 @@ func main() { if hubPusher != nil && cfg.Hub.Enabled { go pushInfraBackup(cfg, sett, stackProv, hubPusher, logger) } + // Write local infra backup to all connected drives + go writeLocalInfraBackup(cfg, sett, stackProv, logger) return err }) @@ -397,7 +347,17 @@ func main() { } sched.Every("hub-report", pushInterval, func(ctx context.Context) error { r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths()) - return hubPusher.Push(r) + if err := hubPusher.Push(r); err != nil { + return err + } + // Drain pending events (e.g., DR recovery completed) after successful push + if events := sett.DrainPendingEvents(); len(events) > 0 { + for _, ev := range events { + notifier.Notify(ev.EventType, ev.Severity, ev.Message, ev.Details) + } + logger.Printf("[INFO] Drained %d pending events to Hub", len(events)) + } + return nil }) logger.Printf("[INFO] Hub reporting enabled (every %s to %s)", pushInterval, cfg.Hub.URL) } else { @@ -468,6 +428,22 @@ func main() { sched.Start(ctx) defer sched.Stop() + // Generate recovery info file if retrieval password is set + if rp := sett.GetRetrievalPassword(); rp != "" { + go func() { + info := recovery.Info{ + CustomerID: cfg.Customer.ID, + RetrievalPassword: rp, + HubURL: cfg.Hub.URL, + SupportEmail: "support@felhom.eu", + SupportURL: "https://felhom.eu/kapcsolat", + } + if err := recovery.GenerateRecoveryFile(info, Version, cfg.Paths.DataDir); err != nil { + logger.Printf("[WARN] Failed to generate recovery-info.txt: %v", err) + } + }() + } + // Fire startup pings + hub report immediately (don't wait for first scheduler tick) go func() { time.Sleep(5 * time.Second) // Let all subsystems fully initialize @@ -511,6 +487,8 @@ func main() { } // Also push infra backup on startup go pushInfraBackup(cfg, sett, stackProv, hubPusher, logger) + // Write local infra backup to all connected drives + go writeLocalInfraBackup(cfg, sett, stackProv, logger) } else { // Send a minimal "disabled" notification so hub knows reporting is intentionally off r := &report.Report{ @@ -632,12 +610,6 @@ func main() { backupMgr.MigrationActiveCheck = driveMigrator.IsActive } - // Phase 3: Set DR restore mode if a restore plan was built - if restorePlan != nil && len(restorePlan.Apps) > 0 { - webServer.SetRestoreState(restorePlan) - logger.Println("[INFO] DR restore mode activated — all web routes redirect to /restore") - } - // --- Build HTTP mux --- mux := http.NewServeMux() @@ -923,46 +895,112 @@ func fileExists(path string) bool { return err == nil } -// restorePasswordsFromHub restores restic passwords from a Hub infra backup. -func restorePasswordsFromHub(ib *report.InfraBackup, cfg *config.Config, - sett *settings.Settings, 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) - if ib.ResticPassword != "" { - decoded, err := base64.StdEncoding.DecodeString(ib.ResticPassword) - if err == nil && len(decoded) > 0 { - dir := filepath.Dir(cfg.Backup.ResticPasswordFile) - if err := os.MkdirAll(dir, 0700); err != nil { - logger.Printf("[WARN] Failed to create restic password directory %s: %v", dir, err) - } else if err := os.WriteFile(cfg.Backup.ResticPasswordFile, decoded, 0600); err == nil { - logger.Println("[INFO] Primary restic password restored from Hub") - } else { - logger.Printf("[WARN] Failed to write restic password file: %v", err) - } - } + 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") } -// restoreSettingsFromHub restores settings.json from a Hub infra backup. -func restoreSettingsFromHub(ib *report.InfraBackup, cfg *config.Config, logger *log.Logger) { - if ib.SettingsJSONB64 == "" { - return - } - decoded, err := base64.StdEncoding.DecodeString(ib.SettingsJSONB64) +// writeLocalInfraBackup builds an infra snapshot and writes it to all connected drives. +func writeLocalInfraBackup(cfg *config.Config, sett *settings.Settings, + stackProv *stackAdapter, logger *log.Logger) { + + ib, err := report.BuildInfraBackup( + cfg.Customer.ID, cfg.Customer.Domain, Version, + "/opt/docker/felhom-controller/controller.yaml", + filepath.Join(cfg.Paths.DataDir, "settings.json"), + cfg.Backup.ResticPasswordFile, + cfg.Paths.SystemDataPath, + sett, stackProv, logger, + ) if err != nil { - logger.Printf("[WARN] Failed to decode settings from Hub: %v", err) + logger.Printf("[WARN] Failed to build infra backup for local write: %v", err) return } - if err := os.MkdirAll(cfg.Paths.DataDir, 0755); err != nil { - logger.Printf("[WARN] Failed to create data directory for settings restore: %v", err) + + data, err := json.Marshal(ib) + if err != nil { + logger.Printf("[WARN] Failed to marshal infra backup for local write: %v", err) return } - settingsPath := filepath.Join(cfg.Paths.DataDir, "settings.json") - if err := os.WriteFile(settingsPath, decoded, 0600); err != nil { - logger.Printf("[WARN] Failed to write restored settings.json: %v", err) - } else { - logger.Println("[INFO] Settings restored from Hub backup") + + // Collect all connected drive paths (skip disconnected and decommissioned) + var drives []string + for _, sp := range sett.GetStoragePaths() { + if !sp.Disconnected && !sp.Decommissioned { + drives = append(drives, sp.Path) + } } + // Also include system data path if set + if cfg.Paths.SystemDataPath != "" { + drives = append(drives, cfg.Paths.SystemDataPath) + } + + if len(drives) == 0 { + logger.Println("[DEBUG] No connected drives for local infra backup") + return + } + + backup.WriteLocalInfraBackup(data, cfg.Customer.ID, Version, ib.Timestamp, drives, logger) } // discoverHDDPaths scans deployed apps' app.yaml for HDD_PATH env values. diff --git a/controller/docker-compose.yml b/controller/docker-compose.yml index 3d4a756..5a01b7f 100644 --- a/controller/docker-compose.yml +++ b/controller/docker-compose.yml @@ -11,6 +11,7 @@ services: privileged: true # Required for disk operations (mkfs, mount, sfdisk) ports: - "8080:8080" + - "8081:8081" # Setup wizard direct HTTP (only active during setup mode) volumes: # Docker socket — required for compose operations + DB dumps (docker exec) - /var/run/docker.sock:/var/run/docker.sock diff --git a/controller/internal/backup/local_infra.go b/controller/internal/backup/local_infra.go new file mode 100644 index 0000000..e75b067 --- /dev/null +++ b/controller/internal/backup/local_infra.go @@ -0,0 +1,136 @@ +package backup + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" +) + +// MaxSchemaVersion is the highest infra backup schema version this controller can read. +const MaxSchemaVersion = 1 + +// InfraMetadata is the lightweight metadata file written alongside backup.json. +type InfraMetadata struct { + SchemaVersion int `json:"schema_version"` + Timestamp string `json:"timestamp"` + CustomerID string `json:"customer_id"` + ControllerVersion string `json:"controller_version"` + Checksum string `json:"checksum"` // SHA256 hex of backup.json +} + +// WriteLocalInfraBackup writes the infra backup to .felhom-infra-backup/ on each drive. +// Individual drive failures are logged but not returned — the function is best-effort. +func WriteLocalInfraBackup(backupJSON []byte, customerID, controllerVersion, timestamp string, drives []string, logger *log.Logger) { + if len(drives) == 0 { + logger.Printf("[DEBUG] No drives configured for local infra backup") + return + } + + // Compute checksum of backup data + hash := sha256.Sum256(backupJSON) + checksum := hex.EncodeToString(hash[:]) + + meta := InfraMetadata{ + SchemaVersion: 1, + Timestamp: timestamp, + CustomerID: customerID, + ControllerVersion: controllerVersion, + Checksum: checksum, + } + + metaJSON, err := json.MarshalIndent(meta, "", " ") + if err != nil { + logger.Printf("[ERROR] Local infra backup: failed to marshal metadata: %v", err) + return + } + + written := 0 + for _, drive := range drives { + dir := InfraBackupDir(drive) + if err := writeInfraToDir(dir, backupJSON, metaJSON); err != nil { + logger.Printf("[WARN] Local infra backup: failed to write to %s: %v", drive, err) + continue + } + written++ + } + + logger.Printf("[INFO] Local infra backup written to %d/%d drive(s)", written, len(drives)) +} + +// writeInfraToDir writes backup.json and metadata.json atomically to the given directory. +func writeInfraToDir(dir string, backupData, metaData []byte) error { + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("creating dir: %w", err) + } + + // Write backup.json atomically + backupPath := filepath.Join(dir, "backup.json") + if err := atomicWrite(backupPath, backupData, 0600); err != nil { + return fmt.Errorf("writing backup.json: %w", err) + } + + // Write metadata.json atomically + metaPath := filepath.Join(dir, "metadata.json") + if err := atomicWrite(metaPath, metaData, 0600); err != nil { + return fmt.Errorf("writing metadata.json: %w", err) + } + + return nil +} + +// atomicWrite writes data to a .tmp file then renames to the target path. +func atomicWrite(path string, data []byte, perm os.FileMode) error { + 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) + return err + } + return nil +} + +// ReadLocalInfraBackup reads and validates an infra backup from a mount point. +// Returns the raw backup JSON, metadata, and any error. +func ReadLocalInfraBackup(mountPath string) ([]byte, *InfraMetadata, error) { + dir := InfraBackupDir(mountPath) + + // Read metadata + metaPath := filepath.Join(dir, "metadata.json") + metaData, err := os.ReadFile(metaPath) + if err != nil { + return nil, nil, fmt.Errorf("reading metadata.json: %w", err) + } + + var meta InfraMetadata + if err := json.Unmarshal(metaData, &meta); err != nil { + return nil, nil, fmt.Errorf("parsing metadata.json: %w", err) + } + + // Check schema version + if meta.SchemaVersion > MaxSchemaVersion { + return nil, &meta, fmt.Errorf("backup schema version %d is newer than supported version %d — upgrade the controller", meta.SchemaVersion, MaxSchemaVersion) + } + + // Read backup data + backupPath := filepath.Join(dir, "backup.json") + backupData, err := os.ReadFile(backupPath) + if err != nil { + return nil, &meta, fmt.Errorf("reading backup.json: %w", err) + } + + // Verify checksum + hash := sha256.Sum256(backupData) + actual := hex.EncodeToString(hash[:]) + if actual != meta.Checksum { + return nil, &meta, fmt.Errorf("checksum mismatch: expected %s, got %s", meta.Checksum, actual) + } + + return backupData, &meta, nil +} diff --git a/controller/internal/backup/local_infra_test.go b/controller/internal/backup/local_infra_test.go new file mode 100644 index 0000000..cb3d68d --- /dev/null +++ b/controller/internal/backup/local_infra_test.go @@ -0,0 +1,163 @@ +package backup + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "log" + "os" + "path/filepath" + "testing" +) + +func TestWriteAndReadLocalInfraBackup(t *testing.T) { + tmpDir := t.TempDir() + drive := filepath.Join(tmpDir, "mnt", "hdd_0") + if err := os.MkdirAll(drive, 0755); err != nil { + t.Fatal(err) + } + + backupJSON := []byte(`{"customer_id":"test-123","domain":"test.hu","controller_version":"v0.21.0","timestamp":"2026-02-21T10:00:00Z"}`) + logger := testLogger(t) + + WriteLocalInfraBackup(backupJSON, "test-123", "v0.21.0", "2026-02-21T10:00:00Z", []string{drive}, logger) + + // Verify files exist + dir := InfraBackupDir(drive) + if _, err := os.Stat(filepath.Join(dir, "backup.json")); err != nil { + t.Fatalf("backup.json not found: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "metadata.json")); err != nil { + t.Fatalf("metadata.json not found: %v", err) + } + + // Read back + data, meta, err := ReadLocalInfraBackup(drive) + if err != nil { + t.Fatalf("ReadLocalInfraBackup failed: %v", err) + } + if string(data) != string(backupJSON) { + t.Errorf("backup data mismatch: got %s", string(data)) + } + if meta.SchemaVersion != 1 { + t.Errorf("expected schema version 1, got %d", meta.SchemaVersion) + } + if meta.CustomerID != "test-123" { + t.Errorf("expected customer_id test-123, got %s", meta.CustomerID) + } + if meta.ControllerVersion != "v0.21.0" { + t.Errorf("expected controller version v0.21.0, got %s", meta.ControllerVersion) + } + + // Verify checksum + hash := sha256.Sum256(backupJSON) + expected := hex.EncodeToString(hash[:]) + if meta.Checksum != expected { + t.Errorf("checksum mismatch: expected %s, got %s", expected, meta.Checksum) + } +} + +func TestReadLocalInfraBackup_ChecksumMismatch(t *testing.T) { + tmpDir := t.TempDir() + drive := filepath.Join(tmpDir, "mnt", "hdd_0") + dir := InfraBackupDir(drive) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + + // Write valid metadata with wrong checksum + meta := InfraMetadata{SchemaVersion: 1, Checksum: "0000000000000000000000000000000000000000000000000000000000000000"} + metaJSON, _ := json.Marshal(meta) + os.WriteFile(filepath.Join(dir, "metadata.json"), metaJSON, 0600) + os.WriteFile(filepath.Join(dir, "backup.json"), []byte(`{"test":true}`), 0600) + + _, _, err := ReadLocalInfraBackup(drive) + if err == nil { + t.Fatal("expected checksum mismatch error") + } + if got := err.Error(); !contains(got, "checksum mismatch") { + t.Errorf("expected checksum mismatch error, got: %s", got) + } +} + +func TestReadLocalInfraBackup_SchemaVersionTooNew(t *testing.T) { + tmpDir := t.TempDir() + drive := filepath.Join(tmpDir, "mnt", "hdd_0") + dir := InfraBackupDir(drive) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + + meta := InfraMetadata{SchemaVersion: 999} + metaJSON, _ := json.Marshal(meta) + os.WriteFile(filepath.Join(dir, "metadata.json"), metaJSON, 0600) + os.WriteFile(filepath.Join(dir, "backup.json"), []byte(`{}`), 0600) + + _, _, err := ReadLocalInfraBackup(drive) + if err == nil { + t.Fatal("expected schema version error") + } + if got := err.Error(); !contains(got, "newer than supported") { + t.Errorf("expected schema version error, got: %s", got) + } +} + +func TestReadLocalInfraBackup_MissingFiles(t *testing.T) { + tmpDir := t.TempDir() + _, _, err := ReadLocalInfraBackup(tmpDir) + if err == nil { + t.Fatal("expected error for missing files") + } +} + +func TestWriteLocalInfraBackup_MultipleDrives(t *testing.T) { + tmpDir := t.TempDir() + drives := []string{ + filepath.Join(tmpDir, "drive1"), + filepath.Join(tmpDir, "drive2"), + filepath.Join(tmpDir, "drive3_fail"), // won't be created as a dir, but MkdirAll should handle it + } + for _, d := range drives { + os.MkdirAll(d, 0755) + } + + backupJSON := []byte(`{"test":"multi"}`) + logger := testLogger(t) + + WriteLocalInfraBackup(backupJSON, "multi-test", "v1.0", "2026-01-01T00:00:00Z", drives, logger) + + // All 3 should succeed + for _, d := range drives { + data, _, err := ReadLocalInfraBackup(d) + if err != nil { + t.Errorf("drive %s: read failed: %v", d, err) + continue + } + if string(data) != string(backupJSON) { + t.Errorf("drive %s: data mismatch", d) + } + } +} + +func TestWriteLocalInfraBackup_NoDrives(t *testing.T) { + logger := testLogger(t) + // Should not panic + WriteLocalInfraBackup([]byte(`{}`), "test", "v1.0", "2026-01-01T00:00:00Z", nil, logger) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr)) +} + +func containsStr(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func testLogger(t *testing.T) *log.Logger { + return log.New(os.Stderr, "[test] ", log.LstdFlags) +} diff --git a/controller/internal/backup/paths.go b/controller/internal/backup/paths.go index 2e2f23a..afb8a86 100644 --- a/controller/internal/backup/paths.go +++ b/controller/internal/backup/paths.go @@ -41,3 +41,8 @@ func SecondaryInfraPath(drivePath string) string { func AppDataDir(drivePath, stackName string) string { return filepath.Join(drivePath, "appdata", stackName) } + +// InfraBackupDir returns the hidden infra backup directory on a drive. +func InfraBackupDir(mountPath string) string { + return filepath.Join(mountPath, ".felhom-infra-backup") +} diff --git a/controller/internal/config/config.go b/controller/internal/config/config.go index 2bdb95d..bd59603 100644 --- a/controller/internal/config/config.go +++ b/controller/internal/config/config.go @@ -56,6 +56,7 @@ type PathsConfig struct { type WebConfig struct { Listen string `yaml:"listen"` + SetupListen string `yaml:"setup_listen"` // Plain HTTP listener for setup wizard (only active during setup mode) PasswordHash string `yaml:"password_hash"` SessionSecret string `yaml:"session_secret"` } @@ -149,6 +150,31 @@ type HubConfig struct { // Load reads and parses the config file, applies defaults, and validates. func Load(path string) (*Config, error) { + cfg, err := loadAndParse(path) + if err != nil { + return nil, err + } + if err := validate(cfg); err != nil { + return nil, fmt.Errorf("config validation: %w", err) + } + return cfg, nil +} + +// LoadPermissive reads and parses the config file, applies defaults, but skips validation. +// Used during setup mode where customer.id and domain may not be set yet. +func LoadPermissive(path string) (*Config, error) { + return loadAndParse(path) +} + +// Default returns a Config with all defaults applied. Used when the config file +// is missing or unreadable and the controller needs to enter setup mode. +func Default() *Config { + cfg := &Config{} + applyDefaults(cfg) + return cfg +} + +func loadAndParse(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config file: %w", err) @@ -165,10 +191,6 @@ func Load(path string) (*Config, error) { applyDefaults(cfg) applyEnvOverrides(cfg) - if err := validate(cfg); err != nil { - return nil, fmt.Errorf("config validation: %w", err) - } - return cfg, nil } @@ -212,6 +234,7 @@ func applyDefaults(cfg *Config) { d(&cfg.Paths.DataDir, "/opt/docker/felhom-controller/data") d(&cfg.Paths.SystemDataPath, "/mnt/sys_drive") d(&cfg.Web.Listen, ":8080") + d(&cfg.Web.SetupListen, ":8081") d(&cfg.Git.Branch, "main") d(&cfg.Git.SyncInterval, "15m") d(&cfg.Stacks.UpdateWindow, "03:00-05:00") diff --git a/controller/internal/recovery/info.go b/controller/internal/recovery/info.go new file mode 100644 index 0000000..2076dc1 --- /dev/null +++ b/controller/internal/recovery/info.go @@ -0,0 +1,68 @@ +package recovery + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +// Info holds the data needed for the recovery info file and settings UI. +type Info struct { + CustomerID string + RetrievalPassword string + HubURL string + SupportEmail string + SupportURL string +} + +// GenerateRecoveryFile writes a plain text recovery-info.txt to the given directory. +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 := fmt.Sprintf(`Felhom Controller — Vészhelyzeti információk +============================================= + +Ü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 + 3. Nyissa meg a böngészőben: http://: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.HubURL, + info.RetrievalPassword, + info.SupportEmail, + info.SupportURL, + time.Now().Format("2006-01-02 15:04:05"), + version, + ) + + path := filepath.Join(outputDir, "recovery-info.txt") + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(content), 0600); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} diff --git a/controller/internal/report/infra_pull.go b/controller/internal/report/infra_pull.go index c61edd5..d77bffe 100644 --- a/controller/internal/report/infra_pull.go +++ b/controller/internal/report/infra_pull.go @@ -2,6 +2,7 @@ package report import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -9,6 +10,103 @@ import ( "time" ) +// Recovery pull error types for UI display. +var ( + ErrHubUnreachable = errors.New("hub unreachable") + ErrAuthFailed = errors.New("authentication failed") + ErrNotFound = errors.New("customer not found") + ErrHubError = errors.New("hub error") +) + +// RecoveryResponse is the combined config + infra backup from the Hub recovery endpoint. +type RecoveryResponse struct { + CustomerID string `json:"customer_id"` + ConfigYAML string `json:"config_yaml"` + InfraBackup *InfraBackup `json:"infra_backup"` + HasInfraBackup bool `json:"has_infra_backup"` +} + +// PullRecovery fetches combined recovery data from the Hub (config + infra backup). +// Auth: X-Retrieval-Password header. +func PullRecovery(hubURL, customerID, retrievalPassword string) (*RecoveryResponse, error) { + url := strings.TrimRight(hubURL, "/") + "/api/v1/recovery/" + customerID + + client := &http.Client{Timeout: 30 * time.Second} + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrHubError, err) + } + req.Header.Set("X-Retrieval-Password", retrievalPassword) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrHubUnreachable, err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + // success, continue below + case http.StatusUnauthorized: + return nil, ErrAuthFailed + case http.StatusNotFound: + return nil, ErrNotFound + default: + return nil, fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10MB limit + if err != nil { + return nil, fmt.Errorf("%w: reading response: %v", ErrHubError, err) + } + + var rr RecoveryResponse + if err := json.Unmarshal(body, &rr); err != nil { + return nil, fmt.Errorf("%w: parsing response: %v", ErrHubError, err) + } + + return &rr, nil +} + +// PullConfig fetches a generated controller.yaml from the Hub config endpoint. +// Auth: X-Retrieval-Password header. +func PullConfig(hubURL, customerID, retrievalPassword string) (string, error) { + url := strings.TrimRight(hubURL, "/") + "/api/v1/config/" + customerID + + client := &http.Client{Timeout: 30 * time.Second} + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrHubError, err) + } + req.Header.Set("X-Retrieval-Password", retrievalPassword) + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrHubUnreachable, err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + // success + case http.StatusUnauthorized: + return "", ErrAuthFailed + case http.StatusNotFound: + return "", ErrNotFound + default: + return "", fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit + if err != nil { + return "", fmt.Errorf("%w: reading response: %v", ErrHubError, err) + } + + return string(body), nil +} + // PullInfraBackup fetches the infrastructure backup from the Hub. // Returns nil, nil if no backup exists for this customer. func PullInfraBackup(hubURL, apiKey, customerID string) (*InfraBackup, error) { diff --git a/controller/internal/report/pusher.go b/controller/internal/report/pusher.go index 09eade1..cc76240 100644 --- a/controller/internal/report/pusher.go +++ b/controller/internal/report/pusher.go @@ -22,6 +22,12 @@ type PushStatus struct { Consecutive int // consecutive failures } +// PushResponse is the parsed response from the Hub after a report push. +type PushResponse struct { + Status string `json:"status"` + CustomerBlocked bool `json:"customer_blocked"` +} + // Pusher sends reports to the central hub. type Pusher struct { hubURL string @@ -32,6 +38,10 @@ type Pusher struct { statusMu sync.RWMutex status PushStatus + + // OnPushResponse is called after each successful report push with the parsed response. + // Set by main.go to update hub verification state. + OnPushResponse func(resp *PushResponse) } // NewPusher creates a new report pusher from hub configuration. @@ -85,7 +95,9 @@ func (p *Pusher) Push(report *Report) error { lastErr = err continue } - io.Copy(io.Discard, resp.Body) + + // Read response body to parse customer_blocked field + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { @@ -95,6 +107,14 @@ func (p *Pusher) Push(report *Report) error { p.status.LastError = "" p.status.Consecutive = 0 p.statusMu.Unlock() + + // Parse response for customer_blocked field + if p.OnPushResponse != nil && len(respBody) > 0 { + var pr PushResponse + if json.Unmarshal(respBody, &pr) == nil { + p.OnPushResponse(&pr) + } + } return nil } lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index f4b6ad9..af7983b 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -35,6 +35,17 @@ type Settings struct { // Cross-drive restic repo password (auto-generated on first use) CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"` + + // Hub verification state + HubVerified bool `json:"hub_verified,omitempty"` + HubVerifiedAt string `json:"hub_verified_at,omitempty"` // RFC3339 + HubLastCheck string `json:"hub_last_check,omitempty"` // RFC3339 + + // Recovery credentials (saved from setup wizard input) + RetrievalPassword string `json:"retrieval_password,omitempty"` + + // Pending events (queued for next Hub push) + PendingEvents []PendingEvent `json:"pending_events,omitempty"` } // AppBackupPrefs holds per-app backup toggle state. @@ -96,6 +107,15 @@ var DefaultEnabledEvents = []string{ "expected_dbdump_missed", } +// PendingEvent is an event queued for the next Hub push cycle. +type PendingEvent struct { + EventType string `json:"event_type"` + Severity string `json:"severity"` + Message string `json:"message"` + Details string `json:"details"` // JSON string + CreatedAt string `json:"created_at"` // RFC3339 +} + // DBValidationCache holds cached DB dump validation results. type DBValidationCache struct { ValidatedAt string `json:"validated_at"` // RFC3339 @@ -672,3 +692,102 @@ func (s *Settings) GetDecommissionedPaths() []StoragePath { } return result } + +// --- Hub Verification --- + +// GetHubVerified returns the hub verification state. +func (s *Settings) GetHubVerified() (verified bool, verifiedAt string) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.HubVerified, s.HubVerifiedAt +} + +// SetHubVerified updates the hub verification state and saves to disk. +func (s *Settings) SetHubVerified(verified bool, at time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + s.HubVerified = verified + s.HubVerifiedAt = at.UTC().Format(time.RFC3339) + s.HubLastCheck = at.UTC().Format(time.RFC3339) + return s.save() +} + +// SetHubLastCheck updates the last Hub check timestamp without changing verification status. +func (s *Settings) SetHubLastCheck(at time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + s.HubLastCheck = at.UTC().Format(time.RFC3339) + return s.save() +} + +// IsLimitedMode returns true if the controller should operate in limited mode +// (new deployments blocked). This happens when: +// - Never verified AND >7 days since controller started, OR +// - Hub explicitly set customer as blocked (HubVerified=false after a successful check) +func (s *Settings) IsLimitedMode() bool { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.HubVerified { + return false + } + + // If we have a last check timestamp and it says not verified, limited mode + if s.HubLastCheck != "" { + return true + } + + // Never checked yet — check if grace period (7 days) expired + if s.HubVerifiedAt == "" { + // No verification timestamp at all — not yet in limited mode (grace period from startup) + return false + } + t, err := time.Parse(time.RFC3339, s.HubVerifiedAt) + if err != nil { + return false + } + return time.Since(t) > 7*24*time.Hour +} + +// --- Retrieval Password --- + +// GetRetrievalPassword returns the stored retrieval password (thread-safe). +func (s *Settings) GetRetrievalPassword() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.RetrievalPassword +} + +// SetRetrievalPassword updates the retrieval password and saves to disk. +func (s *Settings) SetRetrievalPassword(password string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.RetrievalPassword = password + return s.save() +} + +// --- Pending Events --- + +// AddPendingEvent queues an event for the next Hub push cycle. +func (s *Settings) AddPendingEvent(event PendingEvent) error { + s.mu.Lock() + defer s.mu.Unlock() + s.PendingEvents = append(s.PendingEvents, event) + return s.save() +} + +// DrainPendingEvents returns and clears all pending events (thread-safe). +func (s *Settings) DrainPendingEvents() []PendingEvent { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.PendingEvents) == 0 { + return nil + } + events := make([]PendingEvent, len(s.PendingEvents)) + copy(events, s.PendingEvents) + s.PendingEvents = nil + if err := s.save(); err != nil { + s.log.Printf("[ERROR] Failed to save after draining pending events: %v", err) + } + return events +} diff --git a/controller/internal/setup/csrf.go b/controller/internal/setup/csrf.go new file mode 100644 index 0000000..37968f3 --- /dev/null +++ b/controller/internal/setup/csrf.go @@ -0,0 +1,54 @@ +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 { + // Fallback to time-based (extremely unlikely) + return "fallback-csrf-token" + } + 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 +} diff --git a/controller/internal/setup/handlers.go b/controller/internal/setup/handlers.go new file mode 100644 index 0000000..6a66f9d --- /dev/null +++ b/controller/internal/setup/handlers.go @@ -0,0 +1,922 @@ +package setup + +import ( + crand "crypto/rand" + "crypto/sha256" + "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/report" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + + "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 + + // Scan state for async drive scanning + scanMu sync.Mutex + scanRunning bool + scanResults []DriveBackup + scanDone bool + scanError string + + // Restore progress + restoreMu sync.Mutex + restoreRunning bool + restoreSteps []RestoreStep + restoreError string + restoreDone bool +} + +// RestoreStep tracks progress of a restore operation. +type RestoreStep struct { + Label string `json:"label"` + Status string `json:"status"` // "pending", "running", "done", "failed" + Error string `json:"error,omitempty"` +} + +// 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() + return s +} + +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() + + mux.HandleFunc("/", s.handleRoot) + mux.HandleFunc("/setup", s.handleWelcome) + mux.HandleFunc("/setup/scan", s.handleScan) + mux.HandleFunc("/setup/scan/status", s.handleScanStatus) + mux.HandleFunc("/setup/hub-restore", s.handleHubRestore) + mux.HandleFunc("/setup/restore", s.handleRestore) + mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus) + 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, + } + s.render(w, "setup_welcome", data) +} + +func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) { + csrf := ensureCSRFToken(w, r) + + // Start scan if not already running + s.scanMu.Lock() + if !s.scanRunning && !s.scanDone { + s.scanRunning = true + go s.runDriveScan() + } + s.scanMu.Unlock() + + s.state.SetStep("scan") + data := map[string]interface{}{ + "CSRF": csrf, + } + s.render(w, "setup_scan", data) +} + +func (s *Server) handleScanStatus(w http.ResponseWriter, r *http.Request) { + s.scanMu.Lock() + defer s.scanMu.Unlock() + + resp := map[string]interface{}{ + "running": s.scanRunning, + "done": s.scanDone, + "results": s.scanResults, + "error": s.scanError, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) handleHubRestore(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.processHubRestore(w, r) + return + } + + data := map[string]interface{}{ + "CSRF": csrf, + "CustomerID": s.state.GetFormField("customer_id"), + } + s.render(w, "setup_hub_restore", data) +} + +func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Redirect(w, r, "/setup", http.StatusFound) + return + } + + if !validateCSRF(r) { + http.Error(w, "Invalid CSRF token", http.StatusForbidden) + return + } + + source := r.FormValue("source") + switch source { + case "local": + drivePath := r.FormValue("drive_path") + go s.executeLocalRestore(drivePath) + case "hub": + go s.executeHubRestore() + default: + http.Error(w, "Invalid restore source", http.StatusBadRequest) + return + } + + csrf := ensureCSRFToken(w, r) + data := map[string]interface{}{ + "CSRF": csrf, + } + s.render(w, "setup_restore_exec", data) +} + +func (s *Server) handleRestoreStatus(w http.ResponseWriter, r *http.Request) { + s.restoreMu.Lock() + defer s.restoreMu.Unlock() + + resp := map[string]interface{}{ + "running": s.restoreRunning, + "done": s.restoreDone, + "steps": s.restoreSteps, + "error": s.restoreError, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +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 + } + + 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) { + // Read the main style.css from the web package templates + cssPath := filepath.Join(filepath.Dir(s.dataDir), "..", "internal", "web", "templates", "style.css") + data, err := os.ReadFile(cssPath) + if err != nil { + // Fallback: serve minimal CSS + 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) { + logoPath := filepath.Join(filepath.Dir(s.dataDir), "..", "internal", "web", "static", "felhom-logo.svg") + data, err := os.ReadFile(logoPath) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write(data) +} + +// --- Processing Logic --- + +func (s *Server) processHubRestore(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, "setup_hub_restore", "Kérem töltse ki mindkét mezőt.", customerID) + return + } + + recovery, err := report.PullRecovery(hubURL, customerID, password) + if err != nil { + 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, "setup_hub_restore", msg, customerID) + return + } + + // Store recovery data in state for restore execution + s.state.SelectedBackup = &SelectedBackup{ + Source: "hub", + CustomerID: customerID, + } + s.state.SetFormField("retrieval_password", password) + s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML) + if recovery.HasInfraBackup && recovery.InfraBackup != nil { + ibJSON, _ := json.Marshal(recovery.InfraBackup) + s.state.SetFormField("hub_infra_backup", string(ibJSON)) + s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp + } + s.state.SetStep("restore-confirm") + s.state.Save() + + // Show confirmation page with backup details + csrf := ensureCSRFToken(w, r) + data := map[string]interface{}{ + "CSRF": csrf, + "CustomerID": customerID, + "HasInfraBackup": recovery.HasInfraBackup, + "HasConfig": recovery.ConfigYAML != "", + "Source": "hub", + } + if recovery.HasInfraBackup && recovery.InfraBackup != nil { + data["Timestamp"] = recovery.InfraBackup.Timestamp + data["StackCount"] = len(recovery.InfraBackup.DeployedStacks) + } + s.render(w, "setup_restore_exec", data) +} + +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, "setup_fresh_hub", "Kérem töltse ki mindkét mezőt.", customerID) + return + } + + configYAML, err := report.PullConfig(hubURL, customerID, password) + if err != nil { + 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, "setup_fresh_hub", msg, customerID) + return + } + + // Write config and finish setup + s.state.SetFormField("retrieval_password", password) + if err := s.writeFreshConfig(configYAML, password); err != nil { + s.renderError(w, "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 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) executeLocalRestore(drivePath string) { + s.restoreMu.Lock() + s.restoreRunning = true + s.restoreDone = false + s.restoreError = "" + s.restoreSteps = []RestoreStep{ + {Label: "Mentés beolvasása...", Status: "running"}, + {Label: "Konfiguráció visszaállítása...", Status: "pending"}, + {Label: "Beállítás befejezése...", Status: "pending"}, + } + s.restoreMu.Unlock() + + // Step 1: Read backup + backupData, _, err := backup.ReadLocalInfraBackup(drivePath) + if err != nil { + s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err)) + return + } + + var ib report.InfraBackup + if err := json.Unmarshal(backupData, &ib); err != nil { + s.setRestoreError(0, fmt.Sprintf("Mentés formátum hiba: %v", err)) + return + } + s.setRestoreStepDone(0) + + // Step 2: Write config files + s.setRestoreStepRunning(1) + if err := s.writeRestoredConfig(&ib); err != nil { + s.setRestoreError(1, fmt.Sprintf("Konfiguráció írási hiba: %v", err)) + return + } + s.setRestoreStepDone(1) + + // Step 3: Finalize + s.setRestoreStepRunning(2) + + // Save retrieval password from state if available + retrievalPw := s.state.GetFormField("retrieval_password") + if retrievalPw != "" { + sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger) + if err == nil { + sett.SetRetrievalPassword(retrievalPw) + } + } + + // Queue DR event + s.queueDREvent("local", ib.Timestamp, len(ib.DeployedStacks)) + + s.setRestoreStepDone(2) + + s.restoreMu.Lock() + s.restoreRunning = false + s.restoreDone = true + s.restoreMu.Unlock() + + s.logger.Printf("[INFO] Setup: local restore completed from %s", drivePath) + + // Wait a moment for the UI to poll, then exit + time.Sleep(2 * time.Second) + s.finishSetup() +} + +func (s *Server) executeHubRestore() { + s.restoreMu.Lock() + s.restoreRunning = true + s.restoreDone = false + s.restoreError = "" + s.restoreSteps = []RestoreStep{ + {Label: "Konfiguráció visszaállítása...", Status: "running"}, + {Label: "Beállítás befejezése...", Status: "pending"}, + } + s.restoreMu.Unlock() + + // Get stored data from state + configYAML := s.state.GetFormField("hub_config_yaml") + ibJSON := s.state.GetFormField("hub_infra_backup") + + // Write controller.yaml + configPath := "/opt/docker/felhom-controller/controller.yaml" + if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil { + s.setRestoreError(0, fmt.Sprintf("Konfiguráció írási hiba: %v", err)) + return + } + + // Restore settings from infra backup if available + if ibJSON != "" { + var ib report.InfraBackup + if err := json.Unmarshal([]byte(ibJSON), &ib); err == nil { + s.restoreFromInfraBackup(&ib) + } + } + s.setRestoreStepDone(0) + + // Step 2: Finalize + s.setRestoreStepRunning(1) + + // Save retrieval password + retrievalPw := s.state.GetFormField("retrieval_password") + if retrievalPw != "" { + sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger) + if err == nil { + sett.SetRetrievalPassword(retrievalPw) + } + } + + // Queue DR event + stackCount := 0 + timestamp := "" + if ibJSON != "" { + var ib report.InfraBackup + if json.Unmarshal([]byte(ibJSON), &ib) == nil { + stackCount = len(ib.DeployedStacks) + timestamp = ib.Timestamp + } + } + s.queueDREvent("hub", timestamp, stackCount) + + s.setRestoreStepDone(1) + + s.restoreMu.Lock() + s.restoreRunning = false + s.restoreDone = true + s.restoreMu.Unlock() + + s.logger.Printf("[INFO] Setup: Hub restore completed") + + time.Sleep(2 * time.Second) + s.finishSetup() +} + +// --- Config Writing --- + +func (s *Server) writeRestoredConfig(ib *report.InfraBackup) error { + // Decode and write controller.yaml + if ib.ControllerConfigB64 != "" { + configData, err := base64.StdEncoding.DecodeString(ib.ControllerConfigB64) + if err != nil { + return fmt.Errorf("decoding controller.yaml: %w", err) + } + configPath := "/opt/docker/felhom-controller/controller.yaml" + if err := atomicWriteFile(configPath, configData, 0600); err != nil { + return fmt.Errorf("writing controller.yaml: %w", err) + } + } + + s.restoreFromInfraBackup(ib) + return nil +} + +func (s *Server) restoreFromInfraBackup(ib *report.InfraBackup) { + // Decode and write settings.json + if ib.SettingsJSONB64 != "" { + if data, err := base64.StdEncoding.DecodeString(ib.SettingsJSONB64); err == nil { + settingsPath := filepath.Join(s.dataDir, "settings.json") + if err := atomicWriteFile(settingsPath, data, 0644); err != nil { + s.logger.Printf("[WARN] Setup: failed to restore settings.json: %v", err) + } + } + } + + // Restore restic password + if ib.ResticPassword != "" { + if data, err := base64.StdEncoding.DecodeString(ib.ResticPassword); err == nil { + pwFile := "/opt/docker/felhom-controller/data/restic-password" + if err := atomicWriteFile(pwFile, data, 0600); err != nil { + s.logger.Printf("[WARN] Setup: failed to restore restic password: %v", err) + } + } + } +} + +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(" update_window: \"03:00-05:00\"\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) queueDREvent(source, backupTimestamp string, stackCount int) { + sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger) + if err != nil { + s.logger.Printf("[WARN] Setup: failed to load settings for DR event: %v", err) + return + } + + details, _ := json.Marshal(map[string]interface{}{ + "source": source, + "backup_timestamp": backupTimestamp, + "stacks_count": stackCount, + "controller_version": s.version, + }) + + sett.AddPendingEvent(settings.PendingEvent{ + EventType: "disaster_recovery_completed", + Severity: "warning", + Message: "System restored from backup", + Details: string(details), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }) +} + +func (s *Server) setRestoreStepDone(idx int) { + s.restoreMu.Lock() + defer s.restoreMu.Unlock() + if idx < len(s.restoreSteps) { + s.restoreSteps[idx].Status = "done" + } +} + +func (s *Server) setRestoreStepRunning(idx int) { + s.restoreMu.Lock() + defer s.restoreMu.Unlock() + if idx < len(s.restoreSteps) { + s.restoreSteps[idx].Status = "running" + } +} + +func (s *Server) setRestoreError(idx int, msg string) { + s.restoreMu.Lock() + defer s.restoreMu.Unlock() + if idx < len(s.restoreSteps) { + s.restoreSteps[idx].Status = "failed" + s.restoreSteps[idx].Error = msg + } + s.restoreRunning = false + s.restoreError = msg +} + +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, tmpl, msg, customerID string) { + csrf := ensureCSRFToken(w, nil) + 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) + return err + } + 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 +const minimalCSS = ` +:root { --bg-primary: #0d1117; --bg-card: #1c2128; --text-primary: #e6edf3; --text-secondary: #8b949e; --accent-blue: #0088cc; --border: #30363d; --green: #238636; --red: #da3633; } +* { 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: 8px; 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: 6px; 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: 6px; 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: #2ea043; } +.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border); } +.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; } +.alert-error { background: rgba(218,54,51,0.15); color: #f85149; border: 1px solid rgba(218,54,51,0.3); } +.alert-info { background: rgba(0,136,204,0.15); color: #58a6ff; border: 1px solid rgba(0,136,204,0.3); } +.info-box { background: rgba(0,136,204,0.1); border: 1px solid rgba(0,136,204,0.2); border-radius: 6px; 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(218,54,51,0.15); 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: 6px; 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); } +` diff --git a/controller/internal/setup/network.go b/controller/internal/setup/network.go new file mode 100644 index 0000000..c921fbd --- /dev/null +++ b/controller/internal/setup/network.go @@ -0,0 +1,49 @@ +package setup + +import ( + "net" + "sort" + "strings" +) + +// DetectLocalIPs returns non-loopback, non-docker IPv4 addresses. +func DetectLocalIPs() []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()) + } + } + + sort.Strings(ips) + return ips +} diff --git a/controller/internal/setup/scanner.go b/controller/internal/setup/scanner.go new file mode 100644 index 0000000..7b8ce56 --- /dev/null +++ b/controller/internal/setup/scanner.go @@ -0,0 +1,271 @@ +package setup + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" +) + +// DriveBackup represents a found infra backup on a drive. +type DriveBackup struct { + Device string `json:"device"` + Label string `json:"label"` + MountPoint string `json:"mount_point"` + CustomerID string `json:"customer_id"` + Timestamp string `json:"timestamp"` + CtrlVersion string `json:"controller_version"` + IntegrityOK bool `json:"integrity_ok"` + Error string `json:"error,omitempty"` + WasTempMounted bool `json:"-"` +} + +// lsblkOutput represents the JSON output of lsblk. +type lsblkOutput struct { + Blockdevices []lsblkDevice `json:"blockdevices"` +} + +type lsblkDevice struct { + Name string `json:"name"` + Path string `json:"path"` + FSType *string `json:"fstype"` + MountPoint *string `json:"mountpoint"` + Label *string `json:"label"` + Size interface{} `json:"size"` // string or int + Type string `json:"type"` // "disk", "part" + Children []lsblkDevice `json:"children,omitempty"` +} + +// ScanDrivesForInfraBackups scans all block devices for .felhom-infra-backup/ directories. +func ScanDrivesForInfraBackups(logger *log.Logger) ([]DriveBackup, error) { + logger.Printf("[INFO] Setup: scanning drives for infra backups...") + + // Read currently mounted filesystems + mountedFS := readMountedFilesystems() + + // Get root device to skip + rootDevices := getRootDevices() + + // Run lsblk + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "lsblk", "-J", "-o", "NAME,PATH,FSTYPE,MOUNTPOINT,LABEL,SIZE,TYPE").Output() + if err != nil { + return nil, fmt.Errorf("lsblk failed: %w", err) + } + + var lsblk lsblkOutput + if err := json.Unmarshal(out, &lsblk); err != nil { + return nil, fmt.Errorf("parsing lsblk: %w", err) + } + + var results []DriveBackup + + // Flatten all partitions + var partitions []lsblkDevice + for _, disk := range lsblk.Blockdevices { + if disk.Type == "part" { + partitions = append(partitions, disk) + } + for _, child := range disk.Children { + if child.Type == "part" { + partitions = append(partitions, child) + } + } + } + + for _, part := range partitions { + // Skip partitions without filesystem + if part.FSType == nil || *part.FSType == "" || *part.FSType == "swap" { + continue + } + + // Skip LUKS encrypted partitions + if *part.FSType == "crypto_LUKS" { + logger.Printf("[DEBUG] Setup: skipping LUKS partition %s", part.Path) + continue + } + + // Skip LVM + if part.Type == "lvm" { + logger.Printf("[DEBUG] Setup: skipping LVM volume %s", part.Path) + continue + } + + // Skip root partitions + if isRootPartition(part.Path, rootDevices) { + continue + } + + result := scanPartition(part, mountedFS, logger) + if result != nil { + results = append(results, *result) + } + } + + logger.Printf("[INFO] Setup: drive scan complete — found %d backup(s)", countValid(results)) + return results, nil +} + +// CleanupTempMounts unmounts any partitions that were temporarily mounted during scanning. +func CleanupTempMounts(results []DriveBackup, logger *log.Logger) { + for _, r := range results { + if r.WasTempMounted && r.MountPoint != "" { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + exec.CommandContext(ctx, "umount", r.MountPoint).Run() + cancel() + os.Remove(r.MountPoint) + logger.Printf("[DEBUG] Setup: unmounted temp mount %s", r.MountPoint) + } + } +} + +func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Logger) *DriveBackup { + label := "" + if part.Label != nil { + label = *part.Label + } + + // Check if already mounted + var mountPoint string + var tempMounted bool + + if part.MountPoint != nil && *part.MountPoint != "" { + mountPoint = *part.MountPoint + } else if mp, ok := mountedFS[part.Path]; ok { + mountPoint = mp + } else { + // Try to mount temporarily + tmpDir := filepath.Join("/mnt", ".felhom-scan", part.Name) + if err := os.MkdirAll(tmpDir, 0700); err != nil { + logger.Printf("[DEBUG] Setup: skip %s — cannot create temp dir: %v", part.Path, err) + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Try read-only mount + err := exec.CommandContext(ctx, "mount", "-o", "ro", part.Path, tmpDir).Run() + if err != nil { + // Retry with noload for journal errors + err = exec.CommandContext(ctx, "mount", "-o", "ro,noload", part.Path, tmpDir).Run() + } + if err != nil { + os.Remove(tmpDir) + logger.Printf("[DEBUG] Setup: skip %s — mount failed: %v", part.Path, err) + return nil + } + mountPoint = tmpDir + tempMounted = true + } + + // Check for .felhom-infra-backup/ + infraDir := backup.InfraBackupDir(mountPoint) + if _, err := os.Stat(infraDir); os.IsNotExist(err) { + if tempMounted { + exec.Command("umount", mountPoint).Run() + os.Remove(mountPoint) + } + return nil + } + + // Found backup — read and validate + _, meta, err := backup.ReadLocalInfraBackup(mountPoint) + + result := &DriveBackup{ + Device: part.Path, + Label: label, + MountPoint: mountPoint, + WasTempMounted: tempMounted, + } + + if err != nil { + result.IntegrityOK = false + result.Error = err.Error() + if meta != nil { + result.CustomerID = meta.CustomerID + result.Timestamp = meta.Timestamp + result.CtrlVersion = meta.ControllerVersion + } + } else { + result.IntegrityOK = true + result.CustomerID = meta.CustomerID + result.Timestamp = meta.Timestamp + result.CtrlVersion = meta.ControllerVersion + } + + logger.Printf("[INFO] Setup: found infra backup on %s (%s) — customer=%s, integrity=%v", + part.Path, label, result.CustomerID, result.IntegrityOK) + + return result +} + +func readMountedFilesystems() map[string]string { + result := make(map[string]string) + + f, err := os.Open("/proc/mounts") + if err != nil { + return result + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) >= 2 { + result[fields[0]] = fields[1] + } + } + return result +} + +func getRootDevices() map[string]bool { + result := make(map[string]bool) + mountedFS := readMountedFilesystems() + for dev, mp := range mountedFS { + if mp == "/" || mp == "/boot" || mp == "/boot/efi" { + result[dev] = true + } + } + return result +} + +func isRootPartition(devPath string, rootDevices map[string]bool) bool { + return rootDevices[devPath] +} + +func countValid(results []DriveBackup) int { + n := 0 + for _, r := range results { + if r.IntegrityOK { + n++ + } + } + return n +} + +// runDriveScan runs the scan asynchronously and stores results on the Server. +func (s *Server) runDriveScan() { + results, err := ScanDrivesForInfraBackups(s.logger) + + s.scanMu.Lock() + defer s.scanMu.Unlock() + + s.scanRunning = false + s.scanDone = true + if err != nil { + s.scanError = err.Error() + } else { + s.scanResults = results + } +} diff --git a/controller/internal/setup/setup.go b/controller/internal/setup/setup.go new file mode 100644 index 0000000..9b09b54 --- /dev/null +++ b/controller/internal/setup/setup.go @@ -0,0 +1,132 @@ +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. +func NeedsSetup(cfg *config.Config) bool { + return cfg.Customer.ID == "" || cfg.Customer.ID == "demo-felhom" +} + +// 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 { + // Best effort — don't crash + } +} + +// 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) + } +} diff --git a/controller/internal/setup/templates/setup_failed.html b/controller/internal/setup/templates/setup_failed.html new file mode 100644 index 0000000..5cd4ebc --- /dev/null +++ b/controller/internal/setup/templates/setup_failed.html @@ -0,0 +1,32 @@ +{{define "setup_failed"}} + + + + + + Visszaállítás sikertelen — Felhom + + + +
+
+ Felhom.eu +

A visszaállítás nem sikerült

+
+ +
+

Kérjük, vegye fel a kapcsolatot a támogatással:

+ +
+ + +
+ + +{{end}} diff --git a/controller/internal/setup/templates/setup_fresh_hub.html b/controller/internal/setup/templates/setup_fresh_hub.html new file mode 100644 index 0000000..ad008b3 --- /dev/null +++ b/controller/internal/setup/templates/setup_fresh_hub.html @@ -0,0 +1,46 @@ +{{define "setup_fresh_hub"}} + + + + + + Új telepítés — Felhom + + + +
+
+ Felhom.eu +

Új telepítés

+

Konfiguráció letöltése a Hub-ról.

+
+ + {{if .Error}}
{{.Error}}
{{end}} + +
+
+ +
+ + +
+
+ + +
+
+ + Vissza +
+
+
+ +

+ Nincs Hub hozzáférés? Kézi beállítás → +

+
+ + +{{end}} diff --git a/controller/internal/setup/templates/setup_hub_restore.html b/controller/internal/setup/templates/setup_hub_restore.html new file mode 100644 index 0000000..e7617fa --- /dev/null +++ b/controller/internal/setup/templates/setup_hub_restore.html @@ -0,0 +1,42 @@ +{{define "setup_hub_restore"}} + + + + + + Hub visszaállítás — Felhom + + + +
+
+ Felhom.eu +

Visszaállítás a Hub-ról

+

Adja meg az ügyfél-azonosítót és jelszót a mentés letöltéséhez.

+
+ + {{if .Error}}
{{.Error}}
{{end}} + +
+
+ +
+ + +
+
+ + +
+
+ + Vissza +
+
+
+
+ + +{{end}} diff --git a/controller/internal/setup/templates/setup_manual.html b/controller/internal/setup/templates/setup_manual.html new file mode 100644 index 0000000..ee931a9 --- /dev/null +++ b/controller/internal/setup/templates/setup_manual.html @@ -0,0 +1,111 @@ +{{define "setup_manual"}} + + + + + + Kézi beállítás — Felhom + + + +
+
+ Felhom.eu +

Kézi beállítás

+
+ + {{if .Errors}} +
+ {{range .Errors}}
{{.}}
{{end}} +
+ {{end}} + +
+ + +
+

Ügyfél azonosítás

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Infrastruktúra

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Dashboard jelszó

+
+ + +
+
+ + +
+
+ +
+

Alkalmazás-katalógus

+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + Vissza +
+
+
+ + +{{end}} diff --git a/controller/internal/setup/templates/setup_restore_exec.html b/controller/internal/setup/templates/setup_restore_exec.html new file mode 100644 index 0000000..92789ee --- /dev/null +++ b/controller/internal/setup/templates/setup_restore_exec.html @@ -0,0 +1,78 @@ +{{define "setup_restore_exec"}} + + + + + + Visszaállítás folyamatban — Felhom + + + +
+
+ Felhom.eu +

Visszaállítás

+
+ +
+
    +
  • Indítás...
  • +
+
+ + + +
+ + + + +{{end}} diff --git a/controller/internal/setup/templates/setup_scan.html b/controller/internal/setup/templates/setup_scan.html new file mode 100644 index 0000000..44a8bb2 --- /dev/null +++ b/controller/internal/setup/templates/setup_scan.html @@ -0,0 +1,123 @@ +{{define "setup_scan"}} + + + + + + Meghajtók keresése — Felhom + + + +
+
+ Felhom.eu +

Visszaállítás mentésből

+
+ +
+

Külső meghajtók keresése...

+

Ha vannak külső meghajtók csatlakoztatva a szerverhez, győződjön meg róla, hogy most csatlakoztatva vannak.

+
+
+
+
+ + + + + + +
+ + + + +{{end}} diff --git a/controller/internal/setup/templates/setup_welcome.html b/controller/internal/setup/templates/setup_welcome.html new file mode 100644 index 0000000..ba33520 --- /dev/null +++ b/controller/internal/setup/templates/setup_welcome.html @@ -0,0 +1,37 @@ +{{define "setup_welcome"}} + + + + + + Felhom Szerver Beállítás + + + + + + +{{end}} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index c914274..06b6447 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1003,6 +1003,12 @@ func (s *Server) settingsData() map[string]interface{} { } data["StoragePaths"] = storageViews + // Recovery info for emergency section + data["RetrievalPassword"] = s.settings.GetRetrievalPassword() + data["HubURL"] = s.cfg.Hub.URL + data["SupportEmail"] = "support@felhom.eu" + data["SupportURL"] = "https://felhom.eu/kapcsolat" + return data } diff --git a/controller/internal/web/templates/settings.html b/controller/internal/web/templates/settings.html index 5b99df2..b178d78 100644 --- a/controller/internal/web/templates/settings.html +++ b/controller/internal/web/templates/settings.html @@ -487,6 +487,46 @@ function pollUntilBack() { {{end}} + +{{if .RetrievalPassword}} +
+

Veszhelyzeti informaciok

+

+ Ezeket az adatokat mentse el biztos helyre. Ujratelepites eseten szukseg lesz rajuk a rendszer visszaallitasahoz. +

+
+
+ Ugyfel azonosito + {{.CustomerID}} +
+
+ Hub URL + {{.HubURL}} +
+
+ Visszaallitasi jelszo + + •••••••••••••••• + + + + +
+
+ Tamogatas + + {{.SupportEmail}} +  |  + felhom.eu/kapcsolat + +
+
+
+{{end}} +