5fdd2039fd
The previous commit landed only the new test/badge files: a 'git stash' used to compare REUSE.md ref-check output silently dropped the staged index, so every modification to an existing file was left behind and that commit does not build. This adds the metadata field, the predicates, the fail-closed deploy gate, the catalog filter, the funcmap entries, the template edits and the docs that those tests exercise.
1306 lines
48 KiB
Go
1306 lines
48 KiB
Go
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()
|
||
|
||
// classifyFSPath classifies a path's filesystem in this process's namespace (the deploy-time
|
||
// stub gate, RCA fix 2). Defaults to system.ClassifyPathFSTimeout; tests inject fake classes.
|
||
classifyFSPath func(path string) string
|
||
|
||
// 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) }
|
||
r.classifyFSPath = system.ClassifyPathFSTimeout
|
||
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 }
|
||
|
||
// refuseNetworkStubDeploy is the deploy-time stub gate (RCA fix 2). Non-empty return = the
|
||
// Hungarian refusal for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
|
||
// local stub. Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
|
||
// classification timeout/unknown fails OPEN (a wedged share is the unreachable badge's business);
|
||
// local (non-network) and empty paths keep today's behavior exactly.
|
||
func (r *Router) refuseNetworkStubDeploy(hdd string) string {
|
||
if hdd == "" || r.sett == nil || !r.sett.IsNetworkStoragePath(hdd) {
|
||
return ""
|
||
}
|
||
if r.classifyFSPath(hdd) != system.FSClassStub {
|
||
return ""
|
||
}
|
||
return "A kiválasztott hálózati tárhely jelenleg nem érhető el az alkalmazások környezetéből — a telepítés nem indítható. Próbálja újra pár perc múlva, vagy jelezze az üzemeltetőnek."
|
||
}
|
||
|
||
// 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)
|
||
|
||
// GET /api/backup/restore-status — async restore progress for the backups-page banner (Part B).
|
||
// Distinct from /backup/status (which proxies the agent's PBS whole-guest status).
|
||
case path == "/backup/restore-status" && req.Method == http.MethodGet:
|
||
r.backupRestoreStatus(w, req)
|
||
|
||
// GET /api/backup/snapshots?stack=<name> — restorable keep-side backups for the restore panel
|
||
case path == "/backup/snapshots" && req.Method == http.MethodGet:
|
||
r.backupSnapshots(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
|
||
}
|
||
|
||
// Lifecycle gate: an app withdrawn from the catalog (`lifecycle: hidden` / `abandoned`) is not
|
||
// installable. FAIL-CLOSED and server-side on purpose — the catalog page already omits these, so
|
||
// anything reaching here is a stale link, a bookmarked deploy form, or a direct POST, and a gate
|
||
// that only hides the button is not a gate. Deliberately BEFORE every mutation.
|
||
//
|
||
// This does NOT touch an already-deployed instance: it is on the deploy path only, and the
|
||
// manager refuses a redeploy of an existing stack through its own "already deployed" check.
|
||
if st, ok := r.stackMgr.GetStack(name); ok && !st.Meta.CanInstall() {
|
||
r.logger.Printf("[WARN] [api] Deploy refused for %s: lifecycle=%s (not offered for new installs)",
|
||
name, st.Meta.EffectiveLifecycle())
|
||
writeJSON(w, http.StatusConflict, apiResponse{OK: false,
|
||
Error: "Ez az alkalmazás jelenleg nem telepíthető."})
|
||
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
|
||
}
|
||
|
||
// RCA fix 2 (AUDIT-nas-cwa-rca-2026-07-11): a deploy targeting a registered NETWORK storage path
|
||
// must see a network filesystem (or its healthy idle autofs trigger) in THIS namespace — the one
|
||
// the app will consume the path in. A stub (plain local dir after a guest reboot) would silently
|
||
// send the app's data to the system drive.
|
||
if msg := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); msg != "" {
|
||
r.logger.Printf("[WARN] [api] Deploy refused for %s: network HDD_PATH %s is a stub in the controller namespace", name, body.Values["HDD_PATH"])
|
||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: msg})
|
||
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()
|
||
}
|
||
|
||
// v0.139.0: the hub sees the deploy in seconds (debounced trigger, not per-request)
|
||
r.reportPushNow()
|
||
|
||
// 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()
|
||
}
|
||
|
||
// v0.139.0: the hub sees the removal in seconds (debounced trigger, not per-request)
|
||
r.reportPushNow()
|
||
}
|
||
|
||
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"})
|
||
|
||
// v0.139.0: the hub sees the delete in seconds (debounced trigger, not per-request)
|
||
r.reportPushNow()
|
||
}
|
||
|
||
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})
|
||
}
|
||
|
||
// backupRestoreStatus (Part B) surfaces the async restore-op progress the backups page polls to drive
|
||
// its banner (running: <op>/<stack> → terminal last{ok,message}). Display-only; empty when idle.
|
||
func (r *Router) backupRestoreStatus(w http.ResponseWriter, _ *http.Request) {
|
||
if r.backupMgr == nil {
|
||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"running": false}})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.backupMgr.RestoreStatus()})
|
||
}
|
||
|
||
// validStackParam reports whether a stack name from a request is a safe single path segment
|
||
// (same semantics as web.validStackName — see internal/web/validate.go; duplicated here because
|
||
// api ↔ web would be a circular import). Rejects traversal/escape so the name can never become
|
||
// a filesystem path outside the app's namespace root.
|
||
func validStackParam(name string) bool {
|
||
if name == "" || name == "." || name == ".." {
|
||
return false
|
||
}
|
||
if strings.ContainsAny(name, "/\\\x00") {
|
||
return false
|
||
}
|
||
return name == filepath.Clean(name)
|
||
}
|
||
|
||
// backupSnapshots lists the restorable keep-side backups for one app — the data source of the
|
||
// /backups restore panel's snapshot dropdown (F1: this route was fetched by the template but never
|
||
// existed, so the dropdown could never populate and the restore button never enabled).
|
||
func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
|
||
stack := req.URL.Query().Get("stack")
|
||
if !validStackParam(stack) {
|
||
r.dbg("backupSnapshots: invalid stack param %q", stack)
|
||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
||
return
|
||
}
|
||
if r.backupMgr == nil {
|
||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
||
return
|
||
}
|
||
|
||
points, found := r.backupMgr.ListRestorePoints(stack)
|
||
if !found {
|
||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + stack})
|
||
return
|
||
}
|
||
r.dbg("backupSnapshots: stack=%s points=%d", stack, len(points))
|
||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: points})
|
||
}
|
||
|
||
// 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
|
||
}
|