package handlers import ( "log/slog" "net" "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) g.GET("/interfaces", h.Interfaces) } 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", }) } // 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"` } // 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 } out := make([]interfaceInfo, 0, len(ifaces)) for _, ifc := range ifaces { info := interfaceInfo{ IfIndex: ifc.Index, IfName: ifc.Name, MTU: ifc.MTU, Address: ifc.HardwareAddr.String(), LinkType: classifyLinkType(ifc), Flags: flagsToList(ifc.Flags), AddrInfo: []addrInfo{}, } 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 } // 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 }