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"` ReqTot int64 `json:"req_tot"` // total requests since start (req_tot) ReqRate int64 `json:"req_rate"` // requests/s last second (req_rate, server-level) LastChg int64 `json:"last_change_sec"` // seconds since last status change Health string `json:"health,omitempty"` // check_status (e.g. L7OK) } // frontendStat holds per-listener traffic counters from HAProxy's // show-stat CSV. Gives the operator a global view of how much HTTP(S) // traffic is flowing through the gateway. type frontendStat struct { Name string `json:"name"` // pxname (e.g. "https_in", "http_in") Sessions int64 `json:"sessions"` // current sessions (scur) MaxSess int64 `json:"max_sess"` // maximum concurrent sessions since start (smax) BIn int64 `json:"bytes_in"` BOut int64 `json:"bytes_out"` ReqTot int64 `json:"req_tot"` // total requests since start ReqRate int64 `json:"req_rate"` // requests/s last second } func (h *HAProxyStatsHandler) Stats(c *gin.Context) { d := net.Dialer{Timeout: 2 * time.Second} conn, err := d.DialContext(c.Request.Context(), "unix", haproxyAdminSock) 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{}, "frontends": []frontendStat{}, "error": err.Error()}) return } defer func() { _ = 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{}, "frontends": []frontendStat{}, "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{} backends := []backendStat{} frontends := []frontendStat{} 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 } svname := safeAt(fields, colIdx["svname"]) pxname := safeAt(fields, colIdx["pxname"]) // Skip internal infrastructure rows and the BACKEND summary row. // api_backend = management API; rl_* = rate-limit stick-tables (no servers). if pxname == "internal_stats" || pxname == "api_backend" || strings.HasPrefix(pxname, "rl_") || svname == "BACKEND" || svname == "" { continue } if svname == "FRONTEND" { // Collect frontend (listener) counters. frontends = append(frontends, frontendStat{ Name: pxname, Sessions: parseInt64(safeAt(fields, colIdx["scur"])), MaxSess: parseInt64(safeAt(fields, colIdx["smax"])), BIn: parseInt64(safeAt(fields, colIdx["bin"])), BOut: parseInt64(safeAt(fields, colIdx["bout"])), ReqTot: parseInt64(safeAt(fields, colIdx["req_tot"])), ReqRate: parseInt64(safeAt(fields, colIdx["req_rate"])), }) continue } // Per-server backend row. backends = append(backends, 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"])), ReqTot: parseInt64(safeAt(fields, colIdx["req_tot"])), ReqRate: parseInt64(safeAt(fields, colIdx["req_rate"])), LastChg: parseInt64(safeAt(fields, colIdx["lastchg"])), Health: safeAt(fields, colIdx["check_status"]), }) } response.OK(c, gin.H{"backends": backends, "frontends": frontends}) } 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 }