Support routes.

This commit is contained in:
2026-07-10 05:21:43 +00:00
parent 5c26c7530a
commit 43253ca120
8 changed files with 597 additions and 85 deletions

View File

@@ -21,12 +21,66 @@ The domain is the unique key. One registration per domain.
|-------|------|----------|---------|-------------| |-------|------|----------|---------|-------------|
| `domain` | string | yes | — | FQDN to route. Unique key. | | `domain` | string | yes | — | FQDN to route. Unique key. |
| `source` | string | no | `"manual"` | Who registered this: `wild-cloud`, `wild-works`, `manual`. | | `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.address` | string | yes* | — | Target host:port (e.g., `192.168.8.240:80`). |
| `backend.type` | string | yes | — | `tcp-passthrough` or `http`. | | `backend.type` | string | yes* | — | `tcp-passthrough` or `http`. |
| `backend.health` | string | no | — | Health check path for L7 services (e.g., `/health`). | | `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. | | `public` | bool | no | `false` | If true, domain gets public DNS + internet exposure. |
| `tls` | string | no | inferred | `passthrough` or `terminate`. Defaults based on backend type. | | `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 ## User-facing concepts
@@ -50,7 +104,7 @@ The API fields map to three user-visible controls:
`tls: "passthrough"` or `tls: "terminate"` `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"`. - **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 ## What Central does per registration
@@ -62,24 +116,26 @@ When a service is registered, Central automatically:
| **LAN DNS** | `address=/<domain>/<backend-IP>` — direct to backend | `address=/<domain>/<central-IP>` — through Central | | **LAN DNS** | `address=/<domain>/<backend-IP>` — direct to backend | `address=/<domain>/<central-IP>` — through Central |
| **LAN DNS (private)** | Also `local=/<domain>/` — prevents upstream forwarding | Same | | **LAN DNS (private)** | Also `local=/<domain>/` — prevents upstream forwarding | Same |
| **Public DNS** | DDNS A record if public | 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 | | **TLS** | Backend handles — Central passes through | Central provisions cert via Let's Encrypt |
## Examples ## 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 ```json
{ {
"domain": "cloud.payne.io", "domain": "cloud.payne.io",
"source": "wild-cloud", "source": "wild-cloud",
"backend": {"address": "192.168.8.240:443", "type": "tcp-passthrough"}, "backend": {"address": "192.168.8.240:80", "type": "http"},
"subdomains": true, "subdomains": true,
"public": 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) ### 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. 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 ```json
{ {
"domain": "payne.io", "domain": "payne.io",
"source": "wild-cloud", "source": "wild-cloud",
"backend": {"address": "192.168.8.240:443", "type": "tcp-passthrough"}, "backend": {"address": "192.168.8.240:443", "type": "tcp-passthrough"},
"subdomains": false,
"public": true "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 ## What is NOT a registration

View File

@@ -88,10 +88,6 @@ type GlobalConfig struct {
Central struct { Central struct {
Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // e.g. "central.payne.io" Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // e.g. "central.payne.io"
} `yaml:"central,omitempty" json:"central,omitempty"` } `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 { Dnsmasq struct {
IP string `yaml:"ip,omitempty" json:"ip,omitempty"` IP string `yaml:"ip,omitempty" json:"ip,omitempty"`
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
@@ -121,8 +117,8 @@ type GlobalConfig struct {
} `yaml:"cloud,omitempty" json:"cloud,omitempty"` } `yaml:"cloud,omitempty" json:"cloud,omitempty"`
} }
// LoadGlobalConfig loads configuration from the specified path // LoadState loads state from the specified path
func LoadGlobalConfig(configPath string) (*GlobalConfig, error) { func LoadState(configPath string) (*GlobalConfig, error) {
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("reading config file %s: %w", configPath, err) return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
@@ -136,8 +132,8 @@ func LoadGlobalConfig(configPath string) (*GlobalConfig, error) {
return config, nil return config, nil
} }
// SaveGlobalConfig saves the configuration to the specified path // SaveState saves the state to the specified path
func SaveGlobalConfig(config *GlobalConfig, configPath string) error { func SaveState(config *GlobalConfig, configPath string) error {
// Ensure the directory exists // Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err) return fmt.Errorf("creating config directory: %w", err)
@@ -158,7 +154,7 @@ func (c *GlobalConfig) IsEmpty() bool {
} }
// Check if essential fields are empty // 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 { type NodeConfig struct {

View File

@@ -7,9 +7,12 @@ import (
"net" "net"
"os" "os"
"os/exec" "os/exec"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/wild-cloud/wild-central/internal/services"
) )
// InstanceRoute represents a Wild Cloud instance's ingress routing configuration // 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" 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. // 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 { type HTTPRoute struct {
Name string // e.g. "my-api" Name string // e.g. "my-api"
Domain string // e.g. "my-api.payne.io" Domain string // e.g. "my-api.payne.io"
Backend string // e.g. "192.168.8.60:9001" Subdomains bool // if true, also match *.domain
HealthPath string // e.g. "/health" (optional, for active health checks) Routes []HTTPRouteBackend // ordered path→backend mappings
} }
const defaultConfigPath = "/etc/haproxy/haproxy.cfg" const defaultConfigPath = "/etc/haproxy/haproxy.cfg"
@@ -134,14 +147,28 @@ frontend http_in
// 3. L4 wildcard matches (*.cloud.payne.io) → instance backends // 3. L4 wildcard matches (*.cloud.payne.io) → instance backends
// 4. Default → L7 termination (if any HTTP routes exist) // 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 { 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 { for _, route := range opts.HTTPRoutes {
if route.Subdomains {
continue // handled below
}
aclName := "is_l7_" + sanitizeName(route.Name) aclName := "is_l7_" + sanitizeName(route.Name)
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %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) 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") sb.WriteString("\n")
} }
@@ -209,24 +236,99 @@ frontend http_in
sb.WriteString(" mode http\n") sb.WriteString(" mode http\n")
sb.WriteString("\n") sb.WriteString("\n")
for _, route := range opts.HTTPRoutes { // Host ACLs for all services
aclName := "host_" + sanitizeName(route.Name) for _, httpRoute := range opts.HTTPRoutes {
fmt.Fprintf(&sb, " acl %s hdr(host) -i %s\n", aclName, route.Domain) hostACL := "host_" + sanitizeName(httpRoute.Name)
fmt.Fprintf(&sb, " use_backend be_l7_%s if %s\n", sanitizeName(route.Name), aclName) 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") sb.WriteString("\n")
// L7 backends // Per-route ACLs (IP whitelisting, headers) and routing rules
for _, route := range opts.HTTPRoutes { for _, httpRoute := range opts.HTTPRoutes {
fmt.Fprintf(&sb, "backend be_l7_%s\n", sanitizeName(route.Name)) hostACL := "host_" + sanitizeName(httpRoute.Name)
sb.WriteString(" mode http\n") routeName := sanitizeName(httpRoute.Name)
if route.HealthPath != "" {
fmt.Fprintf(&sb, " option httpchk GET %s\n", route.HealthPath) for i, rb := range httpRoute.Routes {
fmt.Fprintf(&sb, " server s0 %s check\n", route.Backend) 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 { } else {
fmt.Fprintf(&sb, " server s0 %s\n", route.Backend) 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") 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")
}
} }
} }
} }
@@ -247,6 +349,16 @@ frontend http_in
return sb.String() 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) // sanitizeName converts a name to a valid HAProxy identifier (alphanumeric + underscore)
func sanitizeName(name string) string { func sanitizeName(name string) string {
return strings.Map(func(r rune) rune { return strings.Map(func(r rune) rune {

View File

@@ -3,6 +3,8 @@ package haproxy
import ( import (
"strings" "strings"
"testing" "testing"
"github.com/wild-cloud/wild-central/internal/services"
) )
func TestNewManager_DefaultPath(t *testing.T) { 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}, {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.240", Subdomains: true},
} }
httpRoutes := []HTTPRoute{ httpRoutes := []HTTPRoute{
{Name: "wild-central", Domain: "central.payne.io", Backend: "127.0.0.1:15055"}, {Name: "wild-central", Domain: "central.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:15055"}}},
{Name: "wild-cloud", Domain: "wild-cloud.payne.io", Backend: "127.0.0.1:5055"}, {Name: "wild-cloud", Domain: "wild-cloud.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:5055"}}},
} }
out := m.GenerateWithOpts(instances, nil, GenerateOpts{HTTPRoutes: httpRoutes}) 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) { func TestGenerateWithOpts_HTTPRoutes_L7Frontend(t *testing.T) {
m := NewManager("") m := NewManager("")
httpRoutes := []HTTPRoute{ httpRoutes := []HTTPRoute{
{Name: "my-api", Domain: "my-api.payne.io", Backend: "192.168.8.60:9001", HealthPath: "/health"}, {Name: "my-api", Domain: "my-api.payne.io", Routes: []HTTPRouteBackend{{Backend: "192.168.8.60:9001", HealthPath: "/health"}}},
{Name: "dashboard", Domain: "dashboard.payne.io", Backend: "192.168.8.60:8080"}, {Name: "dashboard", Domain: "dashboard.payne.io", Routes: []HTTPRouteBackend{{Backend: "192.168.8.60:8080"}}},
} }
out := m.GenerateWithOpts(nil, nil, GenerateOpts{ out := m.GenerateWithOpts(nil, nil, GenerateOpts{
@@ -252,7 +254,7 @@ func TestGenerateWithOpts_HTTPRoutes_L7Frontend(t *testing.T) {
} }
// L7 backends // 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) t.Errorf("expected L7 backend for my-api, got:\n%s", out)
} }
if !strings.Contains(out, "server s0 192.168.8.60:9001 check") { 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) // 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) t.Errorf("expected L7 backend for dashboard, got:\n%s", out)
} }
if !strings.Contains(out, "server s0 192.168.8.60:8080") { 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"}, {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.100"},
} }
httpRoutes := []HTTPRoute{ 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{ out := m.GenerateWithOpts(instances, nil, GenerateOpts{
@@ -291,7 +293,7 @@ func TestGenerateWithOpts_MixedL4AndL7(t *testing.T) {
if !strings.Contains(out, "frontend l7_https") { if !strings.Contains(out, "frontend l7_https") {
t.Errorf("expected L7 frontend, got:\n%s", out) 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) t.Errorf("expected L7 backend for my-api, got:\n%s", out)
} }
// L7 fallback should be present // 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}, {Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.240", Subdomains: true},
} }
httpRoutes := []HTTPRoute{ httpRoutes := []HTTPRoute{
{Name: "wild-central", Domain: "central.payne.io", Backend: "127.0.0.1:15055"}, {Name: "wild-central", Domain: "central.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:15055"}}},
{Name: "wild-cloud", Domain: "wild-cloud.payne.io", Backend: "127.0.0.1:5055"}, {Name: "wild-cloud", Domain: "wild-cloud.payne.io", Routes: []HTTPRouteBackend{{Backend: "127.0.0.1:5055"}}},
} }
out := m.GenerateWithOpts(instances, nil, GenerateOpts{HTTPRoutes: httpRoutes}) out := m.GenerateWithOpts(instances, nil, GenerateOpts{HTTPRoutes: httpRoutes})
@@ -443,7 +445,7 @@ func TestGenerateWithOpts_OnlyHTTPRoutes(t *testing.T) {
m := NewManager("") m := NewManager("")
httpRoutes := []HTTPRoute{ 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}) 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) t.Errorf("expected L7 frontend for HTTP routes, got:\n%s", out)
} }
// L7 backend should be present // 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) t.Errorf("expected L7 backend for my-api, got:\n%s", out)
} }
// No L4 instance backends // No L4 instance backends
@@ -497,7 +499,7 @@ func TestGenerateWithOpts_CertsDir(t *testing.T) {
m := NewManager("") m := NewManager("")
httpRoutes := []HTTPRoute{ 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{ out := m.GenerateWithOpts(nil, nil, GenerateOpts{
@@ -514,7 +516,7 @@ func TestGenerateWithOpts_DefaultCertsDir(t *testing.T) {
m := NewManager("") m := NewManager("")
httpRoutes := []HTTPRoute{ 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/ // Empty CertsDir should default to /etc/haproxy/certs/
@@ -556,7 +558,7 @@ func TestGenerateWithOpts_HealthCheckInL7Backend(t *testing.T) {
m := NewManager("") m := NewManager("")
httpRoutes := []HTTPRoute{ 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}) 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) 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)
}
}

View File

@@ -40,14 +40,66 @@ type Backend struct {
Health string `yaml:"health,omitempty" json:"health,omitempty"` 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. // 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 { type Service struct {
Domain string `yaml:"domain" json:"domain"` // FQDN — unique key Domain string `yaml:"domain" json:"domain"` // FQDN — unique key
Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual 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 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 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 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. // Manager handles service registration CRUD and triggers networking reconciliation.
@@ -92,17 +144,36 @@ func (m *Manager) Register(svc Service) error {
if svc.Domain == "" { if svc.Domain == "" {
return fmt.Errorf("domain is required") 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 !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 == "" { if svc.Backend.Type == "" {
return fmt.Errorf("backend type is required") 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 // Default TLS mode based on backend type
if svc.TLS == "" { if svc.TLS == "" {
if svc.Backend.Type == BackendTCPPassthrough { bt := svc.EffectiveBackendType()
if bt == BackendTCPPassthrough {
svc.TLS = TLSPassthrough svc.TLS = TLSPassthrough
} else { } else {
svc.TLS = TLSTerminate 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) slog.Info("service registered", "domain", svc.Domain, "public", svc.Public, "source", svc.Source, "subdomains", svc.Subdomains)
if m.reconcileFn != nil { 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 return nil
@@ -222,7 +300,7 @@ func (m *Manager) DeregisterBySource(source, backendAddress string) error {
} }
for _, svc := range svcs { 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 { if err := m.Deregister(svc.Domain); err != nil {
slog.Warn("failed to deregister service during cleanup", "domain", svc.Domain, "error", err) 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 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) return m.Register(*svc)
} }

View File

@@ -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) { func TestList_IgnoresNonYAML(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
mgr := NewManager(dir) mgr := NewManager(dir)

View File

@@ -41,11 +41,6 @@ const cloudDnsSchema = z.object({
ip: ipAddressSchema, ip: ipAddressSchema,
}); });
// Cloud router configuration schema
const cloudRouterSchema = z.object({
ip: ipAddressSchema,
});
// Cloud dnsmasq configuration schema // Cloud dnsmasq configuration schema
const cloudDnsmasqSchema = z.object({ const cloudDnsmasqSchema = z.object({
interface: interfaceSchema, interface: interfaceSchema,
@@ -57,7 +52,6 @@ const cloudConfigSchema = z.object({
internalDomain: domainSchema, internalDomain: domainSchema,
dhcpRange: dhcpRangeSchema, dhcpRange: dhcpRangeSchema,
dns: cloudDnsSchema, dns: cloudDnsSchema,
router: cloudRouterSchema,
dnsmasq: cloudDnsmasqSchema, dnsmasq: cloudDnsmasqSchema,
}); });
@@ -121,12 +115,6 @@ export const configFormSchema = z.object({
'Must be a valid IP address' '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({ dnsmasq: z.object({
interface: z.string().min(1, 'Interface is required').refine( interface: z.string().min(1, 'Interface is required').refine(
(val) => interfaceSchema.safeParse(val).success, (val) => interfaceSchema.safeParse(val).success,
@@ -168,9 +156,6 @@ export const defaultConfigValues: ConfigFormData = {
dns: { dns: {
ip: '192.168.8.50', ip: '192.168.8.50',
}, },
router: {
ip: '192.168.8.1',
},
dnsmasq: { dnsmasq: {
interface: 'eth0', interface: 'eth0',
}, },

View File

@@ -8,7 +8,7 @@ export interface Status {
// ======================================== // ========================================
// Global Config Types (Wild Central level) // Global Config Types (Wild Central level)
// Endpoint: /api/v1/config // Endpoint: /api/v1/config
// File: {dataDir}/config.yaml // File: {dataDir}/state.yaml
// ======================================== // ========================================
export interface HAProxyCustomRoute { export interface HAProxyCustomRoute {
@@ -25,10 +25,6 @@ export interface GlobalConfig {
central?: { central?: {
domain?: string; domain?: string;
}; };
router?: {
ip?: string;
dynamicDns?: string;
};
dnsmasq?: { dnsmasq?: {
ip?: string; ip?: string;
interface?: string; interface?: string;