95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1412 lines
46 KiB
Go
1412 lines
46 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
"time"
|
|
|
|
"crypto/subtle"
|
|
"strings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
|
|
"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/selftest"
|
|
"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/web"
|
|
)
|
|
|
|
var (
|
|
// Set at build time via ldflags
|
|
Version = "dev"
|
|
BuildTime = "unknown"
|
|
GitCommit = "unknown"
|
|
)
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "/opt/docker/felhom-controller/controller.yaml", "Path to configuration file")
|
|
showVersion := flag.Bool("version", false, "Show version and exit")
|
|
flag.Parse()
|
|
|
|
if *showVersion {
|
|
fmt.Printf("felhom-controller %s (built %s, commit %s)\n", Version, BuildTime, GitCommit)
|
|
os.Exit(0)
|
|
}
|
|
|
|
startTime := time.Now()
|
|
|
|
// --- Load configuration ---
|
|
// 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 {
|
|
cfg = config.Default()
|
|
log.Printf("[WARN] Config load failed (%s), using defaults: %v", *configPath, err)
|
|
}
|
|
|
|
logger, logBuffer := setupLogger(cfg)
|
|
|
|
// --- Wire system package debug logging ---
|
|
if cfg.Logging.Level == "debug" {
|
|
system.DebugLogger = logger
|
|
}
|
|
|
|
// --- 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)
|
|
|
|
// --- Load settings ---
|
|
settingsPath := cfg.Paths.DataDir + "/settings.json"
|
|
sett, err := settings.Load(settingsPath, logger)
|
|
if err != nil {
|
|
logger.Fatalf("[FATAL] Failed to load settings from %s: %v", settingsPath, err)
|
|
}
|
|
sett.SetDebug(cfg.Logging.Level == "debug")
|
|
|
|
// --- Auto-discover storage paths from deployed apps ---
|
|
discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger)
|
|
sett.AutoDiscoverStoragePaths(discoveredPaths, cfg.Paths.HDDPath, logger)
|
|
|
|
// --- Load or create encryption key ---
|
|
encKeyPath := filepath.Join(cfg.Paths.DataDir, "encryption.key")
|
|
encKey, err := crypto.LoadOrCreateKey(encKeyPath)
|
|
if err != nil {
|
|
logger.Fatalf("[FATAL] Failed to load encryption key: %v", err)
|
|
}
|
|
logger.Printf("[INFO] Encryption key loaded from %s", encKeyPath)
|
|
|
|
// --- Initialize stack manager ---
|
|
stackMgr, err := stacks.NewManager(cfg, logger)
|
|
if err != nil {
|
|
logger.Fatalf("[FATAL] Failed to initialize stack manager: %v", err)
|
|
}
|
|
stackMgr.SetEncryptionKey(encKey)
|
|
|
|
// Initial stack scan
|
|
if err := stackMgr.ScanStacks(); err != nil {
|
|
logger.Printf("[WARN] Initial stack scan failed: %v", err)
|
|
}
|
|
|
|
// Inject missing deploy fields for all deployed stacks on startup
|
|
if names := stackMgr.DeployedStackNames(); len(names) > 0 {
|
|
stackMgr.InjectMissingFields(names)
|
|
}
|
|
|
|
// Migrate existing plaintext passwords to encrypted
|
|
stackMgr.MigrateEncryption()
|
|
|
|
// --- Initialize catalog syncer ---
|
|
syncer := catalogsync.New(cfg, logger, stackMgr.ScanStacks, func(updated []string) {
|
|
stackMgr.InjectMissingFields(updated)
|
|
})
|
|
syncer.Start()
|
|
defer syncer.Stop()
|
|
|
|
// --- Graceful shutdown context ---
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// --- Start CPU collector ---
|
|
cpuCollector := system.NewCPUCollector(5 * time.Second)
|
|
cpuCollector.Start(ctx)
|
|
defer cpuCollector.Stop()
|
|
|
|
// --- Initialize metrics store + collector ---
|
|
metricsDBPath := "/opt/docker/felhom-controller/data/metrics.db"
|
|
metricsStore, err := metrics.NewMetricsStore(metricsDBPath, logger)
|
|
if err != nil {
|
|
logger.Printf("[WARN] Failed to initialize metrics store: %v — monitoring disabled", err)
|
|
} else {
|
|
logger.Printf("[INFO] Metrics store opened at %s", metricsDBPath)
|
|
}
|
|
|
|
if metricsStore != nil {
|
|
defer metricsStore.Close()
|
|
metricsHDDPath := cfg.Paths.HDDPath
|
|
if p := sett.GetDefaultStoragePath(); p != "" {
|
|
metricsHDDPath = p
|
|
}
|
|
metricsCollector := metrics.NewMetricsCollector(metricsStore, cpuCollector, metricsHDDPath, logger)
|
|
metricsCollector.Start(ctx)
|
|
defer metricsCollector.Stop()
|
|
logger.Println("[INFO] Metrics collector started (60s interval)")
|
|
}
|
|
|
|
// --- Initialize health pinger (legacy, will be removed) ---
|
|
pinger := monitor.NewPinger(&cfg.Monitoring, logger)
|
|
pinger.SetDebug(cfg.Logging.Level == "debug")
|
|
|
|
// Deprecation notice for ping UUIDs
|
|
uuids := cfg.Monitoring.PingUUIDs
|
|
if uuids.Heartbeat != "" || uuids.SystemHealth != "" || uuids.DBDump != "" || uuids.Backup != "" || uuids.BackupIntegrity != "" {
|
|
logger.Println("[INFO] Healthchecks ping UUIDs configured but no longer used — monitoring is now handled by the Hub")
|
|
}
|
|
|
|
// --- Initialize backup manager ---
|
|
var backupMgr *backup.Manager
|
|
stackProv := &stackAdapter{
|
|
mgr: stackMgr,
|
|
getStoragePaths: func() []settings.StoragePath { return sett.GetStoragePaths() },
|
|
}
|
|
if cfg.Backup.Enabled {
|
|
backupMgr = backup.NewManager(cfg, pinger, sett, logger)
|
|
backupMgr.SetStackProvider(stackProv)
|
|
backupMgr.AfterBackup = func() {
|
|
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
|
|
nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
|
|
backupMgr.RefreshCache(nextDBDump, nextBackup)
|
|
}
|
|
go backupMgr.LoadSnapshotHistory()
|
|
}
|
|
|
|
// --- Initialize cross-drive backup runner ---
|
|
crossDriveRunner := backup.NewCrossDriveRunner(sett, stackProv, cfg.Paths.SystemDataPath, cfg.Paths.StacksDir, logger, cfg.Logging.Level == "debug")
|
|
|
|
// Wire cross-drive → backup manager for pre-backup DB dumps
|
|
if backupMgr != nil {
|
|
crossDriveRunner.SetDBDumper(backupMgr)
|
|
}
|
|
|
|
// --- Initialize alert manager ---
|
|
alertMgr := web.NewAlertManager(logger)
|
|
|
|
// --- Initialize notifier ---
|
|
notifier := notify.New(cfg.Hub.URL, cfg.Hub.APIKey, cfg.Customer.ID, sett, logger, cfg.Logging.Level == "debug")
|
|
|
|
// --- Initialize self-updater ---
|
|
var updater *selfupdate.Updater
|
|
if cfg.SelfUpdate.Enabled {
|
|
composePath := filepath.Join(filepath.Dir(cfg.Paths.DataDir), "docker-compose.yml")
|
|
updater = selfupdate.NewUpdater(&cfg.SelfUpdate, &cfg.Git, Version, cfg.Paths.DataDir, composePath, logger, cfg.Logging.Level == "debug")
|
|
updater.SetBackupRunningCheck(func() bool {
|
|
return backupMgr != nil && backupMgr.IsRunning()
|
|
})
|
|
// Check for post-update state (did a previous update succeed or fail?)
|
|
if state := updater.VerifyStartup(); state != nil {
|
|
notifier.NotifyControllerUpdated(state.PreviousVersion, state.TargetVersion, state.Status == "success")
|
|
}
|
|
logger.Printf("[INFO] Self-update enabled (check every %s, auto-update: %v, auto-update time: %s)",
|
|
cfg.SelfUpdate.CheckInterval, cfg.SelfUpdate.AutoUpdate, cfg.SelfUpdate.AutoUpdateTime)
|
|
}
|
|
|
|
// --- Initialize scheduler ---
|
|
sched := scheduler.New(logger)
|
|
sched.SetDebug(cfg.Logging.Level == "debug")
|
|
|
|
// Existing periodic tasks (migrated from ad-hoc goroutines)
|
|
sched.Every("status-refresh", 30*time.Second, func(ctx context.Context) error {
|
|
return stackMgr.RefreshStatus()
|
|
})
|
|
sched.Every("stack-scan", 2*time.Minute, func(ctx context.Context) error {
|
|
return stackMgr.ScanStacks()
|
|
})
|
|
sched.Every("health-probes", 10*time.Second, func(ctx context.Context) error {
|
|
return stackMgr.RunHealthProbes()
|
|
})
|
|
|
|
// Heartbeat — lightweight "I'm alive" signal
|
|
sched.Every("heartbeat", 5*time.Minute, func(ctx context.Context) error {
|
|
pinger.Ping(cfg.Monitoring.PingUUIDs.Heartbeat, "")
|
|
return nil
|
|
})
|
|
|
|
// System health ping
|
|
healthInterval, err := time.ParseDuration(cfg.Monitoring.SystemHealthInterval)
|
|
if err != nil {
|
|
healthInterval = 5 * time.Minute
|
|
}
|
|
sched.Every("system-health", healthInterval, func(ctx context.Context) error {
|
|
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), logger)
|
|
body := healthReport.FormatMessage()
|
|
healthUUID := cfg.Monitoring.PingUUIDs.SystemHealth
|
|
if healthReport.Status == "fail" {
|
|
pinger.Fail(healthUUID, body)
|
|
} else {
|
|
pinger.Ping(healthUUID, body)
|
|
}
|
|
// Refresh dashboard alerts from health report
|
|
updateAvailable := false
|
|
latestVersion := ""
|
|
if updater != nil {
|
|
status := updater.GetStatus()
|
|
if status.LastCheck != nil {
|
|
updateAvailable = status.LastCheck.UpdateAvailable
|
|
latestVersion = status.LastCheck.LatestVersion
|
|
}
|
|
}
|
|
alertMgr.Refresh(healthReport, cfg, backupMgr, updateAvailable, latestVersion, sett.GetStoragePaths())
|
|
// Notify on health status changes
|
|
notifier.NotifyHealthChange(healthReport.Status, healthReport.Issues, healthReport.Warnings)
|
|
return nil
|
|
})
|
|
|
|
// --- Central hub pusher (declared early so backup closure can reference it) ---
|
|
var hubPusher *report.Pusher
|
|
if cfg.Hub.URL != "" && cfg.Hub.APIKey != "" {
|
|
hubPusher = report.NewPusher(&cfg.Hub, logger, cfg.Logging.Level == "debug")
|
|
// 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()
|
|
return web.HubPushStatusData{
|
|
LastAttempt: s.LastAttempt,
|
|
LastSuccess: s.LastSuccess,
|
|
LastError: s.LastError,
|
|
Consecutive: s.Consecutive,
|
|
}
|
|
})
|
|
}
|
|
|
|
// Backup daily jobs
|
|
if cfg.Backup.Enabled && backupMgr != nil {
|
|
sched.Daily("db-dump", cfg.Backup.DBDumpSchedule, func(ctx context.Context) error {
|
|
err := backupMgr.RunDBDumps(ctx)
|
|
if err != nil {
|
|
notifier.NotifyDBDumpFailed("Adatbázis mentés sikertelen", err.Error())
|
|
} else {
|
|
notifier.NotifyDBDumpCompleted(notify.DBDumpDetails{})
|
|
}
|
|
return err
|
|
})
|
|
sched.Daily("backup", cfg.Backup.ResticSchedule, func(ctx context.Context) error {
|
|
err := backupMgr.RunBackup(ctx)
|
|
if err != nil {
|
|
notifier.NotifyBackupFailed("Biztonsági mentés sikertelen", err.Error())
|
|
} else {
|
|
notifier.NotifyBackupCompleted(notify.BackupDetails{})
|
|
}
|
|
// Phase 3: Chain cross-drive backups immediately after restic (regardless of restic success)
|
|
// Daily jobs run every night; weekly jobs only on Sunday
|
|
if crossDriveRunner != nil {
|
|
if cdErr := crossDriveRunner.RunAllScheduled(ctx, "daily"); cdErr != nil {
|
|
logger.Printf("[WARN] Cross-drive daily backup had errors: %v", cdErr)
|
|
}
|
|
if time.Now().Weekday() == time.Sunday {
|
|
if cdErr := crossDriveRunner.RunAllScheduled(ctx, "weekly"); cdErr != nil {
|
|
logger.Printf("[WARN] Cross-drive weekly backup had errors: %v", cdErr)
|
|
}
|
|
}
|
|
}
|
|
// Push infra backup to Hub after all backup tiers complete
|
|
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
|
|
})
|
|
|
|
// Weekly integrity check — Sunday 04:00
|
|
sched.Daily("backup-integrity", "04:00", func(ctx context.Context) error {
|
|
if time.Now().Weekday() != time.Sunday {
|
|
return nil
|
|
}
|
|
err := backupMgr.RunIntegrityCheck(ctx)
|
|
if err != nil {
|
|
notifier.NotifyIntegrityFailed("Mentés integritás ellenőrzés sikertelen", err.Error())
|
|
} else {
|
|
notifier.NotifyIntegrityOK("Mentés integritás ellenőrzés sikeres")
|
|
}
|
|
return err
|
|
})
|
|
|
|
// Cache refresh: every 5 minutes
|
|
sched.Every("backup-cache", 5*time.Minute, func(ctx context.Context) error {
|
|
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
|
|
nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
|
|
backupMgr.RefreshCache(nextDBDump, nextBackup)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Metrics prune — daily at 04:00
|
|
if metricsStore != nil {
|
|
sched.Daily("metrics-prune", "04:00", func(ctx context.Context) error {
|
|
deleted, err := metricsStore.Prune(30 * 24 * time.Hour)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.Printf("[INFO] Pruned %d old metric rows", deleted)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// --- Central hub reporting schedule ---
|
|
if hubPusher != nil {
|
|
if cfg.Hub.Enabled {
|
|
pushInterval, err := time.ParseDuration(cfg.Hub.PushInterval)
|
|
if err != nil {
|
|
pushInterval = 15 * time.Minute
|
|
}
|
|
sched.Every("hub-report", pushInterval, func(ctx context.Context) error {
|
|
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
|
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 {
|
|
logger.Printf("[INFO] Hub reporting disabled — will send disabled notification to %s", cfg.Hub.URL)
|
|
}
|
|
}
|
|
|
|
// Self-update scheduler jobs
|
|
if cfg.SelfUpdate.Enabled && updater != nil {
|
|
// Periodic version check (populates UI, never triggers update)
|
|
checkInterval, ciErr := time.ParseDuration(cfg.SelfUpdate.CheckInterval)
|
|
if ciErr != nil {
|
|
checkInterval = 6 * time.Hour
|
|
}
|
|
sched.Every("selfupdate-check", checkInterval, func(ctx context.Context) error {
|
|
result := updater.CheckForUpdate()
|
|
if result.UpdateAvailable {
|
|
logger.Printf("[INFO] Update available: %s -> %s", result.CurrentVersion, result.LatestVersion)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Auto-update (daily, fires after typical backup completion)
|
|
if cfg.SelfUpdate.AutoUpdate {
|
|
sched.Daily("selfupdate-auto", cfg.SelfUpdate.AutoUpdateTime, func(ctx context.Context) error {
|
|
result := updater.CheckForUpdate()
|
|
if !result.UpdateAvailable {
|
|
return nil
|
|
}
|
|
if err := updater.TriggerUpdate("auto"); err != nil {
|
|
logger.Printf("[WARN] Auto-update skipped: %v", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Storage watchdog ---
|
|
storageWatchdog := monitor.NewStorageWatchdog(sett, &watchdogStackAdapter{mgr: stackMgr}, notifier, cfg, logger)
|
|
storageWatchdog.SetAlertRefresh(func() {
|
|
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), logger)
|
|
updateAvailable := false
|
|
latestVersion := ""
|
|
if updater != nil {
|
|
status := updater.GetStatus()
|
|
if status.LastCheck != nil {
|
|
updateAvailable = status.LastCheck.UpdateAvailable
|
|
latestVersion = status.LastCheck.LatestVersion
|
|
}
|
|
}
|
|
alertMgr.Refresh(healthReport, cfg, backupMgr, updateAvailable, latestVersion, sett.GetStoragePaths())
|
|
})
|
|
if hubPusher != nil {
|
|
storageWatchdog.SetHubReportPusher(func() {
|
|
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
|
hubPusher.Push(r)
|
|
})
|
|
}
|
|
if backupMgr != nil {
|
|
storageWatchdog.SetRepoUnlocker(func(ctx context.Context, repoPath string) error {
|
|
return backupMgr.UnlockRepo(ctx, repoPath)
|
|
})
|
|
}
|
|
sched.Every("storage-watchdog", 5*time.Second, func(ctx context.Context) error {
|
|
return storageWatchdog.Check(ctx)
|
|
})
|
|
|
|
// --- Asset syncer (download from Hub) ---
|
|
var assetsSyncer *assets.Syncer
|
|
if cfg.Hub.Enabled && cfg.Assets.SyncEnabled && cfg.Hub.URL != "" && cfg.Hub.APIKey != "" {
|
|
assetsDir := filepath.Join(cfg.Paths.DataDir, "assets")
|
|
assetsSyncer = assets.New(cfg.Hub.URL, cfg.Hub.APIKey, assetsDir, "/usr/share/felhom/assets", logger, cfg.Logging.Level == "debug")
|
|
go func() {
|
|
time.Sleep(10 * time.Second)
|
|
if err := assetsSyncer.Sync(ctx); err != nil {
|
|
logger.Printf("[WARN] Initial asset sync failed: %v", err)
|
|
}
|
|
}()
|
|
sched.Daily("asset-sync", cfg.Assets.SyncSchedule, func(ctx context.Context) error {
|
|
return assetsSyncer.Sync(ctx)
|
|
})
|
|
logger.Printf("[INFO] Asset sync enabled (daily at %s from Hub)", cfg.Assets.SyncSchedule)
|
|
}
|
|
|
|
// --- Startup self-test ---
|
|
selfTestResult := selftest.Run(cfg, sett, logger)
|
|
|
|
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
|
|
|
|
// Push controller startup event to Hub
|
|
notifier.NotifyControllerStarted(Version, map[string]interface{}{
|
|
"selftest_pass": selfTestResult.Pass,
|
|
"selftest_warn": selfTestResult.Warn,
|
|
"selftest_fail": selfTestResult.Fail,
|
|
})
|
|
|
|
// Heartbeat ping
|
|
pinger.Ping(cfg.Monitoring.PingUUIDs.Heartbeat, "startup")
|
|
logger.Println("[INFO] Startup heartbeat ping sent")
|
|
|
|
// System health ping
|
|
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), logger)
|
|
body := healthReport.FormatMessage()
|
|
healthUUID := cfg.Monitoring.PingUUIDs.SystemHealth
|
|
if healthReport.Status == "fail" {
|
|
pinger.Fail(healthUUID, body)
|
|
} else {
|
|
pinger.Ping(healthUUID, body)
|
|
}
|
|
logger.Printf("[INFO] Startup health ping sent (status: %s)", healthReport.Status)
|
|
|
|
// Hub report
|
|
if hubPusher != nil {
|
|
if cfg.Hub.Enabled {
|
|
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
|
var pushErr error
|
|
for attempt := 1; attempt <= 3; attempt++ {
|
|
pushErr = hubPusher.Push(r)
|
|
if pushErr == nil {
|
|
logger.Println("[INFO] Startup hub report sent")
|
|
break
|
|
}
|
|
logger.Printf("[WARN] Startup hub report attempt %d/3 failed: %v", attempt, pushErr)
|
|
if attempt < 3 {
|
|
time.Sleep(15 * time.Second)
|
|
}
|
|
}
|
|
if pushErr != nil {
|
|
logger.Printf("[WARN] Startup hub report failed after 3 attempts — next scheduled push in %s", cfg.Hub.PushInterval)
|
|
}
|
|
// 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{
|
|
Version: 1,
|
|
CustomerID: cfg.Customer.ID,
|
|
CustomerName: cfg.Customer.Name,
|
|
ControllerVersion: Version,
|
|
Timestamp: time.Now().UTC(),
|
|
ReportingDisabled: true,
|
|
Health: report.HealthReport{Status: "disabled", Issues: []string{}, Warnings: []string{}},
|
|
Stacks: report.StacksReport{Deployed: []string{}, Available: []string{}},
|
|
Containers: report.ContainerReport{List: []report.ContainerDetailReport{}},
|
|
}
|
|
hubPusher.PushOnce(r)
|
|
}
|
|
}
|
|
|
|
// Initial self-update check (so settings page shows version info quickly)
|
|
if updater != nil {
|
|
time.Sleep(25 * time.Second) // Additional delay after hub report
|
|
result := updater.CheckForUpdate()
|
|
if result.UpdateAvailable {
|
|
logger.Printf("[INFO] Startup: update available %s -> %s", result.CurrentVersion, result.LatestVersion)
|
|
} else if result.Error != "" {
|
|
logger.Printf("[DEBUG] Startup version check: %s", result.Error)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Initial backup cache population (don't block startup)
|
|
if cfg.Backup.Enabled && backupMgr != nil {
|
|
go func() {
|
|
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
|
|
nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
|
|
backupMgr.RefreshCache(nextDBDump, nextBackup)
|
|
}()
|
|
}
|
|
|
|
// Sync notification preferences to hub on startup (handles hub DB rebuild recovery)
|
|
if notifier.IsEnabled() {
|
|
go func() {
|
|
prefs := sett.GetNotificationPrefs()
|
|
if prefs.Email != "" {
|
|
if err := notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours); err != nil {
|
|
logger.Printf("[WARN] Failed to sync notification preferences on startup: %v", err)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Initial alert refresh (so alerts appear immediately, not after first 5min health check)
|
|
go func() {
|
|
report := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), logger)
|
|
alertMgr.Refresh(report, cfg, backupMgr, false, "")
|
|
}()
|
|
|
|
// --- Initialize API router ---
|
|
apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, crossDriveRunner, metricsStore, updater, notifier, logger)
|
|
if hubPusher != nil {
|
|
apiRouter.OnConfigApplied = func() {
|
|
pushInfraBackup(cfg, sett, stackProv, hubPusher, logger)
|
|
}
|
|
apiRouter.OnCrossDriveComplete = func() {
|
|
pushInfraBackup(cfg, sett, stackProv, hubPusher, logger)
|
|
writeLocalInfraBackup(cfg, sett, stackProv, logger)
|
|
}
|
|
}
|
|
if assetsSyncer != nil {
|
|
apiRouter.SetAssetsSyncer(assetsSyncer)
|
|
}
|
|
|
|
// --- Initialize Cloudflare geo-restriction ---
|
|
var geoSync *cf.GeoSyncManager
|
|
if cfg.Infrastructure.CFAPIToken != "" {
|
|
cfClient := cf.New(cfg.Infrastructure.CFAPIToken, logger, cfg.Logging.Level == "debug")
|
|
geoStacks := &geoStackAdapter{mgr: stackMgr, domain: cfg.Customer.Domain}
|
|
geoSync = cf.NewGeoSyncManager(cfClient, sett, cfg.Customer.Domain, geoStacks, logger)
|
|
geoSync.SetDebug(cfg.Logging.Level == "debug")
|
|
apiRouter.SetGeoSync(geoSync)
|
|
|
|
// Re-sync geo rules when apps are deployed/removed
|
|
apiRouter.OnGeoRelevantChange = func() {
|
|
geo := sett.GetGeoRestriction()
|
|
if geo != nil && geo.Enabled {
|
|
if err := geoSync.Sync(context.Background()); err != nil {
|
|
logger.Printf("[WARN] Geo sync after app change failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Periodic verification every 6 hours
|
|
sched.Every("geo-verify", 6*time.Hour, func(ctx context.Context) error {
|
|
geo := sett.GetGeoRestriction()
|
|
if geo == nil || !geo.Enabled {
|
|
return nil
|
|
}
|
|
return geoSync.Sync(ctx)
|
|
})
|
|
|
|
// Initial sync (delayed, non-blocking)
|
|
go func() {
|
|
time.Sleep(15 * time.Second)
|
|
if geo := sett.GetGeoRestriction(); geo != nil && geo.Enabled {
|
|
if err := geoSync.Sync(context.Background()); err != nil {
|
|
logger.Printf("[WARN] Initial geo sync failed: %v", err)
|
|
}
|
|
}
|
|
}()
|
|
|
|
logger.Printf("[INFO] Geo-restriction support enabled (CF API token configured)")
|
|
}
|
|
|
|
// --- Initialize integration manager ---
|
|
integrationStacks := &integrationStackAdapter{mgr: stackMgr}
|
|
integrationMgr := integrations.NewManager(sett, integrationStacks, cfg.Customer.Domain, cfg.Paths.StacksDir, encKey, logger)
|
|
integrationMgr.SetDebug(cfg.Logging.Level == "debug")
|
|
apiRouter.SetIntegrationManager(integrationMgr)
|
|
|
|
// --- Initialize app exporter ---
|
|
exportProv := &exportAdapter{mgr: stackMgr, encKey: encKey}
|
|
appExporter := appexport.NewExporter(exportProv, logger, Version)
|
|
appExporter.SetDebug(cfg.Logging.Level == "debug")
|
|
apiRouter.SetDebug(cfg.Logging.Level == "debug")
|
|
|
|
// --- Initialize web server ---
|
|
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)
|
|
webServer.SetEncryptionKey(encKey)
|
|
webServer.SetAppExporter(appExporter)
|
|
webServer.SetIntegrationManager(integrationMgr)
|
|
webServer.SetStorageWatchdog(storageWatchdog)
|
|
if assetsSyncer != nil {
|
|
webServer.SetAssetsSyncer(assetsSyncer)
|
|
}
|
|
if hubPusher != nil {
|
|
webServer.SetHubPushStatus(func() web.HubPushStatusData {
|
|
s := hubPusher.GetStatus()
|
|
return web.HubPushStatusData{
|
|
LastAttempt: s.LastAttempt,
|
|
LastSuccess: s.LastSuccess,
|
|
LastError: s.LastError,
|
|
Consecutive: s.Consecutive,
|
|
}
|
|
})
|
|
}
|
|
if logBuffer != nil {
|
|
webServer.SetLogBuffer(logBuffer)
|
|
}
|
|
webServer.SetStartTime(startTime)
|
|
|
|
// Wire debug callbacks (only in debug mode)
|
|
if cfg.Logging.Level == "debug" {
|
|
dc := &web.DebugCallbacks{}
|
|
if hubPusher != nil {
|
|
dc.TriggerHubReportPush = func() error {
|
|
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
|
return hubPusher.Push(r)
|
|
}
|
|
dc.TriggerHubInfraPush = func() error {
|
|
pushInfraBackup(cfg, sett, stackProv, hubPusher, logger)
|
|
return nil
|
|
}
|
|
}
|
|
dc.TriggerLocalInfraWrite = func() error {
|
|
writeLocalInfraBackup(cfg, sett, stackProv, logger)
|
|
return nil
|
|
}
|
|
dc.HubConnectivityTest = func() (int, int64, error) {
|
|
start := time.Now()
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Get(cfg.Hub.URL + "/healthz")
|
|
latency := time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
return 0, latency, err
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode, latency, nil
|
|
}
|
|
if cfg.Git.RepoURL != "" {
|
|
dc.GiteaConnectivityTest = func() (int, int64, error) {
|
|
start := time.Now()
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
resp, err := client.Head(cfg.Git.RepoURL)
|
|
latency := time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
return 0, latency, err
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode, latency, nil
|
|
}
|
|
}
|
|
dc.GetTelemetryPreview = func() ([]report.AppTelemetry, error) {
|
|
return report.BuildAppTelemetryForDebug(stackMgr, metricsStore, logger), nil
|
|
}
|
|
webServer.SetDebugCallbacks(dc)
|
|
}
|
|
|
|
// --- Initialize drive migrator ---
|
|
driveMigrator := &storage.DriveMigrator{
|
|
Sett: sett,
|
|
StackProvider: &driveMigrateStackAdapter{mgr: stackMgr},
|
|
Logger: logger,
|
|
}
|
|
// Only set BackupTrigger if backup is enabled (avoid non-nil interface with nil concrete value)
|
|
if backupMgr != nil {
|
|
driveMigrator.BackupTrigger = backupMgr
|
|
}
|
|
driveMigrator.AlertRefresh = func() {
|
|
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths(), logger)
|
|
updateAvailable := false
|
|
latestVersion := ""
|
|
if updater != nil {
|
|
status := updater.GetStatus()
|
|
if status.LastCheck != nil {
|
|
updateAvailable = status.LastCheck.UpdateAvailable
|
|
latestVersion = status.LastCheck.LatestVersion
|
|
}
|
|
}
|
|
alertMgr.Refresh(healthReport, cfg, backupMgr, updateAvailable, latestVersion, sett.GetStoragePaths())
|
|
}
|
|
if hubPusher != nil {
|
|
driveMigrator.PushHubReport = func() {
|
|
r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
|
hubPusher.Push(r)
|
|
}
|
|
driveMigrator.PushInfraBackup = func() {
|
|
pushInfraBackup(cfg, sett, stackProv, hubPusher, logger)
|
|
}
|
|
}
|
|
driveMigrator.SyncFBMounts = func() {
|
|
webServer.SyncFileBrowserMounts()
|
|
}
|
|
webServer.SetDriveMigrator(driveMigrator)
|
|
|
|
// Wire migration-active check into backup manager
|
|
if backupMgr != nil {
|
|
backupMgr.MigrationActiveCheck = driveMigrator.IsActive
|
|
}
|
|
|
|
// --- Build HTTP mux ---
|
|
mux := http.NewServeMux()
|
|
|
|
// API routes (no auth for health endpoint, auth for everything else)
|
|
mux.HandleFunc("/api/health", apiRouter.HealthHandler)
|
|
// Storage API routes handled by web server (longer prefix takes precedence over /api/)
|
|
mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI))))
|
|
// App export/import API routes handled by web server
|
|
mux.Handle("/api/export/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeExportAPI))))
|
|
// Debug API routes handled by web server (debug-mode gating inside handler)
|
|
mux.Handle("/api/debug/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDebugAPI))))
|
|
// Self-update API — accepts session auth OR hub API key (for external triggering)
|
|
// CsrfProtect exempts Bearer-token requests automatically.
|
|
mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
|
|
// Config API — accepts session auth OR hub API key (for Hub config push)
|
|
mux.Handle("/api/config/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
|
|
// Geo API — accepts session auth OR hub API key (for Hub geo-disable)
|
|
mux.Handle("/api/geo/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
|
|
mux.Handle("/api/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
|
|
|
|
// Web UI routes (auth required)
|
|
mux.Handle("/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeHTTP))))
|
|
|
|
// --- Start HTTP server ---
|
|
server := &http.Server{
|
|
Addr: cfg.Web.Listen,
|
|
Handler: webServer.CatchAllMiddleware(mux),
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 60 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
go func() {
|
|
sig := <-sigCh
|
|
logger.Printf("[INFO] Received signal %v, shutting down...", sig)
|
|
cancel()
|
|
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer shutdownCancel()
|
|
|
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
|
logger.Printf("[ERROR] HTTP server shutdown error: %v", err)
|
|
}
|
|
}()
|
|
|
|
logger.Printf("[INFO] Web UI listening on %s", cfg.Web.Listen)
|
|
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
|
logger.Fatalf("[FATAL] HTTP server error: %v", err)
|
|
}
|
|
|
|
logger.Println("[INFO] felhom-controller stopped")
|
|
}
|
|
|
|
// selfUpdateAuthMiddleware allows access via session auth (normal UI) OR hub API key bearer token (external).
|
|
func selfUpdateAuthMiddleware(cfg *config.Config, webServer *web.Server, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Check bearer token first (for external API calls: hub, build scripts)
|
|
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
|
token := strings.TrimPrefix(auth, "Bearer ")
|
|
if token != "" && cfg.Hub.APIKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Hub.APIKey)) == 1 {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
// Fall back to session auth
|
|
webServer.RequireAuth(next).ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func setupLogger(cfg *config.Config) (*log.Logger, *web.LogBuffer) {
|
|
if cfg.Logging.Level == "debug" {
|
|
logBuffer := web.NewLogBuffer(1000)
|
|
logger := log.New(io.MultiWriter(os.Stdout, logBuffer), "", log.LstdFlags|log.Lshortfile)
|
|
return logger, logBuffer
|
|
}
|
|
return log.New(os.Stdout, "", log.LstdFlags), nil
|
|
}
|
|
|
|
// stackAdapter implements backup.StackDataProvider using stacks.Manager.
|
|
type stackAdapter struct {
|
|
mgr *stacks.Manager
|
|
getStoragePaths func() []settings.StoragePath
|
|
}
|
|
|
|
func (a *stackAdapter) GetStackComposePath(name string) (string, bool) {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return s.ComposePath, true
|
|
}
|
|
|
|
func (a *stackAdapter) ListDeployedStacks() []backup.StackSummary {
|
|
var result []backup.StackSummary
|
|
for _, s := range a.mgr.GetStacks() {
|
|
if !s.Deployed {
|
|
continue
|
|
}
|
|
result = append(result, backup.StackSummary{
|
|
Name: s.Name,
|
|
DisplayName: s.Meta.DisplayName,
|
|
ComposePath: s.ComposePath,
|
|
NeedsHDD: s.Meta.Resources.NeedsHDD,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (a *stackAdapter) StopStack(name string) error {
|
|
return a.mgr.StopStack(name)
|
|
}
|
|
|
|
func (a *stackAdapter) StartStack(name string) error {
|
|
return a.mgr.StartStack(name)
|
|
}
|
|
|
|
func (a *stackAdapter) GetStackHDDMounts(name string) []string {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Priority 1: Read the app's own HDD_PATH from its app.yaml
|
|
stackDir := filepath.Dir(s.ComposePath)
|
|
appCfg := stacks.LoadAppConfig(stackDir)
|
|
if appCfg != nil && appCfg.Env["HDD_PATH"] != "" {
|
|
return stacks.ParseComposeHDDMounts(s.ComposePath, appCfg.Env["HDD_PATH"])
|
|
}
|
|
|
|
// Priority 2: Try all registered storage paths (fallback)
|
|
var allMounts []string
|
|
seen := make(map[string]bool)
|
|
for _, sp := range a.getStoragePaths() {
|
|
mounts := stacks.ParseComposeHDDMounts(s.ComposePath, sp.Path)
|
|
for _, m := range mounts {
|
|
if !seen[m] {
|
|
seen[m] = true
|
|
allMounts = append(allMounts, m)
|
|
}
|
|
}
|
|
}
|
|
return allMounts
|
|
}
|
|
|
|
func (a *stackAdapter) GetStackHDDPath(name string) string {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
stackDir := filepath.Dir(s.ComposePath)
|
|
appCfg := stacks.LoadAppConfig(stackDir)
|
|
if appCfg != nil && appCfg.Env["HDD_PATH"] != "" {
|
|
return filepath.Clean(appCfg.Env["HDD_PATH"])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// integrationStackAdapter implements integrations.StackProvider using stacks.Manager.
|
|
type integrationStackAdapter struct {
|
|
mgr *stacks.Manager
|
|
}
|
|
|
|
func (a *integrationStackAdapter) GetStack(name string) (*stacks.Stack, bool) {
|
|
return a.mgr.GetStack(name)
|
|
}
|
|
|
|
func (a *integrationStackAdapter) GetStacks() []stacks.Stack {
|
|
return a.mgr.GetStacks()
|
|
}
|
|
|
|
func (a *integrationStackAdapter) RestartStack(name string) error {
|
|
return a.mgr.RestartStack(name)
|
|
}
|
|
|
|
// geoStackAdapter implements cloudflare.StackLister for geo-restriction sync.
|
|
type geoStackAdapter struct {
|
|
mgr *stacks.Manager
|
|
domain string
|
|
}
|
|
|
|
func (a *geoStackAdapter) GetDeployedHostnames() map[string]string {
|
|
result := make(map[string]string)
|
|
for _, stack := range a.mgr.GetStacks() {
|
|
if !stack.Deployed {
|
|
continue
|
|
}
|
|
subdomain := stack.Meta.Subdomain
|
|
// Check for custom subdomain in app.yaml
|
|
if appCfg := a.mgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
|
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
|
subdomain = sd
|
|
}
|
|
}
|
|
if subdomain != "" {
|
|
result[stack.Name] = subdomain + "." + a.domain
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// watchdogStackAdapter implements monitor.WatchdogStackProvider using stacks.Manager.
|
|
type watchdogStackAdapter struct {
|
|
mgr *stacks.Manager
|
|
}
|
|
|
|
func (a *watchdogStackAdapter) ListDeployedStacks() []monitor.WatchdogStackInfo {
|
|
var result []monitor.WatchdogStackInfo
|
|
for _, s := range a.mgr.GetStacks() {
|
|
if !s.Deployed {
|
|
continue
|
|
}
|
|
result = append(result, monitor.WatchdogStackInfo{Name: s.Name})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (a *watchdogStackAdapter) GetStackHDDPath(name string) string {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
stackDir := filepath.Dir(s.ComposePath)
|
|
appCfg := stacks.LoadAppConfig(stackDir)
|
|
if appCfg != nil && appCfg.Env["HDD_PATH"] != "" {
|
|
return filepath.Clean(appCfg.Env["HDD_PATH"])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (a *watchdogStackAdapter) StopStack(name string) error {
|
|
return a.mgr.StopStack(name)
|
|
}
|
|
|
|
func (a *watchdogStackAdapter) StartStack(name string) error {
|
|
return a.mgr.StartStack(name)
|
|
}
|
|
|
|
// driveMigrateStackAdapter implements storage.StackProviderForMigration using stacks.Manager.
|
|
type driveMigrateStackAdapter struct {
|
|
mgr *stacks.Manager
|
|
}
|
|
|
|
func (a *driveMigrateStackAdapter) ListDeployedStacks() []storage.StackSummaryForMigration {
|
|
var result []storage.StackSummaryForMigration
|
|
for _, s := range a.mgr.GetStacks() {
|
|
if !s.Deployed {
|
|
continue
|
|
}
|
|
result = append(result, storage.StackSummaryForMigration{
|
|
Name: s.Name,
|
|
DisplayName: s.Meta.DisplayName,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (a *driveMigrateStackAdapter) GetStackHDDPath(name string) string {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
stackDir := filepath.Dir(s.ComposePath)
|
|
appCfg := stacks.LoadAppConfig(stackDir)
|
|
if appCfg != nil && appCfg.Env["HDD_PATH"] != "" {
|
|
return filepath.Clean(appCfg.Env["HDD_PATH"])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (a *driveMigrateStackAdapter) StopStack(name string) error {
|
|
return a.mgr.StopStack(name)
|
|
}
|
|
|
|
func (a *driveMigrateStackAdapter) StartStack(name string) error {
|
|
return a.mgr.StartStack(name)
|
|
}
|
|
|
|
func (a *driveMigrateStackAdapter) UpdateStackHDDPath(name, newPath string) error {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return fmt.Errorf("stack not found: %s", name)
|
|
}
|
|
stackDir := filepath.Dir(s.ComposePath)
|
|
appCfg := stacks.LoadAppConfig(stackDir)
|
|
if appCfg == nil {
|
|
return fmt.Errorf("app.yaml not found for stack: %s", name)
|
|
}
|
|
appCfg.Env["HDD_PATH"] = newPath
|
|
return stacks.SaveAppConfig(stackDir, appCfg, nil, nil)
|
|
}
|
|
|
|
func (a *driveMigrateStackAdapter) StackExists(name string) bool {
|
|
_, ok := a.mgr.GetStack(name)
|
|
return ok
|
|
}
|
|
|
|
// exportAdapter implements appexport.ExportStackProvider using stacks.Manager.
|
|
type exportAdapter struct {
|
|
mgr *stacks.Manager
|
|
encKey []byte
|
|
}
|
|
|
|
func (a *exportAdapter) GetStackDir(name string) (string, bool) {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return filepath.Dir(s.ComposePath), true
|
|
}
|
|
|
|
func (a *exportAdapter) GetStackComposePath(name string) (string, bool) {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return s.ComposePath, true
|
|
}
|
|
|
|
func (a *exportAdapter) GetStackHDDMounts(name string) []string {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
stackDir := filepath.Dir(s.ComposePath)
|
|
appCfg := stacks.LoadAppConfig(stackDir)
|
|
if appCfg != nil && appCfg.Env["HDD_PATH"] != "" {
|
|
return stacks.ParseComposeHDDMounts(s.ComposePath, appCfg.Env["HDD_PATH"])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *exportAdapter) GetStackHDDPath(name string) string {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
stackDir := filepath.Dir(s.ComposePath)
|
|
appCfg := stacks.LoadAppConfig(stackDir)
|
|
if appCfg != nil && appCfg.Env["HDD_PATH"] != "" {
|
|
return filepath.Clean(appCfg.Env["HDD_PATH"])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (a *exportAdapter) IsStackRunning(name string) bool {
|
|
s, ok := a.mgr.GetStack(name)
|
|
return ok && s.State == stacks.StateRunning
|
|
}
|
|
|
|
func (a *exportAdapter) StopStack(name string) error {
|
|
return a.mgr.StopStack(name)
|
|
}
|
|
|
|
func (a *exportAdapter) StartStack(name string) error {
|
|
return a.mgr.StartStack(name)
|
|
}
|
|
|
|
func (a *exportAdapter) GetStackDisplayName(name string) string {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return name
|
|
}
|
|
return s.Meta.DisplayName
|
|
}
|
|
|
|
func (a *exportAdapter) GetStackNeedsHDD(name string) bool {
|
|
s, ok := a.mgr.GetStack(name)
|
|
return ok && s.Meta.Resources.NeedsHDD
|
|
}
|
|
|
|
func (a *exportAdapter) GetDockerVolumes(name string) []string {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
vols := backup.ParseComposeNamedVolumes(s.ComposePath)
|
|
var names []string
|
|
for _, v := range vols {
|
|
names = append(names, v.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (a *exportAdapter) IsStackDeployed(name string) bool {
|
|
s, ok := a.mgr.GetStack(name)
|
|
return ok && s.Deployed
|
|
}
|
|
|
|
func (a *exportAdapter) GetDecryptedEnv(name string) map[string]string {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
stackDir := filepath.Dir(s.ComposePath)
|
|
cfg := stacks.LoadAppConfigDecrypted(stackDir, a.encKey)
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
return cfg.Env
|
|
}
|
|
|
|
func (a *exportAdapter) GetStacksBaseDir() string {
|
|
return a.mgr.GetStacksBaseDir()
|
|
}
|
|
|
|
func (a *exportAdapter) SaveEncryptedAppConfig(stackDir string, env map[string]string) error {
|
|
meta := stacks.LoadMetadata(stackDir)
|
|
sensitiveVars := stacks.SensitiveEnvVars(&meta)
|
|
cfg := &stacks.AppConfig{
|
|
Deployed: true,
|
|
DeployedAt: time.Now().Format(time.RFC3339),
|
|
Env: env,
|
|
}
|
|
return stacks.SaveAppConfig(stackDir, cfg, a.encKey, sensitiveVars)
|
|
}
|
|
|
|
func (a *exportAdapter) RefreshStacks() error {
|
|
return a.mgr.RefreshStatus()
|
|
}
|
|
|
|
func (a *exportAdapter) RemoveStackVolumes(name string) error {
|
|
s, ok := a.mgr.GetStack(name)
|
|
if !ok {
|
|
return fmt.Errorf("stack %q not found", name)
|
|
}
|
|
stackDir := filepath.Dir(s.ComposePath)
|
|
|
|
// Build env from decrypted app config
|
|
cmdEnv := os.Environ()
|
|
appCfg := stacks.LoadAppConfigDecrypted(stackDir, a.encKey)
|
|
if appCfg != nil {
|
|
for k, v := range appCfg.Env {
|
|
cmdEnv = append(cmdEnv, k+"="+v)
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "docker", "compose", "down", "--volumes")
|
|
cmd.Dir = stackDir
|
|
cmd.Env = cmdEnv
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("compose down --volumes: %s — %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pushInfraBackup builds and sends the infrastructure snapshot to the Hub.
|
|
func pushInfraBackup(cfg *config.Config, sett *settings.Settings,
|
|
stackProv *stackAdapter, pusher *report.Pusher, logger *log.Logger) {
|
|
|
|
encKeyPath := filepath.Join(cfg.Paths.DataDir, "encryption.key")
|
|
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,
|
|
encKeyPath,
|
|
cfg.Paths.SystemDataPath,
|
|
sett, stackProv, logger,
|
|
)
|
|
if err != nil {
|
|
logger.Printf("[WARN] Failed to build infra backup: %v", err)
|
|
return
|
|
}
|
|
|
|
data, err := json.Marshal(ib)
|
|
if err != nil {
|
|
logger.Printf("[WARN] Failed to marshal infra backup: %v", err)
|
|
return
|
|
}
|
|
|
|
if err := pusher.PushInfraBackup(data); err != nil {
|
|
logger.Printf("[WARN] Failed to push infra backup to Hub: %v", err)
|
|
}
|
|
}
|
|
|
|
// fileExists returns true if the path exists (file or directory).
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// runSetupMode starts the setup wizard on dual listeners and blocks until signal.
|
|
func runSetupMode(cfg *config.Config, logger *log.Logger) {
|
|
ips := setup.DetectLocalIPs()
|
|
setup.LogSetupMode(cfg.Customer.Domain, ips, cfg.Web.SetupListen, logger)
|
|
|
|
setupSrv := setup.NewServer(cfg, cfg.Paths.DataDir, logger, Version)
|
|
handler := setupSrv.Handler()
|
|
|
|
// Health endpoint wrapper (returns setup_mode: true)
|
|
healthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"ok": true, "message": "felhom-controller is healthy",
|
|
"setup_mode": true, "version": Version,
|
|
})
|
|
})
|
|
|
|
// Mux for both listeners
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/health", healthHandler)
|
|
mux.Handle("/", handler)
|
|
|
|
// Start main listener (:8080, behind Traefik for domain access)
|
|
mainServer := &http.Server{
|
|
Addr: cfg.Web.Listen,
|
|
Handler: mux,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 60 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
go func() {
|
|
logger.Printf("[INFO] Setup wizard (main) listening on %s", cfg.Web.Listen)
|
|
if err := mainServer.ListenAndServe(); err != http.ErrServerClosed {
|
|
logger.Printf("[ERROR] Main HTTP server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Start setup-only listener (:8081, direct HTTP for LAN access)
|
|
setupServer := &http.Server{
|
|
Addr: cfg.Web.SetupListen,
|
|
Handler: mux,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 60 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
go func() {
|
|
logger.Printf("[INFO] Setup wizard (LAN) listening on %s", cfg.Web.SetupListen)
|
|
if err := setupServer.ListenAndServe(); err != http.ErrServerClosed {
|
|
logger.Printf("[ERROR] Setup HTTP server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Wait for signal
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
sig := <-sigCh
|
|
logger.Printf("[INFO] Received signal %v, shutting down setup wizard...", sig)
|
|
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer shutdownCancel()
|
|
mainServer.Shutdown(shutdownCtx)
|
|
setupServer.Shutdown(shutdownCtx)
|
|
logger.Println("[INFO] Setup wizard stopped")
|
|
}
|
|
|
|
// 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) {
|
|
|
|
encKeyPath := filepath.Join(cfg.Paths.DataDir, "encryption.key")
|
|
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,
|
|
encKeyPath,
|
|
cfg.Paths.SystemDataPath,
|
|
sett, stackProv, logger,
|
|
)
|
|
if err != nil {
|
|
logger.Printf("[WARN] Failed to build infra backup for local write: %v", err)
|
|
return
|
|
}
|
|
|
|
data, err := json.Marshal(ib)
|
|
if err != nil {
|
|
logger.Printf("[WARN] Failed to marshal infra backup for local write: %v", err)
|
|
return
|
|
}
|
|
|
|
// 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, cfg.Logging.Level == "debug")
|
|
}
|
|
|
|
// discoverHDDPaths scans deployed apps' app.yaml for HDD_PATH env values.
|
|
func discoverHDDPaths(stacksDir string, logger *log.Logger) []string {
|
|
entries, err := os.ReadDir(stacksDir)
|
|
if err != nil {
|
|
logger.Printf("[WARN] Cannot read stacks dir for HDD path discovery: %v", err)
|
|
return nil
|
|
}
|
|
seen := make(map[string]bool)
|
|
var paths []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
appCfg := stacks.LoadAppConfig(filepath.Join(stacksDir, e.Name()))
|
|
if appCfg == nil || !appCfg.Deployed {
|
|
continue
|
|
}
|
|
if hddPath, ok := appCfg.Env["HDD_PATH"]; ok && hddPath != "" {
|
|
cleaned := filepath.Clean(hddPath)
|
|
if !seen[cleaned] {
|
|
seen[cleaned] = true
|
|
paths = append(paths, cleaned)
|
|
}
|
|
}
|
|
}
|
|
return paths
|
|
}
|