From 43253ca12050b400f44c7fafab7bbc4b9217fe99 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Fri, 10 Jul 2026 05:21:43 +0000 Subject: [PATCH] Support routes. --- docs/registrations.md | 119 +++++++++++++++++-- internal/config/config.go | 14 +-- internal/haproxy/config.go | 154 ++++++++++++++++++++---- internal/haproxy/config_test.go | 188 +++++++++++++++++++++++++++--- internal/services/manager.go | 98 ++++++++++++++-- internal/services/manager_test.go | 88 ++++++++++++++ web/src/schemas/config.ts | 15 --- web/src/types/index.ts | 6 +- 8 files changed, 597 insertions(+), 85 deletions(-) diff --git a/docs/registrations.md b/docs/registrations.md index bb9aab5..e433e2d 100644 --- a/docs/registrations.md +++ b/docs/registrations.md @@ -21,12 +21,66 @@ The domain is the unique key. One registration per domain. |-------|------|----------|---------|-------------| | `domain` | string | yes | — | FQDN to route. Unique key. | | `source` | string | no | `"manual"` | Who registered this: `wild-cloud`, `wild-works`, `manual`. | -| `backend.address` | string | yes | — | Target host:port (e.g., `192.168.8.240:443`). | -| `backend.type` | string | yes | — | `tcp-passthrough` or `http`. | +| `backend.address` | string | yes* | — | Target host:port (e.g., `192.168.8.240:80`). | +| `backend.type` | string | yes* | — | `tcp-passthrough` or `http`. | | `backend.health` | string | no | — | Health check path for L7 services (e.g., `/health`). | -| `subdomains` | bool | no | `false` | If true, also routes `*.domain` traffic to this backend. | +| `subdomains` | bool | no | `false` | If true, also routes `*.domain` traffic. | | `public` | bool | no | `false` | If true, domain gets public DNS + internet exposure. | | `tls` | string | no | inferred | `passthrough` or `terminate`. Defaults based on backend type. | +| `routes` | array | no* | — | Path-based multi-backend routing (see below). | + +*A service must have either `backend` (simple) or `routes` (multi-backend), not both. + +## Simple vs multi-backend services + +### Simple (single backend) + +Most services use a single backend. All traffic for the domain goes to one place: + +```json +{ + "domain": "my-api.payne.io", + "backend": {"address": "192.168.8.60:9001", "type": "http"}, + "tls": "terminate" +} +``` + +### Multi-backend (path-based routing) + +When different URL paths on the same domain need to reach different backends, use `routes`. Each route maps path prefixes to a backend with its own L7 options. Routes are evaluated in order — first match wins. A route with empty `paths` is a catch-all (should be last). + +```json +{ + "domain": "payne.io", + "public": true, + "tls": "terminate", + "routes": [ + { + "paths": ["/_matrix", "/.well-known/matrix"], + "backend": {"address": "192.168.8.60:8008", "type": "http", "health": "/health"} + }, + { + "backend": {"address": "192.168.8.240:80", "type": "http"} + } + ] +} +``` + +Here `/_matrix` and `/.well-known/matrix` go to a Matrix server, everything else goes to a website. Both backends can be on different hosts. + +### Route fields + +Each route in the `routes` array supports: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `paths` | []string | no | Path prefixes to match (empty = catch-all). | +| `backend.address` | string | yes | Target host:port. | +| `backend.type` | string | yes | `http` (routes only support L7). | +| `backend.health` | string | no | Health check path. | +| `headers.request` | map | no | Request headers to set (e.g., `X-Forwarded-Proto: https`). | +| `headers.response` | map | no | Response headers to set (e.g., CORS headers). | +| `ipAllow` | []string | no | CIDR whitelist — deny requests not matching (e.g., `["10.0.0.0/8"]`). | ## User-facing concepts @@ -50,7 +104,7 @@ The API fields map to three user-visible controls: `tls: "passthrough"` or `tls: "terminate"` -- **Passthrough**: Central forwards encrypted traffic directly to the backend. The backend handles its own TLS certificates (e.g., a k8s cluster with traefik). Maps to `backend.type: "tcp-passthrough"`. +- **Passthrough**: Central forwards encrypted traffic directly to the backend. The backend handles its own TLS certificates. Maps to `backend.type: "tcp-passthrough"`. - **Terminate**: Central provisions a TLS certificate and handles HTTPS. Traffic is decrypted at Central and proxied as HTTP to the backend. Maps to `backend.type: "http"`. ## What Central does per registration @@ -62,24 +116,26 @@ When a service is registered, Central automatically: | **LAN DNS** | `address=//` — direct to backend | `address=//` — through Central | | **LAN DNS (private)** | Also `local=//` — prevents upstream forwarding | Same | | **Public DNS** | DDNS A record if public | Same | -| **Proxy** | L4 SNI passthrough. Subdomains adds `*.domain` matching. | L7 HTTP reverse proxy by Host header. | +| **Proxy** | L4 SNI passthrough. Subdomains adds `*.domain` matching. | L7 HTTP reverse proxy by Host header. Routes add path-based ACLs. | | **TLS** | Backend handles — Central passes through | Central provisions cert via Let's Encrypt | ## Examples -### k8s cluster (passthrough, public, with subdomains) +### Wild Cloud k8s cluster (terminate, public, wildcard) + +Wild Cloud registers its instances as HTTP terminate services. Central handles TLS and forwards plain HTTP to the cluster's Traefik ingress controller. ```json { "domain": "cloud.payne.io", "source": "wild-cloud", - "backend": {"address": "192.168.8.240:443", "type": "tcp-passthrough"}, + "backend": {"address": "192.168.8.240:80", "type": "http"}, "subdomains": true, "public": true } ``` -Central creates: LAN DNS → 192.168.8.240, public DNS A record, HAProxy L4 SNI + wildcard *.cloud.payne.io, TLS passthrough. +Central creates: LAN DNS → Central IP, public DNS A record, HAProxy L7 TLS terminate + wildcard `*.cloud.payne.io`, cert via certbot. ### Internal web app (terminate, private) @@ -93,19 +149,60 @@ Central creates: LAN DNS → 192.168.8.240, public DNS A record, HAProxy L4 SNI Central creates: LAN DNS → Central IP (with `local=/`), HAProxy L7 reverse proxy, TLS cert via certbot. No public DNS. -### Custom domain (passthrough, public, exact match) +### Service with custom response headers + +```json +{ + "domain": "keila.cloud.payne.io", + "source": "wild-cloud", + "routes": [{ + "backend": {"address": "192.168.8.240:80", "type": "http"}, + "headers": { + "response": { + "Access-Control-Allow-Origin": "https://cloud.payne.io" + } + } + }], + "public": true +} +``` + +### Service with IP whitelisting (internal only) + +```json +{ + "domain": "headlamp.internal.cloud.payne.io", + "source": "wild-cloud", + "routes": [{ + "backend": {"address": "192.168.8.240:80", "type": "http"}, + "ipAllow": ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] + }] +} +``` + +### L4 passthrough (non-HTTP services) + +For services that handle their own TLS (e.g., standalone apps with built-in cert management): ```json { "domain": "payne.io", "source": "wild-cloud", "backend": {"address": "192.168.8.240:443", "type": "tcp-passthrough"}, - "subdomains": false, "public": true } ``` -Central creates: LAN DNS → 192.168.8.240, public DNS A record, HAProxy L4 SNI exact match only. No wildcard. +Central creates: LAN DNS → backend IP, public DNS A record, HAProxy L4 SNI passthrough. TLS handled by backend. + +## Wild Cloud integration + +Wild Cloud instances with `central.registration: per-app` in their instance config use a client-go Ingress watcher that automatically registers domains with Central: + +- One wildcard registration per instance domain (e.g., `*.cloud.payne.io` with `subdomains: true`) +- One wildcard registration per internal domain (e.g., `*.internal.cloud.payne.io`) +- Individual registrations for apps with special routing needs (custom headers, IP whitelisting, extra domains) +- Apps define special routing via a `central` section in their manifest config ## What is NOT a registration diff --git a/internal/config/config.go b/internal/config/config.go index dc12cbc..f23d9cd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -88,10 +88,6 @@ type GlobalConfig struct { Central struct { Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // e.g. "central.payne.io" } `yaml:"central,omitempty" json:"central,omitempty"` - Router struct { - IP string `yaml:"ip,omitempty" json:"ip,omitempty"` - DynamicDns string `yaml:"dynamicDns,omitempty" json:"dynamicDns,omitempty"` - } `yaml:"router,omitempty" json:"router,omitempty"` Dnsmasq struct { IP string `yaml:"ip,omitempty" json:"ip,omitempty"` Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` @@ -121,8 +117,8 @@ type GlobalConfig struct { } `yaml:"cloud,omitempty" json:"cloud,omitempty"` } -// LoadGlobalConfig loads configuration from the specified path -func LoadGlobalConfig(configPath string) (*GlobalConfig, error) { +// LoadState loads state from the specified path +func LoadState(configPath string) (*GlobalConfig, error) { data, err := os.ReadFile(configPath) if err != nil { return nil, fmt.Errorf("reading config file %s: %w", configPath, err) @@ -136,8 +132,8 @@ func LoadGlobalConfig(configPath string) (*GlobalConfig, error) { return config, nil } -// SaveGlobalConfig saves the configuration to the specified path -func SaveGlobalConfig(config *GlobalConfig, configPath string) error { +// SaveState saves the state to the specified path +func SaveState(config *GlobalConfig, configPath string) error { // Ensure the directory exists if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { return fmt.Errorf("creating config directory: %w", err) @@ -158,7 +154,7 @@ func (c *GlobalConfig) IsEmpty() bool { } // Check if essential fields are empty - return c.Cloud.Router.IP == "" && c.Operator.Email == "" + return c.Cloud.Central.Domain == "" && c.Operator.Email == "" } type NodeConfig struct { diff --git a/internal/haproxy/config.go b/internal/haproxy/config.go index 8e3a7a4..91a5ee9 100644 --- a/internal/haproxy/config.go +++ b/internal/haproxy/config.go @@ -7,9 +7,12 @@ import ( "net" "os" "os/exec" + "sort" "strconv" "strings" "time" + + "github.com/wild-cloud/wild-central/internal/services" ) // InstanceRoute represents a Wild Cloud instance's ingress routing configuration @@ -28,13 +31,23 @@ type CustomRoute struct { Backend string // e.g. "192.168.8.10:22" } -// HTTPRoute represents an L7 HTTP reverse proxy route (for Wild Works services). +// HTTPRouteBackend maps path prefixes to a specific backend with its own L7 options. +type HTTPRouteBackend struct { + Paths []string // path prefixes (empty = catch-all) + Backend string // host:port + HealthPath string // optional health check path + Headers *services.HeaderConfig // per-route headers + IPAllow []string // per-route CIDR whitelist +} + +// HTTPRoute represents an L7 HTTP reverse proxy route. // Central terminates TLS using a wildcard certificate and proxies by Host header. +// Routes are evaluated in order — path-specific before catch-all. 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) + Name string // e.g. "my-api" + Domain string // e.g. "my-api.payne.io" + Subdomains bool // if true, also match *.domain + Routes []HTTPRouteBackend // ordered path→backend mappings } const defaultConfigPath = "/etc/haproxy/haproxy.cfg" @@ -134,14 +147,28 @@ frontend http_in // 3. L4 wildcard matches (*.cloud.payne.io) → instance backends // 4. Default → L7 termination (if any HTTP routes exist) - // 1. L7 HTTP services — exact match, route to L7 TLS termination frontend + // 1. L7 HTTP services — route to L7 TLS termination frontend if len(opts.HTTPRoutes) > 0 { - sb.WriteString(" # L7 HTTP services (exact match → TLS termination)\n") + sb.WriteString(" # L7 HTTP services (→ TLS termination)\n") + // Exact matches first for _, route := range opts.HTTPRoutes { + if route.Subdomains { + continue // handled below + } 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) } + // Wildcard matches after exact + for _, route := range opts.HTTPRoutes { + if !route.Subdomains { + continue + } + aclName := "is_l7_" + sanitizeName(route.Name) + fmt.Fprintf(&sb, " acl %s req_ssl_sni -m end .%s\n", aclName, route.Domain) + 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("\n") } @@ -209,24 +236,99 @@ frontend http_in 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) + // Host ACLs for all services + for _, httpRoute := range opts.HTTPRoutes { + hostACL := "host_" + sanitizeName(httpRoute.Name) + if httpRoute.Subdomains { + fmt.Fprintf(&sb, " acl %s hdr(host) -i -m end .%s\n", hostACL, httpRoute.Domain) + fmt.Fprintf(&sb, " acl %s hdr(host) -i -m str %s\n", hostACL, httpRoute.Domain) + } else { + fmt.Fprintf(&sb, " acl %s hdr(host) -i %s\n", hostACL, httpRoute.Domain) + } } 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) + // Per-route ACLs (IP whitelisting, headers) and routing rules + for _, httpRoute := range opts.HTTPRoutes { + hostACL := "host_" + sanitizeName(httpRoute.Name) + routeName := sanitizeName(httpRoute.Name) + + for i, rb := range httpRoute.Routes { + aclSuffix := fmt.Sprintf("%s_%d", routeName, i) + + // IP whitelisting for this route + if len(rb.IPAllow) > 0 { + ipACL := "ip_" + aclSuffix + fmt.Fprintf(&sb, " acl %s src %s\n", ipACL, strings.Join(rb.IPAllow, " ")) + if len(rb.Paths) > 0 { + pathACL := "path_" + aclSuffix + fmt.Fprintf(&sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " ")) + fmt.Fprintf(&sb, " http-request deny if %s %s !%s\n", hostACL, pathACL, ipACL) + } else { + fmt.Fprintf(&sb, " http-request deny if %s !%s\n", hostACL, ipACL) + } + } + + // Request headers + if rb.Headers != nil { + for _, k := range sortedKeys(rb.Headers.Request) { + fmt.Fprintf(&sb, " http-request set-header %s %q if %s\n", k, rb.Headers.Request[k], hostACL) + } + } + + // Response headers + if rb.Headers != nil { + for _, k := range sortedKeys(rb.Headers.Response) { + fmt.Fprintf(&sb, " http-response set-header %s %q if %s\n", k, rb.Headers.Response[k], hostACL) + } + } + } + } + + sb.WriteString("\n") + + // Routing rules: path-specific first, then catch-all + for _, httpRoute := range opts.HTTPRoutes { + hostACL := "host_" + sanitizeName(httpRoute.Name) + routeName := sanitizeName(httpRoute.Name) + + // Path-specific routes first (most specific wins) + for i, rb := range httpRoute.Routes { + if len(rb.Paths) == 0 { + continue + } + backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i) + pathACL := fmt.Sprintf("path_%s_%d", routeName, i) + fmt.Fprintf(&sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " ")) + fmt.Fprintf(&sb, " use_backend %s if %s %s\n", backendName, hostACL, pathACL) + } + + // Catch-all route last + for i, rb := range httpRoute.Routes { + if len(rb.Paths) > 0 { + continue + } + backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i) + fmt.Fprintf(&sb, " use_backend %s if %s\n", backendName, hostACL) + } + } + sb.WriteString("\n") + + // L7 backends — one per route + for _, httpRoute := range opts.HTTPRoutes { + routeName := sanitizeName(httpRoute.Name) + for i, rb := range httpRoute.Routes { + backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i) + fmt.Fprintf(&sb, "backend %s\n", backendName) + sb.WriteString(" mode http\n") + if rb.HealthPath != "" { + fmt.Fprintf(&sb, " option httpchk GET %s\n", rb.HealthPath) + fmt.Fprintf(&sb, " server s0 %s check\n", rb.Backend) + } else { + fmt.Fprintf(&sb, " server s0 %s\n", rb.Backend) + } + sb.WriteString("\n") } - sb.WriteString("\n") } } } @@ -247,6 +349,16 @@ frontend http_in return sb.String() } +// sortedKeys returns the keys of a map in sorted order for deterministic output. +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + // sanitizeName converts a name to a valid HAProxy identifier (alphanumeric + underscore) func sanitizeName(name string) string { return strings.Map(func(r rune) rune { diff --git a/internal/haproxy/config_test.go b/internal/haproxy/config_test.go index 8fab099..41875a3 100644 --- a/internal/haproxy/config_test.go +++ b/internal/haproxy/config_test.go @@ -3,6 +3,8 @@ package haproxy import ( "strings" "testing" + + "github.com/wild-cloud/wild-central/internal/services" ) func TestNewManager_DefaultPath(t *testing.T) { @@ -174,8 +176,8 @@ func TestGenerate_ACLOrdering(t *testing.T) { {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.240", Subdomains: true}, } httpRoutes := []HTTPRoute{ - {Name: "wild-central", Domain: "central.payne.io", Backend: "127.0.0.1:15055"}, - {Name: "wild-cloud", Domain: "wild-cloud.payne.io", Backend: "127.0.0.1:5055"}, + {Name: "wild-central", Domain: "central.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:15055"}}}, + {Name: "wild-cloud", Domain: "wild-cloud.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:5055"}}}, } out := m.GenerateWithOpts(instances, nil, GenerateOpts{HTTPRoutes: httpRoutes}) @@ -226,8 +228,8 @@ func TestGenerate_CustomRoute(t *testing.T) { 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"}, + {Name: "my-api", Domain: "my-api.payne.io", Routes: []HTTPRouteBackend{{Backend: "192.168.8.60:9001", HealthPath: "/health"}}}, + {Name: "dashboard", Domain: "dashboard.payne.io", Routes: []HTTPRouteBackend{{Backend: "192.168.8.60:8080"}}}, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{ @@ -252,7 +254,7 @@ func TestGenerateWithOpts_HTTPRoutes_L7Frontend(t *testing.T) { } // L7 backends - if !strings.Contains(out, "backend be_l7_my_api") { + if !strings.Contains(out, "backend be_l7_my_api_0") { t.Errorf("expected L7 backend for my-api, got:\n%s", out) } if !strings.Contains(out, "server s0 192.168.8.60:9001 check") { @@ -263,7 +265,7 @@ func TestGenerateWithOpts_HTTPRoutes_L7Frontend(t *testing.T) { } // Dashboard backend (no health check) - if !strings.Contains(out, "backend be_l7_dashboard") { + if !strings.Contains(out, "backend be_l7_dashboard_0") { t.Errorf("expected L7 backend for dashboard, got:\n%s", out) } if !strings.Contains(out, "server s0 192.168.8.60:8080") { @@ -277,7 +279,7 @@ func TestGenerateWithOpts_MixedL4AndL7(t *testing.T) { {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"}, + {Name: "my-api", Domain: "my-api.payne.io", Routes: []HTTPRouteBackend{{Backend: "192.168.8.60:9001"}}}, } out := m.GenerateWithOpts(instances, nil, GenerateOpts{ @@ -291,7 +293,7 @@ func TestGenerateWithOpts_MixedL4AndL7(t *testing.T) { 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") { + if !strings.Contains(out, "backend be_l7_my_api_0") { t.Errorf("expected L7 backend for my-api, got:\n%s", out) } // L7 fallback should be present @@ -311,8 +313,8 @@ func TestGenerateWithOpts_ACLOrdering_L7BeforeL4Wildcard(t *testing.T) { {Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.240", Subdomains: true}, } httpRoutes := []HTTPRoute{ - {Name: "wild-central", Domain: "central.payne.io", Backend: "127.0.0.1:15055"}, - {Name: "wild-cloud", Domain: "wild-cloud.payne.io", Backend: "127.0.0.1:5055"}, + {Name: "wild-central", Domain: "central.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:15055"}}}, + {Name: "wild-cloud", Domain: "wild-cloud.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:5055"}}}, } out := m.GenerateWithOpts(instances, nil, GenerateOpts{HTTPRoutes: httpRoutes}) @@ -443,7 +445,7 @@ func TestGenerateWithOpts_OnlyHTTPRoutes(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ - {Name: "my-api", Domain: "api.example.com", Backend: "192.168.1.10:9001"}, + {Name: "my-api", Domain: "api.example.com", Routes: []HTTPRouteBackend{{Backend: "192.168.1.10:9001"}}}, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) @@ -457,7 +459,7 @@ func TestGenerateWithOpts_OnlyHTTPRoutes(t *testing.T) { t.Errorf("expected L7 frontend for HTTP routes, got:\n%s", out) } // L7 backend should be present - if !strings.Contains(out, "backend be_l7_my_api") { + if !strings.Contains(out, "backend be_l7_my_api_0") { t.Errorf("expected L7 backend for my-api, got:\n%s", out) } // No L4 instance backends @@ -497,7 +499,7 @@ func TestGenerateWithOpts_CertsDir(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ - {Name: "app", Domain: "app.example.com", Backend: "127.0.0.1:8080"}, + {Name: "app", Domain: "app.example.com", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:8080"}}}, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{ @@ -514,7 +516,7 @@ func TestGenerateWithOpts_DefaultCertsDir(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ - {Name: "app", Domain: "app.example.com", Backend: "127.0.0.1:8080"}, + {Name: "app", Domain: "app.example.com", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:8080"}}}, } // Empty CertsDir should default to /etc/haproxy/certs/ @@ -556,7 +558,7 @@ func TestGenerateWithOpts_HealthCheckInL7Backend(t *testing.T) { m := NewManager("") httpRoutes := []HTTPRoute{ - {Name: "api", Domain: "api.example.com", Backend: "10.0.0.5:8080", HealthPath: "/healthz"}, + {Name: "api", Domain: "api.example.com", Routes: []HTTPRouteBackend{{Backend: "10.0.0.5:8080", HealthPath: "/healthz"}}}, } out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) @@ -568,3 +570,159 @@ func TestGenerateWithOpts_HealthCheckInL7Backend(t *testing.T) { t.Errorf("expected server line with 'check' flag for health check, got:\n%s", out) } } + +// --- L7 extended features tests (paths, headers, IP whitelisting) --- + +func TestGenerateWithOpts_PathRestriction(t *testing.T) { + m := NewManager("") + + httpRoutes := []HTTPRoute{ + { + Name: "synapse-fed", + Domain: "payne.io", + Routes: []HTTPRouteBackend{{ + Backend: "192.168.8.80:80", + Paths: []string{"/.well-known/matrix", "/_matrix"}, + }}, + }, + } + + out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) + + if !strings.Contains(out, "acl path_synapse_fed_0 path_beg /.well-known/matrix /_matrix") { + t.Errorf("expected path ACL for synapse-fed, got:\n%s", out) + } + if !strings.Contains(out, "use_backend be_l7_synapse_fed_0 if host_synapse_fed path_synapse_fed_0") { + t.Errorf("expected path-restricted use_backend, got:\n%s", out) + } +} + +func TestGenerateWithOpts_ResponseHeaders(t *testing.T) { + m := NewManager("") + + httpRoutes := []HTTPRoute{ + { + Name: "plausible", + Domain: "plausible.cloud.payne.io", + Routes: []HTTPRouteBackend{{ + Backend: "192.168.8.80:80", + Headers: &services.HeaderConfig{ + Response: map[string]string{ + "Access-Control-Allow-Private-Network": "true", + }, + }, + }}, + }, + } + + out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) + + if !strings.Contains(out, `http-response set-header Access-Control-Allow-Private-Network "true" if host_plausible`) { + t.Errorf("expected response header rule, got:\n%s", out) + } +} + +func TestGenerateWithOpts_RequestHeaders(t *testing.T) { + m := NewManager("") + + httpRoutes := []HTTPRoute{ + { + Name: "my-api", + Domain: "api.payne.io", + Routes: []HTTPRouteBackend{{ + Backend: "192.168.8.80:80", + Headers: &services.HeaderConfig{ + Request: map[string]string{ + "X-Forwarded-Proto": "https", + }, + }, + }}, + }, + } + + out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) + + if !strings.Contains(out, `http-request set-header X-Forwarded-Proto "https" if host_my_api`) { + t.Errorf("expected request header rule, got:\n%s", out) + } +} + +func TestGenerateWithOpts_IPWhitelist(t *testing.T) { + m := NewManager("") + + httpRoutes := []HTTPRoute{ + { + Name: "headlamp", + Domain: "headlamp.internal.cloud.payne.io", + Routes: []HTTPRouteBackend{{ + Backend: "192.168.8.80:80", + IPAllow: []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}, + }}, + }, + } + + out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) + + if !strings.Contains(out, "acl ip_headlamp_0 src 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16") { + t.Errorf("expected IP ACL for headlamp, got:\n%s", out) + } + if !strings.Contains(out, "http-request deny if host_headlamp !ip_headlamp_0") { + t.Errorf("expected IP deny rule for headlamp, got:\n%s", out) + } +} + +func TestGenerateWithOpts_PathRoutingOrder(t *testing.T) { + m := NewManager("") + + // Path-restricted routes should appear BEFORE unrestricted routes + // In the new model, multiple backends for the same domain go in one HTTPRoute + httpRoutes := []HTTPRoute{ + {Name: "app", Domain: "payne.io", Routes: []HTTPRouteBackend{ + {Backend: "192.168.8.80:80", Paths: []string{"/_matrix"}}, + {Backend: "192.168.8.80:80"}, + }}, + } + + out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) + + pathPos := strings.Index(out, "use_backend be_l7_app_0 if") + hostPos := strings.Index(out, "use_backend be_l7_app_1 if") + + if pathPos < 0 { + t.Fatalf("expected path-restricted use_backend for route 0, got:\n%s", out) + } + if hostPos < 0 { + t.Fatalf("expected unrestricted use_backend for route 1, got:\n%s", out) + } + if pathPos > hostPos { + t.Errorf("path-restricted routes (pos %d) must appear before unrestricted (pos %d):\n%s", pathPos, hostPos, out) + } +} + +func TestGenerateWithOpts_NoL7Fields_BackwardCompat(t *testing.T) { + m := NewManager("") + + // Route with no paths/headers/ipAllow should work exactly as before + httpRoutes := []HTTPRoute{ + {Name: "simple", Domain: "simple.payne.io", Routes: []HTTPRouteBackend{{Backend: "192.168.8.80:80"}}}, + } + + out := m.GenerateWithOpts(nil, nil, GenerateOpts{HTTPRoutes: httpRoutes}) + + if !strings.Contains(out, "acl host_simple hdr(host) -i simple.payne.io") { + t.Errorf("expected host ACL, got:\n%s", out) + } + if !strings.Contains(out, "use_backend be_l7_simple_0 if host_simple") { + t.Errorf("expected simple use_backend, got:\n%s", out) + } + // Should NOT have path, IP, or header rules + if strings.Contains(out, "path_beg") { + t.Errorf("unexpected path ACL in simple route, got:\n%s", out) + } + if strings.Contains(out, "http-request deny") { + t.Errorf("unexpected IP deny in simple route, got:\n%s", out) + } + if strings.Contains(out, "http-response set-header") { + t.Errorf("unexpected response header in simple route, got:\n%s", out) + } +} diff --git a/internal/services/manager.go b/internal/services/manager.go index 9980a7c..1a2835c 100644 --- a/internal/services/manager.go +++ b/internal/services/manager.go @@ -40,14 +40,66 @@ type Backend struct { Health string `yaml:"health,omitempty" json:"health,omitempty"` } +// HeaderConfig describes custom request/response headers for L7 HTTP services. +type HeaderConfig struct { + Request map[string]string `yaml:"request,omitempty" json:"request,omitempty"` + Response map[string]string `yaml:"response,omitempty" json:"response,omitempty"` +} + +// Route describes one path→backend mapping within a service. +// Routes are evaluated in order — first match wins. +// A route with empty Paths is a catch-all (should be last). +type Route struct { + Paths []string `yaml:"paths,omitempty" json:"paths,omitempty"` // path prefixes (empty = catch-all) + Backend Backend `yaml:"backend" json:"backend"` // where to route + Headers *HeaderConfig `yaml:"headers,omitempty" json:"headers,omitempty"` // per-route headers + IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist +} + // Service represents a registered service. The Domain is the unique key. +// +// Simple services use Backend directly. Multi-backend services use Routes +// for path-based splitting (Backend is ignored when Routes is present). type Service struct { Domain string `yaml:"domain" json:"domain"` // FQDN — unique key Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual - Backend Backend `yaml:"backend" json:"backend"` // where to route + Backend Backend `yaml:"backend,omitempty" json:"backend,omitempty"` // single backend (simple case) Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate + + // Multi-backend path routing. When present, Backend is ignored. + // Each route maps path prefixes to a specific backend with its own L7 options. + Routes []Route `yaml:"routes,omitempty" json:"routes,omitempty"` +} + +// EffectiveRoutes returns the service's routing as a normalized []Route. +// Simple services (Backend, no Routes) are wrapped in a single-element slice. +func (s *Service) EffectiveRoutes() []Route { + if len(s.Routes) > 0 { + return s.Routes + } + if s.Backend.Address == "" { + return nil + } + return []Route{{Backend: s.Backend}} +} + +// EffectiveBackendAddress returns the primary backend address for DNS/DDNS purposes. +// For multi-route services, returns the first route's backend. +func (s *Service) EffectiveBackendAddress() string { + if len(s.Routes) > 0 { + return s.Routes[0].Backend.Address + } + return s.Backend.Address +} + +// EffectiveBackendType returns the backend type for routing decisions. +func (s *Service) EffectiveBackendType() BackendType { + if len(s.Routes) > 0 { + return s.Routes[0].Backend.Type + } + return s.Backend.Type } // Manager handles service registration CRUD and triggers networking reconciliation. @@ -92,17 +144,36 @@ func (m *Manager) Register(svc Service) error { if svc.Domain == "" { return fmt.Errorf("domain is required") } - if svc.Backend.Address == "" { - return fmt.Errorf("backend address is required") + + hasBackend := svc.Backend.Address != "" + hasRoutes := len(svc.Routes) > 0 + + if hasBackend && hasRoutes { + return fmt.Errorf("service must use backend or routes, not both") } - if svc.Backend.Type == "" { - return fmt.Errorf("backend type is required") + if !hasBackend && !hasRoutes { + return fmt.Errorf("backend address or routes required") + } + + if hasRoutes { + for i, r := range svc.Routes { + if r.Backend.Address == "" { + return fmt.Errorf("route %d: backend address is required", i) + } + if r.Backend.Type == "" { + return fmt.Errorf("route %d: backend type is required", i) + } + } + } else { + if svc.Backend.Type == "" { + return fmt.Errorf("backend type is required") + } } - // Public field is a bool — no validation needed (defaults to false) // Default TLS mode based on backend type if svc.TLS == "" { - if svc.Backend.Type == BackendTCPPassthrough { + bt := svc.EffectiveBackendType() + if bt == BackendTCPPassthrough { svc.TLS = TLSPassthrough } else { svc.TLS = TLSTerminate @@ -126,7 +197,14 @@ func (m *Manager) Register(svc Service) error { slog.Info("service registered", "domain", svc.Domain, "public", svc.Public, "source", svc.Source, "subdomains", svc.Subdomains) if m.reconcileFn != nil { - go m.reconcileFn() + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("reconcile panicked", "error", r) + } + }() + m.reconcileFn() + }() } return nil @@ -222,7 +300,7 @@ func (m *Manager) DeregisterBySource(source, backendAddress string) error { } for _, svc := range svcs { - if svc.Source == source && svc.Backend.Address == backendAddress { + if svc.Source == source && svc.EffectiveBackendAddress() == backendAddress { if err := m.Deregister(svc.Domain); err != nil { slog.Warn("failed to deregister service during cleanup", "domain", svc.Domain, "error", err) } @@ -256,6 +334,8 @@ func (m *Manager) Update(domain string, updates map[string]any) error { svc.Backend.Health = health } } + // Routes is a full replacement — partial route updates are too complex + // to express via map[string]any. Re-register the full service instead. return m.Register(*svc) } diff --git a/internal/services/manager_test.go b/internal/services/manager_test.go index ca48377..2054a57 100644 --- a/internal/services/manager_test.go +++ b/internal/services/manager_test.go @@ -463,6 +463,94 @@ func TestList_EmptyDir(t *testing.T) { } } +func TestRegisterWithL7Fields(t *testing.T) { + mgr := NewManager(t.TempDir()) + + svc := Service{ + Domain: "keila.cloud.payne.io", + Source: "wild-cloud", + Routes: []Route{{ + Paths: []string{"/.well-known/matrix", "/_matrix"}, + Backend: Backend{ + Address: "192.168.8.80:80", + Type: BackendHTTP, + }, + Headers: &HeaderConfig{ + Response: map[string]string{ + "Access-Control-Allow-Origin": "https://payne.io", + }, + Request: map[string]string{ + "X-Forwarded-Proto": "https", + }, + }, + IPAllow: []string{"10.0.0.0/8", "192.168.0.0/16"}, + }}, + } + + if err := mgr.Register(svc); err != nil { + t.Fatalf("Register failed: %v", err) + } + + got, err := mgr.Get("keila.cloud.payne.io") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if len(got.Routes) != 1 { + t.Fatalf("expected 1 route, got %d", len(got.Routes)) + } + r := got.Routes[0] + if len(r.Paths) != 2 || r.Paths[0] != "/.well-known/matrix" { + t.Errorf("expected 2 paths, got %v", r.Paths) + } + if r.Headers == nil { + t.Fatal("expected headers, got nil") + } + if r.Headers.Response["Access-Control-Allow-Origin"] != "https://payne.io" { + t.Errorf("expected response header, got %v", r.Headers.Response) + } + if r.Headers.Request["X-Forwarded-Proto"] != "https" { + t.Errorf("expected request header, got %v", r.Headers.Request) + } + if len(r.IPAllow) != 2 || r.IPAllow[0] != "10.0.0.0/8" { + t.Errorf("expected 2 ipAllow CIDRs, got %v", r.IPAllow) + } +} + +func TestUpdateL7Fields(t *testing.T) { + mgr := NewManager(t.TempDir()) + + mgr.Register(Service{ + Domain: "updatable.example.com", + Source: "test", + Routes: []Route{{ + Paths: []string{"/api", "/webhook"}, + Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, + IPAllow: []string{"10.0.0.0/8"}, + Headers: &HeaderConfig{ + Response: map[string]string{ + "X-Custom": "value", + }, + }, + }}, + }) + + got, _ := mgr.Get("updatable.example.com") + if len(got.Routes) != 1 { + t.Fatalf("expected 1 route, got %d", len(got.Routes)) + } + r := got.Routes[0] + if len(r.Paths) != 2 || r.Paths[0] != "/api" { + t.Errorf("expected paths [/api /webhook], got %v", r.Paths) + } + if len(r.IPAllow) != 1 || r.IPAllow[0] != "10.0.0.0/8" { + t.Errorf("expected ipAllow [10.0.0.0/8], got %v", r.IPAllow) + } + if r.Headers == nil || r.Headers.Response["X-Custom"] != "value" { + t.Errorf("expected response header X-Custom=value, got %v", r.Headers) + } +} + func TestList_IgnoresNonYAML(t *testing.T) { dir := t.TempDir() mgr := NewManager(dir) diff --git a/web/src/schemas/config.ts b/web/src/schemas/config.ts index e8a67d2..d58f0cd 100644 --- a/web/src/schemas/config.ts +++ b/web/src/schemas/config.ts @@ -41,11 +41,6 @@ const cloudDnsSchema = z.object({ ip: ipAddressSchema, }); -// Cloud router configuration schema -const cloudRouterSchema = z.object({ - ip: ipAddressSchema, -}); - // Cloud dnsmasq configuration schema const cloudDnsmasqSchema = z.object({ interface: interfaceSchema, @@ -57,7 +52,6 @@ const cloudConfigSchema = z.object({ internalDomain: domainSchema, dhcpRange: dhcpRangeSchema, dns: cloudDnsSchema, - router: cloudRouterSchema, dnsmasq: cloudDnsmasqSchema, }); @@ -121,12 +115,6 @@ export const configFormSchema = z.object({ 'Must be a valid IP address' ), }), - router: z.object({ - ip: z.string().min(1, 'Router IP is required').refine( - (val) => ipAddressSchema.safeParse(val).success, - 'Must be a valid IP address' - ), - }), dnsmasq: z.object({ interface: z.string().min(1, 'Interface is required').refine( (val) => interfaceSchema.safeParse(val).success, @@ -168,9 +156,6 @@ export const defaultConfigValues: ConfigFormData = { dns: { ip: '192.168.8.50', }, - router: { - ip: '192.168.8.1', - }, dnsmasq: { interface: 'eth0', }, diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 17e546f..e08a9d0 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -8,7 +8,7 @@ export interface Status { // ======================================== // Global Config Types (Wild Central level) // Endpoint: /api/v1/config -// File: {dataDir}/config.yaml +// File: {dataDir}/state.yaml // ======================================== export interface HAProxyCustomRoute { @@ -25,10 +25,6 @@ export interface GlobalConfig { central?: { domain?: string; }; - router?: { - ip?: string; - dynamicDns?: string; - }; dnsmasq?: { ip?: string; interface?: string;