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>
This commit is contained in:
@@ -27,6 +27,15 @@ type CustomRoute struct {
|
|||||||
Backend string // e.g. "192.168.8.10:22"
|
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 defaultConfigPath = "/etc/haproxy/haproxy.cfg"
|
||||||
const defaultSocketPath = "/var/run/haproxy/admin.sock"
|
const defaultSocketPath = "/var/run/haproxy/admin.sock"
|
||||||
|
|
||||||
@@ -54,11 +63,32 @@ func (m *Manager) GetConfigPath() string {
|
|||||||
return m.configPath
|
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,
|
// 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 —
|
// and optional config. Uses L4 TCP mode with SNI inspection for HTTPS —
|
||||||
// TLS terminates at each k8s cluster's traefik (or nginx for central).
|
// TLS terminates at each k8s cluster's traefik. HTTP routes add an L7 frontend
|
||||||
// centralDomain, if non-empty, is routed via SNI to local nginx:443 before instance ACLs.
|
// 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 {
|
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
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(`# Wild Cloud HAProxy Configuration
|
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("# HTTPS: SNI-based L4 routing (TLS passes through to k8s traefik)\n")
|
||||||
sb.WriteString("frontend https_in\n")
|
sb.WriteString("frontend https_in\n")
|
||||||
sb.WriteString(" bind *:443\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(" tcp-request content accept if { req_ssl_hello_type 1 }\n")
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Central domain: SNI routes to local TLS-terminating frontend before instance wildcards
|
// Central domain: SNI routes to local TLS-terminating frontend
|
||||||
if len(centralDomain) > 0 && centralDomain[0] != "" {
|
if opts.CentralDomain != "" {
|
||||||
fmt.Fprintf(&sb, " acl is_central req_ssl_sni -m str %s\n", centralDomain[0])
|
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(" use_backend be_central_tls if is_central\n")
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Instance routes: L4 SNI passthrough to k8s load balancers
|
||||||
for _, inst := range instances {
|
for _, inst := range instances {
|
||||||
aclName := "is_" + sanitizeName(inst.Name)
|
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 end .%s\n", aclName, inst.Domain)
|
||||||
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %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 {
|
for _, extra := range inst.ExtraDomains {
|
||||||
@@ -126,19 +157,34 @@ frontend http_in
|
|||||||
sb.WriteString("\n")
|
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")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Central: TCP passthrough to local TLS-terminating frontend
|
// Central TLS-terminating backend and frontend
|
||||||
if len(centralDomain) > 0 && centralDomain[0] != "" {
|
if opts.CentralDomain != "" {
|
||||||
sb.WriteString("backend be_central_tls\n")
|
sb.WriteString("backend be_central_tls\n")
|
||||||
sb.WriteString(" mode tcp\n")
|
sb.WriteString(" mode tcp\n")
|
||||||
sb.WriteString(" server s0 127.0.0.1:4443\n")
|
sb.WriteString(" server s0 127.0.0.1:4443\n")
|
||||||
sb.WriteString("\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("# Wild Central: TLS termination for management UI\n")
|
||||||
sb.WriteString("frontend central_https\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(" mode http\n")
|
||||||
sb.WriteString(" default_backend be_central_http\n")
|
sb.WriteString(" default_backend be_central_http\n")
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
@@ -148,6 +194,7 @@ frontend http_in
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Instance L4 backends
|
||||||
for _, inst := range instances {
|
for _, inst := range instances {
|
||||||
fmt.Fprintf(&sb, "backend be_https_%s\n", sanitizeName(inst.Name))
|
fmt.Fprintf(&sb, "backend be_https_%s\n", sanitizeName(inst.Name))
|
||||||
sb.WriteString(" mode tcp\n")
|
sb.WriteString(" mode tcp\n")
|
||||||
@@ -155,6 +202,45 @@ frontend http_in
|
|||||||
fmt.Fprintf(&sb, " server s0 %s:443 check\n", inst.BackendIP)
|
fmt.Fprintf(&sb, " server s0 %s:443 check\n", inst.BackendIP)
|
||||||
sb.WriteString("\n")
|
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 {
|
for _, route := range custom {
|
||||||
|
|||||||
@@ -194,3 +194,99 @@ func TestGenerate_CustomRoute(t *testing.T) {
|
|||||||
t.Errorf("expected server line with backend address, got:\n%s", out)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user