diff --git a/internal/haproxy/config.go b/internal/haproxy/config.go index 86bed4a..1499cb2 100644 --- a/internal/haproxy/config.go +++ b/internal/haproxy/config.go @@ -27,6 +27,15 @@ type CustomRoute struct { 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" @@ -54,11 +63,32 @@ 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 an optional central domain. Uses L4 TCP mode with SNI inspection for HTTPS — -// TLS terminates at each k8s cluster's traefik (or nginx for central). -// centralDomain, if non-empty, is routed via SNI to local nginx:443 before instance ACLs. +// 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 @@ -96,7 +126,9 @@ frontend http_in `) - if len(instances) > 0 { + 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") @@ -105,17 +137,16 @@ frontend http_in 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 before instance wildcards - if len(centralDomain) > 0 && centralDomain[0] != "" { - fmt.Fprintf(&sb, " acl is_central req_ssl_sni -m str %s\n", centralDomain[0]) + // 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) - // Two ACL lines with the same name combine with OR: - // match subdomain (*.cloud.payne.io) and exact domain (cloud.payne.io) 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 { @@ -126,19 +157,34 @@ frontend http_in 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: TCP passthrough to local TLS-terminating frontend - if len(centralDomain) > 0 && centralDomain[0] != "" { + // 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") - // TLS-terminating frontend for central domain → proxy to Go API 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", centralDomain[0]) + 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") @@ -148,6 +194,7 @@ frontend http_in 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") @@ -155,6 +202,45 @@ frontend http_in 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 { diff --git a/internal/haproxy/config_test.go b/internal/haproxy/config_test.go index fcad7ea..bbec68f 100644 --- a/internal/haproxy/config_test.go +++ b/internal/haproxy/config_test.go @@ -194,3 +194,99 @@ func TestGenerate_CustomRoute(t *testing.T) { t.Errorf("expected server line with backend address, got:\n%s", out) } } + +// --- L7 HTTP route tests --- + +func TestGenerateWithOpts_HTTPRoutes_L7Frontend(t *testing.T) { + m := NewManager("") + httpRoutes := []HTTPRoute{ + {Name: "my-api", Domain: "my-api.payne.io", Backend: "192.168.8.60:9001", HealthPath: "/health"}, + {Name: "dashboard", Domain: "dashboard.payne.io", Backend: "192.168.8.60:8080"}, + } + + out := m.GenerateWithOpts(nil, nil, GenerateOpts{ + HTTPRoutes: httpRoutes, + WildcardCert: "/etc/haproxy/certs/wildcard.pem", + }) + + // L7 TLS termination frontend should exist + if !strings.Contains(out, "frontend l7_https") { + t.Errorf("expected L7 frontend, got:\n%s", out) + } + if !strings.Contains(out, "ssl crt /etc/haproxy/certs/wildcard.pem") { + t.Errorf("expected wildcard cert in L7 bind, got:\n%s", out) + } + + // Host-based ACLs + if !strings.Contains(out, "hdr(host) -i my-api.payne.io") { + t.Errorf("expected Host ACL for my-api, got:\n%s", out) + } + if !strings.Contains(out, "hdr(host) -i dashboard.payne.io") { + t.Errorf("expected Host ACL for dashboard, got:\n%s", out) + } + + // L7 backends + if !strings.Contains(out, "backend be_l7_my_api") { + t.Errorf("expected L7 backend for my-api, got:\n%s", out) + } + if !strings.Contains(out, "server s0 192.168.8.60:9001 check") { + t.Errorf("expected my-api server with health check, got:\n%s", out) + } + if !strings.Contains(out, "option httpchk GET /health") { + t.Errorf("expected httpchk for my-api, got:\n%s", out) + } + + // Dashboard backend (no health check) + if !strings.Contains(out, "backend be_l7_dashboard") { + t.Errorf("expected L7 backend for dashboard, got:\n%s", out) + } + if !strings.Contains(out, "server s0 192.168.8.60:8080") { + t.Errorf("expected dashboard server line, got:\n%s", out) + } +} + +func TestGenerateWithOpts_MixedL4AndL7(t *testing.T) { + m := NewManager("") + instances := []InstanceRoute{ + {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.100"}, + } + httpRoutes := []HTTPRoute{ + {Name: "my-api", Domain: "my-api.payne.io", Backend: "192.168.8.60:9001"}, + } + + out := m.GenerateWithOpts(instances, nil, GenerateOpts{ + HTTPRoutes: httpRoutes, + }) + + // Both L4 SNI route and L7 route should be in the same config + if !strings.Contains(out, "use_backend be_https_payne_cloud if is_payne_cloud") { + t.Errorf("expected L4 instance route, got:\n%s", out) + } + if !strings.Contains(out, "frontend l7_https") { + t.Errorf("expected L7 frontend, got:\n%s", out) + } + if !strings.Contains(out, "backend be_l7_my_api") { + t.Errorf("expected L7 backend for my-api, got:\n%s", out) + } + // L7 fallback should be present + if !strings.Contains(out, "default_backend be_l7_termination") { + t.Errorf("expected default_backend for L7 termination, got:\n%s", out) + } +} + +func TestGenerateWithOpts_BackwardCompatible(t *testing.T) { + m := NewManager("") + instances := []InstanceRoute{ + {Name: "test", Domain: "test.example.com", BackendIP: "10.0.0.1"}, + } + + // Using old Generate function should still work + out := m.Generate(instances, nil) + if !strings.Contains(out, "be_https_test") { + t.Errorf("backward compat: expected instance backend, got:\n%s", out) + } + // No L7 frontend when no HTTP routes + if strings.Contains(out, "frontend l7_https") { + t.Errorf("backward compat: unexpected L7 frontend, got:\n%s", out) + } +}