feat: Fix HAProxy ACL ordering + Subdomains field
Rewrite HAProxy config generation with correct ACL ordering: 1. L7 HTTP exact matches first (central, wild-cloud app) 2. L4 exact matches (payne.io, civilsociety.dev) 3. L4 wildcard matches (*.cloud.payne.io) — only when subdomains:true 4. Default → L7 termination Key changes: - InstanceRoute: removed ExtraDomains, added Subdomains bool - GenerateOpts: removed CentralDomain (Central is now just another HTTPRoute injected by reconciliation), renamed WildcardCert→CertsDir - Central's domain comes from config, injected as HTTPRoute with the actual running port (no more hardcoded 5055) - Added API.SetPort() so reconciliation knows the running port - subdomains:false → exact match only (no *.domain shadowing) New tests: TestGenerate_WithSubdomains, TestGenerate_ACLOrdering (verifies L7 exact matches come before L4 wildcards). Fixes: BUG 1 (central routing to wrong port), BUG 2 (wild-cloud getting traefik cert), BUG 3 (*.payne.io too broad). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,11 +13,12 @@ import (
|
||||
)
|
||||
|
||||
// InstanceRoute represents a Wild Cloud instance's ingress routing configuration
|
||||
// InstanceRoute represents an L4 TCP passthrough route (k8s instance).
|
||||
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
|
||||
Name string `json:"name"` // identifier for backend naming
|
||||
Domain string `json:"domain"` // e.g. "cloud.payne.io"
|
||||
BackendIP string `json:"backendIP"` // k8s load balancer IP
|
||||
Subdomains bool `json:"subdomains"` // if true, also match *.domain
|
||||
}
|
||||
|
||||
// CustomRoute represents a custom TCP proxy rule for non-Wild Cloud services
|
||||
@@ -65,9 +66,8 @@ func (m *Manager) GetConfigPath() string {
|
||||
|
||||
// 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
|
||||
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
|
||||
CertsDir string // directory containing per-domain PEM files for L7 TLS
|
||||
}
|
||||
|
||||
// Generate creates a complete HAProxy configuration from instance routes, custom routes,
|
||||
@@ -75,16 +75,7 @@ type GenerateOpts struct {
|
||||
// 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 ""
|
||||
return m.GenerateWithOpts(instances, custom, GenerateOpts{})
|
||||
}
|
||||
|
||||
// GenerateWithOpts creates a complete HAProxy configuration with full options.
|
||||
@@ -126,7 +117,7 @@ frontend http_in
|
||||
|
||||
`)
|
||||
|
||||
hasL4Routes := len(instances) > 0 || opts.CentralDomain != "" || len(opts.HTTPRoutes) > 0
|
||||
hasL4Routes := len(instances) > 0 || len(opts.HTTPRoutes) > 0
|
||||
|
||||
if hasL4Routes {
|
||||
sb.WriteString("# HTTPS: SNI-based L4 routing (TLS passes through to k8s traefik)\n")
|
||||
@@ -137,63 +128,58 @@ 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
|
||||
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")
|
||||
}
|
||||
// ACL ordering is critical. Process in this order:
|
||||
// 1. L7 HTTP exact matches (central, wild-cloud app) → L7 termination
|
||||
// 2. L4 exact matches (payne.io, civilsociety.dev) → instance backends
|
||||
// 3. L4 wildcard matches (*.cloud.payne.io) → instance backends
|
||||
// 4. Default → L7 termination (if any HTTP routes exist)
|
||||
|
||||
// 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
|
||||
// 1. L7 HTTP services — exact match, route to L7 TLS termination frontend
|
||||
if len(opts.HTTPRoutes) > 0 {
|
||||
sb.WriteString(" # L7 HTTP services (exact match → TLS termination)\n")
|
||||
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)
|
||||
fmt.Fprintf(&sb, " use_backend be_l7_termination if %s\n", aclName)
|
||||
}
|
||||
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)
|
||||
// 2. L4 exact matches — instances with subdomains:false
|
||||
hasExact := false
|
||||
for _, inst := range instances {
|
||||
if inst.Subdomains {
|
||||
continue // handled in step 3
|
||||
}
|
||||
hasExact = true
|
||||
aclName := "is_" + sanitizeName(inst.Name)
|
||||
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 — instances with subdomains:true
|
||||
sb.WriteString(" # L4 instance routes (wildcard subdomain matching)\n")
|
||||
for _, inst := range instances {
|
||||
if !inst.Subdomains {
|
||||
continue // already handled in step 2
|
||||
}
|
||||
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)
|
||||
fmt.Fprintf(&sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// 4. Default → L7 termination fallback
|
||||
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))
|
||||
@@ -207,7 +193,7 @@ frontend http_in
|
||||
if len(opts.HTTPRoutes) > 0 {
|
||||
// Load all PEM files from the certs directory — HAProxy serves
|
||||
// the right cert per-SNI. Each service gets its own <domain>.pem.
|
||||
certPath := opts.WildcardCert
|
||||
certPath := opts.CertsDir
|
||||
if certPath == "" {
|
||||
certPath = "/etc/haproxy/certs/"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user