feat: Extract Wild Central as standalone Go service
Extract the Central networking functionality from wild-cloud/api into a standalone service. Wild Central manages DNS (dnsmasq), gateway (HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot), security (CrowdSec), and DDNS — all the network appliance concerns. All Cloud-specific code (instances, clusters, nodes, apps, backups, operations, kubectl/talosctl tooling) has been removed. The API struct and route registration contain only Central endpoints. Tests updated to match the new API signature. Builds and all tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
388
internal/haproxy/config.go
Normal file
388
internal/haproxy/config.go
Normal file
@@ -0,0 +1,388 @@
|
||||
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"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *Manager) Generate(instances []InstanceRoute, custom []CustomRoute, centralDomain ...string) 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
|
||||
|
||||
`)
|
||||
|
||||
if len(instances) > 0 {
|
||||
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 before instance wildcards
|
||||
if len(centralDomain) > 0 && centralDomain[0] != "" {
|
||||
fmt.Fprintf(&sb, " acl is_central req_ssl_sni -m str %s\n", centralDomain[0])
|
||||
sb.WriteString(" use_backend be_central_tls if is_central\n")
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Central: TCP passthrough to local TLS-terminating frontend
|
||||
if len(centralDomain) > 0 && centralDomain[0] != "" {
|
||||
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])
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
196
internal/haproxy/config_test.go
Normal file
196
internal/haproxy/config_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user