Files
wild-central/internal/haproxy/config_test.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

293 lines
8.7 KiB
Go

package haproxy
import (
"strings"
"testing"
)
func TestNewManager_DefaultPath(t *testing.T) {
m := NewManager("")
if m.GetConfigPath() != "/etc/haproxy/haproxy.cfg" {
t.Errorf("got %q, want default path", m.GetConfigPath())
}
}
func TestNewManager_CustomPath(t *testing.T) {
m := NewManager("/tmp/haproxy.cfg")
if m.GetConfigPath() != "/tmp/haproxy.cfg" {
t.Errorf("got %q, want /tmp/haproxy.cfg", m.GetConfigPath())
}
}
func TestSanitizeName(t *testing.T) {
cases := []struct {
in string
want string
}{
{"payne-cloud", "payne_cloud"},
{"cloud.payne.io", "cloud_payne_io"},
{"civil_ssh", "civil_ssh"},
{"test cloud 1", "test_cloud_1"},
{"abc123", "abc123"},
}
for _, c := range cases {
if got := sanitizeName(c.in); got != c.want {
t.Errorf("sanitizeName(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestGetListenPorts_BasePorts(t *testing.T) {
m := NewManager("")
ports := m.GetListenPorts(nil, nil)
want := map[int]bool{80: true, 443: true, 8404: true}
for _, p := range ports {
delete(want, p)
}
if len(want) > 0 {
t.Errorf("missing base ports: %v", want)
}
}
func TestGetListenPorts_IncludesCustom(t *testing.T) {
m := NewManager("")
custom := []CustomRoute{{Name: "ssh", Port: 2222, Backend: "192.168.1.10:22"}}
ports := m.GetListenPorts(nil, custom)
found := false
for _, p := range ports {
if p == 2222 {
found = true
}
}
if !found {
t.Errorf("custom port 2222 not in listen ports: %v", ports)
}
}
func TestGenerate_AlwaysIncludesBoilerplate(t *testing.T) {
m := NewManager("")
out := m.Generate(nil, nil)
for _, want := range []string{
"global\n",
"defaults\n",
"frontend stats\n",
"frontend http_in\n",
"redirect scheme https code 301",
"bind *:8404",
} {
if !strings.Contains(out, want) {
t.Errorf("expected %q in output, got:\n%s", want, out)
}
}
}
func TestGenerate_NoInstances_NoHTTPSFrontend(t *testing.T) {
m := NewManager("")
out := m.Generate(nil, nil)
if strings.Contains(out, "frontend https_in") {
t.Errorf("https_in frontend should be absent when no instances configured")
}
}
func TestGenerate_WithInstance_SNIACLs(t *testing.T) {
m := NewManager("")
instances := []InstanceRoute{
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20"},
}
out := m.Generate(instances, nil)
// Both ACL lines for OR-matching (subdomain and exact)
if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") {
t.Errorf("expected subdomain SNI ACL, got:\n%s", out)
}
if !strings.Contains(out, "req_ssl_sni -m str cloud.payne.io") {
t.Errorf("expected exact SNI ACL, got:\n%s", out)
}
if !strings.Contains(out, "use_backend be_https_payne_cloud if is_payne_cloud") {
t.Errorf("expected use_backend directive, got:\n%s", out)
}
}
func TestGenerate_WithInstance_Backend(t *testing.T) {
m := NewManager("")
instances := []InstanceRoute{
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20"},
}
out := m.Generate(instances, nil)
if !strings.Contains(out, "backend be_https_payne_cloud") {
t.Errorf("expected backend block, got:\n%s", out)
}
if !strings.Contains(out, "server s0 192.168.8.20:443 check") {
t.Errorf("expected server line with LB IP, got:\n%s", out)
}
}
func TestGenerate_MultipleInstances(t *testing.T) {
m := NewManager("")
instances := []InstanceRoute{
{Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20"},
{Name: "test-cloud", Domain: "cloud2.payne.io", BackendIP: "192.168.8.30"},
}
out := m.Generate(instances, nil)
if !strings.Contains(out, "be_https_payne_cloud") {
t.Errorf("expected payne-cloud backend, got:\n%s", out)
}
if !strings.Contains(out, "be_https_test_cloud") {
t.Errorf("expected test-cloud backend, got:\n%s", out)
}
if !strings.Contains(out, "192.168.8.20:443") {
t.Errorf("expected payne-cloud IP, got:\n%s", out)
}
if !strings.Contains(out, "192.168.8.30:443") {
t.Errorf("expected test-cloud IP, got:\n%s", out)
}
}
func TestGenerate_WithExtraDomains(t *testing.T) {
m := NewManager("")
instances := []InstanceRoute{
{
Name: "payne-cloud",
Domain: "cloud.payne.io",
BackendIP: "192.168.8.20",
ExtraDomains: []string{"payne.io", "mywildcloud.org"},
},
{Name: "test-cloud", Domain: "cloud2.payne.io", BackendIP: "192.168.8.30"},
}
out := m.Generate(instances, nil)
// Extra domains should add ACL entries under the same payne-cloud ACL name
for _, domain := range []string{"payne.io", "mywildcloud.org"} {
if !strings.Contains(out, "req_ssl_sni -m end ."+domain) {
t.Errorf("expected subdomain ACL for %s, got:\n%s", domain, out)
}
if !strings.Contains(out, "req_ssl_sni -m str "+domain) {
t.Errorf("expected exact ACL for %s, got:\n%s", domain, out)
}
}
// Extra domains should route to payne-cloud, not test-cloud
if !strings.Contains(out, "use_backend be_https_payne_cloud if is_payne_cloud") {
t.Errorf("expected payne-cloud use_backend, got:\n%s", out)
}
}
func TestGenerate_CustomRoute(t *testing.T) {
m := NewManager("")
custom := []CustomRoute{
{Name: "civil_ssh", Port: 2222, Backend: "192.168.8.10:22"},
}
out := m.Generate(nil, custom)
if !strings.Contains(out, "frontend fe_civil_ssh") {
t.Errorf("expected custom frontend, got:\n%s", out)
}
if !strings.Contains(out, "bind *:2222") {
t.Errorf("expected bind on port 2222, got:\n%s", out)
}
if !strings.Contains(out, "backend be_civil_ssh") {
t.Errorf("expected custom backend, got:\n%s", out)
}
if !strings.Contains(out, "server s0 192.168.8.10:22") {
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)
}
}