Files
wild-central/internal/haproxy/config.go
Paul Payne 3172e56288 Standardize codebase consistency: naming, JSON tags, logging, error wrapping
- JSON tags: fix snake_case to camelCase in dnsmasq (configFile, domainsConfigured,
  lastRestart), crowdsec Machine (lastPush, lastHeartbeat), network (primaryIP,
  primaryInterface). cscli raw parsing structs keep snake_case to match CLI output.
- Error wrapping: fix %v to %w in enableAuthelia for proper error chain preservation
- Naming: rename dnsmasq.ConfigGenerator to dnsmasq.Manager (matches all other packages),
  rename ServiceStatus to Status in dnsmasq and haproxy (matches authelia, crowdsec, etc.)
- Logging: standardize all slog calls to use "component" key instead of message prefixes.
  Affects reconcile, dnsfilter, ddns — now consistent with dnsmasq, haproxy, nftables, sse.
2026-07-14 04:38:48 +00:00

714 lines
23 KiB
Go

package haproxy
import (
"bufio"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
"github.com/wild-cloud/wild-central/internal/domains"
)
// L4Route represents an L4 TCP passthrough route.
// HAProxy inspects the SNI header and forwards the entire TCP connection
// to the backend, which handles its own TLS.
type L4Route struct {
Name string `json:"name"` // identifier for backend naming
Domain string `json:"domain"` // e.g. "cloud.payne.io"
BackendIP string `json:"backendIP"` // backend server IP
Subdomains bool `json:"subdomains"` // if true, also match *.domain
}
// CustomRoute represents a custom TCP proxy rule for non-Wild Cloud services
type CustomRoute struct {
Name string // e.g. "civil_ssh"
Port int // external listen port
Backend string // e.g. "192.168.8.10:22"
}
// HTTPRouteBackend maps path prefixes to a specific backend with its own L7 options.
type HTTPRouteBackend struct {
Paths []string // path prefixes (empty = catch-all)
Backend string // host:port
HealthPath string // optional health check path
Headers *domains.HeaderConfig // per-route headers
IPAllow []string // per-route CIDR whitelist
AuthEnabled bool // forward-auth protection via Authelia
AuthPolicy string // one_factor, two_factor (for access control)
}
// HTTPRoute represents an L7 HTTP reverse proxy route.
// Central terminates TLS using a wildcard certificate and proxies by Host header.
// Routes are evaluated in order — path-specific before catch-all.
type HTTPRoute struct {
Name string // e.g. "my-api"
Domain string // e.g. "my-api.payne.io"
Subdomains bool // if true, also match *.domain
Routes []HTTPRouteBackend // ordered path→backend mappings
}
const defaultConfigPath = "/etc/haproxy/haproxy.cfg"
const defaultSocketPath = "/var/run/haproxy/admin.sock"
// Manager handles HAProxy configuration generation and service management
type Manager struct {
configPath string
socketPath string
}
// NewManager creates a new HAProxy manager
func NewManager(configPath string) *Manager {
if configPath == "" {
configPath = defaultConfigPath
}
return &Manager{configPath: configPath, socketPath: defaultSocketPath}
}
// SetSocketPath overrides the HAProxy stats socket path (useful for testing)
func (m *Manager) SetSocketPath(path string) {
m.socketPath = path
}
// GetConfigPath returns the HAProxy config file path
func (m *Manager) GetConfigPath() string {
return m.configPath
}
// GenerateOpts holds optional parameters for HAProxy config generation.
type GenerateOpts struct {
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
CertsDir string // directory containing per-domain PEM files for L7 TLS
AuthEnabled bool // global Authelia forward-auth toggle
AuthBackend string // Authelia backend address (e.g. "127.0.0.1:9091")
AuthDomain string // Authelia login portal domain (excluded from protection)
AuthSessionDomain string // session cookie scope (e.g. "payne.io") — only subdomains are eligible for forward-auth
}
// GenerateWithOpts creates a complete HAProxy configuration with full options.
func (m *Manager) GenerateWithOpts(instances []L4Route, custom []CustomRoute, opts GenerateOpts) string {
var sb strings.Builder
writeGlobalDefaults(&sb, opts.AuthEnabled)
if len(instances) > 0 || len(opts.HTTPRoutes) > 0 {
writeHTTPSFrontend(&sb, instances, opts)
writeL4Backends(&sb, instances)
if len(opts.HTTPRoutes) > 0 {
writeL7Stack(&sb, opts)
}
}
if opts.AuthEnabled && opts.AuthBackend != "" {
sb.WriteString("# Authelia forward-auth backend\n")
sb.WriteString("backend be_authelia\n")
sb.WriteString(" mode http\n")
fmt.Fprintf(&sb, " server s0 %s\n", opts.AuthBackend)
sb.WriteString("\n")
}
writeCustomRoutes(&sb, custom)
return sb.String()
}
// writeGlobalDefaults writes the HAProxy global, defaults, stats, and HTTP redirect sections.
func writeGlobalDefaults(sb *strings.Builder, authEnabled bool) {
sb.WriteString(`# Wild Cloud HAProxy Configuration
# Managed by Wild Cloud Central API — do not edit manually
global
log /dev/log local0 info
stats socket /var/run/haproxy/admin.sock mode 666 level admin expose-fd listeners
stats timeout 30s
maxconn 50000
daemon
`)
if authEnabled {
sb.WriteString(" lua-prepend-path /etc/haproxy/lua/?.lua\n")
sb.WriteString(" lua-load /etc/haproxy/lua/haproxy-auth-request.lua\n")
}
sb.WriteString(`
defaults
log global
mode tcp
option tcplog
option dontlognull
timeout connect 5s
timeout client 50s
timeout server 50s
# Stats page (LAN access only — not proxied externally)
frontend stats
bind *:8404
mode http
stats enable
stats uri /stats
stats refresh 10s
# HTTP: redirect all to HTTPS
frontend http_in
bind *:80
mode http
redirect scheme https code 301
`)
}
// writeHTTPSFrontend writes the SNI-based HTTPS frontend with ACL routing.
// ACL ordering is critical:
// 1. L7 HTTP exact matches → L7 termination
// 2. L4 exact matches → instance backends
// 3. L4 wildcard matches → instance backends
// 4. Default → L7 termination (if any HTTP routes exist)
func writeHTTPSFrontend(sb *strings.Builder, instances []L4Route, opts GenerateOpts) {
sb.WriteString("# HTTPS: SNI-based L4 routing (TLS passes through to k8s traefik)\n")
sb.WriteString("frontend https_in\n")
sb.WriteString(" bind *:443\n")
sb.WriteString(" mode tcp\n")
sb.WriteString(" tcp-request inspect-delay 5s\n")
sb.WriteString(" tcp-request content accept if { req_ssl_hello_type 1 }\n")
sb.WriteString("\n")
// 1. L7 HTTP services — route to L7 TLS termination frontend
if len(opts.HTTPRoutes) > 0 {
sb.WriteString(" # L7 HTTP services (→ TLS termination)\n")
for _, route := range opts.HTTPRoutes {
if route.Subdomains {
continue
}
aclName := "is_l7_" + sanitizeName(route.Name)
fmt.Fprintf(sb, " # service: %s\n", route.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
fmt.Fprintf(sb, " use_backend be_l7_termination if %s\n", aclName)
}
for _, route := range opts.HTTPRoutes {
if !route.Subdomains {
continue
}
aclName := "is_l7_" + sanitizeName(route.Name)
fmt.Fprintf(sb, " # service: %s\n", route.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m end .%s\n", aclName, route.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
fmt.Fprintf(sb, " use_backend be_l7_termination if %s\n", aclName)
}
sb.WriteString("\n")
}
// 2. L4 exact matches
hasExact := false
for _, inst := range instances {
if inst.Subdomains {
continue
}
hasExact = true
aclName := "is_" + sanitizeName(inst.Name)
fmt.Fprintf(sb, " # service: %s\n", inst.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
fmt.Fprintf(sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
}
if hasExact {
sb.WriteString("\n")
}
// 3. L4 wildcard matches
sb.WriteString(" # L4 instance routes (wildcard subdomain matching)\n")
for _, inst := range instances {
if !inst.Subdomains {
continue
}
aclName := "is_" + sanitizeName(inst.Name)
fmt.Fprintf(sb, " # service: %s\n", inst.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m end .%s\n", aclName, inst.Domain)
fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain)
fmt.Fprintf(sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
}
sb.WriteString("\n")
// 4. Default fallback
if len(opts.HTTPRoutes) > 0 {
sb.WriteString(" default_backend be_l7_termination\n")
}
sb.WriteString("\n")
}
// writeL4Backends writes L4 TCP passthrough backend definitions.
func writeL4Backends(sb *strings.Builder, instances []L4Route) {
for _, inst := range instances {
fmt.Fprintf(sb, "# service: %s\n", inst.Domain)
fmt.Fprintf(sb, "backend be_https_%s\n", sanitizeName(inst.Name))
sb.WriteString(" mode tcp\n")
sb.WriteString(" option tcp-check\n")
fmt.Fprintf(sb, " server s0 %s:443 check\n", inst.BackendIP)
sb.WriteString("\n")
}
}
// writeL7Stack writes the L7 TLS termination backend, frontend, host ACLs,
// per-route rules (IP whitelisting, headers, auth), routing, and backends.
func writeL7Stack(sb *strings.Builder, opts GenerateOpts) {
certPath := opts.CertsDir
if certPath == "" {
certPath = "/etc/haproxy/certs/"
}
// TLS termination backend + frontend binding
sb.WriteString("backend be_l7_termination\n")
sb.WriteString(" mode tcp\n")
sb.WriteString(" server s0 127.0.0.1:8443\n")
sb.WriteString("\n")
sb.WriteString("# L7 HTTP frontend: TLS termination + Host-based routing\n")
sb.WriteString("frontend l7_https\n")
fmt.Fprintf(sb, " bind 127.0.0.1:8443 ssl crt %s\n", certPath)
sb.WriteString(" mode http\n")
sb.WriteString("\n")
// Host ACLs
for _, httpRoute := range opts.HTTPRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
fmt.Fprintf(sb, " # service: %s\n", httpRoute.Domain)
if httpRoute.Subdomains {
fmt.Fprintf(sb, " acl %s hdr(host) -i -m end .%s\n", hostACL, httpRoute.Domain)
fmt.Fprintf(sb, " acl %s hdr(host) -i -m str %s\n", hostACL, httpRoute.Domain)
} else {
fmt.Fprintf(sb, " acl %s hdr(host) -i %s\n", hostACL, httpRoute.Domain)
}
}
sb.WriteString("\n")
// Per-route ACLs: IP whitelisting and headers
writeL7RouteACLs(sb, opts.HTTPRoutes)
// Authelia forward-auth directives
if opts.AuthEnabled && opts.AuthBackend != "" {
writeL7AuthDirectives(sb, opts)
}
// Routing rules: path-specific first, then catch-all
writeL7RoutingRules(sb, opts.HTTPRoutes)
// L7 backends — one per route
writeL7Backends(sb, opts.HTTPRoutes)
}
// writeL7RouteACLs writes per-route IP whitelisting and header directives.
func writeL7RouteACLs(sb *strings.Builder, httpRoutes []HTTPRoute) {
for _, httpRoute := range httpRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
routeName := sanitizeName(httpRoute.Name)
for i, rb := range httpRoute.Routes {
aclSuffix := fmt.Sprintf("%s_%d", routeName, i)
if len(rb.IPAllow) > 0 {
ipACL := "ip_" + aclSuffix
fmt.Fprintf(sb, " acl %s src %s\n", ipACL, strings.Join(rb.IPAllow, " "))
if len(rb.Paths) > 0 {
pathACL := "path_" + aclSuffix
fmt.Fprintf(sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
fmt.Fprintf(sb, " http-request deny if %s %s !%s\n", hostACL, pathACL, ipACL)
} else {
fmt.Fprintf(sb, " http-request deny if %s !%s\n", hostACL, ipACL)
}
}
if rb.Headers != nil {
for _, k := range sortedKeys(rb.Headers.Request) {
fmt.Fprintf(sb, " http-request set-header %s %q if %s\n", k, rb.Headers.Request[k], hostACL)
}
for _, k := range sortedKeys(rb.Headers.Response) {
fmt.Fprintf(sb, " http-response set-header %s %q if %s\n", k, rb.Headers.Response[k], hostACL)
}
}
}
}
}
// writeL7AuthDirectives writes Authelia forward-auth directives for eligible routes.
func writeL7AuthDirectives(sb *strings.Builder, opts GenerateOpts) {
authDomainACL := ""
if opts.AuthDomain != "" {
authDomainACL = "host_" + sanitizeName(opts.AuthDomain)
}
for _, httpRoute := range opts.HTTPRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
// Skip auth portal domain (would cause infinite redirect)
if hostACL == authDomainACL {
continue
}
// Skip domains outside the auth session scope
if opts.AuthSessionDomain != "" &&
httpRoute.Domain != opts.AuthSessionDomain &&
!strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) {
continue
}
hasAuth := false
for _, rb := range httpRoute.Routes {
if rb.AuthEnabled {
hasAuth = true
break
}
}
if !hasAuth {
continue
}
fmt.Fprintf(sb, " # auth: %s\n", httpRoute.Domain)
fmt.Fprintf(sb, " http-request lua.auth-request be_authelia /api/authz/forward-auth if %s\n", hostACL)
fmt.Fprintf(sb, " http-request redirect location https://%s/?rd=https://%%[hdr(host)]%%[path] if %s !{ var(txn.auth_response_successful) -m bool }\n", opts.AuthDomain, hostACL)
}
sb.WriteString("\n")
}
// writeL7RoutingRules writes use_backend rules: path-specific first, then catch-all.
func writeL7RoutingRules(sb *strings.Builder, httpRoutes []HTTPRoute) {
for _, httpRoute := range httpRoutes {
hostACL := "host_" + sanitizeName(httpRoute.Name)
routeName := sanitizeName(httpRoute.Name)
for i, rb := range httpRoute.Routes {
if len(rb.Paths) == 0 {
continue
}
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
pathACL := fmt.Sprintf("path_%s_%d", routeName, i)
fmt.Fprintf(sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
fmt.Fprintf(sb, " use_backend %s if %s %s\n", backendName, hostACL, pathACL)
}
for i, rb := range httpRoute.Routes {
if len(rb.Paths) > 0 {
continue
}
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
fmt.Fprintf(sb, " use_backend %s if %s\n", backendName, hostACL)
}
}
sb.WriteString("\n")
}
// writeL7Backends writes L7 HTTP backend definitions — one per route.
func writeL7Backends(sb *strings.Builder, httpRoutes []HTTPRoute) {
for _, httpRoute := range httpRoutes {
routeName := sanitizeName(httpRoute.Name)
for i, rb := range httpRoute.Routes {
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
fmt.Fprintf(sb, "# service: %s\n", httpRoute.Domain)
fmt.Fprintf(sb, "backend %s\n", backendName)
sb.WriteString(" mode http\n")
if rb.HealthPath != "" {
fmt.Fprintf(sb, " option httpchk GET %s\n", rb.HealthPath)
fmt.Fprintf(sb, " server s0 %s check\n", rb.Backend)
} else {
fmt.Fprintf(sb, " server s0 %s\n", rb.Backend)
}
sb.WriteString("\n")
}
}
}
// writeCustomRoutes writes custom TCP proxy frontend/backend pairs.
func writeCustomRoutes(sb *strings.Builder, custom []CustomRoute) {
for _, route := range custom {
fmt.Fprintf(sb, "# Custom route: %s\n", route.Name)
fmt.Fprintf(sb, "frontend fe_%s\n", sanitizeName(route.Name))
fmt.Fprintf(sb, " bind *:%d\n", route.Port)
sb.WriteString(" mode tcp\n")
fmt.Fprintf(sb, " default_backend be_%s\n", sanitizeName(route.Name))
sb.WriteString("\n")
fmt.Fprintf(sb, "backend be_%s\n", sanitizeName(route.Name))
sb.WriteString(" mode tcp\n")
fmt.Fprintf(sb, " server s0 %s\n", route.Backend)
sb.WriteString("\n")
}
}
// sortedKeys returns the keys of a map in sorted order for deterministic output.
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// ParseValidationErrors extracts line numbers from haproxy -c ALERT output.
// HAProxy format: [ALERT] (pid) : config : parsing [/path/to/file:LINE]: message
func ParseValidationErrors(output string) []int {
var lines []int
for _, line := range strings.Split(output, "\n") {
if !strings.Contains(line, "[ALERT]") || !strings.Contains(line, "parsing") {
continue
}
// Find the bracketed path:line — e.g., [/tmp/test.cfg:111]
bracketStart := strings.LastIndex(line, "[")
bracketEnd := strings.Index(line[bracketStart:], "]")
if bracketStart < 0 || bracketEnd < 0 {
continue
}
bracket := line[bracketStart+1 : bracketStart+bracketEnd]
// Extract line number after the last colon in the bracket
colonIdx := strings.LastIndex(bracket, ":")
if colonIdx < 0 {
continue
}
if n, err := strconv.Atoi(bracket[colonIdx+1:]); err == nil {
lines = append(lines, n)
}
}
return lines
}
// FindBrokenServices maps failing line numbers to service domains using
// "# service: domain" comment markers in the generated config.
// For each failing line, scans backwards to find the nearest marker.
func FindBrokenServices(config string, failingLines []int) []string {
configLines := strings.Split(config, "\n")
seen := map[string]bool{}
var broken []string
for _, lineNum := range failingLines {
if lineNum < 1 || lineNum > len(configLines) {
continue
}
// Scan backwards from the failing line to find the nearest service marker
for i := lineNum - 1; i >= 0; i-- {
trimmed := strings.TrimSpace(configLines[i])
if strings.HasPrefix(trimmed, "# service: ") {
domain := strings.TrimPrefix(trimmed, "# service: ")
if !seen[domain] {
seen[domain] = true
broken = append(broken, domain)
}
break
}
}
}
return broken
}
// sanitizeName converts a name to a valid HAProxy identifier (alphanumeric + underscore)
func sanitizeName(name string) string {
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
return r
}
return '_'
}, name)
}
// GetListenPorts returns all TCP/HTTP ports HAProxy is configured to listen on.
// Used to keep nftables rules in sync.
func (m *Manager) GetListenPorts(custom []CustomRoute) []int {
ports := []int{80, 443, 8404}
for _, route := range custom {
ports = append(ports, route.Port)
}
return ports
}
// ValidateConfig tests an HAProxy configuration file for syntax errors
func (m *Manager) ValidateConfig(configPath string) error {
cmd := exec.Command("haproxy", "-c", "-f", configPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("haproxy config validation failed: %w (output: %s)", err, string(output))
}
return nil
}
// WriteConfig validates and atomically writes the HAProxy configuration
func (m *Manager) WriteConfig(content string) error {
tempFile := m.configPath + ".tmp"
if err := os.WriteFile(tempFile, []byte(content), 0644); err != nil {
return fmt.Errorf("writing temp config: %w", err)
}
if err := m.ValidateConfig(tempFile); err != nil {
os.Remove(tempFile)
return err
}
if err := os.Rename(tempFile, m.configPath); err != nil {
os.Remove(tempFile)
return fmt.Errorf("installing config: %w", err)
}
slog.Info("haproxy config written", "component", "haproxy", "path", m.configPath)
return nil
}
// ReloadService gracefully reloads HAProxy (starts it if not running)
func (m *Manager) ReloadService() error {
cmd := exec.Command("systemctl", "reload-or-restart", "haproxy.service")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to reload haproxy: %w (output: %s)", err, string(output))
}
slog.Info("haproxy service reloaded", "component", "haproxy")
return nil
}
// RestartService performs a full restart of HAProxy
func (m *Manager) RestartService() error {
cmd := exec.Command("systemctl", "restart", "haproxy.service")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to restart haproxy: %w (output: %s)", err, string(output))
}
slog.Info("haproxy service restarted", "component", "haproxy")
return nil
}
// Status represents the status of the HAProxy service
type Status struct {
Status string `json:"status"`
PID int `json:"pid"`
ConfigFile string `json:"configFile"`
LastRestart time.Time `json:"lastRestart"`
}
// GetStatus returns the current HAProxy service status
func (m *Manager) GetStatus() (*Status, error) {
status := &Status{
ConfigFile: m.configPath,
}
cmd := exec.Command("systemctl", "is-active", "haproxy.service")
output, err := cmd.Output()
if err != nil {
status.Status = "inactive"
return status, nil
}
status.Status = strings.TrimSpace(string(output))
if status.Status == "active" {
cmd = exec.Command("systemctl", "show", "haproxy.service", "--property=MainPID")
if output, err := cmd.Output(); err == nil {
parts := strings.Split(strings.TrimSpace(string(output)), "=")
if len(parts) == 2 {
if pid, err := strconv.Atoi(parts[1]); err == nil {
status.PID = pid
}
}
}
cmd = exec.Command("systemctl", "show", "haproxy.service", "--property=ActiveEnterTimestamp")
if output, err := cmd.Output(); err == nil {
parts := strings.Split(strings.TrimSpace(string(output)), "=")
if len(parts) == 2 {
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", parts[1]); err == nil {
status.LastRestart = t
}
}
}
}
return status, nil
}
// ReadConfig reads the current HAProxy configuration file
func (m *Manager) ReadConfig() (string, error) {
data, err := os.ReadFile(m.configPath)
if err != nil {
return "", fmt.Errorf("reading haproxy config: %w", err)
}
return string(data), nil
}
// BackendStat holds per-backend connection counts from the HAProxy stats socket
type BackendStat struct {
Name string `json:"name"`
Status string `json:"status"`
ActiveConns int `json:"activeConns"`
Rate int `json:"rate"`
PeakRate int `json:"peakRate"`
TotalConns int64 `json:"totalConns"`
Errors int64 `json:"errors"`
}
// GetStats reads live connection stats from the HAProxy stats socket.
// Returns an empty slice (not an error) when HAProxy is not running.
func (m *Manager) GetStats() ([]BackendStat, error) {
conn, err := net.Dial("unix", m.socketPath)
if err != nil {
// HAProxy not running or socket not yet created — not a hard error
return []BackendStat{}, nil
}
defer conn.Close()
if _, err := fmt.Fprintf(conn, "show stat\n"); err != nil {
return nil, fmt.Errorf("sending stats command: %w", err)
}
// CSV format: # pxname,svname,...,status,...,scur,...,stot,...,eresp,...
var stats []BackendStat
scanner := bufio.NewScanner(conn)
var headers []string
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "#") {
// Header line: "# pxname,svname,..."
headers = strings.Split(strings.TrimPrefix(line, "# "), ",")
continue
}
if line == "" || len(headers) == 0 {
continue
}
fields := strings.Split(line, ",")
if len(fields) < len(headers) {
continue
}
idx := func(name string) int {
for i, h := range headers {
if h == name {
return i
}
}
return -1
}
// Only report backends (svname == "BACKEND")
svIdx := idx("svname")
if svIdx < 0 || fields[svIdx] != "BACKEND" {
continue
}
stat := BackendStat{
Name: fields[idx("pxname")],
Status: fields[idx("status")],
}
if i := idx("scur"); i >= 0 {
fmt.Sscanf(fields[i], "%d", &stat.ActiveConns)
}
if i := idx("rate"); i >= 0 {
fmt.Sscanf(fields[i], "%d", &stat.Rate)
}
if i := idx("rate_max"); i >= 0 {
fmt.Sscanf(fields[i], "%d", &stat.PeakRate)
}
if i := idx("stot"); i >= 0 {
fmt.Sscanf(fields[i], "%d", &stat.TotalConns)
}
if i := idx("eresp"); i >= 0 {
fmt.Sscanf(fields[i], "%d", &stat.Errors)
}
stats = append(stats, stat)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading stats: %w", err)
}
return stats, nil
}