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
+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.
**Current version: v0.21.0**
**Current version: v0.22.0**
---
@@ -20,6 +20,8 @@ A single, lightweight Go container that replaces Portainer + scattered systemd s
- [Update Management](#6-update-management)
- [Authentication & Settings](#7-authentication--settings)
- [Central Hub](#8-central-hub-reporting)
- [Setup Wizard](#9-first-run-setup-wizard)
- [Disaster Recovery](#10-disaster-recovery)
- [Repository Layout](#repository-layout)
- [Configuration](#configuration)
- [REST API](#rest-api)
@@ -812,28 +814,95 @@ The hub service (separate Go app in the `felhom.eu` repo) provides:
- Color coding: green (<30min), yellow (30-60min), red (>60min since last report)
- 90-day report + event retention with daily prune at 04:30 Budapest time
### 9. Disaster Recovery
### 9. First-Run Setup Wizard
When a system drive fails and is replaced, the controller can automatically restore the full deployment:
When the controller starts with no valid customer configuration (`customer.id` empty or `"demo-felhom"`), it enters **setup mode** — a web-based wizard that handles all initial configuration. This replaces the old interactive shell wizard in `docker-setup.sh`.
#### Setup Mode Detection (`internal/setup/setup.go`)
`NeedsSetup(cfg)` returns true when `customer.id` is empty or `"demo-felhom"`. In setup mode, the controller skips normal startup (no scheduler, no backup, no stacks) and serves only the wizard UI on two listeners:
- `:8080` — behind Traefik (accessible via domain, e.g. `https://felhom.example.com`)
- `:8081` — direct HTTP (accessible via LAN IP, e.g. `http://192.168.0.100:8081`)
#### Wizard Flow
```
1. docker-setup.sh deploys fresh controller (Hub enabled, customer_id configured)
2. Controller detects empty data dir → fresh deployment
3. Controller pulls infra backup from Hub → gets disk layout, passwords, configs
4. Controller scans block devices for UUIDs matching stored disk layout
5. Controller mounts surviving drives (e.g., HDD with backups)
6. Controller scans mounted drives for local backup data (_infra/ + rsync copies)
7. Controller auto-restores stack configs → apps appear in dashboard
8. User opens dashboard → "Visszaállítás" (Restore) wizard
9. User confirms → sequential restore: rsync first, restic fallback, DB import
10. Apps restored and running
┌──────────────────────────────────┐
│ 1. Welcome │
│ Choose: Restore / Fresh install │
└─────────┬───────────┬────────────┘
│ │
┌─────▼─────┐ ┌──▼───────────────┐
│ 2a. Scan │ │ 2b. Hub download │
│ drives for│ │ (customer ID + │
│ local │ │ password) │
│ backups │ │ │
└─────┬─────┘ └──────┬────────────┘
│ │
┌─────▼─────┐ │
│ 2a.2 Hub │ │
│ recovery │ │
│ (fallback)│ │
└─────┬─────┘ │
│ │
┌─────▼─────┐ ┌──────▼───────────┐
│ Execute │ │ Execute fresh │
│ restore │ │ install │
└─────┬─────┘ └──────┬───────────┘
│ │
└───────┬───────┘
os.Exit(0) → Docker restarts
→ normal mode
```
#### Key Components
| File | Purpose |
|------|---------|
| `setup/setup.go` | `NeedsSetup()` detection, `SetupState` persistence to `setup-state.json` |
| `setup/handlers.go` | HTTP handlers for each wizard step (welcome, scan, hub-restore, fresh, manual) |
| `setup/scanner.go` | Scans all block devices for `.felhom-infra-backup/` directories via `lsblk` + temp mounts |
| `setup/hub.go` | Hub recovery pull (`GET /api/v1/recovery/{id}`) and config download |
| `setup/csrf.go` | Lightweight CSRF protection (cookie + hidden field, `SameSite=Strict`) |
| `setup/network.go` | Detects local IPs for LAN access URL display |
| `setup/templates/` | 7 embedded HTML templates (Hungarian, dark theme matching main UI) |
#### Local Infra Backup (`internal/backup/local_infra.go`)
The controller writes infrastructure snapshots to **every connected drive** after each backup cycle and on startup. Location: `<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):**
1. **Rsync copies** (cross-drive, plain files, no password needed) — fastest, most reliable
2. **Restic snapshots** (encrypted, needs password from Hub) — comprehensive but slower
1. **Local infra backup** (`.felhom-infra-backup/` on surviving drives) — fastest, no network needed
2. **Hub recovery endpoint** (`GET /api/v1/recovery/{id}`) — requires retrieval password
3. **Manual config** (wizard form) — enter all details manually as last resort
**Fallback:** If the Hub is unreachable, the controller can still detect backups on already-mounted drives (manual mount or pre-existing fstab entries).
**Hub verification:** After setup, the controller periodically verifies customer standing via the Hub report push response (`customer_blocked` field). If blocked or Hub unreachable for >7 days, the controller enters limited mode (no new deployments).
---
@@ -841,7 +910,7 @@ When a system drive fails and is replaced, the controller can automatically rest
```
controller/
├── cmd/controller/main.go # Entry point, wires all 14 modules
├── cmd/controller/main.go # Entry point, wires all 15 modules (setup mode branch + normal startup)
├── internal/
│ ├── config/config.go # YAML loader, validation, env overrides
│ ├── settings/settings.go # Runtime settings (JSON, atomic writes, RWMutex)
@@ -860,7 +929,8 @@ controller/
│ │ └── *_other.go # Non-Linux stubs for cross-compilation
│ ├── backup/
│ │ ├── backup.go # Orchestrator (per-drive dumps + restic + cross-drive chain)
│ │ ├── paths.go # Per-drive path helpers (PrimaryResticRepoPath, AppDBDumpPath, etc.)
│ │ ├── paths.go # Per-drive path helpers (PrimaryResticRepoPath, InfraBackupDir, etc.)
│ │ ├── local_infra.go # Local infra backup to all drives (.felhom-infra-backup/)
│ │ ├── dbdump.go # DB auto-discovery + dump (pg_dump, mariadb-dump)
│ │ ├── restic.go # Restic operations (init, snapshot, prune, check) — repoPath as param
│ │ ├── appdata.go # StackDataProvider interface, app data discovery
@@ -890,8 +960,16 @@ controller/
│ ├── notify/notifier.go # Email relay to hub, preference sync, cooldowns
│ ├── report/
│ │ ├── builder.go # Hub report builder (all subsystems → JSON)
│ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth)
│ │ └── infra_pull.go # DR: pull infra backup from Hub for fresh deployment
│ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth, parses customer_blocked)
│ │ └── infra_pull.go # DR: pull recovery/config from Hub (retrieval password auth)
│ ├── setup/ # First-run setup wizard (web-based, replaces docker-setup.sh wizard)
│ │ ├── setup.go # NeedsSetup() detection, state persistence
│ │ ├── handlers.go # HTTP handlers for all wizard steps
│ │ ├── scanner.go # Drive scanner for local infra backups
│ │ ├── csrf.go # Lightweight CSRF (cookie + hidden field)
│ │ ├── network.go # Local IP detection for LAN access URLs
│ │ └── templates/ # 7 wizard HTML templates (Hungarian)
│ ├── recovery/info.go # Recovery info file generator (recovery-info.txt)
│ └── web/
│ ├── server.go # HTTP server, routing, static files
│ ├── auth.go # Session auth, login/logout, session cleanup
@@ -953,6 +1031,10 @@ monitoring:
backup: "uuid-here"
backup_integrity: "uuid-here"
web:
listen: ":8080"
setup_listen: ":8081" # Plain HTTP for setup wizard LAN access
hub:
enabled: true
url: "https://hub.felhom.eu"
@@ -966,7 +1048,7 @@ Environment variable overrides: `FELHOM_LOGGING_LEVEL=debug`, `FELHOM_HUB_ENABLE
### Runtime settings (`settings.json`)
Auto-managed by the controller. Contains password hash overrides, notification preferences, per-app backup configs, storage path registry, DB validation cache. All writes are atomic.
Auto-managed by the controller. Contains password hash overrides, notification preferences, per-app backup configs, storage path registry, DB validation cache, Hub verification state (`hub_verified`, `hub_verified_at`), retrieval password for disaster recovery, and pending event queue. All writes are atomic (write `.tmp`, rename).
### Per-app config (`app.yaml`)
+151 -113
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
@@ -22,14 +21,16 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/recovery"
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/setup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/storage"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/storage"
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
)
@@ -51,12 +52,23 @@ func main() {
}
// --- Load configuration ---
cfg, err := config.Load(*configPath)
// Use LoadPermissive to tolerate minimal configs (e.g. only domain set by docker-setup.sh).
// If even that fails (file missing/unreadable), fall back to defaults.
cfg, err := config.LoadPermissive(*configPath)
if err != nil {
log.Fatalf("[FATAL] Failed to load config from %s: %v", *configPath, err)
cfg = config.Default()
log.Printf("[WARN] Config load failed (%s), using defaults: %v", *configPath, err)
}
logger := setupLogger(cfg)
// --- Setup mode: if no customer ID configured, run setup wizard ---
if setup.NeedsSetup(cfg) {
logger.Printf("[INFO] felhom-controller %s — setup mode", Version)
runSetupMode(cfg, logger)
return
}
logger.Printf("[INFO] felhom-controller %s starting (customer: %s, domain: %s)",
Version, cfg.Customer.ID, cfg.Customer.Domain)
@@ -67,79 +79,6 @@ func main() {
logger.Fatalf("[FATAL] Failed to load settings from %s: %v", settingsPath, err)
}
// --- Detect fresh deployment (Phase 2+3: DR restore from Hub) ---
var restorePlan *backup.RestorePlan
isFreshDeployment := !fileExists(settingsPath)
if isFreshDeployment && cfg.Hub.Enabled && cfg.Hub.URL != "" {
logger.Println("[INFO] Fresh deployment detected — checking Hub for infra backup")
ib, pullErr := report.PullInfraBackup(cfg.Hub.URL, cfg.Hub.APIKey, cfg.Customer.ID)
if pullErr != nil {
logger.Printf("[WARN] Could not reach Hub for infra backup: %v", pullErr)
} else if ib != nil {
logger.Printf("[INFO] Found infra backup on Hub: %s (%s), %d stacks, synced %s",
ib.Domain, ib.CustomerID, len(ib.DeployedStacks), ib.Timestamp)
// Restore settings.json from Hub backup first
restoreSettingsFromHub(ib, cfg, logger)
// Re-load settings (now from restored file)
if restoredSett, loadErr := settings.Load(settingsPath, logger); loadErr == nil {
sett = restoredSett
logger.Println("[INFO] Settings reloaded after Hub restore")
}
// Restore restic passwords AFTER settings reload so cross-drive password persists
restorePasswordsFromHub(ib, cfg, sett, logger)
// Mount drives using stored disk layout
mountCtx, mountCancel := context.WithTimeout(context.Background(), 2*time.Minute)
mountedPaths, mountErr := backup.MountDrivesFromLayout(mountCtx, ib.DiskLayout, logger)
mountCancel()
if mountErr != nil {
logger.Printf("[WARN] Drive mounting error: %v", mountErr)
} else if len(mountedPaths) > 0 {
logger.Printf("[INFO] Mounted %d drives from Hub disk layout: %v", len(mountedPaths), mountedPaths)
} else {
logger.Println("[INFO] No matching drives found to mount from Hub disk layout")
}
// Phase 3: Scan mounted drives for backup data and build restore plan
if len(ib.DeployedStacks) > 0 {
// Collect mount paths from disk layout
var drivePaths []string
for _, dm := range ib.DiskLayout.Mounts {
if dm.MountPoint != "" {
drivePaths = append(drivePaths, dm.MountPoint)
}
}
// Convert report stacks to backup scan format
var infraStacks []backup.InfraStackInfo
for _, s := range ib.DeployedStacks {
infraStacks = append(infraStacks, backup.InfraStackInfo{
Name: s.Name,
DisplayName: s.DisplayName,
HDDPath: s.HDDPath,
NeedsHDD: s.NeedsHDD,
})
}
restorePlan = backup.ScanDrivesForBackups(drivePaths, infraStacks, logger)
if restorePlan != nil {
restorePlan.CustomerID = ib.CustomerID
restorePlan.Domain = ib.Domain
restorePlan.Timestamp = ib.Timestamp
logger.Printf("[INFO] DR restore plan ready: %d apps to restore", len(restorePlan.Apps))
} else {
logger.Println("[WARN] ScanDrivesForBackups returned nil — no restore plan created")
}
}
} else {
logger.Println("[INFO] No infra backup found on Hub for this customer")
}
}
// --- Auto-discover storage paths from deployed apps ---
discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger)
sett.AutoDiscoverStoragePaths(discoveredPaths, cfg.Paths.HDDPath, logger)
@@ -304,6 +243,15 @@ func main() {
var hubPusher *report.Pusher
if cfg.Hub.URL != "" && cfg.Hub.APIKey != "" {
hubPusher = report.NewPusher(&cfg.Hub, logger)
// Wire hub verification: update settings when hub reports customer status
hubPusher.OnPushResponse = func(resp *report.PushResponse) {
if resp.CustomerBlocked {
sett.SetHubVerified(false, time.Now())
logger.Printf("[WARN] Customer blocked on Hub — new deployments may be restricted")
} else {
sett.SetHubVerified(true, time.Now())
}
}
// Wire hub push status into alert manager for dashboard alerts
alertMgr.SetHubPushStatus(func() web.HubPushStatusData {
s := hubPusher.GetStatus()
@@ -350,6 +298,8 @@ func main() {
if hubPusher != nil && cfg.Hub.Enabled {
go pushInfraBackup(cfg, sett, stackProv, hubPusher, logger)
}
// Write local infra backup to all connected drives
go writeLocalInfraBackup(cfg, sett, stackProv, logger)
return err
})
@@ -397,7 +347,17 @@ func main() {
}
sched.Every("hub-report", pushInterval, func(ctx context.Context) error {
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths())
return hubPusher.Push(r)
if err := hubPusher.Push(r); err != nil {
return err
}
// Drain pending events (e.g., DR recovery completed) after successful push
if events := sett.DrainPendingEvents(); len(events) > 0 {
for _, ev := range events {
notifier.Notify(ev.EventType, ev.Severity, ev.Message, ev.Details)
}
logger.Printf("[INFO] Drained %d pending events to Hub", len(events))
}
return nil
})
logger.Printf("[INFO] Hub reporting enabled (every %s to %s)", pushInterval, cfg.Hub.URL)
} else {
@@ -468,6 +428,22 @@ func main() {
sched.Start(ctx)
defer sched.Stop()
// Generate recovery info file if retrieval password is set
if rp := sett.GetRetrievalPassword(); rp != "" {
go func() {
info := recovery.Info{
CustomerID: cfg.Customer.ID,
RetrievalPassword: rp,
HubURL: cfg.Hub.URL,
SupportEmail: "support@felhom.eu",
SupportURL: "https://felhom.eu/kapcsolat",
}
if err := recovery.GenerateRecoveryFile(info, Version, cfg.Paths.DataDir); err != nil {
logger.Printf("[WARN] Failed to generate recovery-info.txt: %v", err)
}
}()
}
// Fire startup pings + hub report immediately (don't wait for first scheduler tick)
go func() {
time.Sleep(5 * time.Second) // Let all subsystems fully initialize
@@ -511,6 +487,8 @@ func main() {
}
// Also push infra backup on startup
go pushInfraBackup(cfg, sett, stackProv, hubPusher, logger)
// Write local infra backup to all connected drives
go writeLocalInfraBackup(cfg, sett, stackProv, logger)
} else {
// Send a minimal "disabled" notification so hub knows reporting is intentionally off
r := &report.Report{
@@ -632,12 +610,6 @@ func main() {
backupMgr.MigrationActiveCheck = driveMigrator.IsActive
}
// Phase 3: Set DR restore mode if a restore plan was built
if restorePlan != nil && len(restorePlan.Apps) > 0 {
webServer.SetRestoreState(restorePlan)
logger.Println("[INFO] DR restore mode activated — all web routes redirect to /restore")
}
// --- Build HTTP mux ---
mux := http.NewServeMux()
@@ -923,46 +895,112 @@ func fileExists(path string) bool {
return err == nil
}
// restorePasswordsFromHub restores restic passwords from a Hub infra backup.
func restorePasswordsFromHub(ib *report.InfraBackup, cfg *config.Config,
sett *settings.Settings, logger *log.Logger) {
// runSetupMode starts the setup wizard on dual listeners and blocks until signal.
func runSetupMode(cfg *config.Config, logger *log.Logger) {
ips := setup.DetectLocalIPs()
setup.LogSetupMode(cfg.Customer.Domain, ips, cfg.Web.SetupListen, logger)
if ib.ResticPassword != "" {
decoded, err := base64.StdEncoding.DecodeString(ib.ResticPassword)
if err == nil && len(decoded) > 0 {
dir := filepath.Dir(cfg.Backup.ResticPasswordFile)
if err := os.MkdirAll(dir, 0700); err != nil {
logger.Printf("[WARN] Failed to create restic password directory %s: %v", dir, err)
} else if err := os.WriteFile(cfg.Backup.ResticPasswordFile, decoded, 0600); err == nil {
logger.Println("[INFO] Primary restic password restored from Hub")
} else {
logger.Printf("[WARN] Failed to write restic password file: %v", err)
}
}
setupSrv := setup.NewServer(cfg, cfg.Paths.DataDir, logger, Version)
handler := setupSrv.Handler()
// Health endpoint wrapper (returns setup_mode: true)
healthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true, "message": "felhom-controller is healthy",
"setup_mode": true, "version": Version,
})
})
// Mux for both listeners
mux := http.NewServeMux()
mux.HandleFunc("/api/health", healthHandler)
mux.Handle("/", handler)
// Start main listener (:8080, behind Traefik for domain access)
mainServer := &http.Server{
Addr: cfg.Web.Listen,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
logger.Printf("[INFO] Setup wizard (main) listening on %s", cfg.Web.Listen)
if err := mainServer.ListenAndServe(); err != http.ErrServerClosed {
logger.Printf("[ERROR] Main HTTP server error: %v", err)
}
}()
// Start setup-only listener (:8081, direct HTTP for LAN access)
setupServer := &http.Server{
Addr: cfg.Web.SetupListen,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
logger.Printf("[INFO] Setup wizard (LAN) listening on %s", cfg.Web.SetupListen)
if err := setupServer.ListenAndServe(); err != http.ErrServerClosed {
logger.Printf("[ERROR] Setup HTTP server error: %v", err)
}
}()
// Wait for signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
logger.Printf("[INFO] Received signal %v, shutting down setup wizard...", sig)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
mainServer.Shutdown(shutdownCtx)
setupServer.Shutdown(shutdownCtx)
logger.Println("[INFO] Setup wizard stopped")
}
// restoreSettingsFromHub restores settings.json from a Hub infra backup.
func restoreSettingsFromHub(ib *report.InfraBackup, cfg *config.Config, logger *log.Logger) {
if ib.SettingsJSONB64 == "" {
return
}
decoded, err := base64.StdEncoding.DecodeString(ib.SettingsJSONB64)
// writeLocalInfraBackup builds an infra snapshot and writes it to all connected drives.
func writeLocalInfraBackup(cfg *config.Config, sett *settings.Settings,
stackProv *stackAdapter, logger *log.Logger) {
ib, err := report.BuildInfraBackup(
cfg.Customer.ID, cfg.Customer.Domain, Version,
"/opt/docker/felhom-controller/controller.yaml",
filepath.Join(cfg.Paths.DataDir, "settings.json"),
cfg.Backup.ResticPasswordFile,
cfg.Paths.SystemDataPath,
sett, stackProv, logger,
)
if err != nil {
logger.Printf("[WARN] Failed to decode settings from Hub: %v", err)
logger.Printf("[WARN] Failed to build infra backup for local write: %v", err)
return
}
if err := os.MkdirAll(cfg.Paths.DataDir, 0755); err != nil {
logger.Printf("[WARN] Failed to create data directory for settings restore: %v", err)
data, err := json.Marshal(ib)
if err != nil {
logger.Printf("[WARN] Failed to marshal infra backup for local write: %v", err)
return
}
settingsPath := filepath.Join(cfg.Paths.DataDir, "settings.json")
if err := os.WriteFile(settingsPath, decoded, 0600); err != nil {
logger.Printf("[WARN] Failed to write restored settings.json: %v", err)
} else {
logger.Println("[INFO] Settings restored from Hub backup")
// Collect all connected drive paths (skip disconnected and decommissioned)
var drives []string
for _, sp := range sett.GetStoragePaths() {
if !sp.Disconnected && !sp.Decommissioned {
drives = append(drives, sp.Path)
}
}
// Also include system data path if set
if cfg.Paths.SystemDataPath != "" {
drives = append(drives, cfg.Paths.SystemDataPath)
}
if len(drives) == 0 {
logger.Println("[DEBUG] No connected drives for local infra backup")
return
}
backup.WriteLocalInfraBackup(data, cfg.Customer.ID, Version, ib.Timestamp, drives, logger)
}
// discoverHDDPaths scans deployed apps' app.yaml for HDD_PATH env values.
+1
View File
@@ -11,6 +11,7 @@ services:
privileged: true # Required for disk operations (mkfs, mount, sfdisk)
ports:
- "8080:8080"
- "8081:8081" # Setup wizard direct HTTP (only active during setup mode)
volumes:
# Docker socket — required for compose operations + DB dumps (docker exec)
- /var/run/docker.sock:/var/run/docker.sock
+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 {
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 {
Listen string `yaml:"listen"`
SetupListen string `yaml:"setup_listen"` // Plain HTTP listener for setup wizard (only active during setup mode)
PasswordHash string `yaml:"password_hash"`
SessionSecret string `yaml:"session_secret"`
}
@@ -149,6 +150,31 @@ type HubConfig struct {
// Load reads and parses the config file, applies defaults, and validates.
func Load(path string) (*Config, error) {
cfg, err := loadAndParse(path)
if err != nil {
return nil, err
}
if err := validate(cfg); err != nil {
return nil, fmt.Errorf("config validation: %w", err)
}
return cfg, nil
}
// LoadPermissive reads and parses the config file, applies defaults, but skips validation.
// Used during setup mode where customer.id and domain may not be set yet.
func LoadPermissive(path string) (*Config, error) {
return loadAndParse(path)
}
// Default returns a Config with all defaults applied. Used when the config file
// is missing or unreadable and the controller needs to enter setup mode.
func Default() *Config {
cfg := &Config{}
applyDefaults(cfg)
return cfg
}
func loadAndParse(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
@@ -165,10 +191,6 @@ func Load(path string) (*Config, error) {
applyDefaults(cfg)
applyEnvOverrides(cfg)
if err := validate(cfg); err != nil {
return nil, fmt.Errorf("config validation: %w", err)
}
return cfg, nil
}
@@ -212,6 +234,7 @@ func applyDefaults(cfg *Config) {
d(&cfg.Paths.DataDir, "/opt/docker/felhom-controller/data")
d(&cfg.Paths.SystemDataPath, "/mnt/sys_drive")
d(&cfg.Web.Listen, ":8080")
d(&cfg.Web.SetupListen, ":8081")
d(&cfg.Git.Branch, "main")
d(&cfg.Git.SyncInterval, "15m")
d(&cfg.Stacks.UpdateWindow, "03:00-05:00")
+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 (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -9,6 +10,103 @@ import (
"time"
)
// Recovery pull error types for UI display.
var (
ErrHubUnreachable = errors.New("hub unreachable")
ErrAuthFailed = errors.New("authentication failed")
ErrNotFound = errors.New("customer not found")
ErrHubError = errors.New("hub error")
)
// RecoveryResponse is the combined config + infra backup from the Hub recovery endpoint.
type RecoveryResponse struct {
CustomerID string `json:"customer_id"`
ConfigYAML string `json:"config_yaml"`
InfraBackup *InfraBackup `json:"infra_backup"`
HasInfraBackup bool `json:"has_infra_backup"`
}
// PullRecovery fetches combined recovery data from the Hub (config + infra backup).
// Auth: X-Retrieval-Password header.
func PullRecovery(hubURL, customerID, retrievalPassword string) (*RecoveryResponse, error) {
url := strings.TrimRight(hubURL, "/") + "/api/v1/recovery/" + customerID
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrHubError, err)
}
req.Header.Set("X-Retrieval-Password", retrievalPassword)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrHubUnreachable, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// success, continue below
case http.StatusUnauthorized:
return nil, ErrAuthFailed
case http.StatusNotFound:
return nil, ErrNotFound
default:
return nil, fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10MB limit
if err != nil {
return nil, fmt.Errorf("%w: reading response: %v", ErrHubError, err)
}
var rr RecoveryResponse
if err := json.Unmarshal(body, &rr); err != nil {
return nil, fmt.Errorf("%w: parsing response: %v", ErrHubError, err)
}
return &rr, nil
}
// PullConfig fetches a generated controller.yaml from the Hub config endpoint.
// Auth: X-Retrieval-Password header.
func PullConfig(hubURL, customerID, retrievalPassword string) (string, error) {
url := strings.TrimRight(hubURL, "/") + "/api/v1/config/" + customerID
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrHubError, err)
}
req.Header.Set("X-Retrieval-Password", retrievalPassword)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrHubUnreachable, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// success
case http.StatusUnauthorized:
return "", ErrAuthFailed
case http.StatusNotFound:
return "", ErrNotFound
default:
return "", fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
if err != nil {
return "", fmt.Errorf("%w: reading response: %v", ErrHubError, err)
}
return string(body), nil
}
// PullInfraBackup fetches the infrastructure backup from the Hub.
// Returns nil, nil if no backup exists for this customer.
func PullInfraBackup(hubURL, apiKey, customerID string) (*InfraBackup, error) {
+21 -1
View File
@@ -22,6 +22,12 @@ type PushStatus struct {
Consecutive int // consecutive failures
}
// PushResponse is the parsed response from the Hub after a report push.
type PushResponse struct {
Status string `json:"status"`
CustomerBlocked bool `json:"customer_blocked"`
}
// Pusher sends reports to the central hub.
type Pusher struct {
hubURL string
@@ -32,6 +38,10 @@ type Pusher struct {
statusMu sync.RWMutex
status PushStatus
// OnPushResponse is called after each successful report push with the parsed response.
// Set by main.go to update hub verification state.
OnPushResponse func(resp *PushResponse)
}
// NewPusher creates a new report pusher from hub configuration.
@@ -85,7 +95,9 @@ func (p *Pusher) Push(report *Report) error {
lastErr = err
continue
}
io.Copy(io.Discard, resp.Body)
// Read response body to parse customer_blocked field
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
@@ -95,6 +107,14 @@ func (p *Pusher) Push(report *Report) error {
p.status.LastError = ""
p.status.Consecutive = 0
p.statusMu.Unlock()
// Parse response for customer_blocked field
if p.OnPushResponse != nil && len(respBody) > 0 {
var pr PushResponse
if json.Unmarshal(respBody, &pr) == nil {
p.OnPushResponse(&pr)
}
}
return nil
}
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
+119
View File
@@ -35,6 +35,17 @@ type Settings struct {
// Cross-drive restic repo password (auto-generated on first use)
CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"`
// Hub verification state
HubVerified bool `json:"hub_verified,omitempty"`
HubVerifiedAt string `json:"hub_verified_at,omitempty"` // RFC3339
HubLastCheck string `json:"hub_last_check,omitempty"` // RFC3339
// Recovery credentials (saved from setup wizard input)
RetrievalPassword string `json:"retrieval_password,omitempty"`
// Pending events (queued for next Hub push)
PendingEvents []PendingEvent `json:"pending_events,omitempty"`
}
// AppBackupPrefs holds per-app backup toggle state.
@@ -96,6 +107,15 @@ var DefaultEnabledEvents = []string{
"expected_dbdump_missed",
}
// PendingEvent is an event queued for the next Hub push cycle.
type PendingEvent struct {
EventType string `json:"event_type"`
Severity string `json:"severity"`
Message string `json:"message"`
Details string `json:"details"` // JSON string
CreatedAt string `json:"created_at"` // RFC3339
}
// DBValidationCache holds cached DB dump validation results.
type DBValidationCache struct {
ValidatedAt string `json:"validated_at"` // RFC3339
@@ -672,3 +692,102 @@ func (s *Settings) GetDecommissionedPaths() []StoragePath {
}
return result
}
// --- Hub Verification ---
// GetHubVerified returns the hub verification state.
func (s *Settings) GetHubVerified() (verified bool, verifiedAt string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.HubVerified, s.HubVerifiedAt
}
// SetHubVerified updates the hub verification state and saves to disk.
func (s *Settings) SetHubVerified(verified bool, at time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
s.HubVerified = verified
s.HubVerifiedAt = at.UTC().Format(time.RFC3339)
s.HubLastCheck = at.UTC().Format(time.RFC3339)
return s.save()
}
// SetHubLastCheck updates the last Hub check timestamp without changing verification status.
func (s *Settings) SetHubLastCheck(at time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
s.HubLastCheck = at.UTC().Format(time.RFC3339)
return s.save()
}
// IsLimitedMode returns true if the controller should operate in limited mode
// (new deployments blocked). This happens when:
// - Never verified AND >7 days since controller started, OR
// - Hub explicitly set customer as blocked (HubVerified=false after a successful check)
func (s *Settings) IsLimitedMode() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.HubVerified {
return false
}
// If we have a last check timestamp and it says not verified, limited mode
if s.HubLastCheck != "" {
return true
}
// Never checked yet — check if grace period (7 days) expired
if s.HubVerifiedAt == "" {
// No verification timestamp at all — not yet in limited mode (grace period from startup)
return false
}
t, err := time.Parse(time.RFC3339, s.HubVerifiedAt)
if err != nil {
return false
}
return time.Since(t) > 7*24*time.Hour
}
// --- Retrieval Password ---
// GetRetrievalPassword returns the stored retrieval password (thread-safe).
func (s *Settings) GetRetrievalPassword() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.RetrievalPassword
}
// SetRetrievalPassword updates the retrieval password and saves to disk.
func (s *Settings) SetRetrievalPassword(password string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.RetrievalPassword = password
return s.save()
}
// --- Pending Events ---
// AddPendingEvent queues an event for the next Hub push cycle.
func (s *Settings) AddPendingEvent(event PendingEvent) error {
s.mu.Lock()
defer s.mu.Unlock()
s.PendingEvents = append(s.PendingEvents, event)
return s.save()
}
// DrainPendingEvents returns and clears all pending events (thread-safe).
func (s *Settings) DrainPendingEvents() []PendingEvent {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.PendingEvents) == 0 {
return nil
}
events := make([]PendingEvent, len(s.PendingEvents))
copy(events, s.PendingEvents)
s.PendingEvents = nil
if err := s.save(); err != nil {
s.log.Printf("[ERROR] Failed to save after draining pending events: %v", err)
}
return events
}
+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
// 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
}
@@ -487,6 +487,46 @@ function pollUntilBack() {
{{end}}
</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>
function editStorageLabel(path, currentLabel) {
var wrap = document.getElementById('label-wrap-' + path);