package handlers import ( "bufio" "net" "strconv" "strings" "time" "github.com/gin-gonic/gin" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" ) // HAProxyStatsHandler exposes /api/v1/haproxy/stats — a parsed view // of the haproxy runtime API ('show stat'). Used by the dashboard's // backend-live-health card. Reads from the unix socket at // /run/haproxy/admin.sock; postinst adds the edgeguard user to the // haproxy group so the socket (mode 0660 root:haproxy) is readable. type HAProxyStatsHandler struct{} func NewHAProxyStatsHandler() *HAProxyStatsHandler { return &HAProxyStatsHandler{} } func (h *HAProxyStatsHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/haproxy") g.GET("/stats", h.Stats) } const haproxyAdminSock = "/run/haproxy/admin.sock" // Backend is one server inside one backend, parsed from haproxy's // 'show stat' CSV. We only emit the fields the dashboard cares // about — full CSV is ~80 columns of which 90% are noise here. type backendStat struct { Backend string `json:"backend"` // pxname Server string `json:"server"` // svname Status string `json:"status"` // UP|DOWN|MAINT|... Sessions int64 `json:"sessions"` // current sessions (scur) BIn int64 `json:"bytes_in"` BOut int64 `json:"bytes_out"` LastChg int64 `json:"last_change_sec"` // seconds since last status change Health string `json:"health,omitempty"` // check_status (e.g. L7OK) } func (h *HAProxyStatsHandler) Stats(c *gin.Context) { conn, err := net.DialTimeout("unix", haproxyAdminSock, 2*time.Second) if err != nil { // Socket nicht erreichbar (haproxy down oder no perm) → // leere Liste statt 500 damit das Dashboard nicht rot wird. response.OK(c, gin.H{"backends": []backendStat{}, "error": err.Error()}) return } defer conn.Close() _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) if _, err := conn.Write([]byte("show stat\n")); err != nil { response.OK(c, gin.H{"backends": []backendStat{}, "error": err.Error()}) return } // CSV-format der haproxy stats: erste Zeile beginnt mit // "# pxname,svname,..." — die nutzen wir um Spalten-Indizes // zu finden, weil das Format zwischen Versionen wechseln kann. colIdx := map[string]int{} out := []backendStat{} scanner := bufio.NewScanner(conn) scanner.Buffer(make([]byte, 64*1024), 1024*1024) for scanner.Scan() { line := scanner.Text() if line == "" { continue } fields := strings.Split(line, ",") if strings.HasPrefix(line, "# ") { // Header — strip "# " prefix. fields[0] = strings.TrimPrefix(fields[0], "# ") for i, name := range fields { colIdx[name] = i } continue } // Skip frontend rows + the "BACKEND" summary row — we want // the per-server view ("L4OK", "L7OK", etc.). svname := safeAt(fields, colIdx["svname"]) pxname := safeAt(fields, colIdx["pxname"]) if svname == "" || svname == "FRONTEND" || svname == "BACKEND" { continue } // Skip our internal api_backend stats listener and frontends. if pxname == "internal_stats" { continue } st := backendStat{ Backend: pxname, Server: svname, Status: safeAt(fields, colIdx["status"]), Sessions: parseInt64(safeAt(fields, colIdx["scur"])), BIn: parseInt64(safeAt(fields, colIdx["bin"])), BOut: parseInt64(safeAt(fields, colIdx["bout"])), LastChg: parseInt64(safeAt(fields, colIdx["lastchg"])), Health: safeAt(fields, colIdx["check_status"]), } out = append(out, st) } response.OK(c, gin.H{"backends": out}) } func safeAt(fields []string, i int) string { if i <= 0 || i >= len(fields) { return "" } return fields[i] } func parseInt64(s string) int64 { n, _ := strconv.ParseInt(s, 10, 64) return n }