package api import ( "bytes" "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/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/integrations" "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 metricsStore *metrics.MetricsStore updater *selfupdate.Updater notifier *notify.Notifier logger *log.Logger // restart triggers a graceful self-restart (config-apply + the manual restart button). // Defaults to a real exit-after-flush so Docker's restart:unless-stopped brings the // process back with fresh config; tests inject a recorder via SetRestarter. restart func() // triggerReportPush fires an out-of-band, non-blocking hub report push (e.g. after a // geo settings change so the hub reflects the new state immediately). Nil = no-op. triggerReportPush 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, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router { r := &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger} r.restart = func() { gracefulSelfRestart(r.logger) } return r } // SetRestarter overrides the graceful-restart action. Tests inject a recorder so the // process is not actually killed. func (r *Router) SetRestarter(fn func()) { r.restart = fn } // SetReportPushTrigger wires the out-of-band hub report push used after geo changes. // The provided func MUST be non-blocking (it is called from request handlers). func (r *Router) SetReportPushTrigger(fn func()) { r.triggerReportPush = fn } // reportPushNow fires the report-push trigger if wired. Called after a state change the // hub should reflect immediately (geo settings/sync) instead of waiting for the next cycle. func (r *Router) reportPushNow() { if r.triggerReportPush != nil { r.triggerReportPush() } } 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) // F4: /api/stacks/rescan with a non-POST method must be a clear 405, not fall through to the // GET /stacks/{name} lookup below (which returned the misleading "stack not found: rescan"). case path == "/stacks/rescan": w.Header().Set("Allow", http.MethodPost) writeJSON(w, http.StatusMethodNotAllowed, apiResponse{OK: false, Error: "method not allowed: use POST /api/stacks/rescan"}) // 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) // POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount) case path == "/selfrestart" && req.Method == http.MethodPost: r.selfRestart(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/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) // POST /api/backup/tier2 — run off-drive Tier 2 copies for all HDD apps case path == "/backup/tier2" && req.Method == http.MethodPost: r.triggerTier2(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("[INFO] [api] Manual stack rescan requested") if err := r.stackMgr.ScanStacks(); err != nil { r.logger.Printf("[ERROR] [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("[INFO] [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("[INFO] [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 } // Prevention layer (storage-split): refuse a deploy when the Docker-data volume is at/under its // reserved buffer, so customer apps can't fill the volume the infra containers (controller, // traefik, cloudflared, filebrowser) depend on. Fail-OPEN on a measurement error — the buffer is // a safety net, not a security control, so a transient statfs failure must not block all deploys. if hr := system.GetDockerVolumeHeadroom(); hr.OK && hr.BelowReserve { r.logger.Printf("[WARN] [api] Deploy refused for %s: Docker volume below reserved buffer (%.1fG free, reserve %.1fG of %.0fG)", name, hr.AvailGB, hr.ReserveGB, hr.TotalGB) writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false, Error: fmt.Sprintf( "Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.", hr.AvailGB, hr.ReserveGB)}) return } deployReq := stacks.DeployRequest{ StackName: name, Values: body.Values, } warning, err := r.stackMgr.DeployStack(deployReq) if err != nil { r.logger.Printf("[ERROR] [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 } // F6: the deploy runs asynchronously (compose pull/up + health happen after this returns; the UI // polls GET /api/stacks/{name}). The old "Stack X deployed" message asserted completion before it // was true — misleading for API/script consumers. Report that the deploy STARTED, not that it // finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI. resp := apiResponse{OK: true, Message: "Telepítés elindítva – az állapot a kártyán követhető"} if warning != "" { resp.Data = map[string]string{"warning": warning} } writeJSON(w, http.StatusAccepted, 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) } } // startGatedByMissingDrive reports whether starting `name` must be BLOCKED because the drive its // HDD_PATH points at is currently disconnected or decommissioned. Returns the storage path for the // message. SSD-resident apps (no HDD_PATH) are never gated. func (r *Router) startGatedByMissingDrive(name string) (bool, string) { cfg := r.stackMgr.LoadAppConfigByName(name) if cfg == nil { return false, "" } hdd := cfg.Env["HDD_PATH"] if hdd == "" { return false, "" } for _, sp := range r.sett.GetStoragePaths() { if sp.Path == hdd && (sp.Disconnected || sp.Decommissioned) { return true, hdd } } return false, "" } func (r *Router) actionStack(w http.ResponseWriter, action, name string) { r.logger.Printf("[INFO] [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 } // Drive-absent gate: refuse to start an app whose data drive is currently disconnected/decommissioned // (the intermediary-mount gate). Starting it would let it write to the empty fail-closed stable path // or just crash-loop; block with a clear message until the drive returns (then the gate auto-restarts). if action == "start" { if gated, hdd := r.startGatedByMissingDrive(name); gated { writeJSON(w, http.StatusConflict, apiResponse{ OK: false, Error: fmt.Sprintf("A(z) %s tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.", hdd), }) 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 { r.logger.Printf("[ERROR] [api] %s failed for %s: %v", action, name, err) 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("[INFO] [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("[ERROR] [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("[INFO] [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("[ERROR] [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 felhom-data namespace root for this stack (drive-resident apps: the in-guest // mount IS the namespace; SSD-only: /felhom-data). Passing the namespace root // (not the raw drive) keeps GetStackBackupData's paths single-nested under Model A. var nsRoot string if r.backupMgr != nil { nsRoot = r.backupMgr.AppNamespaceRoot(name) } resp, err := r.stackMgr.GetStackBackupData(name, nsRoot) 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("[INFO] [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. Disk-tier (cross-drive rsync) // backup has moved to the host agent; only the app-data DB-dump path is removed here. var backupPaths []string if body.RemoveBackups && r.backupMgr != nil { nsRoot := r.backupMgr.AppNamespaceRoot(name) if nsRoot != "" { backupPaths = append(backupPaths, backup.AppDBDumpPath(nsRoot, name)) } } resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths) if err != nil { r.logger.Printf("[ERROR] [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] [api] 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("[INFO] [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("[ERROR] [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("[INFO] [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("[INFO] [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) // F1: GetInfo now reports the guest RAM cap (from the Docker daemon) as TotalMemMB, but the guest-wide // "used" is not observable from the container. Report the controller's accurate committed-app memory // (sum of running apps' mem requests) as used — a meaningful "allocated of cap" figure for the UI. if r.stackMgr != nil && info.TotalMemMB > 0 { if reqMB, _ := r.stackMgr.CommittedMemory(); reqMB >= 0 { used := uint64(reqMB) if used > info.TotalMemMB { used = info.TotalMemMB } info.UsedMemMB = used info.AvailMemMB = info.TotalMemMB - used info.MemPercent = float64(used) / float64(info.TotalMemMB) * 100 } } 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 := 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), } } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data}) } // triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has // moved to the host agent (slice 8C). 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("[INFO] [api] Manual app-data backup (DB dump) triggered") go r.backupMgr.RunDBDumps(context.Background()) writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"}) } // triggerTier2 runs the off-drive Tier 2 copies for all HDD apps (recovery unit + userdata to a // different physical disk). Auto-targets and applies the rootfs-headroom guard internally. func (r *Router) triggerTier2(w http.ResponseWriter, _ *http.Request) { if r.backupMgr == nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"}) return } if r.backupMgr.IsRunning() { writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"}) return } r.logger.Println("[INFO] [api] Manual Tier 2 (off-drive) backup triggered") go r.backupMgr.RunAllTier2() writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "2. mentés elindítva"}) } // --- 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 { r.logger.Printf("[ERROR] [api] Failed to query system metrics: %v", err) 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 { r.logger.Printf("[ERROR] [api] Failed to query container summary: %v", err) 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 { r.logger.Printf("[ERROR] [api] Failed to query container metrics for %s: %v", name, err) 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}) } // 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("[INFO] [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("[WARN] [api] Config apply rejected: invalid YAML: %v", err) writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)}) return } // No-op guard: if the pushed config is byte-identical to what is already on disk, do // nothing — don't rewrite, don't restart. The hub may re-push the same config // idempotently, and a self-restart on every push would be a needless flap. if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) { r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body)) writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "A konfiguráció változatlan — nincs szükség újraindításra."}) return } // Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in // plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the // target file already existed with looser perms (os.WriteFile does not chmod an existing file). if err := writeConfig0600(r.configPath, body); err != nil { r.logger.Printf("[ERROR] [api] Config apply: failed to write config: %v", err) writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"}) return } // Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes // effect on restart — singletons such as the Cloudflare client are built once at // startup, so an in-process write alone would leave e.g. a rotated CF token unused. r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body)) writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Konfiguráció alkalmazva — a vezérlő újraindul."}) flushResponse(w) if r.restart != nil { r.restart() } } // writeConfig0600 writes config bytes to path with mode 0600, atomically when possible (tmp+rename), // falling back to a direct write for Docker bind mounts (where os.Rename returns EBUSY). It ALWAYS // enforces 0600 on the final file — even if it already existed with looser perms — because controller.yaml // holds infra credentials in plaintext (F8); os.WriteFile only applies the mode when creating a new file. func writeConfig0600(path string, body []byte) error { tmpPath := path + ".tmp" if err := os.WriteFile(tmpPath, body, 0600); err != nil { return fmt.Errorf("writing temp config: %w", err) } if err := os.Rename(tmpPath, path); err != nil { os.Remove(tmpPath) if err := os.WriteFile(path, body, 0600); err != nil { // bind-mount fallback return fmt.Errorf("writing config: %w", err) } } if err := os.Chmod(path, 0600); err != nil { return fmt.Errorf("chmod config 0600: %w", err) } return nil } 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("[INFO] [api] Manual asset sync requested") go func() { if err := r.assetsSyncer.Sync(context.Background()); err != nil { r.logger.Printf("[WARN] [api] 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] [api] 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 }