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 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 12:33:17 +01:00
parent e217c3a445
commit 6eb75204b6
28 changed files with 2970 additions and 505 deletions
+45
View File
@@ -1,5 +1,50 @@
## Changelog ## 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) ### 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. - **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)". - **Fix double "v" prefix in startup event**: "Controller elindult (vv0.21.2)" → "Controller elindult (v0.21.3)".
+103 -21
View File
@@ -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. 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) - [Update Management](#6-update-management)
- [Authentication & Settings](#7-authentication--settings) - [Authentication & Settings](#7-authentication--settings)
- [Central Hub](#8-central-hub-reporting) - [Central Hub](#8-central-hub-reporting)
- [Setup Wizard](#9-first-run-setup-wizard)
- [Disaster Recovery](#10-disaster-recovery)
- [Repository Layout](#repository-layout) - [Repository Layout](#repository-layout)
- [Configuration](#configuration) - [Configuration](#configuration)
- [REST API](#rest-api) - [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) - Color coding: green (<30min), yellow (30-60min), red (>60min since last report)
- 90-day report + event retention with daily prune at 04:30 Budapest time - 90-day report + event retention with daily prune at 04:30 Budapest time
### 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 │ 1. Welcome │
3. Controller pulls infra backup from Hub → gets disk layout, passwords, configs │ Choose: Restore / Fresh install │
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 │ 2a. Scan │ │ 2b. Hub download │
8. User opens dashboard → "Visszaállítás" (Restore) wizard │ drives for│ │ (customer ID + │
9. User confirms → sequential restore: rsync first, restic fallback, DB import │ local │ │ password) │
10. Apps restored and running │ 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: `<drive>/.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://<LAN-IP>: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):** **Backup sources (priority order):**
1. **Rsync copies** (cross-drive, plain files, no password needed) — fastest, most reliable 1. **Local infra backup** (`.felhom-infra-backup/` on surviving drives) — fastest, no network needed
2. **Restic snapshots** (encrypted, needs password from Hub) — comprehensive but slower 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/ 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/ ├── internal/
│ ├── config/config.go # YAML loader, validation, env overrides │ ├── config/config.go # YAML loader, validation, env overrides
│ ├── settings/settings.go # Runtime settings (JSON, atomic writes, RWMutex) │ ├── settings/settings.go # Runtime settings (JSON, atomic writes, RWMutex)
@@ -860,7 +929,8 @@ controller/
│ │ └── *_other.go # Non-Linux stubs for cross-compilation │ │ └── *_other.go # Non-Linux stubs for cross-compilation
│ ├── backup/ │ ├── backup/
│ │ ├── backup.go # Orchestrator (per-drive dumps + restic + cross-drive chain) │ │ ├── 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) │ │ ├── dbdump.go # DB auto-discovery + dump (pg_dump, mariadb-dump)
│ │ ├── restic.go # Restic operations (init, snapshot, prune, check) — repoPath as param │ │ ├── restic.go # Restic operations (init, snapshot, prune, check) — repoPath as param
│ │ ├── appdata.go # StackDataProvider interface, app data discovery │ │ ├── appdata.go # StackDataProvider interface, app data discovery
@@ -890,8 +960,16 @@ controller/
│ ├── notify/notifier.go # Email relay to hub, preference sync, cooldowns │ ├── notify/notifier.go # Email relay to hub, preference sync, cooldowns
│ ├── report/ │ ├── report/
│ │ ├── builder.go # Hub report builder (all subsystems → JSON) │ │ ├── builder.go # Hub report builder (all subsystems → JSON)
│ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth) │ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth, parses customer_blocked)
│ │ └── infra_pull.go # DR: pull infra backup from Hub for fresh deployment │ │ └── 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/ │ └── web/
│ ├── server.go # HTTP server, routing, static files │ ├── server.go # HTTP server, routing, static files
│ ├── auth.go # Session auth, login/logout, session cleanup │ ├── auth.go # Session auth, login/logout, session cleanup
@@ -953,6 +1031,10 @@ monitoring:
backup: "uuid-here" backup: "uuid-here"
backup_integrity: "uuid-here" backup_integrity: "uuid-here"
web:
listen: ":8080"
setup_listen: ":8081" # Plain HTTP for setup wizard LAN access
hub: hub:
enabled: true enabled: true
url: "https://hub.felhom.eu" url: "https://hub.felhom.eu"
@@ -966,7 +1048,7 @@ Environment variable overrides: `FELHOM_LOGGING_LEVEL=debug`, `FELHOM_HUB_ENABLE
### Runtime settings (`settings.json`) ### 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`) ### Per-app config (`app.yaml`)
+151 -113
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
@@ -22,14 +21,16 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics" "gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor" "gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify" "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/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate" "gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/setup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/storage"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync" catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/system" "gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/storage"
"gitea.dooplex.hu/admin/felhom-controller/internal/web" "gitea.dooplex.hu/admin/felhom-controller/internal/web"
) )
@@ -51,12 +52,23 @@ func main() {
} }
// --- Load configuration --- // --- 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 { 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) 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)", logger.Printf("[INFO] felhom-controller %s starting (customer: %s, domain: %s)",
Version, cfg.Customer.ID, cfg.Customer.Domain) 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) 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 --- // --- Auto-discover storage paths from deployed apps ---
discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger) discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger)
sett.AutoDiscoverStoragePaths(discoveredPaths, cfg.Paths.HDDPath, logger) sett.AutoDiscoverStoragePaths(discoveredPaths, cfg.Paths.HDDPath, logger)
@@ -304,6 +243,15 @@ func main() {
var hubPusher *report.Pusher var hubPusher *report.Pusher
if cfg.Hub.URL != "" && cfg.Hub.APIKey != "" { if cfg.Hub.URL != "" && cfg.Hub.APIKey != "" {
hubPusher = report.NewPusher(&cfg.Hub, logger) 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 // Wire hub push status into alert manager for dashboard alerts
alertMgr.SetHubPushStatus(func() web.HubPushStatusData { alertMgr.SetHubPushStatus(func() web.HubPushStatusData {
s := hubPusher.GetStatus() s := hubPusher.GetStatus()
@@ -350,6 +298,8 @@ func main() {
if hubPusher != nil && cfg.Hub.Enabled { if hubPusher != nil && cfg.Hub.Enabled {
go pushInfraBackup(cfg, sett, stackProv, hubPusher, logger) go pushInfraBackup(cfg, sett, stackProv, hubPusher, logger)
} }
// Write local infra backup to all connected drives
go writeLocalInfraBackup(cfg, sett, stackProv, logger)
return err return err
}) })
@@ -397,7 +347,17 @@ func main() {
} }
sched.Every("hub-report", pushInterval, func(ctx context.Context) error { sched.Every("hub-report", pushInterval, func(ctx context.Context) error {
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths()) 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) logger.Printf("[INFO] Hub reporting enabled (every %s to %s)", pushInterval, cfg.Hub.URL)
} else { } else {
@@ -468,6 +428,22 @@ func main() {
sched.Start(ctx) sched.Start(ctx)
defer sched.Stop() 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) // Fire startup pings + hub report immediately (don't wait for first scheduler tick)
go func() { go func() {
time.Sleep(5 * time.Second) // Let all subsystems fully initialize time.Sleep(5 * time.Second) // Let all subsystems fully initialize
@@ -511,6 +487,8 @@ func main() {
} }
// Also push infra backup on startup // Also push infra backup on startup
go pushInfraBackup(cfg, sett, stackProv, hubPusher, logger) go pushInfraBackup(cfg, sett, stackProv, hubPusher, logger)
// Write local infra backup to all connected drives
go writeLocalInfraBackup(cfg, sett, stackProv, logger)
} else { } else {
// Send a minimal "disabled" notification so hub knows reporting is intentionally off // Send a minimal "disabled" notification so hub knows reporting is intentionally off
r := &report.Report{ r := &report.Report{
@@ -632,12 +610,6 @@ func main() {
backupMgr.MigrationActiveCheck = driveMigrator.IsActive 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 --- // --- Build HTTP mux ---
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -923,46 +895,112 @@ func fileExists(path string) bool {
return err == nil return err == nil
} }
// restorePasswordsFromHub restores restic passwords from a Hub infra backup. // runSetupMode starts the setup wizard on dual listeners and blocks until signal.
func restorePasswordsFromHub(ib *report.InfraBackup, cfg *config.Config, func runSetupMode(cfg *config.Config, logger *log.Logger) {
sett *settings.Settings, logger *log.Logger) { ips := setup.DetectLocalIPs()
setup.LogSetupMode(cfg.Customer.Domain, ips, cfg.Web.SetupListen, logger)
if ib.ResticPassword != "" { setupSrv := setup.NewServer(cfg, cfg.Paths.DataDir, logger, Version)
decoded, err := base64.StdEncoding.DecodeString(ib.ResticPassword) handler := setupSrv.Handler()
if err == nil && len(decoded) > 0 {
dir := filepath.Dir(cfg.Backup.ResticPasswordFile) // Health endpoint wrapper (returns setup_mode: true)
if err := os.MkdirAll(dir, 0700); err != nil { healthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Printf("[WARN] Failed to create restic password directory %s: %v", dir, err) w.Header().Set("Content-Type", "application/json")
} else if err := os.WriteFile(cfg.Backup.ResticPasswordFile, decoded, 0600); err == nil { json.NewEncoder(w).Encode(map[string]interface{}{
logger.Println("[INFO] Primary restic password restored from Hub") "ok": true, "message": "felhom-controller is healthy",
} else { "setup_mode": true, "version": Version,
logger.Printf("[WARN] Failed to write restic password file: %v", err) })
} })
}
// 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. // writeLocalInfraBackup builds an infra snapshot and writes it to all connected drives.
func restoreSettingsFromHub(ib *report.InfraBackup, cfg *config.Config, logger *log.Logger) { func writeLocalInfraBackup(cfg *config.Config, sett *settings.Settings,
if ib.SettingsJSONB64 == "" { stackProv *stackAdapter, logger *log.Logger) {
return
} ib, err := report.BuildInfraBackup(
decoded, err := base64.StdEncoding.DecodeString(ib.SettingsJSONB64) 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 { 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 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 return
} }
settingsPath := filepath.Join(cfg.Paths.DataDir, "settings.json")
if err := os.WriteFile(settingsPath, decoded, 0600); err != nil { // Collect all connected drive paths (skip disconnected and decommissioned)
logger.Printf("[WARN] Failed to write restored settings.json: %v", err) var drives []string
} else { for _, sp := range sett.GetStoragePaths() {
logger.Println("[INFO] Settings restored from Hub backup") 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. // discoverHDDPaths scans deployed apps' app.yaml for HDD_PATH env values.
+1
View File
@@ -11,6 +11,7 @@ services:
privileged: true # Required for disk operations (mkfs, mount, sfdisk) privileged: true # Required for disk operations (mkfs, mount, sfdisk)
ports: ports:
- "8080:8080" - "8080:8080"
- "8081:8081" # Setup wizard direct HTTP (only active during setup mode)
volumes: volumes:
# Docker socket — required for compose operations + DB dumps (docker exec) # Docker socket — required for compose operations + DB dumps (docker exec)
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
+136
View File
@@ -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
}
@@ -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)
}
+5
View File
@@ -41,3 +41,8 @@ func SecondaryInfraPath(drivePath string) string {
func AppDataDir(drivePath, stackName string) string { func AppDataDir(drivePath, stackName string) string {
return filepath.Join(drivePath, "appdata", stackName) 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")
}
+27 -4
View File
@@ -56,6 +56,7 @@ type PathsConfig struct {
type WebConfig struct { type WebConfig struct {
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
SetupListen string `yaml:"setup_listen"` // Plain HTTP listener for setup wizard (only active during setup mode)
PasswordHash string `yaml:"password_hash"` PasswordHash string `yaml:"password_hash"`
SessionSecret string `yaml:"session_secret"` SessionSecret string `yaml:"session_secret"`
} }
@@ -149,6 +150,31 @@ type HubConfig struct {
// Load reads and parses the config file, applies defaults, and validates. // Load reads and parses the config file, applies defaults, and validates.
func Load(path string) (*Config, error) { 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) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("reading config file: %w", err) return nil, fmt.Errorf("reading config file: %w", err)
@@ -165,10 +191,6 @@ func Load(path string) (*Config, error) {
applyDefaults(cfg) applyDefaults(cfg)
applyEnvOverrides(cfg) applyEnvOverrides(cfg)
if err := validate(cfg); err != nil {
return nil, fmt.Errorf("config validation: %w", err)
}
return cfg, nil return cfg, nil
} }
@@ -212,6 +234,7 @@ func applyDefaults(cfg *Config) {
d(&cfg.Paths.DataDir, "/opt/docker/felhom-controller/data") d(&cfg.Paths.DataDir, "/opt/docker/felhom-controller/data")
d(&cfg.Paths.SystemDataPath, "/mnt/sys_drive") d(&cfg.Paths.SystemDataPath, "/mnt/sys_drive")
d(&cfg.Web.Listen, ":8080") d(&cfg.Web.Listen, ":8080")
d(&cfg.Web.SetupListen, ":8081")
d(&cfg.Git.Branch, "main") d(&cfg.Git.Branch, "main")
d(&cfg.Git.SyncInterval, "15m") d(&cfg.Git.SyncInterval, "15m")
d(&cfg.Stacks.UpdateWindow, "03:00-05:00") d(&cfg.Stacks.UpdateWindow, "03:00-05:00")
+68
View File
@@ -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 <domain>
3. Nyissa meg a böngészőben: http://<ip>:8081
4. Válassza a "Visszaállítás mentésből" opciót
5. Adja meg az ügyfél azonosítót és a visszaállítási jelszót
6. Kövesse a varázsló utasításait
Generálva: %s
Controller verzió: %s
`,
info.CustomerID,
info.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
}
+98
View File
@@ -2,6 +2,7 @@ package report
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -9,6 +10,103 @@ import (
"time" "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. // PullInfraBackup fetches the infrastructure backup from the Hub.
// Returns nil, nil if no backup exists for this customer. // Returns nil, nil if no backup exists for this customer.
func PullInfraBackup(hubURL, apiKey, customerID string) (*InfraBackup, error) { func PullInfraBackup(hubURL, apiKey, customerID string) (*InfraBackup, error) {
+21 -1
View File
@@ -22,6 +22,12 @@ type PushStatus struct {
Consecutive int // consecutive failures 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. // Pusher sends reports to the central hub.
type Pusher struct { type Pusher struct {
hubURL string hubURL string
@@ -32,6 +38,10 @@ type Pusher struct {
statusMu sync.RWMutex statusMu sync.RWMutex
status PushStatus 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. // NewPusher creates a new report pusher from hub configuration.
@@ -85,7 +95,9 @@ func (p *Pusher) Push(report *Report) error {
lastErr = err lastErr = err
continue 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() resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 { if resp.StatusCode >= 200 && resp.StatusCode < 300 {
@@ -95,6 +107,14 @@ func (p *Pusher) Push(report *Report) error {
p.status.LastError = "" p.status.LastError = ""
p.status.Consecutive = 0 p.status.Consecutive = 0
p.statusMu.Unlock() 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 return nil
} }
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
+119
View File
@@ -35,6 +35,17 @@ type Settings struct {
// Cross-drive restic repo password (auto-generated on first use) // Cross-drive restic repo password (auto-generated on first use)
CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"` 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. // AppBackupPrefs holds per-app backup toggle state.
@@ -96,6 +107,15 @@ var DefaultEnabledEvents = []string{
"expected_dbdump_missed", "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. // DBValidationCache holds cached DB dump validation results.
type DBValidationCache struct { type DBValidationCache struct {
ValidatedAt string `json:"validated_at"` // RFC3339 ValidatedAt string `json:"validated_at"` // RFC3339
@@ -672,3 +692,102 @@ func (s *Settings) GetDecommissionedPaths() []StoragePath {
} }
return result 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
}
+54
View File
@@ -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
}
+922
View File
@@ -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); }
`
+49
View File
@@ -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
}
+271
View File
@@ -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
}
}
+132
View File
@@ -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)
}
}
@@ -0,0 +1,32 @@
{{define "setup_failed"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visszaállítás sikertelen — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>A visszaállítás nem sikerült</h1>
</div>
<div class="setup-card">
<p>Kérjük, vegye fel a kapcsolatot a támogatással:</p>
<div style="margin-top: 1rem;">
<p><strong>Email:</strong> <a href="mailto:support@felhom.eu" style="color: var(--accent-blue, #0088cc);">support@felhom.eu</a></p>
<p><strong>Web:</strong> <a href="https://felhom.eu/kapcsolat" target="_blank" style="color: var(--accent-blue, #0088cc);">felhom.eu/kapcsolat</a></p>
</div>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1.5rem;">
<a href="/setup/fresh" class="btn btn-primary">Új telepítés</a>
<a href="/setup" class="btn btn-outline">Vissza a kezdőlapra</a>
</div>
</div>
</body>
</html>
{{end}}
@@ -0,0 +1,46 @@
{{define "setup_fresh_hub"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Új telepítés — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Új telepítés</h1>
<p style="color: var(--text-secondary, #8b949e);">Konfiguráció letöltése a Hub-ról.</p>
</div>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<div class="setup-card">
<form method="POST" action="/setup/fresh">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="form-group">
<label for="customer_id">Ügyfél-azonosító</label>
<input type="text" id="customer_id" name="customer_id" class="form-control"
value="{{.CustomerID}}" required autofocus placeholder="pl. kiscsalad-bp">
</div>
<div class="form-group">
<label for="password">Visszaállítási jelszó</label>
<input type="password" id="password" name="password" class="form-control"
required placeholder="A Hub-on beállított jelszó">
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary">Letöltés</button>
<a href="/setup" class="btn btn-outline">Vissza</a>
</div>
</form>
</div>
<p style="text-align: center; margin-top: 1rem;">
<a href="/setup/manual" style="color: var(--text-secondary, #8b949e); font-size: 0.85rem;">Nincs Hub hozzáférés? Kézi beállítás &rarr;</a>
</p>
</div>
</body>
</html>
{{end}}
@@ -0,0 +1,42 @@
{{define "setup_hub_restore"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hub visszaállítás — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Visszaállítás a Hub-ról</h1>
<p style="color: var(--text-secondary, #8b949e);">Adja meg az ügyfél-azonosítót és jelszót a mentés letöltéséhez.</p>
</div>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<div class="setup-card">
<form method="POST" action="/setup/hub-restore">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="form-group">
<label for="customer_id">Ügyfél-azonosító</label>
<input type="text" id="customer_id" name="customer_id" class="form-control"
value="{{.CustomerID}}" required autofocus placeholder="pl. kiscsalad-bp">
</div>
<div class="form-group">
<label for="password">Visszaállítási jelszó</label>
<input type="password" id="password" name="password" class="form-control"
required placeholder="A Hub-on beállított jelszó">
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary">Kapcsolódás</button>
<a href="/setup" class="btn btn-outline">Vissza</a>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}
@@ -0,0 +1,111 @@
{{define "setup_manual"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kézi beállítás — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Kézi beállítás</h1>
</div>
{{if .Errors}}
<div class="alert alert-error">
{{range .Errors}}<div>{{.}}</div>{{end}}
</div>
{{end}}
<form method="POST" action="/setup/manual">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="setup-card">
<h3>Ügyfél azonosítás</h3>
<div class="form-group">
<label for="customer_id">Ügyfél-azonosító *</label>
<input type="text" id="customer_id" name="customer_id" class="form-control"
value="{{index .FormData "customer_id"}}" required placeholder="pl. kiscsalad-bp"
pattern="[a-zA-Z0-9_-]+" title="Csak betűk, számok, kötőjel és aláhúzás">
</div>
<div class="form-group">
<label for="display_name">Megjelenítési név</label>
<input type="text" id="display_name" name="display_name" class="form-control"
value="{{index .FormData "display_name"}}" placeholder="pl. Kis Család">
</div>
<div class="form-group">
<label for="domain">Domain *</label>
<input type="text" id="domain" name="domain" class="form-control"
value="{{index .FormData "domain"}}" required placeholder="pl. kiscsalad.hu">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" class="form-control"
value="{{index .FormData "email"}}" placeholder="Opcionális">
</div>
</div>
<div class="setup-card">
<h3>Infrastruktúra</h3>
<div class="form-group">
<label for="cf_tunnel_token">Cloudflare Tunnel token</label>
<input type="password" id="cf_tunnel_token" name="cf_tunnel_token" class="form-control"
value="{{index .FormData "cf_tunnel_token"}}" placeholder="Opcionális">
</div>
<div class="form-group">
<label for="cf_api_token">Cloudflare API token</label>
<input type="password" id="cf_api_token" name="cf_api_token" class="form-control"
value="{{index .FormData "cf_api_token"}}" placeholder="Opcionális — DNS-01 TLS-hez">
</div>
<div class="form-group">
<label for="system_data_path">Rendszer adatpartíció útvonala</label>
<input type="text" id="system_data_path" name="system_data_path" class="form-control"
value="{{index .FormData "system_data_path"}}" placeholder="Alapértelmezett: /mnt/sys_drive">
</div>
</div>
<div class="setup-card">
<h3>Dashboard jelszó</h3>
<div class="form-group">
<label for="password">Jelszó (min. 8 karakter)</label>
<input type="password" id="password" name="password" class="form-control"
placeholder="Hagyja üresen, ha később szeretné beállítani" minlength="8">
</div>
<div class="form-group">
<label for="password_confirm">Jelszó megerősítés</label>
<input type="password" id="password_confirm" name="password_confirm" class="form-control"
placeholder="Adja meg újra a jelszót">
</div>
</div>
<div class="setup-card">
<h3>Alkalmazás-katalógus</h3>
<div class="form-group">
<label for="git_repo_url">Git repo URL</label>
<input type="text" id="git_repo_url" name="git_repo_url" class="form-control"
value="{{index .FormData "git_repo_url"}}" placeholder="{{.DefaultGit}}">
</div>
<div class="form-group">
<label for="git_username">Git felhasználónév</label>
<input type="text" id="git_username" name="git_username" class="form-control"
value="{{index .FormData "git_username"}}" placeholder="Opcionális">
</div>
<div class="form-group">
<label for="git_token">Git token</label>
<input type="password" id="git_token" name="git_token" class="form-control"
value="{{index .FormData "git_token"}}" placeholder="Opcionális">
</div>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
<button type="submit" class="btn btn-primary">Mentés és indítás</button>
<a href="/setup/fresh" class="btn btn-outline">Vissza</a>
</div>
</form>
</div>
</body>
</html>
{{end}}
@@ -0,0 +1,78 @@
{{define "setup_restore_exec"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visszaállítás folyamatban — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Visszaállítás</h1>
</div>
<div class="setup-card">
<ul class="step-list" id="steps">
<li><span class="spinner"></span> Indítás...</li>
</ul>
</div>
<div id="done-msg" style="display: none;">
<div class="alert alert-info">Visszaállítás sikeres! A vezérlőpult újraindul...</div>
</div>
<div id="error-msg" style="display: none;">
<div class="alert alert-error" id="error-text"></div>
<div style="display: flex; gap: 0.75rem; margin-top: 1rem;">
<a href="/setup/failed" class="btn btn-outline">Tovább</a>
</div>
</div>
</div>
<script>
(function() {
function poll() {
fetch('/setup/restore/status')
.then(function(r) { return r.json(); })
.then(function(data) {
var list = document.getElementById('steps');
if (data.steps && data.steps.length > 0) {
list.innerHTML = '';
data.steps.forEach(function(step) {
var li = document.createElement('li');
var icon = '';
if (step.status === 'done') icon = '<span class="step-done">&#10003;</span>';
else if (step.status === 'running') icon = '<span class="spinner"></span>';
else if (step.status === 'failed') icon = '<span class="step-failed">&#10007;</span>';
else icon = '<span style="color: var(--text-secondary);">&#9675;</span>';
li.innerHTML = icon + ' ' + step.label;
if (step.error) li.innerHTML += '<br><small style="color: var(--red, #f85149);">' + step.error + '</small>';
list.appendChild(li);
});
}
if (data.error) {
document.getElementById('error-msg').style.display = 'block';
document.getElementById('error-text').textContent = data.error;
return;
}
if (data.done) {
document.getElementById('done-msg').style.display = 'block';
setTimeout(function() { window.location.href = '/'; }, 5000);
return;
}
setTimeout(poll, 1500);
})
.catch(function() {
// Connection lost — controller may be restarting
document.getElementById('done-msg').style.display = 'block';
setTimeout(function() { window.location.href = '/'; }, 5000);
});
}
poll();
})();
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,123 @@
{{define "setup_scan"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meghajtók keresése — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Visszaállítás mentésből</h1>
</div>
<div class="setup-card" id="scan-status">
<h3>Külső meghajtók keresése...</h3>
<p style="color: var(--text-secondary, #8b949e);">Ha vannak külső meghajtók csatlakoztatva a szerverhez, győződjön meg róla, hogy most csatlakoztatva vannak.</p>
<div style="margin-top: 1rem; text-align: center;">
<div class="spinner"></div>
</div>
</div>
<div id="results" style="display: none;">
<div class="setup-card">
<h3>Találatok</h3>
<table id="results-table">
<thead>
<tr>
<th></th>
<th>Meghajtó</th>
<th>Ügyfél</th>
<th>Dátum</th>
<th>Verzió</th>
<th>Állapot</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
<form method="POST" action="/setup/restore" id="restore-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="source" value="local">
<input type="hidden" name="drive_path" id="selected-drive" value="">
<button type="submit" class="btn btn-primary" id="restore-btn" disabled>Visszaállítás</button>
</form>
<a href="/setup/hub-restore" class="btn btn-outline">Tovább a Hub-hoz</a>
</div>
</div>
<div id="no-results" style="display: none;">
<div class="setup-card">
<h3>Nem található helyi mentés.</h3>
<p style="color: var(--text-secondary, #8b949e);">A csatlakoztatott meghajtókon nem található Felhom infra mentés.</p>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
<a href="/setup/hub-restore" class="btn btn-primary">Tovább a Hub-hoz</a>
<a href="/setup" class="btn btn-outline">Vissza</a>
</div>
</div>
<div id="scan-error" style="display: none;">
<div class="alert alert-error" id="scan-error-msg"></div>
<a href="/setup" class="btn btn-outline">Vissza</a>
</div>
</div>
<script>
(function() {
var selectedDrive = '';
function poll() {
fetch('/setup/scan/status')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
document.getElementById('scan-status').style.display = 'none';
document.getElementById('scan-error').style.display = 'block';
document.getElementById('scan-error-msg').textContent = data.error;
return;
}
if (!data.done) {
setTimeout(poll, 1000);
return;
}
document.getElementById('scan-status').style.display = 'none';
if (!data.results || data.results.length === 0) {
document.getElementById('no-results').style.display = 'block';
return;
}
document.getElementById('results').style.display = 'block';
var tbody = document.querySelector('#results-table tbody');
tbody.innerHTML = '';
var validCount = 0;
data.results.forEach(function(r, i) {
var tr = document.createElement('tr');
var radio = r.integrity_ok ? '<input type="radio" name="backup" value="' + r.mount_point + '" onclick="selectDrive(this)">' : '';
tr.innerHTML = '<td>' + radio + '</td>' +
'<td>' + (r.device || '') + (r.label ? ' (' + r.label + ')' : '') + '</td>' +
'<td>' + (r.customer_id || '-') + '</td>' +
'<td>' + (r.timestamp ? r.timestamp.substring(0, 10) : '-') + '</td>' +
'<td>' + (r.controller_version || '-') + '</td>' +
'<td>' + (r.integrity_ok ? '<span class="badge badge-ok">OK</span>' : '<span class="badge badge-error">' + (r.error || 'Hiba') + '</span>') + '</td>';
tbody.appendChild(tr);
if (r.integrity_ok) validCount++;
});
if (validCount === 1) {
var radio = tbody.querySelector('input[type="radio"]');
if (radio) { radio.checked = true; selectDrive(radio); }
}
});
}
window.selectDrive = function(el) {
document.getElementById('selected-drive').value = el.value;
document.getElementById('restore-btn').disabled = false;
};
poll();
})();
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,37 @@
{{define "setup_welcome"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Felhom Szerver Beállítás</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="setup-container">
<div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Felhom Szerver Beállítás</h1>
<p style="color: var(--text-secondary, #8b949e); font-size: 0.85rem;">v{{.Version}}</p>
</div>
{{if .AccessURLs}}
<div class="info-box">
Ez az oldal elérhető:
{{range .AccessURLs}}<br>{{.}}{{end}}
</div>
{{end}}
<a href="/setup/scan" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
<h3>Visszaállítás mentésből</h3>
<p>Rendszerhiba utáni visszaállítás helyi meghajtóról vagy a Hub-ról. Válassza ezt, ha az operációs rendszert újratelepítette.</p>
</a>
<a href="/setup/fresh" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
<h3>Új telepítés</h3>
<p>Új ügyfél beállítása. Konfiguráció letöltése a Hub-ról vagy kézi beállítás.</p>
</a>
</div>
</body>
</html>
{{end}}
+6
View File
@@ -1003,6 +1003,12 @@ func (s *Server) settingsData() map[string]interface{} {
} }
data["StoragePaths"] = storageViews 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 return data
} }
@@ -487,6 +487,46 @@ function pollUntilBack() {
{{end}} {{end}}
</div> </div>
<!-- Section: Recovery Info -->
{{if .RetrievalPassword}}
<div class="settings-card">
<h3>Veszhelyzeti informaciok</h3>
<p class="settings-card-desc">
Ezeket az adatokat mentse el biztos helyre. Ujratelepites eseten szukseg lesz rajuk a rendszer visszaallitasahoz.
</p>
<div class="settings-grid">
<div class="settings-row">
<span class="settings-label">Ugyfel azonosito</span>
<span class="settings-value mono">{{.CustomerID}}</span>
</div>
<div class="settings-row">
<span class="settings-label">Hub URL</span>
<span class="settings-value mono">{{.HubURL}}</span>
</div>
<div class="settings-row">
<span class="settings-label">Visszaallitasi jelszo</span>
<span class="settings-value">
<span id="retrieval-pw-hidden">••••••••••••••••
<button type="button" class="btn btn-xs btn-outline" onclick="document.getElementById('retrieval-pw-hidden').style.display='none';document.getElementById('retrieval-pw-visible').style.display='inline';">Megjelenit</button>
</span>
<span id="retrieval-pw-visible" style="display:none">
<code class="mono">{{.RetrievalPassword}}</code>
<button type="button" class="btn btn-xs btn-outline" onclick="document.getElementById('retrieval-pw-visible').style.display='none';document.getElementById('retrieval-pw-hidden').style.display='inline';">Elrejt</button>
</span>
</span>
</div>
<div class="settings-row">
<span class="settings-label">Tamogatas</span>
<span class="settings-value">
<a href="mailto:{{.SupportEmail}}" style="color: var(--accent-blue, #0088cc);">{{.SupportEmail}}</a>
&nbsp;|&nbsp;
<a href="{{.SupportURL}}" target="_blank" style="color: var(--accent-blue, #0088cc);">felhom.eu/kapcsolat</a>
</span>
</div>
</div>
</div>
{{end}}
<script> <script>
function editStorageLabel(path, currentLabel) { function editStorageLabel(path, currentLabel) {
var wrap = document.getElementById('label-wrap-' + path); var wrap = document.getElementById('label-wrap-' + path);
+10
View File
@@ -1,5 +1,15 @@
# scripts/ — Changelog # scripts/ — Changelog
## docker-setup.sh v6.0.0 (2026-02-21)
- **Removed interactive configuration wizard**: Full setup now handled by controller's web-based setup wizard
- **Minimal controller.yaml**: Script only generates domain + paths + listener config; controller's web wizard handles the rest
- **New `--cf-tunnel-token` flag**: Cloudflare Tunnel token can now be provided directly via CLI
- **Removed `--hub-customer` and `--hub-password` flags**: Hub config download moved to controller setup wizard
- **Port 8081 mapping**: Controller docker-compose.yml now exposes `:8081` for direct HTTP setup wizard access
- **Updated summary output**: Shows setup wizard URLs (domain + LAN fallback) instead of config file paths
- **controller.yaml mount writable**: No longer `:ro` so setup wizard can write full config after completion
## docker-setup.sh v5.0.0 (2026-02-20) ## docker-setup.sh v5.0.0 (2026-02-20)
- **Hub Config Download:** - **Hub Config Download:**
+80 -366
View File
@@ -1,17 +1,17 @@
#!/bin/bash #!/bin/bash
#=============================================================================== #===============================================================================
# Felhom Infrastructure Setup v5.0 # Felhom Infrastructure Setup v6.0
# Prepares a Debian 13 server for Felhom homeserver deployment # Prepares a Debian 13 server for Felhom homeserver deployment
# #
# This script sets up the complete infrastructure: # This script sets up the infrastructure:
# - Docker Engine + Compose # - Docker Engine + Compose
# - Traefik reverse proxy # - Traefik reverse proxy
# - TLS certificates (Let's Encrypt via Cloudflare DNS or self-signed) # - TLS certificates (Let's Encrypt via Cloudflare DNS or self-signed)
# - Interactive configuration wizard (generates controller.yaml) # - Minimal controller.yaml (full configuration via web UI setup wizard)
# - Cloudflare Tunnel connector (optional, configured via wizard) # - Cloudflare Tunnel connector (optional, via --cf-tunnel-token)
# - FileBrowser Quantum (web-based file manager) # - FileBrowser Quantum (web-based file manager)
# - Felhom Controller (automatic deployment + dashboard) # - Felhom Controller (with web-based setup wizard on :8081)
# - Helper tools (ctop, lazydocker, shell aliases) # - Helper tools (ctop, lazydocker, shell aliases)
# #
# Application stacks are managed via the felhom-controller dashboard. # Application stacks are managed via the felhom-controller dashboard.
@@ -26,10 +26,11 @@
# --gateway ADDRESS Gateway address (default: 192.168.0.1) # --gateway ADDRESS Gateway address (default: 192.168.0.1)
# --dns ADDRESS DNS server (default: 1.1.1.1,8.8.8.8) # --dns ADDRESS DNS server (default: 1.1.1.1,8.8.8.8)
# --interface NAME Network interface (default: auto-detect) # --interface NAME Network interface (default: auto-detect)
# --domain DOMAIN Base domain (pre-seeds wizard) # --domain DOMAIN Base domain for services (required)
# --email EMAIL ACME email (pre-seeds wizard) # --email EMAIL ACME email for Let's Encrypt
# --cf-token TOKEN Cloudflare API token (pre-seeds wizard) # --cf-token TOKEN Cloudflare API token for DNS-01 TLS
# --customer ID Customer identifier (pre-seeds wizard) # --cf-tunnel-token TK Cloudflare Tunnel token (optional)
# --customer ID Customer identifier (optional, set in web wizard)
# --traefik-password PW Password for Traefik dashboard (default: auto-generated) # --traefik-password PW Password for Traefik dashboard (default: auto-generated)
# --self-signed-cert Generate self-signed wildcard certificate # --self-signed-cert Generate self-signed wildcard certificate
# --skip-filebrowser Skip FileBrowser installation # --skip-filebrowser Skip FileBrowser installation
@@ -119,7 +120,7 @@ done
#------------------------------------------------------------------------------- #-------------------------------------------------------------------------------
# Configuration # Configuration
#------------------------------------------------------------------------------- #-------------------------------------------------------------------------------
SCRIPT_VERSION="5.0.0" SCRIPT_VERSION="6.0.0"
# Default values # Default values
STATIC_IP="" STATIC_IP=""
@@ -135,9 +136,7 @@ DRY_RUN=false
SELF_SIGNED_CERT=false SELF_SIGNED_CERT=false
DEBUG_MODE=false DEBUG_MODE=false
CUSTOMER_ID="" CUSTOMER_ID=""
HUB_CUSTOMER_ID="" CF_TUNNEL_TOKEN=""
HUB_PASSWORD=""
HUB_URL="https://hub.felhom.eu"
# Directories # Directories
DOCKER_DATA_DIR="/opt/docker" DOCKER_DATA_DIR="/opt/docker"
@@ -193,11 +192,12 @@ print_banner() {
print_help() { print_help() {
cat << 'EOF' cat << 'EOF'
Felhom Infrastructure Setup v5.0 Felhom Infrastructure Setup v6.0
Prepares a Debian 13 server for Felhom homeserver deployment. Installs Prepares a Debian 13 server for Felhom homeserver deployment. Installs
infrastructure, runs an interactive configuration wizard, generates infrastructure, generates a minimal controller.yaml, and deploys
controller.yaml, and deploys felhom-controller — all in one run. felhom-controller. Full configuration is completed via the web-based
setup wizard at http://<ip>:8081.
USAGE: USAGE:
./docker-setup.sh --bootstrap # First, on fresh Debian ./docker-setup.sh --bootstrap # First, on fresh Debian
@@ -205,14 +205,15 @@ USAGE:
OPTIONS: OPTIONS:
--bootstrap Install sudo (run first on fresh Debian) --bootstrap Install sudo (run first on fresh Debian)
--customer ID Customer identifier (pre-seeds wizard) --domain DOMAIN Base domain for services (required)
--customer ID Customer identifier (optional, set in web wizard)
--ip ADDRESS Static IP address --ip ADDRESS Static IP address
--gateway ADDRESS Gateway (default: 192.168.0.1) --gateway ADDRESS Gateway (default: 192.168.0.1)
--dns ADDRESS DNS servers, comma-separated (default: 1.1.1.1,8.8.8.8) --dns ADDRESS DNS servers, comma-separated (default: 1.1.1.1,8.8.8.8)
--interface NAME Network interface (default: auto-detect) --interface NAME Network interface (default: auto-detect)
--domain DOMAIN Base domain for services (pre-seeds wizard) --email EMAIL Email for Let's Encrypt
--email EMAIL Email for Let's Encrypt (pre-seeds wizard) --cf-token TOKEN Cloudflare API token for DNS-01 TLS
--cf-token TOKEN Cloudflare API token for DNS-01 (pre-seeds wizard) --cf-tunnel-token TK Cloudflare Tunnel token (optional)
--traefik-password PW Password for Traefik dashboard (default: auto-generated) --traefik-password PW Password for Traefik dashboard (default: auto-generated)
--self-signed-cert Generate self-signed wildcard certificate (fallback) --self-signed-cert Generate self-signed wildcard certificate (fallback)
--skip-filebrowser Skip FileBrowser installation --skip-filebrowser Skip FileBrowser installation
@@ -220,9 +221,6 @@ OPTIONS:
--debug Enable verbose debug output --debug Enable verbose debug output
-h, --help Show this help -h, --help Show this help
CLI options pre-seed the interactive wizard — values can be changed
during the wizard prompts.
TLS CERTIFICATE OPTIONS: TLS CERTIFICATE OPTIONS:
There are three TLS modes (in order of preference): There are three TLS modes (in order of preference):
@@ -241,9 +239,9 @@ WHAT THIS SCRIPT INSTALLS:
2. Docker Engine + Docker Compose 2. Docker Engine + Docker Compose
3. Traefik reverse proxy (with dashboard) 3. Traefik reverse proxy (with dashboard)
4. TLS certificates (Let's Encrypt or self-signed) 4. TLS certificates (Let's Encrypt or self-signed)
5. Felhom Controller (with interactive configuration wizard) 5. Felhom Controller (with web-based setup wizard on :8081)
6. FileBrowser Quantum (web file manager at files.<domain>) 6. FileBrowser Quantum (web file manager at files.<domain>)
7. Cloudflare Tunnel (if configured in wizard) 7. Cloudflare Tunnel (if --cf-tunnel-token provided)
8. Helper tools (ctop, lazydocker, shell aliases) 8. Helper tools (ctop, lazydocker, shell aliases)
EXAMPLES: EXAMPLES:
@@ -251,15 +249,16 @@ EXAMPLES:
sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \ sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \
--email certs@felhom.eu --cf-token cf-xxxxxxxxxxxx --email certs@felhom.eu --cf-token cf-xxxxxxxxxxxx
# Minimal (wizard will prompt for everything) # Minimal (setup wizard handles everything)
sudo ./docker-setup.sh sudo ./docker-setup.sh --domain example.com
# Self-signed cert (offline/testing) # Self-signed cert (offline/testing)
sudo ./docker-setup.sh --domain example.com --self-signed-cert sudo ./docker-setup.sh --domain example.com --self-signed-cert
# Full setup with static IP # Full setup with static IP + Cloudflare Tunnel
sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \ sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \
--ip 192.168.0.50 --email certs@felhom.eu --cf-token cf-xxxxxxxxxxxx --ip 192.168.0.50 --email certs@felhom.eu --cf-token cf-xxx \
--cf-tunnel-token eyJhIjoi...
EOF EOF
} }
@@ -304,12 +303,9 @@ parse_args() {
--customer) --customer)
require_arg "$1" "${2:-}" require_arg "$1" "${2:-}"
CUSTOMER_ID="$2"; shift 2 ;; CUSTOMER_ID="$2"; shift 2 ;;
--hub-customer) --cf-tunnel-token)
require_arg "$1" "${2:-}" require_arg "$1" "${2:-}"
HUB_CUSTOMER_ID="$2"; shift 2 ;; CF_TUNNEL_TOKEN="$2"; shift 2 ;;
--hub-password)
require_arg "$1" "${2:-}"
HUB_PASSWORD="$2"; shift 2 ;;
--self-signed-cert) SELF_SIGNED_CERT=true; shift ;; --self-signed-cert) SELF_SIGNED_CERT=true; shift ;;
--skip-filebrowser) SKIP_FILEBROWSER=true; shift ;; --skip-filebrowser) SKIP_FILEBROWSER=true; shift ;;
--dry-run) DRY_RUN=true; shift ;; --dry-run) DRY_RUN=true; shift ;;
@@ -445,8 +441,8 @@ detect_network_manager_debug() {
# Calculate total number of steps dynamically # Calculate total number of steps dynamically
get_total_steps() { get_total_steps() {
local total=8 # base: packages, network, docker, traefik, cert, wizard, controller, tools local total=7 # base: packages, network, docker, traefik, cert, controller, tools
[[ -n "${WIZ_CF_TUNNEL_TOKEN:-}" ]] && ((total++)) [[ -n "${CF_TUNNEL_TOKEN:-}" ]] && ((total++))
[[ "$SKIP_FILEBROWSER" != true ]] && ((total++)) [[ "$SKIP_FILEBROWSER" != true ]] && ((total++))
echo "$total" echo "$total"
} }
@@ -1029,12 +1025,12 @@ EOF
# Step 4b: Install Cloudflare Tunnel connector (optional) # Step 4b: Install Cloudflare Tunnel connector (optional)
#------------------------------------------------------------------------------- #-------------------------------------------------------------------------------
install_cloudflare_tunnel() { install_cloudflare_tunnel() {
if [[ -z "${WIZ_CF_TUNNEL_TOKEN:-}" ]]; then if [[ -z "${CF_TUNNEL_TOKEN:-}" ]]; then
log_skip "Cloudflare Tunnel not configured (skipped in wizard)" log_skip "Cloudflare Tunnel not configured (use --cf-tunnel-token to enable)"
return return
fi fi
local step_num=6 local step_num=5
[[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++)) [[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++))
log_step "${step_num}/$(get_total_steps) - Installing Cloudflare Tunnel connector..." log_step "${step_num}/$(get_total_steps) - Installing Cloudflare Tunnel connector..."
@@ -1061,7 +1057,7 @@ services:
restart: unless-stopped restart: unless-stopped
command: tunnel run command: tunnel run
environment: environment:
- TUNNEL_TOKEN="${WIZ_CF_TUNNEL_TOKEN}" - TUNNEL_TOKEN="${CF_TUNNEL_TOKEN}"
dns: dns:
- 1.1.1.1 - 1.1.1.1
- 8.8.8.8 - 8.8.8.8
@@ -1232,9 +1228,9 @@ install_filebrowser() {
fi fi
# Calculate step number dynamically # Calculate step number dynamically
local step_num=6 local step_num=5
[[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++)) [[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++))
[[ -n "${WIZ_CF_TUNNEL_TOKEN:-}" ]] && ((step_num++)) [[ -n "${CF_TUNNEL_TOKEN:-}" ]] && ((step_num++))
log_step "${step_num}/$(get_total_steps) - Installing FileBrowser Quantum..." log_step "${step_num}/$(get_total_steps) - Installing FileBrowser Quantum..."
# Discover drive mounts for FileBrowser volumes # Discover drive mounts for FileBrowser volumes
@@ -1257,15 +1253,8 @@ install_filebrowser() {
done done
fi fi
# Also add system_data_path from wizard if it's not already in /mnt # Note: system_data_path is now configured via the web setup wizard,
if [[ -n "${WIZ_SYSTEM_DATA_PATH:-}" && -d "${WIZ_SYSTEM_DATA_PATH}" ]]; then # FileBrowser mounts will be synced by the controller after setup completes.
local sdp_name
sdp_name=$(basename "${WIZ_SYSTEM_DATA_PATH}")
if ! echo "$volume_lines" | grep -qF "${WIZ_SYSTEM_DATA_PATH}"; then
volume_lines+=" - \"${WIZ_SYSTEM_DATA_PATH}:/srv/${sdp_name}\""$'\n'
((found_mounts++))
fi
fi
if [[ $found_mounts -eq 0 ]]; then if [[ $found_mounts -eq 0 ]]; then
log_warn "No mount points found in /mnt/ — FileBrowser will have no drive volumes." log_warn "No mount points found in /mnt/ — FileBrowser will have no drive volumes."
@@ -1439,322 +1428,57 @@ EOF
#------------------------------------------------------------------------------- #-------------------------------------------------------------------------------
# Configuration wizard — generates controller.yaml # Generate minimal controller.yaml — full configuration via web UI setup wizard
#------------------------------------------------------------------------------- #-------------------------------------------------------------------------------
# Wizard variables (populated by prompts, used by later steps)
WIZ_CUSTOMER_ID=""
WIZ_CUSTOMER_NAME=""
WIZ_DOMAIN=""
WIZ_EMAIL=""
WIZ_CF_TUNNEL_TOKEN=""
WIZ_CF_API_TOKEN=""
WIZ_SYSTEM_DATA_PATH=""
WIZ_PASSWORD_HASH=""
WIZ_SESSION_SECRET=""
WIZ_GIT_REPO=""
WIZ_GIT_USERNAME=""
WIZ_GIT_TOKEN=""
WIZ_HC_HEARTBEAT=""
WIZ_HC_SYSTEM=""
WIZ_HC_DBDUMP=""
WIZ_HC_BACKUP=""
WIZ_HC_INTEGRITY=""
CONTROLLER_DIR="/opt/docker/felhom-controller" CONTROLLER_DIR="/opt/docker/felhom-controller"
run_config_wizard() { generate_minimal_config() {
local step_num=5 local step_num=5
[[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++)) [[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++))
log_step "${step_num}/$(get_total_steps) - Running configuration wizard..." log_step "${step_num}/$(get_total_steps) - Generating minimal controller.yaml..."
echo ""
echo -e "${BOLD}${CYAN}===========================================================${NC}"
echo -e "${BOLD}${CYAN} Felhom Controller Configuration Wizard${NC}"
echo -e "${BOLD}${CYAN}===========================================================${NC}"
echo ""
# In dry-run mode, skip all interactive prompts and set dummy values
if [[ "$DRY_RUN" == true ]]; then if [[ "$DRY_RUN" == true ]]; then
echo -e "${CYAN}[DRY-RUN]${NC} Would run interactive wizard and generate controller.yaml" echo -e "${CYAN}[DRY-RUN]${NC} Would generate minimal controller.yaml (full setup via web UI)"
WIZ_CUSTOMER_ID="${CUSTOMER_ID:-demo-felhom}"
WIZ_CUSTOMER_NAME="${WIZ_CUSTOMER_ID}"
WIZ_DOMAIN="${BASE_DOMAIN:-homeserver.local}"
WIZ_EMAIL="${ACME_EMAIL:-}"
WIZ_CF_TUNNEL_TOKEN=""
WIZ_CF_API_TOKEN="${CF_DNS_API_TOKEN:-}"
WIZ_SYSTEM_DATA_PATH="/mnt/sys_drive"
WIZ_PASSWORD_HASH='<would-be-generated>'
WIZ_SESSION_SECRET='<would-be-generated>'
WIZ_GIT_REPO="https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
WIZ_GIT_USERNAME=""
WIZ_GIT_TOKEN=""
WIZ_HC_HEARTBEAT=""
WIZ_HC_SYSTEM=""
WIZ_HC_DBDUMP=""
WIZ_HC_BACKUP=""
WIZ_HC_INTEGRITY=""
return return
fi fi
# --- Hub download mode ---
# If hub credentials are provided, download pre-configured YAML and skip wizard
if [[ -n "$HUB_CUSTOMER_ID" && -n "$HUB_PASSWORD" ]]; then
log_info "Downloading configuration from Felhom Hub for customer: $HUB_CUSTOMER_ID"
mkdir -p "${CONTROLLER_DIR}"
local tmp_yaml="${CONTROLLER_DIR}/controller.yaml.tmp"
local http_code
http_code=$(curl -fsSL -o "$tmp_yaml" -w "%{http_code}" \
"${HUB_URL}/api/v1/config/${HUB_CUSTOMER_ID}" \
-H "X-Retrieval-Password: ${HUB_PASSWORD}" \
2>/dev/null) || true
if [[ "$http_code" == "200" ]] && [[ -s "$tmp_yaml" ]] && grep -q '^customer:' "$tmp_yaml"; then
mv "$tmp_yaml" "${CONTROLLER_DIR}/controller.yaml"
chmod 600 "${CONTROLLER_DIR}/controller.yaml"
log_success "Configuration downloaded for customer: $HUB_CUSTOMER_ID"
# Extract key variables that later steps depend on
WIZ_CUSTOMER_ID="$HUB_CUSTOMER_ID"
WIZ_DOMAIN=$(grep -oP '^\s+domain:\s*"\K[^"]+' "${CONTROLLER_DIR}/controller.yaml" || echo "")
WIZ_CF_TUNNEL_TOKEN=$(grep -oP '^\s+cf_tunnel_token:\s*"\K[^"]+' "${CONTROLLER_DIR}/controller.yaml" || echo "")
WIZ_CF_API_TOKEN=$(grep -oP '^\s+cf_api_token:\s*"\K[^"]+' "${CONTROLLER_DIR}/controller.yaml" || echo "")
WIZ_EMAIL=$(grep -oP '^\s+email:\s*"\K[^"]+' "${CONTROLLER_DIR}/controller.yaml" || echo "")
# Update global variables that other steps depend on
BASE_DOMAIN="${WIZ_DOMAIN}"
CUSTOMER_ID="${WIZ_CUSTOMER_ID}"
CF_DNS_API_TOKEN="${WIZ_CF_API_TOKEN}"
ACME_EMAIL="${WIZ_EMAIL}"
return # Skip interactive wizard
else
rm -f "$tmp_yaml"
log_warn "Hub download failed (HTTP ${http_code}). Falling back to manual configuration."
fi
fi
# Pre-seed from CLI flags
local def_customer="${CUSTOMER_ID}"
local def_domain="${BASE_DOMAIN}"
local def_email="${ACME_EMAIL}"
local def_cf_api="${CF_DNS_API_TOKEN}"
# --- Customer identity ---
echo -e "${BOLD}--- Customer identity ---${NC}"
read -rp "Customer ID [${def_customer:-demo-felhom}]: " input
WIZ_CUSTOMER_ID="${input:-${def_customer:-demo-felhom}}"
read -rp "Customer display name [${WIZ_CUSTOMER_ID}]: " input
WIZ_CUSTOMER_NAME="${input:-${WIZ_CUSTOMER_ID}}"
read -rp "Domain [${def_domain}]: " input
WIZ_DOMAIN="${input:-${def_domain}}"
read -rp "Customer email (optional) [${def_email}]: " input
WIZ_EMAIL="${input:-${def_email}}"
echo ""
# --- Infrastructure secrets ---
echo -e "${BOLD}--- Infrastructure secrets ---${NC}"
read -rp "Cloudflare Tunnel token (optional, leave empty to skip) []: " WIZ_CF_TUNNEL_TOKEN
read -rp "Cloudflare API token (for DNS-01 certs, optional) [${def_cf_api}]: " input
WIZ_CF_API_TOKEN="${input:-${def_cf_api}}"
echo ""
# --- Paths ---
echo -e "${BOLD}--- Paths ---${NC}"
echo " System data partition mount point"
echo " (if the system drive was partitioned for user data,"
echo " provide the mount point, e.g., /mnt/sys_drive)"
read -rp "System data path [/mnt/sys_drive]: " input
WIZ_SYSTEM_DATA_PATH="${input:-/mnt/sys_drive}"
echo ""
# --- Dashboard password ---
echo -e "${BOLD}--- Dashboard password ---${NC}"
echo " Set a password for the controller dashboard."
echo " (leave empty for first-visit setup prompt)"
read -rsp "Dashboard password []: " wiz_password
echo ""
if [[ -n "$wiz_password" ]]; then
# Hash with htpasswd (apache2-utils installed in step 1)
if command -v htpasswd &>/dev/null; then
WIZ_PASSWORD_HASH=$(htpasswd -bnBC 10 "" "$wiz_password" 2>/dev/null | cut -d: -f2)
if [[ ! "$WIZ_PASSWORD_HASH" =~ ^\$2[aby]\$ ]]; then
log_warn "htpasswd failed — trying Python fallback"
WIZ_PASSWORD_HASH=""
fi
fi
if [[ -z "$WIZ_PASSWORD_HASH" ]] && command -v python3 &>/dev/null; then
WIZ_PASSWORD_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'${wiz_password}', bcrypt.gensalt(10)).decode())" 2>/dev/null || echo "")
fi
if [[ -z "$WIZ_PASSWORD_HASH" ]]; then
log_warn "Could not hash password — dashboard will prompt on first visit"
fi
fi
echo ""
# Generate session secret
WIZ_SESSION_SECRET=$(openssl rand -hex 32)
# --- Git sync ---
echo -e "${BOLD}--- Git sync ---${NC}"
read -rp "App catalog repository URL [https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git]: " input
WIZ_GIT_REPO="${input:-https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git}"
read -rp "Git username []: " WIZ_GIT_USERNAME
read -rp "Git token []: " WIZ_GIT_TOKEN
echo ""
# --- Healthcheck monitoring ---
echo -e "${BOLD}--- Healthcheck monitoring ---${NC}"
echo " Healthchecks.io ping UUIDs (leave empty to skip):"
read -rp " Heartbeat UUID []: " WIZ_HC_HEARTBEAT
read -rp " System health UUID []: " WIZ_HC_SYSTEM
read -rp " DB dump UUID []: " WIZ_HC_DBDUMP
read -rp " Backup UUID []: " WIZ_HC_BACKUP
read -rp " Backup integrity UUID []: " WIZ_HC_INTEGRITY
echo ""
# Update global variables that other steps depend on
BASE_DOMAIN="${WIZ_DOMAIN}"
CUSTOMER_ID="${WIZ_CUSTOMER_ID}"
CF_DNS_API_TOKEN="${WIZ_CF_API_TOKEN}"
ACME_EMAIL="${WIZ_EMAIL}"
# --- Validate required fields ---
if [[ -z "$WIZ_CUSTOMER_ID" ]] || [[ "$WIZ_CUSTOMER_ID" == "demo-felhom" ]]; then
log_error "Customer ID is required and cannot be the default 'demo-felhom'"
exit 1
fi
if [[ -z "$WIZ_DOMAIN" ]] || [[ "$WIZ_DOMAIN" == "homeserver.local" ]]; then
log_error "A real domain is required (not 'homeserver.local')"
exit 1
fi
# --- Generate controller.yaml ---
mkdir -p "${CONTROLLER_DIR}" mkdir -p "${CONTROLLER_DIR}"
# Build optional customer.id line
local customer_id_line=""
if [[ -n "$CUSTOMER_ID" ]]; then
customer_id_line=" id: \"${CUSTOMER_ID}\""
fi
cat > "${CONTROLLER_DIR}/controller.yaml" << YAMLEOF cat > "${CONTROLLER_DIR}/controller.yaml" << YAMLEOF
# Felhom Controller Configuration # Auto-generated by docker-setup.sh v${SCRIPT_VERSION} on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Generated by docker-setup.sh v${SCRIPT_VERSION} on $(date -u +"%Y-%m-%dT%H:%M:%SZ") # Complete setup via web UI at https://felhom.${BASE_DOMAIN} or http://<ip>:8081
customer: customer:
id: "${WIZ_CUSTOMER_ID}" ${customer_id_line}
name: "${WIZ_CUSTOMER_NAME}" domain: "${BASE_DOMAIN}"
domain: "${WIZ_DOMAIN}"
email: "${WIZ_EMAIL}"
telegram_chat_id: ""
infrastructure:
cf_tunnel_token: "${WIZ_CF_TUNNEL_TOKEN}"
cf_api_token: "${WIZ_CF_API_TOKEN}"
paths: paths:
stacks_dir: "/opt/docker/stacks"
data_dir: "/opt/docker/felhom-controller/data" data_dir: "/opt/docker/felhom-controller/data"
system_data_path: "${WIZ_SYSTEM_DATA_PATH}" stacks_dir: "/opt/docker/stacks"
system:
reserved_memory_mb: 384
web: web:
listen: ":8080" listen: ":8080"
password_hash: "${WIZ_PASSWORD_HASH}" setup_listen: ":8081"
session_secret: "${WIZ_SESSION_SECRET}"
git:
repo_url: "${WIZ_GIT_REPO}"
branch: "main"
sync_interval: "15m"
username: "${WIZ_GIT_USERNAME}"
token: "${WIZ_GIT_TOKEN}"
stacks:
protected:
- "traefik"
- "cloudflared"
- "felhom-controller"
- "filebrowser"
update_window: "03:00-05:00"
compose_command: ""
backup:
enabled: true
restic_password_file: "/opt/docker/felhom-controller/data/restic-password"
db_dump_schedule: "02:30"
restic_schedule: "03:00"
retention:
keep_daily: 7
keep_weekly: 4
keep_monthly: 6
prune_schedule: "weekly"
monitoring:
enabled: true
healthchecks_base: "https://status.felhom.eu"
ping_uuids:
heartbeat: "${WIZ_HC_HEARTBEAT}"
system_health: "${WIZ_HC_SYSTEM}"
db_dump: "${WIZ_HC_DBDUMP}"
backup: "${WIZ_HC_BACKUP}"
backup_integrity: "${WIZ_HC_INTEGRITY}"
system_health_interval: "5m"
health_check_schedule: "06:00"
thresholds:
disk_warn_percent: 80
disk_crit_percent: 90
backup_max_age_hours: 36
cpu_warn_percent: 90
memory_warn_percent: 85
temperature_warn_celsius: 75
hub:
enabled: true
url: "https://hub.felhom.eu"
api_key: "094091de545ce28795c47ac2158fc30750db5c24a621c49329b001ee8db57fb8"
push_interval: "15m"
self_update:
enabled: true
check_interval: "6h"
image: "gitea.dooplex.hu/admin/felhom-controller"
auto_update: false
health_timeout_seconds: 60
notifications:
customer_events:
- "disk_warning"
- "backup_failed"
- "update_available"
- "security_update"
operator_events:
- "disk_critical"
- "backup_failed"
- "self_update_failed"
- "container_unhealthy"
logging:
level: "info"
file: ""
max_size_mb: 10
max_files: 3
assets:
source_url: "https://felhom.eu"
YAMLEOF YAMLEOF
chmod 600 "${CONTROLLER_DIR}/controller.yaml" chmod 600 "${CONTROLLER_DIR}/controller.yaml"
log_success "controller.yaml generated at ${CONTROLLER_DIR}/controller.yaml" log_success "Minimal controller.yaml generated (full config via web setup wizard)"
} }
#------------------------------------------------------------------------------- #-------------------------------------------------------------------------------
# Deploy felhom-controller # Deploy felhom-controller
#------------------------------------------------------------------------------- #-------------------------------------------------------------------------------
install_controller() { install_controller() {
local step_num=6 local step_num=5
[[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++)) [[ "$SELF_SIGNED_CERT" == true ]] && ((step_num++))
[[ -n "${WIZ_CF_TUNNEL_TOKEN:-}" ]] && ((step_num++)) [[ -n "${CF_TUNNEL_TOKEN:-}" ]] && ((step_num++))
[[ "$SKIP_FILEBROWSER" != true ]] && ((step_num++)) [[ "$SKIP_FILEBROWSER" != true ]] && ((step_num++))
log_step "${step_num}/$(get_total_steps) - Deploying felhom-controller..." log_step "${step_num}/$(get_total_steps) - Deploying felhom-controller..."
@@ -1784,9 +1508,10 @@ services:
privileged: true privileged: true
ports: ports:
- "8080:8080" - "8080:8080"
- "8081:8081"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ${CONTROLLER_DIR}/controller.yaml:/opt/docker/felhom-controller/controller.yaml:ro - ${CONTROLLER_DIR}/controller.yaml:/opt/docker/felhom-controller/controller.yaml
- controller-data:/opt/docker/felhom-controller/data - controller-data:/opt/docker/felhom-controller/data
- /opt/docker/stacks:/opt/docker/stacks - /opt/docker/stacks:/opt/docker/stacks
- /srv/backups:/srv/backups - /srv/backups:/srv/backups
@@ -1855,20 +1580,30 @@ print_summary() {
server_ip=$(get_server_ip) server_ip=$(get_server_ip)
echo "" echo ""
echo -e "${BOLD}${GREEN}-==================================================================¬${NC}" echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD}${GREEN}¦ Infrastructure Setup Complete! ¦${NC}" echo -e "${BOLD}${GREEN} Felhom Controller telepítve!${NC}"
echo -e "${BOLD}${GREEN}L==================================================================-${NC}" echo ""
echo -e "${BOLD}${GREEN} A beállítás folytatásához nyissa meg a böngészőben:${NC}"
echo ""
echo -e "${BOLD}${GREEN} ► https://felhom.${BASE_DOMAIN} (ajánlott)${NC}"
echo ""
echo -e "${BOLD}${GREEN} Ha a fenti cím nem érhető el (pl. nincs internet):${NC}"
echo -e "${BOLD}${GREEN} ► http://${server_ip}:8081 (helyi hálózat, tartalék)${NC}"
echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════════════════════${NC}"
echo "" echo ""
echo -e "${BOLD}Server IP:${NC} ${server_ip}" echo -e "${BOLD}Server IP:${NC} ${server_ip}"
echo -e "${BOLD}Domain:${NC} *.${BASE_DOMAIN}" echo -e "${BOLD}Domain:${NC} *.${BASE_DOMAIN}"
echo -e "${BOLD}Customer:${NC} ${CUSTOMER_ID}" if [[ -n "$CUSTOMER_ID" ]]; then
echo -e "${BOLD}Customer:${NC} ${CUSTOMER_ID}"
fi
echo "" echo ""
echo -e "${BOLD}Services:${NC}" echo -e "${BOLD}Services:${NC}"
echo " • Felhom Controller: https://felhom.${BASE_DOMAIN}" echo " • Felhom Controller: https://felhom.${BASE_DOMAIN}"
echo " L| Config: ${CONTROLLER_DIR}/controller.yaml" echo " L| Config: ${CONTROLLER_DIR}/controller.yaml"
echo " L| Setup: http://${server_ip}:8081 (direct HTTP)"
echo " • Traefik Dashboard: https://traefik.${BASE_DOMAIN}/dashboard/" echo " • Traefik Dashboard: https://traefik.${BASE_DOMAIN}/dashboard/"
echo " L| Credentials: admin / ${TRAEFIK_PASSWORD}" echo " L| Credentials: admin / ${TRAEFIK_PASSWORD}"
if [[ -n "${WIZ_CF_TUNNEL_TOKEN:-}" ]]; then if [[ -n "${CF_TUNNEL_TOKEN:-}" ]]; then
echo " • Cloudflare Tunnel: Active (routes configured in CF dashboard)" echo " • Cloudflare Tunnel: Active (routes configured in CF dashboard)"
echo " L| Container: cloudflared" echo " L| Container: cloudflared"
echo " L| Config: ${CLOUDFLARED_DIR}/docker-compose.yml" echo " L| Config: ${CLOUDFLARED_DIR}/docker-compose.yml"
@@ -1878,7 +1613,6 @@ print_summary() {
echo " • FileBrowser: https://files.${BASE_DOMAIN}" echo " • FileBrowser: https://files.${BASE_DOMAIN}"
echo " L| Default login: admin / admin (change immediately!)" echo " L| Default login: admin / admin (change immediately!)"
fi fi
echo -e " • Hub reporting: ${GREEN}enabled${NC} (hub.felhom.eu)"
echo "" echo ""
echo -e "${BOLD}DNS Setup Required:${NC}" echo -e "${BOLD}DNS Setup Required:${NC}"
echo " Add wildcard record to your DNS (Pi-hole, router, etc.):" echo " Add wildcard record to your DNS (Pi-hole, router, etc.):"
@@ -1907,26 +1641,6 @@ print_summary() {
echo " ACME email: ${ACME_EMAIL}" echo " ACME email: ${ACME_EMAIL}"
echo "" echo ""
fi fi
echo -e "${BOLD}${CYAN}===================================================================${NC}"
echo -e "${BOLD}${CYAN} MANAGING APPLICATION STACKS ${NC}"
echo -e "${BOLD}${CYAN}===================================================================${NC}"
echo ""
echo " Deploy and manage applications via the controller dashboard:"
echo ""
echo " 1. Open https://felhom.${BASE_DOMAIN}"
echo " 2. Browse available apps on the Alkalmazások page"
echo " 3. Click Telepítés to deploy"
echo ""
if [[ -n "$CUSTOMER_ID" ]]; then
echo -e "${BOLD}${YELLOW}Disaster Recovery:${NC}"
echo " If this is a reinstallation, the controller will automatically:"
echo " 1. Contact the Hub for your previous configuration"
echo " 2. Mount your existing storage drives"
echo " 3. Detect and offer to restore your applications"
echo ""
echo " Open https://felhom.${BASE_DOMAIN} to monitor the restore process."
echo ""
fi
echo -e "${BOLD}Quick Commands:${NC}" echo -e "${BOLD}Quick Commands:${NC}"
echo " dps → List containers" echo " dps → List containers"
echo " dlogs <n> → View container logs" echo " dlogs <n> → View container logs"
@@ -1968,14 +1682,14 @@ main() {
if [[ "$SELF_SIGNED_CERT" == true ]]; then if [[ "$SELF_SIGNED_CERT" == true ]]; then
echo " 5. Generate self-signed certificate" echo " 5. Generate self-signed certificate"
fi fi
echo " - Run configuration wizard (generates controller.yaml)" echo " - Generate minimal controller.yaml"
echo " - Install Cloudflare Tunnel (if configured in wizard)" echo " - Install Cloudflare Tunnel: $([[ -n "$CF_TUNNEL_TOKEN" ]] && echo "yes" || echo "skip")"
echo " - Install FileBrowser: $([[ "$SKIP_FILEBROWSER" == true ]] && echo "skip" || echo "yes (auto-discover drives)")" echo " - Install FileBrowser: $([[ "$SKIP_FILEBROWSER" == true ]] && echo "skip" || echo "yes (auto-discover drives)")"
echo " - Deploy felhom-controller" echo " - Deploy felhom-controller"
echo " - Install helper tools (ctop, lazydocker, aliases)" echo " - Install helper tools (ctop, lazydocker, aliases)"
echo "" echo ""
echo " Domain: *.${BASE_DOMAIN}" echo " Domain: *.${BASE_DOMAIN}"
echo " Customer: ${CUSTOMER_ID:-<none — will be set in wizard>}" echo " Customer: ${CUSTOMER_ID:-<none — will be set in web setup wizard>}"
echo " Traefik password: ${TRAEFIK_PASSWORD}" echo " Traefik password: ${TRAEFIK_PASSWORD}"
if [[ -n "$ACME_EMAIL" && -n "$CF_DNS_API_TOKEN" ]]; then if [[ -n "$ACME_EMAIL" && -n "$CF_DNS_API_TOKEN" ]]; then
echo -e " TLS: ${GREEN}Let's Encrypt (Cloudflare DNS-01)${NC}" echo -e " TLS: ${GREEN}Let's Encrypt (Cloudflare DNS-01)${NC}"
@@ -2005,7 +1719,7 @@ main() {
install_docker install_docker
install_traefik install_traefik
generate_self_signed_cert generate_self_signed_cert
run_config_wizard generate_minimal_config
install_cloudflare_tunnel install_cloudflare_tunnel
install_filebrowser install_filebrowser
install_controller install_controller