package handlers import ( "bufio" stdcontext "context" "log/slog" "net" "net/http" "os" "os/exec" "regexp" "strconv" "strings" "syscall" "time" "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) g.GET("/services", h.Services) 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) { 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 } } } response.OK(c, r) } 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) { // API läuft als edgeguard-User; ohne sudo schreibt apt-get update // nicht in /var/lib/apt/lists und der candidate bleibt veraltet. // Sudoers-Eintrag in postinst whitelisted exakt diese Zeile. _ = exec.Command("sudo", "-n", "/usr/bin/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 }