Files
deploy-felhom-compose/controller/internal/api/router.go
T
admin 95c821deb2 feat: comprehensive debug logging across all controller modules
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>
2026-02-26 18:14:43 +01:00

1258 lines
42 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// Router handles all /api/* requests.
type Router struct {
cfg *config.Config
configPath string
sett *settings.Settings
stackMgr *stacks.Manager
syncer *catalogsync.Syncer
cpuCollector *system.CPUCollector
backupMgr *backup.Manager
crossDriveRunner *backup.CrossDriveRunner
metricsStore *metrics.MetricsStore
updater *selfupdate.Updater
notifier *notify.Notifier
logger *log.Logger
// OnConfigApplied is called after a successful config apply (e.g., to push infra backup).
OnConfigApplied func()
// OnCrossDriveComplete is called after a manual cross-drive backup completes (to push infra backup to Hub).
OnCrossDriveComplete func()
// OnGeoRelevantChange is called after deploy/remove to re-sync geo rules.
OnGeoRelevantChange func()
// Asset syncer for on-demand Hub asset sync
assetsSyncer *assets.Syncer
// Geo-restriction sync manager
geoSync *cf.GeoSyncManager
// App-to-app integration manager (nil if not configured)
integrationMgr *integrations.Manager
debug bool
}
// SetDebug enables or disables debug logging for API routing.
func (r *Router) SetDebug(on bool) {
r.debug = on
}
func (r *Router) dbg(format string, args ...interface{}) {
if r.debug {
r.logger.Printf("[DEBUG] [api] "+format, args...)
}
}
// SetAssetsSyncer sets the Hub asset syncer for on-demand sync triggers.
func (r *Router) SetAssetsSyncer(as *assets.Syncer) {
r.assetsSyncer = as
}
// SetGeoSync sets the geo-restriction sync manager.
func (r *Router) SetGeoSync(gs *cf.GeoSyncManager) {
r.geoSync = gs
}
// SetIntegrationManager sets the app-to-app integration manager.
func (r *Router) SetIntegrationManager(im *integrations.Manager) {
r.integrationMgr = im
}
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, notif *notify.Notifier, 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, notifier: notif, logger: logger}
}
type apiResponse struct {
OK bool `json:"ok"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
// ServeHTTP routes /api/* requests.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/api")
path = strings.TrimSuffix(path, "/")
r.dbg("%s %s (path=%s)", req.Method, req.URL.Path, path)
switch {
// GET /api/stacks
case path == "/stacks" && req.Method == http.MethodGet:
r.listStacks(w, req)
// POST /api/stacks/rescan — re-scan stacks directory for new/removed stacks
case path == "/stacks/rescan" && req.Method == http.MethodPost:
r.rescanStacks(w, req)
// GET /api/stacks/{name}
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodGet && !hasSubpath(path, "/stacks/"):
r.getStack(w, req, trimSegment(path, "/stacks/"))
// GET /api/selfupdate/status — must be before hasSuffix-based stack cases
case path == "/selfupdate/status" && req.Method == http.MethodGet:
r.selfupdateStatus(w, req)
// POST /api/selfupdate/check — must be before hasSuffix-based stack cases
case path == "/selfupdate/check" && req.Method == http.MethodPost:
r.selfupdateCheck(w, req)
// POST /api/selfupdate/update — must be before hasSuffix("/update") stack case
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/config — return raw controller.yaml content
case path == "/config" && req.Method == http.MethodGet:
r.configContent(w, req)
// --- Integration routes (must be before hasSuffix-based stack cases) ---
// GET /api/integrations/{provider} — list integrations for a provider
case strings.HasPrefix(path, "/integrations/") && !strings.Contains(strings.TrimPrefix(path, "/integrations/"), "/") && req.Method == http.MethodGet:
provider := strings.TrimPrefix(path, "/integrations/")
r.listIntegrations(w, provider)
// POST /api/integrations/{provider}/{target} — toggle integration
case strings.HasPrefix(path, "/integrations/") && req.Method == http.MethodPost:
rest := strings.TrimPrefix(path, "/integrations/")
parts := strings.SplitN(rest, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid integration path"})
return
}
r.toggleIntegration(w, req, parts[0], parts[1])
// GET /api/stacks/{name}/deploy-fields
case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet:
r.getDeployFields(w, req, extractName(path, "/deploy-fields"))
// POST /api/stacks/{name}/deploy
case hasSuffix(path, "/deploy") && req.Method == http.MethodPost:
r.deployStack(w, req, extractName(path, "/deploy"))
// POST /api/stacks/{name}/start
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
r.actionStack(w, "start", extractName(path, "/start"))
// POST /api/stacks/{name}/stop
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
r.actionStack(w, "stop", extractName(path, "/stop"))
// POST /api/stacks/{name}/restart
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
r.actionStack(w, "restart", extractName(path, "/restart"))
// POST /api/stacks/{name}/update
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
r.actionStack(w, "update", extractName(path, "/update"))
// POST /api/stacks/{name}/optional-config
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
r.updateOptionalConfig(w, req, extractName(path, "/optional-config"))
// GET /api/stacks/{name}/logs
case hasSuffix(path, "/logs") && req.Method == http.MethodGet:
r.getStackLogs(w, req, extractName(path, "/logs"))
// GET /api/stacks/{name}/hdd-data
case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet:
r.getStackHDDData(w, req, extractName(path, "/hdd-data"))
// GET /api/stacks/{name}/backup-data
case hasSuffix(path, "/backup-data") && req.Method == http.MethodGet:
r.getStackBackupData(w, req, extractName(path, "/backup-data"))
// POST /api/stacks/{name}/remove — remove a deployed (non-orphaned) stack
case hasSuffix(path, "/remove") && req.Method == http.MethodPost:
r.removeStack(w, req, extractName(path, "/remove"))
// DELETE /api/stacks/{name}
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
r.deleteStack(w, req, trimSegment(path, "/stacks/"))
// POST /api/stacks/{name}/cross-backup — save cross-drive config
case hasSuffix(path, "/cross-backup") && req.Method == http.MethodPost && !hasSuffix(path, "/cross-backup/run") && !hasSuffix(path, "/cross-backup/status"):
r.saveCrossBackupConfig(w, req, extractName(path, "/cross-backup"))
// POST /api/stacks/{name}/cross-backup/run — trigger manual run
case hasSuffix(path, "/cross-backup/run") && req.Method == http.MethodPost:
r.triggerCrossBackup(w, req, extractName(path, "/cross-backup/run"))
// GET /api/stacks/{name}/cross-backup/status — poll status
case hasSuffix(path, "/cross-backup/status") && req.Method == http.MethodGet:
r.getCrossBackupStatus(w, req, extractName(path, "/cross-backup/status"))
// POST /api/backup/cross-drive/run-all — trigger all scheduled cross-drive backups
case path == "/backup/cross-drive/run-all" && req.Method == http.MethodPost:
r.triggerAllCrossBackups(w, req)
// POST /api/sync — trigger immediate catalog sync
case path == "/sync" && req.Method == http.MethodPost:
r.triggerSync(w, req)
// GET /api/system/info
case path == "/system/info" && req.Method == http.MethodGet:
r.systemInfo(w, req)
// GET /api/backup/status
case path == "/backup/status" && req.Method == http.MethodGet:
r.backupStatus(w, req)
// POST /api/backup/run
case path == "/backup/run" && req.Method == http.MethodPost:
r.triggerBackup(w, req)
// GET /api/backup/snapshots
case path == "/backup/snapshots" && req.Method == http.MethodGet:
r.backupSnapshots(w, req)
// GET /api/metrics/system
case path == "/metrics/system" && req.Method == http.MethodGet:
r.metricsSystem(w, req)
// GET /api/metrics/containers/summary
case path == "/metrics/containers/summary" && req.Method == http.MethodGet:
r.metricsContainerSummary(w, req)
// GET /api/metrics/containers/{name}
case strings.HasPrefix(path, "/metrics/containers/") && req.Method == http.MethodGet:
name := strings.TrimPrefix(path, "/metrics/containers/")
r.metricsContainer(w, req, name)
// GET /api/metrics/sysinfo
case path == "/metrics/sysinfo" && req.Method == http.MethodGet:
r.metricsSysInfo(w, req)
// POST /api/assets/sync — trigger immediate asset sync from Hub
case path == "/assets/sync" && req.Method == http.MethodPost:
r.triggerAssetSync(w, req)
// GET /api/assets/status — get asset sync status
case path == "/assets/status" && req.Method == http.MethodGet:
r.assetSyncStatus(w, req)
// --- Geo-restriction endpoints ---
// GET /api/geo/status — current geo settings + sync state
case path == "/geo/status" && req.Method == http.MethodGet:
r.geoStatus(w, req)
// POST /api/geo/settings — update global geo settings
case path == "/geo/settings" && req.Method == http.MethodPost:
r.geoUpdateSettings(w, req)
// POST /api/geo/sync — trigger manual Cloudflare sync
case path == "/geo/sync" && req.Method == http.MethodPost:
r.geoTriggerSync(w, req)
// GET /api/geo/countries — full country list for search UI
case path == "/geo/countries" && req.Method == http.MethodGet:
r.geoCountries(w, req)
// POST /api/stacks/{name}/geo/override — set per-app geo override
case hasSuffix(path, "/geo/override") && req.Method == http.MethodPost:
r.geoSetAppOverride(w, req, extractName(path, "/geo/override"))
// DELETE /api/stacks/{name}/geo/override — remove per-app geo override
case hasSuffix(path, "/geo/override") && req.Method == http.MethodDelete:
r.geoRemoveAppOverride(w, req, extractName(path, "/geo/override"))
default:
r.dbg("no matching route: %s %s", req.Method, path)
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
}
}
// HealthHandler responds to /api/health (no auth required).
func (r *Router) HealthHandler(w http.ResponseWriter, req *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "felhom-controller is healthy"})
}
// --- Stack handlers ---
func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()})
}
func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
r.logger.Printf("[API] Manual stack rescan requested")
if err := r.stackMgr.ScanStacks(); err != nil {
r.logger.Printf("[API] Stack rescan failed: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
stackCount := len(r.stackMgr.GetStacks())
r.logger.Printf("[API] Stack rescan completed: %d stacks found", stackCount)
writeJSON(w, http.StatusOK, apiResponse{
OK: true,
Message: fmt.Sprintf("Rescan completed: %d stacks found", stackCount),
})
}
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
stack, ok := r.stackMgr.GetStack(name)
if !ok {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
}
func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) {
meta, appCfg, err := r.stackMgr.GetDeployFields(name)
if err != nil {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
return
}
data := map[string]interface{}{
"metadata": meta,
"app_config": appCfg,
"domain": r.cfg.Customer.Domain,
"logo_url": r.cfg.AppLogoURL(meta.Slug),
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
limitBody(w, req)
r.logger.Printf("[API] Deploy requested for stack: %s", name)
r.dbg("deployStack: name=%s contentLength=%d", name, req.ContentLength)
var body struct {
Values map[string]string `json:"values"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
return
}
deployReq := stacks.DeployRequest{
StackName: name,
Values: body.Values,
}
warning, err := r.stackMgr.DeployStack(deployReq)
if err != nil {
r.logger.Printf("[API] Deploy failed for %s: %v", name, err)
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "already deployed") {
status = http.StatusConflict
}
if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "kötelező") || strings.Contains(err.Error(), "memória") {
status = http.StatusBadRequest
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
return
}
resp := apiResponse{OK: true, Message: "Stack " + name + " deployed"}
if warning != "" {
resp.Data = map[string]string{"warning": warning}
}
writeJSON(w, http.StatusOK, resp)
// Push app deployed event to Hub
if r.notifier != nil {
displayName := name
if s, ok := r.stackMgr.GetStack(name); ok && s.Meta.DisplayName != "" {
displayName = s.Meta.DisplayName
}
r.notifier.NotifyAppDeployed(name, displayName)
}
// Re-sync geo rules (new hostname may need to be added)
if r.OnGeoRelevantChange != nil {
go r.OnGeoRelevantChange()
}
// Re-apply integrations that target this newly deployed stack
if r.integrationMgr != nil {
go r.integrationMgr.OnStackStart(context.Background(), name)
}
}
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
r.logger.Printf("[API] %s requested for stack: %s", action, name)
r.dbg("actionStack: action=%s name=%s", action, name)
// Protected stacks only allow restart — block all other actions
if r.cfg.IsProtectedStack(name) && action != "restart" {
writeJSON(w, http.StatusForbidden, apiResponse{OK: false, Error: fmt.Sprintf("cannot %s protected stack %s", action, name)})
return
}
// Memory check before starting a stopped app
if action == "start" {
stackMemMB := r.stackMgr.StackMemoryMB(name)
if stackMemMB > 0 {
if totalMB, usedMB, memErr := system.GetMemoryMB(); memErr == nil {
reservedMB := r.cfg.System.ReservedMemoryMB
usableMB := totalMB - reservedMB
if usableMB < 0 {
usableMB = 0
}
afterMB := usedMB + stackMemMB
if afterMB > usableMB {
writeJSON(w, http.StatusConflict, apiResponse{
OK: false,
Error: fmt.Sprintf("Nincs elég memória az indításhoz. Szükséges: %d MB, elérhető: %d MB (használt: %d MB / használható: %d MB)", stackMemMB, usableMB-usedMB, usedMB, usableMB),
})
return
}
}
}
}
var err error
switch action {
case "start":
err = r.stackMgr.StartStack(name)
case "stop":
err = r.stackMgr.StopStack(name)
case "restart":
err = r.stackMgr.RestartStack(name)
case "update":
err = r.stackMgr.UpdateStack(name)
}
if err != nil {
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
// Trigger integration lifecycle hooks after successful action
if r.integrationMgr != nil {
switch action {
case "start", "restart":
go r.integrationMgr.OnStackStart(context.Background(), name)
case "stop":
go r.integrationMgr.OnStackStop(context.Background(), name)
}
}
}
func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) {
limitBody(w, req)
r.logger.Printf("[API] Optional config update requested for stack: %s", name)
var body struct {
Values map[string]string `json:"values"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
return
}
if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil {
r.logger.Printf("[API] Optional config update failed for %s: %v", name, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"})
}
// --- Integration API handlers ---
func (r *Router) listIntegrations(w http.ResponseWriter, provider string) {
if r.integrationMgr == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []integrations.StatusInfo{}})
return
}
list := r.integrationMgr.ListForProvider(provider)
if list == nil {
list = []integrations.StatusInfo{}
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: list})
}
func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, provider, target string) {
limitBody(w, req)
if r.integrationMgr == nil {
writeJSON(w, http.StatusServiceUnavailable, apiResponse{OK: false, Error: "integrations not available"})
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
return
}
action := "enable"
if !body.Enabled {
action = "disable"
}
r.logger.Printf("[API] Integration %s requested: %s:%s", action, provider, target)
state, err := r.integrationMgr.Toggle(req.Context(), provider, target, body.Enabled)
if err != nil {
r.logger.Printf("[API] Integration toggle failed for %s:%s: %v", provider, target, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
msg := "Integráció engedélyezve"
if !body.Enabled {
msg = "Integráció kikapcsolva"
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: state, Message: msg})
}
func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) {
lines := 100
if v := req.URL.Query().Get("lines"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
lines = n
if lines > 10000 {
lines = 10000
}
}
}
output, err := r.stackMgr.GetLogs(name, lines)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
}
func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) {
resp, err := r.stackMgr.GetStackHDDData(name)
if err != nil {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
}
func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) {
if name == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
return
}
// Compute the drive path for this stack (HDD or system data path)
var drivePath string
if r.crossDriveRunner != nil {
drivePath = r.crossDriveRunner.GetAppDrivePath(name)
}
resp, err := r.stackMgr.GetStackBackupData(name, drivePath)
if err != nil {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
}
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
if name == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
return
}
limitBody(w, req)
r.logger.Printf("[API] Remove requested for stack: %s", name)
r.dbg("removeStack: name=%s", name)
var body struct {
RemoveHDDData bool `json:"remove_hdd_data"`
RemoveBackups bool `json:"remove_backups"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
body.RemoveHDDData = false
body.RemoveBackups = false
}
r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups)
// Compute backup paths to remove if requested
var backupPaths []string
if body.RemoveBackups && r.crossDriveRunner != nil {
drivePath := r.crossDriveRunner.GetAppDrivePath(name)
if drivePath != "" {
backupPaths = append(backupPaths,
backup.AppDBDumpPath(drivePath, name),
backup.AppSecondaryRsyncPath(drivePath, name),
)
}
}
resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths)
if err != nil {
r.logger.Printf("[API] Remove failed for %s: %v", name, err)
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
status = http.StatusConflict
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
return
}
// Clean up cross-drive backup config for this stack
if r.sett != nil {
if err := r.sett.SetCrossDriveConfig(name, nil); err != nil {
r.logger.Printf("[WARN] Failed to clean cross-drive config for %s: %v", name, err)
}
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})
// Push app removed event to Hub
if r.notifier != nil {
r.notifier.NotifyAppRemoved(name, name)
}
// Clean up integrations for removed stack
if r.integrationMgr != nil {
r.integrationMgr.OnStackRemove(context.Background(), name)
}
// Re-sync geo rules (hostname removed)
if r.OnGeoRelevantChange != nil {
go r.OnGeoRelevantChange()
}
}
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
limitBody(w, req)
r.logger.Printf("[API] Delete requested for stack: %s", name)
var body struct {
RemoveHDDData bool `json:"remove_hdd_data"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
body.RemoveHDDData = false
}
resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData)
if err != nil {
r.logger.Printf("[API] Delete failed for %s: %v", name, err)
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") {
status = http.StatusConflict
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"})
}
func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
r.logger.Println("[API] Manual catalog sync requested")
result := r.syncer.TriggerSync()
if !result.OK {
writeJSON(w, http.StatusTooManyRequests, apiResponse{OK: false, Error: result.Message})
return
}
r.logger.Printf("[API] Catalog sync completed: %s", result.Message)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: result.Message, Data: result})
}
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
info := system.GetInfo(r.cfg.Paths.HDDPath, r.cpuCollector)
syncStatus := r.syncer.Status()
data := map[string]interface{}{
"system": info,
"sync_status": syncStatus,
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}
// --- Backup handlers ---
func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) {
if r.backupMgr == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
"enabled": false,
}})
return
}
dbDump, backupSt := r.backupMgr.GetStatus()
data := map[string]interface{}{
"enabled": true,
"running": r.backupMgr.IsRunning(),
}
if dbDump != nil {
data["db_dump"] = map[string]interface{}{
"last_run": dbDump.LastRun,
"success": dbDump.Success,
"duration": dbDump.Duration.String(),
"count": len(dbDump.Results),
}
}
if backupSt != nil {
backupData := map[string]interface{}{
"last_run": backupSt.LastRun,
"success": backupSt.Success,
"duration": backupSt.Duration.String(),
}
if backupSt.Snapshot != nil {
backupData["snapshot_id"] = backupSt.Snapshot.SnapshotID
backupData["files_new"] = backupSt.Snapshot.FilesNew
backupData["data_added"] = backupSt.Snapshot.DataAdded
}
if backupSt.RepoStats != nil {
backupData["repo_size"] = backupSt.RepoStats.TotalSize
backupData["snapshot_count"] = backupSt.RepoStats.SnapshotCount
}
data["backup"] = backupData
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
if r.backupMgr == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
return
}
if r.backupMgr.IsRunning() {
r.dbg("triggerBackup: backup already running, rejecting")
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
return
}
r.logger.Println("[API] Manual backup triggered")
go r.backupMgr.RunFullBackup(context.Background())
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
}
func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
if r.backupMgr == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
return
}
snapshots, err := r.backupMgr.ListAllSnapshots(50)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
// Enrich snapshots with drive labels from storage paths
if r.sett != nil {
storagePaths := r.sett.GetStoragePaths()
for i := range snapshots {
repoPath := snapshots[i].RepoPath
for _, sp := range storagePaths {
if strings.HasPrefix(repoPath, sp.Path) {
snapshots[i].DriveLabel = sp.Label
break
}
}
}
}
if snapshots == nil {
snapshots = []backup.SnapshotInfo{}
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots})
}
// --- Metrics handlers ---
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
if r.metricsStore == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}, "temp": []float64{}, "load1": []float64{}}})
return
}
from, to := parseTimeRange(req)
resolution := parseResolution(req, 200)
samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
// Flatten into arrays for Chart.js
labels := make([]int64, len(samples))
cpu := make([]float64, len(samples))
memory := make([]float64, len(samples))
temp := make([]float64, len(samples))
load1 := make([]float64, len(samples))
for i, s := range samples {
labels[i] = s.Timestamp
cpu[i] = s.CPUPercent
memory[i] = float64(s.MemUsedMB) / 1024.0 // Convert to GB
temp[i] = s.TempCelsius
load1[i] = s.LoadAvg1
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
"labels": labels,
"cpu": cpu,
"memory": memory,
"temp": temp,
"load1": load1,
}})
}
func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) {
if r.metricsStore == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
return
}
summary, err := r.metricsStore.QueryContainerSummary()
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: summary})
}
func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name string) {
if r.metricsStore == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}}})
return
}
from, to := parseTimeRange(req)
resolution := parseResolution(req, 150)
samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
labels := make([]int64, len(samples))
cpu := make([]float64, len(samples))
memory := make([]float64, len(samples))
for i, s := range samples {
labels[i] = s.Timestamp
cpu[i] = s.CPUPercent
memory[i] = s.MemUsageMB
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
"labels": labels,
"cpu": cpu,
"memory": memory,
}})
}
func (r *Router) metricsSysInfo(w http.ResponseWriter, _ *http.Request) {
info := metrics.GetStaticInfo()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
}
// --- Cross-drive backup handlers ---
func (r *Router) saveCrossBackupConfig(w http.ResponseWriter, req *http.Request, name string) {
if r.crossDriveRunner == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
return
}
limitBody(w, req)
var body struct {
Enabled bool `json:"enabled"`
DestinationPath string `json:"destination_path"`
Schedule string `json:"schedule"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
return
}
// Validate schedule
if body.Schedule != "daily" && body.Schedule != "weekly" && body.Schedule != "manual" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "schedule must be 'daily', 'weekly', or 'manual'"})
return
}
// C9: Validate DestinationPath against registered storage paths to prevent path traversal.
if body.Enabled && body.DestinationPath != "" {
registeredPaths := r.sett.GetStoragePaths()
validDest := false
for _, sp := range registeredPaths {
if body.DestinationPath == sp.Path {
validDest = true
break
}
}
if !validDest {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "destination_path must be a registered storage path"})
return
}
}
// Preserve existing runtime status
existing := r.sett.GetCrossDriveConfig(name)
var lastRun, lastStatus, lastError, lastDuration, lastSize string
if existing != nil {
lastRun, lastStatus, lastError, lastDuration, lastSize =
existing.LastRun, existing.LastStatus, existing.LastError, existing.LastDuration, existing.LastSizeHuman
}
cfg := &settings.CrossDriveBackup{
Enabled: body.Enabled,
Method: "rsync",
DestinationPath: body.DestinationPath,
Schedule: body.Schedule,
LastRun: lastRun,
LastStatus: lastStatus,
LastError: lastError,
LastDuration: lastDuration,
LastSizeHuman: lastSize,
}
if err := r.sett.SetCrossDriveConfig(name, cfg); err != nil {
r.logger.Printf("[API] Failed to save cross-drive config for %s: %v", name, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
r.logger.Printf("[API] Cross-drive backup config saved for %s: dest=%s schedule=%s",
name, body.DestinationPath, body.Schedule)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Cross-drive backup configuration saved"})
}
func (r *Router) triggerCrossBackup(w http.ResponseWriter, req *http.Request, name string) {
if r.crossDriveRunner == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
return
}
if r.crossDriveRunner.IsRunning(name) {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
return
}
r.logger.Printf("[API] Cross-drive backup triggered for: %s", name)
go func() {
if err := r.crossDriveRunner.RunAppBackup(context.Background(), name); err != nil {
r.logger.Printf("[API] Cross-drive backup failed for %s: %v", name, err)
}
if r.OnCrossDriveComplete != nil {
r.OnCrossDriveComplete()
}
}()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
}
func (r *Router) getCrossBackupStatus(w http.ResponseWriter, _ *http.Request, name string) {
cfg := r.sett.GetCrossDriveConfig(name)
if cfg == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"configured": false}})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
"configured": true,
"enabled": cfg.Enabled,
"method": "rsync",
"schedule": cfg.Schedule,
"running": r.crossDriveRunner != nil && r.crossDriveRunner.IsRunning(name),
"last_run": cfg.LastRun,
"last_status": cfg.LastStatus,
"last_error": cfg.LastError,
"last_duration": cfg.LastDuration,
"last_size": cfg.LastSizeHuman,
}})
}
func (r *Router) triggerAllCrossBackups(w http.ResponseWriter, _ *http.Request) {
if r.crossDriveRunner == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
return
}
r.logger.Println("[API] All cross-drive backups triggered")
go func() {
ctx := context.Background()
if err := r.crossDriveRunner.RunAllScheduled(ctx, "daily"); err != nil {
r.logger.Printf("[API] Cross-drive run-all error: %v", err)
}
if err := r.crossDriveRunner.RunAllScheduled(ctx, "weekly"); err != nil {
r.logger.Printf("[API] Cross-drive run-all weekly error: %v", err)
}
if r.OnCrossDriveComplete != nil {
r.OnCrossDriveComplete()
}
}()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Összes mentés elindítva"})
}
// parseTimeRange reads range or from/to query params.
func parseTimeRange(req *http.Request) (from, to time.Time) {
to = time.Now()
if rangeStr := req.URL.Query().Get("range"); rangeStr != "" {
switch rangeStr {
case "1h":
from = to.Add(-1 * time.Hour)
case "6h":
from = to.Add(-6 * time.Hour)
case "24h":
from = to.Add(-24 * time.Hour)
case "7d":
from = to.Add(-7 * 24 * time.Hour)
case "30d":
from = to.Add(-30 * 24 * time.Hour)
default:
from = to.Add(-24 * time.Hour) // default 24h
}
return
}
if fromStr := req.URL.Query().Get("from"); fromStr != "" {
if t, err := time.Parse(time.RFC3339, fromStr); err == nil {
from = t
}
}
if toStr := req.URL.Query().Get("to"); toStr != "" {
if t, err := time.Parse(time.RFC3339, toStr); err == nil {
to = t
}
}
if from.IsZero() {
from = to.Add(-24 * time.Hour)
}
return
}
// parseResolution reads the resolution query param.
func parseResolution(req *http.Request, defaultVal int) int {
if v := req.URL.Query().Get("resolution"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return defaultVal
}
// --- Helpers ---
func hasSuffix(path, suffix string) bool { return strings.HasSuffix(path, suffix) }
func hasSubpath(path, prefix string) bool {
rest := strings.TrimPrefix(path, prefix)
return strings.Contains(rest, "/")
}
func trimSegment(path, prefix string) string {
return strings.TrimPrefix(path, prefix)
}
func extractName(path, suffix string) string {
s := strings.TrimPrefix(path, "/stacks/")
name := strings.TrimSuffix(s, suffix)
// C7: Reject path traversal characters — name is used in file paths and Docker commands.
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
return ""
}
return name
}
func (r *Router) selfupdateStatus(w http.ResponseWriter, _ *http.Request) {
if r.updater == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"enabled": false}})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.updater.GetStatus()})
}
func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
if r.updater == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
return
}
result := r.updater.CheckForUpdate()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
}
func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
if r.updater == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
return
}
if err := r.updater.TriggerUpdate("manual"); err != nil {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()})
return
}
r.logger.Println("[API] Manual self-update triggered")
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) {
r.dbg("configApply: contentLength=%d remoteAddr=%s", req.ContentLength, req.RemoteAddr)
// 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
}
// Write config: try atomic rename first, fall back to direct write
// (os.Rename fails on Docker bind mounts with "device or resource busy")
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)
// Rename failed (likely Docker bind mount) — write directly
if err := os.WriteFile(r.configPath, body, 0644); err != nil {
r.logger.Printf("[ERROR] Config apply: failed to write config: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
return
}
r.logger.Printf("[API] Config apply: rename failed, wrote directly (bind mount)")
}
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."})
// Push updated infra backup so Hub has fresh config data immediately
if r.OnConfigApplied != nil {
go r.OnConfigApplied()
}
}
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 (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
data, err := os.ReadFile(r.configPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
return
}
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
w.Write(data)
}
// --- Asset sync handlers ---
func (r *Router) triggerAssetSync(w http.ResponseWriter, req *http.Request) {
if r.assetsSyncer == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: false, Error: "asset sync not configured"})
return
}
r.logger.Println("[API] Manual asset sync requested")
go func() {
if err := r.assetsSyncer.Sync(context.Background()); err != nil {
r.logger.Printf("[WARN] Manual asset sync failed: %v", err)
}
}()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Asset sync started"})
}
func (r *Router) assetSyncStatus(w http.ResponseWriter, _ *http.Request) {
if r.assetsSyncer == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"status": "not_configured"}})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.assetsSyncer.Status()})
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("[ERROR] Failed to write JSON response: %v", err)
}
}
// limitBody wraps the request body with a size limit (default 1MB).
func limitBody(w http.ResponseWriter, req *http.Request) {
req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
}