Go-Quality-Baseline-Rollout ABGESCHLOSSEN. Code-Quality-Backlog (55 → 0): - errcheck: unbehandelte Close/Rollback/Remove explizit `_ =`; fmt.Sscanf `_, _ =` (Zero-Value degradiert sauber). - unused: toter Code entfernt (nodeIDOrHostname, stripTrailingNewline, acme.Service.user, strFold + ungenutzter Import). - noctx (net/http): http.NewRequestWithContext mit vorhandenem ctx. - staticcheck: QF1001/S1009/ST1005/SA9003. - contextcheck: detached-by-design-Stellen mit begründetem //nolint. Zwei echte Bugs beim Aufräumen gefunden+gefixt: - backup/remote SFTP-Upload: dst.Close()-Flush-Fehler wurde verschluckt → unvollständiges Remote-File galt als Erfolg. Jetzt geprüft+gemeldet. - haproxy_test: leere if-Assertion (SA9003) testete faktisch nichts → echte t.Errorf-Prüfung (kein HSTS für HSTS-disabled Domain). Bewusste Config-Entscheidungen (.golangci.yml): - noctx-on-os/exec ausgeschlossen: System-Command-Reloads (systemctl/nft/ wg/pg) dürfen NICHT an den Request-Context gebunden werden — ein Client- Disconnect darf keinen laufenden Reload mitten in der Ausführung killen. net/http-noctx bleibt voll aktiv. KEINE exec-Zeile im Code angefasst. - rowserrcheck/sqlclosecheck raus (database/sql-Linter, bei pgx nur FPs). Gate scharf gestellt: Makefile release-check ruft golangci-lint jetzt als HARTEN Gate (install-if-missing, pinned v2.12.2). `make release-check` grün: vet, golangci-lint, govulncheck, build, test -race. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
151 lines
5.3 KiB
Go
151 lines
5.3 KiB
Go
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
|
|
}
|