From 85d1f2f673850b8cbeb6b1f281455eebb6ca0422 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 20 Feb 2026 16:13:35 +0100 Subject: [PATCH] feat: add config apply endpoint and config hash in reports - POST /api/config/apply: accepts YAML body from Hub, validates and writes controller.yaml atomically (tmp+rename) - GET /api/config/hash: returns SHA256 hash of current config file - Report payload now includes config_hash field for Hub comparison - Config endpoints use same dual auth as self-update (session OR Bearer) - config.LoadFromBytes() for validation without file I/O - config.FileHash() helper for SHA256 computation Co-Authored-By: Claude Opus 4.6 --- controller/cmd/controller/main.go | 12 ++--- controller/internal/api/router.go | 65 ++++++++++++++++++++++++++- controller/internal/config/config.go | 26 +++++++++++ controller/internal/report/builder.go | 12 +++++ controller/internal/report/types.go | 1 + 5 files changed, 109 insertions(+), 7 deletions(-) diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 8ae5c46..3b7f90a 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -378,7 +378,7 @@ func main() { pushInterval = 15 * time.Minute } sched.Every("hub-report", pushInterval, func(ctx context.Context) error { - r := report.BuildReport(cfg, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths()) + r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths()) return hubPusher.Push(r) }) logger.Printf("[INFO] Hub reporting enabled (every %s to %s)", pushInterval, cfg.Hub.URL) @@ -434,7 +434,7 @@ func main() { }) if hubPusher != nil { storageWatchdog.SetHubReportPusher(func() { - r := report.BuildReport(cfg, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths()) + r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths()) hubPusher.Push(r) }) } @@ -472,7 +472,7 @@ func main() { // Hub report if hubPusher != nil { if cfg.Hub.Enabled { - r := report.BuildReport(cfg, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths()) + r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths()) var pushErr error for attempt := 1; attempt <= 3; attempt++ { pushErr = hubPusher.Push(r) @@ -547,7 +547,7 @@ func main() { }() // --- Initialize API router --- - apiRouter := api.NewRouter(cfg, sett, stackMgr, syncer, cpuCollector, backupMgr, crossDriveRunner, metricsStore, updater, logger) + apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, crossDriveRunner, metricsStore, updater, logger) // --- Initialize web server --- webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version) @@ -578,7 +578,7 @@ func main() { } if hubPusher != nil { driveMigrator.PushHubReport = func() { - r := report.BuildReport(cfg, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths()) + r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths()) hubPusher.Push(r) } driveMigrator.PushInfraBackup = func() { @@ -610,6 +610,8 @@ func main() { mux.Handle("/api/storage/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeStorageAPI))) // Self-update API — accepts session auth OR hub API key (for external triggering) mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, http.HandlerFunc(apiRouter.ServeHTTP))) + // Config API — accepts session auth OR hub API key (for Hub config push) + mux.Handle("/api/config/", selfUpdateAuthMiddleware(cfg, webServer, http.HandlerFunc(apiRouter.ServeHTTP))) mux.Handle("/api/", webServer.RequireAuth(http.HandlerFunc(apiRouter.ServeHTTP))) // Web UI routes (auth required) diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index f2ef844..aaa7bff 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -4,8 +4,11 @@ import ( "context" "encoding/json" "fmt" + "io" "log" "net/http" + "os" + "path/filepath" "strconv" "strings" "time" @@ -23,6 +26,7 @@ import ( // Router handles all /api/* requests. type Router struct { cfg *config.Config + configPath string sett *settings.Settings stackMgr *stacks.Manager syncer *catalogsync.Syncer @@ -34,8 +38,8 @@ type Router struct { logger *log.Logger } -func NewRouter(cfg *config.Config, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, logger *log.Logger) *Router { - return &Router{cfg: cfg, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, logger: logger} +func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, logger *log.Logger) *Router { + return &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, logger: logger} } type apiResponse struct { @@ -75,6 +79,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case path == "/selfupdate/update" && req.Method == http.MethodPost: r.selfupdateTrigger(w, req) + // POST /api/config/apply — Hub pushes generated YAML to update controller.yaml + case path == "/config/apply" && req.Method == http.MethodPost: + r.configApply(w, req) + + // GET /api/config/hash — return current config file hash + case path == "/config/hash" && req.Method == http.MethodGet: + r.configHash(w, req) + // GET /api/stacks/{name}/deploy-fields case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet: r.getDeployFields(w, req, extractName(path, "/deploy-fields")) @@ -901,6 +913,55 @@ func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"}) } +// --- Config apply handler --- + +func (r *Router) configApply(w http.ResponseWriter, req *http.Request) { + // Read YAML body (limit to 1MB) + body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20)) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"}) + return + } + if len(body) == 0 { + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"}) + return + } + + // Validate it's parseable YAML by attempting to load it + if _, err := config.LoadFromBytes(body); err != nil { + r.logger.Printf("[API] Config apply rejected: invalid YAML: %v", err) + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)}) + return + } + + // Atomic write: write to .tmp, then rename + tmpPath := r.configPath + ".tmp" + if err := os.WriteFile(tmpPath, body, 0644); err != nil { + r.logger.Printf("[ERROR] Config apply: failed to write temp file: %v", err) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to write config"}) + return + } + + if err := os.Rename(tmpPath, r.configPath); err != nil { + os.Remove(tmpPath) + r.logger.Printf("[ERROR] Config apply: failed to rename temp file: %v", err) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"}) + return + } + + r.logger.Printf("[API] Config applied from Hub (%d bytes), restart needed to take effect", len(body)) + writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Config applied. Restart controller to apply changes."}) +} + +func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) { + hash, err := config.FileHash(r.configPath) + if err != nil { + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"}) + return + } + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}}) +} + func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/controller/internal/config/config.go b/controller/internal/config/config.go index 2b714d3..2bdb95d 100644 --- a/controller/internal/config/config.go +++ b/controller/internal/config/config.go @@ -1,6 +1,8 @@ package config import ( + "crypto/sha256" + "encoding/hex" "fmt" "os" "strings" @@ -170,6 +172,30 @@ func Load(path string) (*Config, error) { return cfg, nil } +// LoadFromBytes parses YAML config from raw bytes (for validation without file I/O). +func LoadFromBytes(data []byte) (*Config, error) { + expanded := os.ExpandEnv(string(data)) + cfg := &Config{} + if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + applyDefaults(cfg) + if err := validate(cfg); err != nil { + return nil, err + } + return cfg, nil +} + +// FileHash returns the SHA256 hex digest of the config file at the given path. +func FileHash(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]), nil +} + func applyDefaults(cfg *Config) { d := func(val *string, def string) { if *val == "" { diff --git a/controller/internal/report/builder.go b/controller/internal/report/builder.go index 023cbc0..3e211b3 100644 --- a/controller/internal/report/builder.go +++ b/controller/internal/report/builder.go @@ -1,7 +1,10 @@ package report import ( + "crypto/sha256" + "encoding/hex" "fmt" + "os" "strconv" "strings" "time" @@ -19,6 +22,7 @@ import ( // BuildReport collects current state from all subsystems and returns a Report. func BuildReport( cfg *config.Config, + configPath string, stackMgr *stacks.Manager, backupMgr *backup.Manager, cpuCollector *system.CPUCollector, @@ -39,6 +43,14 @@ func BuildReport( r.ControllerURL = fmt.Sprintf("https://felhom.%s", cfg.Customer.Domain) } + // Config hash for Hub comparison + if configPath != "" { + if data, err := os.ReadFile(configPath); err == nil { + h := sha256.Sum256(data) + r.ConfigHash = hex.EncodeToString(h[:]) + } + } + // System info staticInfo := metrics.GetStaticInfo() hddPath := cfg.Paths.HDDPath diff --git a/controller/internal/report/types.go b/controller/internal/report/types.go index 3a88920..cf5d68a 100644 --- a/controller/internal/report/types.go +++ b/controller/internal/report/types.go @@ -9,6 +9,7 @@ type Report struct { CustomerName string `json:"customer_name"` ControllerVersion string `json:"controller_version"` ControllerURL string `json:"controller_url,omitempty"` + ConfigHash string `json:"config_hash,omitempty"` Timestamp time.Time `json:"timestamp"` ReportingDisabled bool `json:"reporting_disabled,omitempty"` System SystemReport `json:"system"`