Files
edgeguard-native/internal/handlers/system.go
Debian 73619c17f8 fix(system): render-configs rendert jetzt ALLE Dienste, nicht nur HAProxy
- SystemHandler.ExtraReloaders: neues Feld für nftables, wireguard,
  squid, unbound, chrony Reloader
- WithAllReloaders(): Wire-Methode; main.go bindet alle Reloader nach
  Initialisierung ein
- RenderConfigs: läuft alle Reloader durch, sammelt Fehler, antwortet
  mit {ok, rendered[], errors{}} — partiell OK wird als warning gemeldet
- UI: Settings-Button-Label "Re-render all configs"; partial-error
  Toast zeigt welche Dienste fehlschlugen
- Timeout: 30s statt 10s (alle Dienste brauchen mehr Zeit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:29:00 +02:00

1023 lines
33 KiB
Go

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@<name>
"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) {
response.OK(c, gin.H{
"status": "ok",
"version": h.Version,
})
}
// 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
}