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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user