Files
wild-central/internal/haproxy/config.go
Paul Payne c47689c3f2 feat: Add L7 HTTP reverse proxy mode to HAProxy
Extend HAProxy config generation to support L7 HTTP routes alongside
existing L4 SNI passthrough routes. This enables Wild Works services
to be reverse-proxied with TLS termination by Central while Wild Cloud
k8s instances continue using L4 SNI passthrough.

- Add HTTPRoute type: domain, backend address, optional health path
- Add GenerateWithOpts for full config options (HTTP routes, wildcard cert)
- L4 SNI frontend routes known Cloud domains to k8s LBs; unknown SNIs
  fall through to L7 TLS-termination frontend for Host-based routing
- L7 frontend terminates TLS with wildcard cert, routes by Host header
- Health check support via HAProxy httpchk
- Backward compatible: existing Generate() works unchanged
- 3 new tests (L7-only, mixed L4+L7, backward compat)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-08 23:39:30 +00:00

475 lines
15 KiB
Go

package haproxy
import (
"bufio"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// InstanceRoute represents a Wild Cloud instance's ingress routing configuration
type InstanceRoute struct {
Name string `json:"name"` // e.g. "payne-cloud"
Domain string `json:"domain"` // e.g. "cloud.payne.io"
BackendIP string `json:"backendIP"` // k8s load balancer IP
ExtraDomains []string `json:"extraDomains,omitempty"` // additional domains served by this instance
}
// 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"
}
// HTTPRoute represents an L7 HTTP reverse proxy route (for Wild Works services).
// Central terminates TLS using a wildcard certificate and proxies by Host header.
type HTTPRoute struct {
Name string // e.g. "my-api"
Domain string // e.g. "my-api.payne.io"
Backend string // e.g. "192.168.8.60:9001"
HealthPath string // e.g. "/health" (optional, for active health checks)
}
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 {
CentralDomain string // if set, central's own domain is SNI-routed to local TLS frontend
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
WildcardCert string // path to wildcard cert PEM for L7 TLS termination
}
// Generate creates a complete HAProxy configuration from instance routes, custom routes,
// and optional config. Uses L4 TCP mode with SNI inspection for HTTPS —
// TLS terminates at each k8s cluster's traefik. HTTP routes add an L7 frontend
// where Central terminates TLS with a wildcard cert and reverse-proxies by Host header.
func (m *Manager) Generate(instances []InstanceRoute, custom []CustomRoute, centralDomain ...string) string {
return m.GenerateWithOpts(instances, custom, GenerateOpts{
CentralDomain: firstOrEmpty(centralDomain),
})
}
func firstOrEmpty(s []string) string {
if len(s) > 0 {
return s[0]
}
return ""
}
// GenerateWithOpts creates a complete HAProxy configuration with full options.
func (m *Manager) GenerateWithOpts(instances []InstanceRoute, custom []CustomRoute, opts GenerateOpts) string {
var sb strings.Builder
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
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
`)
hasL4Routes := len(instances) > 0 || opts.CentralDomain != "" || len(opts.HTTPRoutes) > 0
if hasL4Routes {
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")
// Central domain: SNI routes to local TLS-terminating frontend
if opts.CentralDomain != "" {
fmt.Fprintf(&sb, " acl is_central req_ssl_sni -m str %s\n", opts.CentralDomain)
sb.WriteString(" use_backend be_central_tls if is_central\n")
sb.WriteString("\n")
}
// Instance routes: L4 SNI passthrough to k8s load balancers
for _, inst := range instances {
aclName := "is_" + sanitizeName(inst.Name)
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)
for _, extra := range inst.ExtraDomains {
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m end .%s\n", aclName, extra)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, extra)
}
fmt.Fprintf(&sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
sb.WriteString("\n")
}
// HTTP route SNI ACLs: route to L7 TLS-termination frontend
if len(opts.HTTPRoutes) > 0 {
for _, route := range opts.HTTPRoutes {
aclName := "is_l7_" + sanitizeName(route.Name)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
}
sb.WriteString(" acl is_l7 req_ssl_sni -m found\n")
sb.WriteString(" use_backend be_l7_termination if is_l7\n")
sb.WriteString("\n")
}
// Fallback: any remaining SNI goes to L7 termination (if we have HTTP routes)
if len(opts.HTTPRoutes) > 0 {
sb.WriteString(" default_backend be_l7_termination\n")
}
sb.WriteString("\n")
// Central TLS-terminating backend and frontend
if opts.CentralDomain != "" {
sb.WriteString("backend be_central_tls\n")
sb.WriteString(" mode tcp\n")
sb.WriteString(" server s0 127.0.0.1:4443\n")
sb.WriteString("\n")
sb.WriteString("# Wild Central: TLS termination for management UI\n")
sb.WriteString("frontend central_https\n")
fmt.Fprintf(&sb, " bind 127.0.0.1:4443 ssl crt /etc/haproxy/certs/%s.pem\n", opts.CentralDomain)
sb.WriteString(" mode http\n")
sb.WriteString(" default_backend be_central_http\n")
sb.WriteString("\n")
sb.WriteString("backend be_central_http\n")
sb.WriteString(" mode http\n")
sb.WriteString(" server s0 127.0.0.1:5055\n")
sb.WriteString("\n")
}
// Instance L4 backends
for _, inst := range instances {
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")
}
// L7 TLS termination backend + frontend (for HTTP routes)
if len(opts.HTTPRoutes) > 0 {
certPath := opts.WildcardCert
if certPath == "" {
certPath = "/etc/haproxy/certs/wildcard.pem"
}
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")
for _, route := range opts.HTTPRoutes {
aclName := "host_" + sanitizeName(route.Name)
fmt.Fprintf(&sb, " acl %s hdr(host) -i %s\n", aclName, route.Domain)
fmt.Fprintf(&sb, " use_backend be_l7_%s if %s\n", sanitizeName(route.Name), aclName)
}
sb.WriteString("\n")
// L7 backends
for _, route := range opts.HTTPRoutes {
fmt.Fprintf(&sb, "backend be_l7_%s\n", sanitizeName(route.Name))
sb.WriteString(" mode http\n")
if route.HealthPath != "" {
fmt.Fprintf(&sb, " option httpchk GET %s\n", route.HealthPath)
fmt.Fprintf(&sb, " server s0 %s check\n", route.Backend)
} else {
fmt.Fprintf(&sb, " server s0 %s\n", route.Backend)
}
sb.WriteString("\n")
}
}
}
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")
}
return sb.String()
}
// 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(instances []InstanceRoute, 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
}
// ServiceStatus represents the status of the HAProxy service
type ServiceStatus 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() (*ServiceStatus, error) {
status := &ServiceStatus{
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
}