- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung - keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload) - confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix) - TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login - Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator - fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen - VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034) - Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032) - unbound-control: edgeguard in unbound-Gruppe via postinst Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1186 lines
37 KiB
Go
1186 lines
37 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bufio"
|
|
stdcontext "context"
|
|
"errors"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"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"
|
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
|
)
|
|
|
|
// 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
|
|
// Users: optional — wired after DB pool opens. Used by AgentAuthCheck
|
|
// so cluster peers can verify credentials against this node's DB.
|
|
Users *usersvc.Repo
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// WithUsers injectet das Users-Repo für AgentAuthCheck.
|
|
func (h *SystemHandler) WithUsers(u *usersvc.Repo) *SystemHandler {
|
|
h.Users = u
|
|
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)
|
|
g.GET("/vip-status", h.VIPStatus)
|
|
}
|
|
|
|
// 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)
|
|
// Auth-Federation: Cluster-Nodes verifizieren Credentials gegen diesen
|
|
// Node via mTLS. Nur über den Agent-Listener (:8443) erreichbar.
|
|
rg.POST("/agent/auth/check", h.AgentAuthCheck)
|
|
}
|
|
|
|
// AgentAuthCheck verifies email+password against the local users table
|
|
// and setup.json. Called by cluster nodes over mTLS when local auth fails
|
|
// so users can log in on any cluster node with the primary's credentials.
|
|
func (h *SystemHandler) AgentAuthCheck(c *gin.Context) {
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, err)
|
|
return
|
|
}
|
|
email := strings.TrimSpace(strings.ToLower(req.Email))
|
|
if email == "" || req.Password == "" {
|
|
response.Unauthorized(c, errInvalidCreds)
|
|
return
|
|
}
|
|
|
|
// Check local users table first.
|
|
if h.Users != nil {
|
|
u, hash, err := h.Users.FindByEmail(c.Request.Context(), email)
|
|
if err == nil && u.Active && usersvc.VerifyPassword(hash, req.Password) {
|
|
response.OK(c, gin.H{"actor": u.Email, "role": u.Role})
|
|
return
|
|
}
|
|
}
|
|
// Fallback: setup.json admin.
|
|
if h.Setup != nil {
|
|
st, _ := h.Setup.Load()
|
|
if st != nil && strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
|
|
response.OK(c, gin.H{"actor": st.AdminEmail, "role": "admin"})
|
|
return
|
|
}
|
|
}
|
|
response.Unauthorized(c, errInvalidCreds)
|
|
}
|
|
|
|
var errInvalidCreds = errors.New("invalid_credentials")
|
|
|
|
// 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"},
|
|
{"keepalived", "keepalived"},
|
|
{"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"`
|
|
NumCPUs int `json:"num_cpus"`
|
|
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{NumCPUs: runtime.NumCPU()}
|
|
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) {
|
|
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()
|
|
}
|
|
|
|
if h.Audit != nil {
|
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.upgrade",
|
|
"", gin.H{"unit": unitName}, h.NodeID)
|
|
}
|
|
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 ""
|
|
}
|
|
|
|
// VIPStatus returns the VRRP state and active VIPs for this node.
|
|
// Uses net.Interfaces() (no shell-out) to check which VIPs from
|
|
// ip_addresses WHERE is_vip=true are currently assigned locally.
|
|
// MASTER = at least one VIP is locally present; BACKUP = none present.
|
|
func (h *SystemHandler) VIPStatus(c *gin.Context) {
|
|
type vipEntry struct {
|
|
Address string `json:"address"`
|
|
Prefix int `json:"prefix"`
|
|
Device string `json:"device"`
|
|
Active bool `json:"active"`
|
|
}
|
|
type vipStatus struct {
|
|
VRRPState string `json:"vrrp_state"`
|
|
KeepalivedActive bool `json:"keepalived_active"`
|
|
VIPs []vipEntry `json:"vips"`
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// keepalived service active?
|
|
kaOut, _ := exec.CommandContext(ctx, "systemctl", "is-active", "keepalived").Output()
|
|
kaActive := strings.TrimSpace(string(kaOut)) == "active"
|
|
|
|
// query VIPs from DB
|
|
var dbVIPs []vipEntry
|
|
if h.Pool != nil {
|
|
rows, err := h.Pool.Query(ctx,
|
|
`SELECT a.address, a.prefix, COALESCE(i.name,'') AS device
|
|
FROM ip_addresses a
|
|
LEFT JOIN network_interfaces i ON i.id = a.interface_id
|
|
WHERE a.is_vip = true AND a.active = true
|
|
ORDER BY a.address`)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var e vipEntry
|
|
if err2 := rows.Scan(&e.Address, &e.Prefix, &e.Device); err2 == nil {
|
|
dbVIPs = append(dbVIPs, e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// build set of locally assigned IPs
|
|
localIPs := make(map[string]bool)
|
|
if ifaces, err := net.Interfaces(); err == nil {
|
|
for _, ifc := range ifaces {
|
|
if addrs, err2 := ifc.Addrs(); err2 == nil {
|
|
for _, a := range addrs {
|
|
if ipnet, ok := a.(*net.IPNet); ok {
|
|
localIPs[ipnet.IP.String()] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
anyActive := false
|
|
for i := range dbVIPs {
|
|
dbVIPs[i].Active = localIPs[dbVIPs[i].Address]
|
|
if dbVIPs[i].Active {
|
|
anyActive = true
|
|
}
|
|
}
|
|
|
|
state := "UNKNOWN"
|
|
if kaActive {
|
|
if anyActive {
|
|
state = "MASTER"
|
|
} else {
|
|
state = "BACKUP"
|
|
}
|
|
}
|
|
|
|
response.OK(c, vipStatus{
|
|
VRRPState: state,
|
|
KeepalivedActive: kaActive,
|
|
VIPs: dbVIPs,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|