package handlers import ( "bufio" stdcontext "context" "log/slog" "net" "net/http" "os" "os/exec" "strconv" "strings" "syscall" "time" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) // SystemHandler covers /system/health, /system/package-versions, // /system/upgrade, /system/auto-update + /system/maintenance. // // Setup + HAProxyReloader sind optional — werden für /maintenance // gebraucht. Wenn nil sind die Maintenance-Endpoints noch verfügbar // aber returnen 503 (Setup nicht abgeschlossen). type SystemHandler struct { Version string Setup *setup.Store HAProxyReloader func(stdcontext.Context) error Audit *audit.Repo NodeID string Pool *pgxpool.Pool // ConfigPreviewers: generator name → render-to-string func. Wired // in main.go after generators are constructed. Read-only access — // no writes, no reloads. ConfigPreviewers map[string]func(stdcontext.Context) (string, error) // ExtraReloaders: additional service renderers triggered by // RenderConfigs. Keyed by service name (nftables, wireguard, etc.). ExtraReloaders map[string]func(stdcontext.Context) error } func NewSystemHandler(version string) *SystemHandler { return &SystemHandler{Version: version} } // WithMaintenance: setup + reloader injection. Aufrufer in main.go // nach DB-Pool-Open. func (h *SystemHandler) WithMaintenance(setupStore *setup.Store, reloader func(stdcontext.Context) error) *SystemHandler { h.Setup = setupStore h.HAProxyReloader = reloader return h } // WithAudit: Audit-Repo + NodeID damit Toggle-Aktionen (Maintenance, // Auto-Update) ins audit_log fließen. func (h *SystemHandler) WithAudit(a *audit.Repo, nodeID string) *SystemHandler { h.Audit = a h.NodeID = nodeID return h } // WithDB: Pool für DB-Size + andere PG-introspection-Endpoints. func (h *SystemHandler) WithDB(pool *pgxpool.Pool) *SystemHandler { h.Pool = pool return h } // WithConfigPreviewers injectet die RenderToString-Funktionen der // Config-Generatoren für den /system/config-preview Endpoint. func (h *SystemHandler) WithConfigPreviewers(previewers map[string]func(stdcontext.Context) (string, error)) *SystemHandler { h.ConfigPreviewers = previewers return h } // WithAllReloaders injectet alle Service-Reloader für RenderConfigs. // Reihenfolge: haproxy ist bereits in HAProxyReloader; extras sind die // restlichen Dienste (nftables, wireguard, squid, unbound, chrony). func (h *SystemHandler) WithAllReloaders(extras map[string]func(stdcontext.Context) error) *SystemHandler { h.ExtraReloaders = extras return h } func (h *SystemHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/system") g.GET("/health", h.Health) g.GET("/package-versions", h.PackageVersions) g.POST("/upgrade", h.Upgrade) g.GET("/interfaces", h.Interfaces) g.GET("/services", h.Services) g.GET("/resources", h.Resources) g.GET("/auto-update", h.AutoUpdate) g.POST("/auto-update", h.ToggleAutoUpdate) g.GET("/maintenance", h.Maintenance) g.POST("/maintenance", h.ToggleMaintenance) g.GET("/backup-retention", h.BackupRetention) g.POST("/backup-retention", h.SetBackupRetention) g.GET("/audit-retention", h.AuditRetention) g.POST("/audit-retention", h.SetAuditRetention) g.GET("/db-size", h.DBSize) g.POST("/haproxy-reload", h.HAProxyReload) g.POST("/render-configs", h.RenderConfigs) g.POST("/service-restart", h.ServiceRestart) g.GET("/upgrade-status", h.UpgradeStatus) g.GET("/ipv6", h.IPv6) g.POST("/ipv6", h.SetIPv6) g.GET("/config-preview", h.ConfigPreview) } // RegisterAgent mountet die read-only System-Endpoints auf der mTLS- // Agent-Engine (Port :8443). Der Cluster-Aggregator auf der Main-API // ruft diese Endpoints parallel auf allen Peers ab und kompiliert das // Ergebnis für /cluster/system/load. // // Bewusst KEINE Mutations + KEIN /package-versions (würde apt-get update // auf jedem Peer triggern), KEIN /upgrade. func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) { g := rg.Group("/agent/system") g.GET("/health", h.Health) g.GET("/resources", h.Resources) } // servicesToCheck is the curated list shown on the dashboard // service-health-grid. Order matters (UI renders in this sequence). // Each entry is a (label, systemd-unit) pair — label is what the // UI shows, unit is what `systemctl is-active` queries. var servicesToCheck = []struct{ Label, Unit string }{ {"edgeguard-api", "edgeguard-api"}, {"edgeguard-scheduler", "edgeguard-scheduler"}, {"haproxy", "haproxy"}, {"nftables", "nftables"}, {"unbound", "unbound"}, {"chrony", "chrony"}, {"squid", "squid"}, {"postgresql", "postgresql"}, } type serviceStatus struct { Label string `json:"label"` Unit string `json:"unit"` Active bool `json:"active"` State string `json:"state"` // active|inactive|failed|activating|... Since string `json:"since,omitempty"` // ActiveEnterTimestamp } // Services returns systemd-unit status for the curated stack. func (h *SystemHandler) Services(c *gin.Context) { out := make([]serviceStatus, 0, len(servicesToCheck)) for _, s := range servicesToCheck { st := serviceStatus{Label: s.Label, Unit: s.Unit} if s.Unit == "nftables" { // Distro-Unit nftables.service ist disabled — wir laden // die Rules direkt via 'nft -f' aus dem Renderer. Status // = ist unsere 'inet edgeguard'-Tabelle im Kernel? loaded, when := nftablesKernelState(c.Request.Context()) st.Active = loaded if loaded { st.State = "kernel-loaded" } else { st.State = "no-table" } st.Since = when out = append(out, st) continue } raw, err := exec.CommandContext(c.Request.Context(), "systemctl", "show", "-p", "ActiveState,ActiveEnterTimestamp", s.Unit).Output() if err == nil { for _, line := range strings.Split(string(raw), "\n") { if k, v, ok := strings.Cut(line, "="); ok { switch k { case "ActiveState": st.State = v st.Active = v == "active" case "ActiveEnterTimestamp": st.Since = v } } } } out = append(out, st) } response.OK(c, gin.H{"services": out}) } // nftablesKernelState reports whether our 'inet edgeguard' table is // present in the kernel ruleset. Errors swallow to false. Returns // the mtime of the source file as 'since' when loaded. func nftablesKernelState(ctx stdcontext.Context) (bool, string) { out, err := exec.CommandContext(ctx, "sudo", "-n", "/usr/sbin/nft", "list", "tables").Output() if err != nil { return false, "" } if !strings.Contains(string(out), "inet edgeguard") { return false, "" } when := "" if fi, err := os.Stat("/etc/edgeguard/nftables.d/ruleset.nft"); err == nil { when = fi.ModTime().UTC().Format(time.RFC3339) } return true, when } type resources struct { LoadAvg1 float64 `json:"load_avg_1"` LoadAvg5 float64 `json:"load_avg_5"` LoadAvg15 float64 `json:"load_avg_15"` MemTotalKB int64 `json:"mem_total_kb"` MemAvailKB int64 `json:"mem_avail_kb"` MemUsedPct float64 `json:"mem_used_pct"` DiskTotalGB float64 `json:"disk_total_gb"` DiskFreeGB float64 `json:"disk_free_gb"` DiskUsedPct float64 `json:"disk_used_pct"` ConntrackCnt int64 `json:"conntrack_count"` ConntrackMax int64 `json:"conntrack_max"` UptimeSec int64 `json:"uptime_sec"` BootTimeUnix int64 `json:"boot_time_unix"` } // Resources reads /proc + statfs for the box-level metrics card. // All best-effort — missing files just leave the field at zero. func (h *SystemHandler) Resources(c *gin.Context) { response.OK(c, computeLocalSystemResources()) } // computeLocalSystemResources extrahiert die /proc + statfs reads aus // dem Handler damit der Cluster-Aggregator denselben Snapshot ohne // gin.Context erstellen kann. Best-effort: fehlende Quellen lassen // die jeweiligen Felder einfach auf 0. func computeLocalSystemResources() resources { r := resources{} if data, err := os.ReadFile("/proc/loadavg"); err == nil { f := strings.Fields(string(data)) if len(f) >= 3 { r.LoadAvg1, _ = strconv.ParseFloat(f[0], 64) r.LoadAvg5, _ = strconv.ParseFloat(f[1], 64) r.LoadAvg15, _ = strconv.ParseFloat(f[2], 64) } } if data, err := os.ReadFile("/proc/meminfo"); err == nil { s := bufio.NewScanner(strings.NewReader(string(data))) for s.Scan() { line := s.Text() fields := strings.Fields(line) if len(fields) < 2 { continue } val, _ := strconv.ParseInt(fields[1], 10, 64) switch strings.TrimSuffix(fields[0], ":") { case "MemTotal": r.MemTotalKB = val case "MemAvailable": r.MemAvailKB = val } } if r.MemTotalKB > 0 { r.MemUsedPct = float64(r.MemTotalKB-r.MemAvailKB) * 100 / float64(r.MemTotalKB) } } var fs syscall.Statfs_t if err := syscall.Statfs("/", &fs); err == nil { total := float64(fs.Blocks) * float64(fs.Bsize) free := float64(fs.Bavail) * float64(fs.Bsize) r.DiskTotalGB = total / 1024 / 1024 / 1024 r.DiskFreeGB = free / 1024 / 1024 / 1024 if total > 0 { r.DiskUsedPct = (total - free) * 100 / total } } if data, err := os.ReadFile("/proc/sys/net/netfilter/nf_conntrack_count"); err == nil { r.ConntrackCnt, _ = strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) } if data, err := os.ReadFile("/proc/sys/net/netfilter/nf_conntrack_max"); err == nil { r.ConntrackMax, _ = strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) } if data, err := os.ReadFile("/proc/uptime"); err == nil { f := strings.Fields(string(data)) if len(f) >= 1 { if up, err := strconv.ParseFloat(f[0], 64); err == nil { r.UptimeSec = int64(up) r.BootTimeUnix = time.Now().Unix() - r.UptimeSec } } } return r } // Maintenance liefert den aktuellen Whole-Box-Maintenance-Status. // Wenn Setup nicht initialisiert: enabled=false. func (h *SystemHandler) Maintenance(c *gin.Context) { if h.Setup == nil { response.OK(c, gin.H{"enabled": false, "message": ""}) return } st, err := h.Setup.Load() if err != nil || st == nil { response.OK(c, gin.H{"enabled": false, "message": ""}) return } response.OK(c, gin.H{ "enabled": st.MaintenanceMode, "message": st.MaintenanceMessage, }) } // ToggleMaintenance schaltet whole-box maintenance an/aus. // Persistiert in setup-State + triggert HAProxy-Render damit das // 503-Block aktiv wird (oder verschwindet). func (h *SystemHandler) ToggleMaintenance(c *gin.Context) { if h.Setup == nil { response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised")) return } var req struct { Enabled bool `json:"enabled"` Message string `json:"message"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := h.Setup.SetMaintenanceMode(req.Enabled, req.Message); err != nil { response.Internal(c, err) return } if h.HAProxyReloader != nil { ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() if err := h.HAProxyReloader(ctx); err != nil { slog.Warn("system: haproxy reload after maintenance toggle failed", "error", err) } } if h.Audit != nil { action := "system.maintenance.off" if req.Enabled { action = "system.maintenance.on" } _ = h.Audit.Log(c.Request.Context(), actorOf(c), action, "", gin.H{"enabled": req.Enabled, "message": req.Message}, h.NodeID) } response.OK(c, gin.H{"enabled": req.Enabled, "message": req.Message}) } type simpleErr string func (e simpleErr) Error() string { return string(e) } // BackupRetention liefert keep_n. 0 = Default (backup.DefaultKeepN). func (h *SystemHandler) BackupRetention(c *gin.Context) { keep := 0 if h.Setup != nil { if st, err := h.Setup.Load(); err == nil && st != nil { keep = st.BackupRetentionKeep } } response.OK(c, gin.H{"keep": keep, "default": 14}) } // SetBackupRetention persistiert die Operator-gewählte Retention. // keep=0 → wieder Default, keep=1..365 → custom. func (h *SystemHandler) SetBackupRetention(c *gin.Context) { if h.Setup == nil { response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised")) return } var req struct { Keep int `json:"keep"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := h.Setup.SetBackupRetention(req.Keep); err != nil { response.BadRequest(c, err) return } if h.Audit != nil { _ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.backup_retention", "", gin.H{"keep": req.Keep}, h.NodeID) } response.OK(c, gin.H{"keep": req.Keep}) } // AuditRetention liefert die konfigurierte Retention in Tagen. // 0 = Default (90 — siehe scheduler/main.go). func (h *SystemHandler) AuditRetention(c *gin.Context) { days := 0 if h.Setup != nil { if st, err := h.Setup.Load(); err == nil && st != nil { days = st.AuditRetentionDays } } response.OK(c, gin.H{"days": days, "default": 90}) } // SetAuditRetention setzt Audit-Retention in Tagen. 0..3650. func (h *SystemHandler) SetAuditRetention(c *gin.Context) { if h.Setup == nil { response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised")) return } var req struct { Days int `json:"days"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := h.Setup.SetAuditRetention(req.Days); err != nil { response.BadRequest(c, err) return } if h.Audit != nil { _ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.audit_retention", "", gin.H{"days": req.Days}, h.NodeID) } response.OK(c, gin.H{"days": req.Days}) } // DBSize liefert pg_database_size + Top-N Tabellen-Größen für Capacity- // Planning. Operator sieht so welche Tabellen Disk fressen (typisch // audit_log + firewall_log → siehe Retention-Settings). type dbSizeTable struct { Name string `json:"name"` Bytes int64 `json:"bytes"` HumanSz string `json:"human_size"` } type dbSizeResponse struct { TotalBytes int64 `json:"total_bytes"` HumanTotal string `json:"human_total"` Tables []dbSizeTable `json:"top_tables"` } func (h *SystemHandler) IPv6(c *gin.Context) { enabled := false if h.Setup != nil { if st, err := h.Setup.Load(); err == nil && st != nil { enabled = st.IPv6Enabled } } response.OK(c, gin.H{"enabled": enabled}) } func (h *SystemHandler) SetIPv6(c *gin.Context) { if h.Setup == nil { response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised")) return } var req struct { Enabled bool `json:"enabled"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := h.Setup.SetIPv6Enabled(req.Enabled); err != nil { response.BadRequest(c, err) return } if h.HAProxyReloader != nil { _ = h.HAProxyReloader(c.Request.Context()) } if h.Audit != nil { _ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.ipv6", "", gin.H{"enabled": req.Enabled}, h.NodeID) } response.OK(c, gin.H{"enabled": req.Enabled}) } func (h *SystemHandler) DBSize(c *gin.Context) { if h.Pool == nil { response.Err(c, http.StatusServiceUnavailable, simpleErr("db pool unavailable")) return } ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() var total int64 var totalH string if err := h.Pool.QueryRow(ctx, ` SELECT pg_database_size(current_database()), pg_size_pretty(pg_database_size(current_database()))`).Scan(&total, &totalH); err != nil { response.Internal(c, err) return } // Top 10 user-tables nach total_relation_size (inkl. Indizes + TOAST). rows, err := h.Pool.Query(ctx, ` SELECT c.relname, pg_total_relation_size(c.oid), pg_size_pretty(pg_total_relation_size(c.oid)) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND n.nspname = 'public' ORDER BY pg_total_relation_size(c.oid) DESC LIMIT 10`) if err != nil { response.Internal(c, err) return } defer rows.Close() out := dbSizeResponse{TotalBytes: total, HumanTotal: totalH, Tables: []dbSizeTable{}} for rows.Next() { var t dbSizeTable if err := rows.Scan(&t.Name, &t.Bytes, &t.HumanSz); err != nil { response.Internal(c, err) return } out.Tables = append(out.Tables, t) } response.OK(c, out) } // HAProxyReload zwingt ein systemctl reload haproxy.service — nützlich // wenn der Operator manuell in /etc/edgeguard/tls/ geschrieben hat // (z. B. eigenes PEM per SSH kopiert) und HAProxy das neue Cert sehen // soll, ohne eine UI-Mutation zu triggern die das automatisch täte. func (h *SystemHandler) HAProxyReload(c *gin.Context) { out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput() if err != nil { response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error())) return } if h.Audit != nil { _ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.haproxy_reload", "", gin.H{}, h.NodeID) } response.OK(c, gin.H{"ok": true}) } // restartAllowlist sind die Dienste die der Operator über die UI neu // starten darf. edgeguard-api selbst ist bewusst ausgeschlossen (würde // die eigene HTTP-Response killen). postgresql ebenfalls (Datenpfad). var restartAllowlist = map[string]bool{ "haproxy": true, "squid": true, "unbound": true, "chrony": true, "nftables": true, "wireguard": true, // wireguard als Metadienst; einzelne wg-Ifaces über wg-quick@ "edgeguard-scheduler": true, } // ServiceRestart startet einen Dienst aus der Allowlist via // `systemctl restart`. Gibt 400 zurück wenn der Dienst nicht auf der // Allowlist steht, 500 wenn systemctl fehlschlägt. func (h *SystemHandler) ServiceRestart(c *gin.Context) { var req struct { Service string `json:"service" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { response.Err(c, http.StatusBadRequest, simpleErr("service required")) return } svc := strings.TrimSpace(req.Service) if !restartAllowlist[svc] { response.Err(c, http.StatusBadRequest, simpleErr("service not in allowlist: "+svc)) return } unitName := svc + ".service" out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "restart", unitName).CombinedOutput() if err != nil { response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error())) return } if h.Audit != nil { _ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.service_restart", svc, gin.H{"service": svc}, h.NodeID) } response.OK(c, gin.H{"ok": true, "service": svc}) } // RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem // aktuellen DB-State. Läuft haproxy + alle ExtraReloaders (nftables, // wireguard, squid, unbound, chrony) durch. Fehler werden gesammelt // und als partial-Antwort zurückgegeben — erfolgreich gerenderte // Dienste stehen in "rendered", fehlgeschlagene in "errors". func (h *SystemHandler) RenderConfigs(c *gin.Context) { if h.HAProxyReloader == nil && len(h.ExtraReloaders) == 0 { response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired")) return } ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 30*time.Second) defer cancel() rendered := []string{} errs := map[string]string{} if h.HAProxyReloader != nil { if err := h.HAProxyReloader(ctx); err != nil { errs["haproxy"] = err.Error() } else { rendered = append(rendered, "haproxy") } } // Defined order so the audit log is deterministic. order := []string{"nftables", "wireguard", "squid", "unbound", "chrony"} for _, name := range order { fn, ok := h.ExtraReloaders[name] if !ok { continue } if err := fn(ctx); err != nil { errs[name] = err.Error() } else { rendered = append(rendered, name) } } if h.Audit != nil { _ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs", "", gin.H{"rendered": rendered, "errors": errs}, h.NodeID) } response.OK(c, gin.H{"ok": len(errs) == 0, "rendered": rendered, "errors": errs}) } // UpgradeStatus liefert den Status des letzten Self-Upgrade-Versuchs. // Reads systemctl show + journalctl der edgeguard-upgrade.service // transient unit. Hilft beim Debuggen wenn der Update-Banner nach // einem Click nicht weg geht — typische Ursachen (apt-Resolver-fail, // dpkg-broken) sind direkt im Log sichtbar. type upgradeStatusResponse struct { State string `json:"state"` // inactive / activating / failed / ... Result string `json:"result"` // success / exit-code / ... ExecMainPID int `json:"exec_main_pid"` // 0 wenn nie gelaufen ExitCode int `json:"exit_code"` // exit-status des letzten Laufs StartedAt string `json:"started_at"` // RFC3339 oder leer FinishedAt string `json:"finished_at"` // RFC3339 oder leer Log []string `json:"log"` // letzte N Zeilen aus journalctl } func (h *SystemHandler) UpgradeStatus(c *gin.Context) { out := upgradeStatusResponse{Log: []string{}} // systemctl show liefert key=value pairs für die transient unit. // Wenn die Unit nie existiert hat → leeres Output / inactive. if data, err := exec.Command("systemctl", "show", "edgeguard-upgrade.service", "--no-page", "-p", "ActiveState", "-p", "Result", "-p", "ExecMainPID", "-p", "ExecMainStatus", "-p", "ExecMainStartTimestamp", "-p", "ExecMainExitTimestamp", ).CombinedOutput(); err == nil { for _, line := range strings.Split(string(data), "\n") { kv := strings.SplitN(strings.TrimSpace(line), "=", 2) if len(kv) != 2 { continue } switch kv[0] { case "ActiveState": out.State = kv[1] case "Result": out.Result = kv[1] case "ExecMainPID": out.ExecMainPID, _ = strconv.Atoi(kv[1]) case "ExecMainStatus": out.ExitCode, _ = strconv.Atoi(kv[1]) case "ExecMainStartTimestamp": if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil { out.StartedAt = t.UTC().Format(time.RFC3339) } case "ExecMainExitTimestamp": if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil { out.FinishedAt = t.UTC().Format(time.RFC3339) } } } } // Letzte 200 Zeilen Journal — reicht für apt-output + Stack-Traces. if data, err := exec.Command("journalctl", "-u", "edgeguard-upgrade.service", "--no-pager", "-n", "200", "-o", "cat", ).CombinedOutput(); err == nil { lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") // Leere "no entries"-Antwort als leeres Log zurückgeben. if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) { out.Log = lines } } response.OK(c, out) } // AutoUpdate liefert den aktuellen Status (Conf-File existiert?). func (h *SystemHandler) AutoUpdate(c *gin.Context) { response.OK(c, gin.H{"enabled": aptsvc.AutoUpdateEnabled()}) } // ToggleAutoUpdate schaltet automatische Updates an/aus. Schreibt // /etc/apt/apt.conf.d/52edgeguard-auto-updates (sudo tee) bzw. // entfernt es (sudo rm). Idempotent. func (h *SystemHandler) ToggleAutoUpdate(c *gin.Context) { var req struct { Enabled bool `json:"enabled"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := aptsvc.SetAutoUpdate(req.Enabled); err != nil { response.Internal(c, err) return } if h.Audit != nil { action := "system.auto_update.off" if req.Enabled { action = "system.auto_update.on" } _ = h.Audit.Log(c.Request.Context(), actorOf(c), action, "", gin.H{"enabled": req.Enabled}, h.NodeID) } response.OK(c, gin.H{"enabled": req.Enabled}) } // ConfigPreview rendert eine Service-Config aus dem aktuellen DB-State // und gibt sie als Plain-Text zurück — ohne sie auf Disk zu schreiben // oder den Dienst zu reloaden. Query-Parameter: ?generator=haproxy|nftables|squid|unbound func (h *SystemHandler) ConfigPreview(c *gin.Context) { gen := strings.TrimSpace(c.Query("generator")) if gen == "" { names := make([]string, 0, len(h.ConfigPreviewers)) for k := range h.ConfigPreviewers { names = append(names, k) } response.OK(c, gin.H{"generators": names}) return } if h.ConfigPreviewers == nil { response.Err(c, http.StatusServiceUnavailable, simpleErr("previewers not wired")) return } fn, ok := h.ConfigPreviewers[gen] if !ok { response.Err(c, http.StatusBadRequest, simpleErr("unknown generator: "+gen)) return } ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() content, err := fn(ctx) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"generator": gen, "content": content}) } func (h *SystemHandler) Health(c *gin.Context) { resp := gin.H{ "status": "ok", "version": h.Version, } if hn, err := os.Hostname(); err == nil { resp["hostname"] = hn } if data, err := os.ReadFile("/proc/version"); err == nil { // /proc/version: "Linux version 6.x.y (...)" — Kurzform: alles // bis zum ersten '(' kürzen. line := strings.TrimSpace(string(data)) if idx := strings.Index(line, " ("); idx > 0 { line = strings.TrimSpace(line[:idx]) } resp["kernel"] = line } if data, err := os.ReadFile("/etc/os-release"); err == nil { for _, line := range strings.Split(string(data), "\n") { if k, v, ok := strings.Cut(line, "="); ok && k == "PRETTY_NAME" { resp["os"] = strings.Trim(v, `"`) break } } } response.OK(c, resp) } // PackageVersions reports installed and available versions for the // edgeguard-* APT packages. Called by the UI's update banner — it // polls every 30s and lights up when available > installed. // // Implementierung delegiert an internal/services/apt: dort sitzt der // 5-min-Throttle für apt-get update, der LC_ALL=C-Fix für deutsche // Locales und das Background-Refresh-Timer. `?force=1` (UI-Button // „Jetzt prüfen") bypassed den Throttle, damit nach einem `make publish` // nicht 5 min auf das nächste Tick gewartet werden muss. func (h *SystemHandler) PackageVersions(c *gin.Context) { force := c.Query("force") == "1" || c.Query("force") == "true" out := aptsvc.PackageVersions(c.Request.Context(), force) response.OK(c, out) } // Upgrade runs the apt upgrade detached via systemd-run so the API // can reply BEFORE the package replaces it. Pattern from netcell- // webpanel/management-agent/internal/handlers/update.go (see // architecture.md §11). Without --collect on a transient service // unit, the apt-get child dies when systemd-cleans up the scope as // the API exits — leaves the box half-upgraded. func (h *SystemHandler) Upgrade(c *gin.Context) { slog.Info("starting package upgrade (detached)") // Skript landet NICHT in /tmp — edgeguard-api.service hat // PrivateTmp=true und sieht damit ein eigenes /tmp, das die // per `sudo systemd-run` gestartete Transient-Unit nicht sieht. // /var/lib/edgeguard ist edgeguard-owned + persistent + von // beiden Namespaces aus zugänglich. const scriptPath = "/var/lib/edgeguard/upgrade.sh" // Retry-Logik gegen Gitea-Packages.gz-Race: nach einem frischen // Publish kann der Packages-Index für ein paar Sekunden inkonsistent // sein (z. B. Meta uploaded, api/ui noch nicht in der regenerierten // Index-Datei) → apt-resolver-fail mit "no choices are installable". // Drei Versuche mit 15s/30s Backoff geben Gitea Zeit den Index // nachzuziehen. Befund 2026-05-17. const script = `#!/bin/bash set -e sleep 2 export DEBIAN_FRONTEND=noninteractive echo "[upgrade] dpkg --configure -a" dpkg --configure -a || true retry_apt() { local attempt=0 local max=3 local wait_for=15 while [ $attempt -lt $max ]; do attempt=$((attempt + 1)) echo "[upgrade] attempt $attempt/$max: apt-get update + install" apt-get update -qq || true if apt-get install -y -qq -o Dpkg::Options::=--force-confold \ edgeguard-api edgeguard-ui edgeguard; then return 0 fi if [ $attempt -lt $max ]; then echo "[upgrade] failed (likely Packages-index race), waiting ${wait_for}s before retry" sleep $wait_for wait_for=$((wait_for * 2)) fi done echo "[upgrade] all $max attempts failed" return 1 } retry_apt echo "[upgrade] complete" rm -f /var/lib/edgeguard/upgrade.sh ` if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { response.Internal(c, err) return } const unitName = "edgeguard-upgrade.service" // API läuft als edgeguard-User; systemd-run + systemctl reset-failed // brauchen root. Sudoers-Whitelist in postinst lässt exakt diese // beiden Aufrufe durch. Ohne sudo schlug das früher mit // "Interactive authentication required" fehl und der Fallback // (setsid bash als edgeguard) konnte kein apt-get update — das // Modal blieb hängen und die Box nicht aktualisiert. _ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run() cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run", "--unit="+unitName, "--description=EdgeGuard self-upgrade", "--collect", "bash", scriptPath) if err := cmd.Run(); err != nil { // systemd-run unavailable (dev env without sudo) — fall back // to setsid. In Prod sollte das nie greifen. slog.Warn("upgrade: sudo systemd-run failed, falling back to setsid", "error", err) fallback := exec.Command("setsid", "bash", scriptPath) fallback.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if err2 := fallback.Start(); err2 != nil { response.Internal(c, err2) return } _ = fallback.Process.Release() } c.JSON(http.StatusAccepted, response.Envelope{ Data: gin.H{"status": "upgrading", "unit": unitName}, Error: nil, Message: "upgrade started", }) } // addrInfo + interfaceInfo mirror the relevant subset of `ip -j addr // show` so the frontend keeps its existing parsing code. type addrInfo struct { Family string `json:"family"` // "inet" | "inet6" Local string `json:"local"` PrefixLen int `json:"prefixlen"` } type interfaceInfo struct { IfIndex int `json:"ifindex"` IfName string `json:"ifname"` Flags []string `json:"flags"` MTU int `json:"mtu"` LinkType string `json:"link_type,omitempty"` Address string `json:"address,omitempty"` AddrInfo []addrInfo `json:"addr_info"` RxBytes int64 `json:"rx_bytes"` TxBytes int64 `json:"tx_bytes"` RxPackets int64 `json:"rx_packets"` TxPackets int64 `json:"tx_packets"` RxDrop int64 `json:"rx_drop"` TxDrop int64 `json:"tx_drop"` } type netDevStat struct { RxBytes, TxBytes int64 RxPackets, TxPackets int64 RxDrop, TxDrop int64 } // readNetDevStats parses /proc/net/dev and returns per-interface counters. // Missing or unreadable: returns empty map (caller gets zero-value stats). func readNetDevStats() map[string]netDevStat { out := map[string]netDevStat{} data, err := os.ReadFile("/proc/net/dev") if err != nil { return out } scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) idx := strings.Index(line, ":") if idx < 0 { continue } name := strings.TrimSpace(line[:idx]) fields := strings.Fields(line[idx+1:]) if len(fields) < 12 { continue } p := func(s string) int64 { n, _ := strconv.ParseInt(s, 10, 64); return n } out[name] = netDevStat{ RxBytes: p(fields[0]), RxPackets: p(fields[1]), RxDrop: p(fields[3]), TxBytes: p(fields[8]), TxPackets: p(fields[9]), TxDrop: p(fields[11]), } } return out } // Interfaces enumerates the kernel-side network interfaces using // Go's net.Interfaces() — no shell-out, no AF_NETLINK exception // in the systemd hardening required (the original `ip -j addr` // approach was blocked by RestrictAddressFamilies). // // Output shape mirrors `ip -j addr show` enough for the UI's // Networks "system-discovered" card. func (h *SystemHandler) Interfaces(c *gin.Context) { ifaces, err := net.Interfaces() if err != nil { slog.Warn("system/interfaces: net.Interfaces failed", "error", err) response.OK(c, gin.H{"interfaces": []interfaceInfo{}}) return } devStats := readNetDevStats() out := make([]interfaceInfo, 0, len(ifaces)) for _, ifc := range ifaces { st := devStats[ifc.Name] info := interfaceInfo{ IfIndex: ifc.Index, IfName: ifc.Name, MTU: ifc.MTU, Address: ifc.HardwareAddr.String(), LinkType: classifyLinkType(ifc), Flags: flagsToList(ifc.Flags), AddrInfo: []addrInfo{}, RxBytes: st.RxBytes, TxBytes: st.TxBytes, RxPackets: st.RxPackets, TxPackets: st.TxPackets, RxDrop: st.RxDrop, TxDrop: st.TxDrop, } addrs, err := ifc.Addrs() if err != nil { out = append(out, info) continue } for _, a := range addrs { ipnet, ok := a.(*net.IPNet) if !ok { continue } family := "inet" if ipnet.IP.To4() == nil { family = "inet6" } ones, _ := ipnet.Mask.Size() info.AddrInfo = append(info.AddrInfo, addrInfo{ Family: family, Local: ipnet.IP.String(), PrefixLen: ones, }) } out = append(out, info) } response.OK(c, gin.H{"interfaces": out}) } func classifyLinkType(ifc net.Interface) string { if ifc.Flags&net.FlagLoopback != 0 { return "loopback" } if len(ifc.HardwareAddr) > 0 { return "ether" } return "" } func flagsToList(f net.Flags) []string { var out []string if f&net.FlagUp != 0 { out = append(out, "UP") } if f&net.FlagBroadcast != 0 { out = append(out, "BROADCAST") } if f&net.FlagLoopback != 0 { out = append(out, "LOOPBACK") } if f&net.FlagPointToPoint != 0 { out = append(out, "POINTOPOINT") } if f&net.FlagMulticast != 0 { out = append(out, "MULTICAST") } if f&net.FlagRunning != 0 { out = append(out, "LOWER_UP") } return out }