Files
felhom-controller/controller/internal/api/router.go
T
admin 55c896624f controller v0.67.0: intermediary-mount — HDD_PATH repoint + drive-absent gate + H1 routes
Drives are visible in-guest only at the STABLE /mnt/felhom-drives/<name>; the
registered path + HDD_PATH + FileBrowser source repoint there while agent calls
map back to raw /mnt/<name> (agentWhere). Enroll binds-under-parent before
register. Drive-absent GATE (planDriveGates + 30s driveGateLoop) stops/blocks
apps when a drive vanishes and auto-restarts on return; start-gate refuses start
when the drive is absent. H1 endpoints (disconnect/reconnect/restart-apps) routed
onto host-side ops. Non-hollow tests + companions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:45:13 +02:00

1155 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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
// OnConfigApplied is called after a successful config apply (e.g., to push infra backup).
OnConfigApplied 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 {
return &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, 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)
// 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)
// --- 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: <systemDataPath>/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
}
// 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
}
r.logger.Printf("[INFO] [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()
}
}
// 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
}