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 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 16:13:35 +01:00
parent dc5209288b
commit 85d1f2f673
5 changed files with 109 additions and 7 deletions
+63 -2
View File
@@ -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)
+26
View File
@@ -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 == "" {
+12
View File
@@ -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
+1
View File
@@ -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"`