package handlers import ( "log/slog" "net/http" "os" "os/exec" "regexp" "strings" "syscall" "github.com/gin-gonic/gin" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" ) // SystemHandler covers /system/health, /system/package-versions and // /system/upgrade. Wired from day 1 because the management UI carries // an update banner that polls package-versions. type SystemHandler struct { Version string } func NewSystemHandler(version string) *SystemHandler { return &SystemHandler{Version: version} } 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) } 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 few minutes and lights up when available > installed. // // `apt-get update -qq` is fired first (best-effort, no error if it // fails — we'd still return the cached candidate). Then `apt-cache // policy` is parsed for each package. func (h *SystemHandler) PackageVersions(c *gin.Context) { _ = exec.Command("apt-get", "update", "-qq").Run() out := map[string]string{} for _, pkg := range []string{"edgeguard-api", "edgeguard-ui", "edgeguard"} { raw, err := exec.Command("apt-cache", "policy", pkg).CombinedOutput() if err != nil { out[pkg+"_installed"] = "" out[pkg+"_available"] = "" continue } installed, candidate := parseAptPolicy(string(raw)) out[pkg+"_installed"] = installed out[pkg+"_available"] = candidate } 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)") const script = `#!/bin/bash set -e sleep 2 export DEBIAN_FRONTEND=noninteractive echo "[upgrade] dpkg --configure -a" dpkg --configure -a || true echo "[upgrade] apt-get update" apt-get update -qq echo "[upgrade] apt-get install -y edgeguard-api edgeguard-ui edgeguard" apt-get install -y -qq -o Dpkg::Options::=--force-confold edgeguard-api edgeguard-ui edgeguard echo "[upgrade] complete" rm -f /tmp/edgeguard-upgrade.sh ` if err := os.WriteFile("/tmp/edgeguard-upgrade.sh", []byte(script), 0o755); err != nil { response.Internal(c, err) return } const unitName = "edgeguard-upgrade.service" _ = exec.Command("systemctl", "reset-failed", unitName).Run() cmd := exec.Command("systemd-run", "--unit="+unitName, "--description=EdgeGuard self-upgrade", "--collect", "bash", "/tmp/edgeguard-upgrade.sh") if err := cmd.Run(); err != nil { // systemd-run unavailable (dev env) — fall back to setsid fallback := exec.Command("setsid", "bash", "/tmp/edgeguard-upgrade.sh") 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 gestartet", }) } // parseAptPolicy extracts "Installed: x" and "Candidate: y" from // apt-cache policy output. Both can be "(none)"; we normalise that to // empty string. var aptPolicyLine = regexp.MustCompile(`^\s+(Installed|Candidate):\s+(.+)\s*$`) func parseAptPolicy(out string) (installed, candidate string) { for _, line := range strings.Split(out, "\n") { m := aptPolicyLine.FindStringSubmatch(line) if m == nil { continue } val := m[2] if val == "(none)" { val = "" } switch m[1] { case "Installed": installed = val case "Candidate": candidate = val } } return installed, candidate }