services -> domains

This commit is contained in:
2026-07-10 20:46:22 +00:00
parent 3c99d26830
commit 79c0c32b98
45 changed files with 1447 additions and 1270 deletions

View File

@@ -12,8 +12,8 @@ Wild Central is the networking and coordination platform for the Wild product fa
- **Tunnels** (cloudflared) — outbound-only public exposure via Cloudflare - **Tunnels** (cloudflared) — outbound-only public exposure via Cloudflare
- **DDNS** (Cloudflare) — A record + CNAME management - **DDNS** (Cloudflare) — A record + CNAME management
- **Security** (CrowdSec) — intrusion detection - **Security** (CrowdSec) — intrusion detection
- **NATS JetStream** — coordination bus for service registration, presence, and events - **NATS JetStream** — coordination bus for domain registration, presence, and events
- **Service Registration** — the contract API for Wild Cloud and Wild Works - **Domain Registration** — the contract API for Wild Cloud and Wild Works
## Architecture ## Architecture
@@ -21,7 +21,7 @@ Wild Central is a Go service that manages Linux networking daemons via config fi
**Consumers:** **Consumers:**
- **Wild Cloud** — registers k8s instance domains for L4 SNI passthrough - **Wild Cloud** — registers k8s instance domains for L4 SNI passthrough
- **Wild Works** — registers services for L7 HTTP reverse proxy with TLS termination - **Wild Works** — registers domains for L7 HTTP reverse proxy with TLS termination
Both connect to Central's NATS (port 4222) or use the HTTP API (port 5055) to register services. Both connect to Central's NATS (port 4222) or use the HTTP API (port 5055) to register services.
@@ -52,23 +52,23 @@ make check # Lint + test
Runtime state is persisted in `{WILD_CENTRAL_DATA_DIR}/`: Runtime state is persisted in `{WILD_CENTRAL_DATA_DIR}/`:
- `state.yaml` — operator, domain, firewall, DDNS, DHCP settings (managed via API) - `state.yaml` — operator, domain, firewall, DDNS, DHCP settings (managed via API)
- `secrets.yaml` — API tokens and credentials - `secrets.yaml` — API tokens and credentials
- `services/` — per-domain service registration files - `domains/` — per-domain registration files
- `nats/` — embedded NATS JetStream data - `nats/` — embedded NATS JetStream data
- `instances/` — Wild Cloud instance configs - `instances/` — Wild Cloud instance configs
## Service Registration API ## Domain Registration API
The key abstraction. Services register with Central to get DNS, proxy, TLS, and public exposure. The key abstraction. Domains register with Central to get DNS, gateway routing, TLS, and public exposure.
``` ```
POST /api/v1/services Register a service POST /api/v1/domains Register a domain
GET /api/v1/services List all services GET /api/v1/domains List all domains
GET /api/v1/services/{name} Get service details GET /api/v1/domains/{domain} Get domain details
PATCH /api/v1/services/{name} Update service PATCH /api/v1/domains/{domain} Update domain
DELETE /api/v1/services/{name} Deregister service DELETE /api/v1/domains/{domain} Deregister domain
``` ```
### Service payload ### Domain payload
```json ```json
{ {
@@ -87,10 +87,10 @@ DELETE /api/v1/services/{name} Deregister service
### Backend types ### Backend types
- `tcp-passthrough` — L4, SNI routing, backend handles TLS (Wild Cloud k8s) - `tcp-passthrough` — L4, SNI routing, backend handles TLS (Wild Cloud k8s)
- `http` — L7, Central terminates TLS with wildcard cert (Wild Works services) - `http` — L7, Central terminates TLS with wildcard cert (Wild Works)
- `static` — L7, static file serving (Wild Works frontends) - `dns-only` — DNS resolution only, no gateway routing (SSH, game servers, etc.)
### Public ### Public
- `false` (default) — LAN-visible only (DNS + proxy + TLS) - `false` (default) — LAN-visible only (DNS + gateway + TLS)
- `true` — internet-visible (+ DDNS or tunnel exposure) - `true` — internet-visible (+ DDNS or tunnel exposure)

View File

@@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"syscall" "syscall"
"time" "time"
@@ -25,7 +26,7 @@ import (
"github.com/wild-cloud/wild-central/internal/network" "github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/nftables" "github.com/wild-cloud/wild-central/internal/nftables"
"github.com/wild-cloud/wild-central/internal/secrets" "github.com/wild-cloud/wild-central/internal/secrets"
"github.com/wild-cloud/wild-central/internal/services" "github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/sse" "github.com/wild-cloud/wild-central/internal/sse"
"github.com/wild-cloud/wild-central/internal/storage" "github.com/wild-cloud/wild-central/internal/storage"
"github.com/wild-cloud/wild-central/internal/wireguard" "github.com/wild-cloud/wild-central/internal/wireguard"
@@ -46,7 +47,7 @@ type API struct {
crowdsec *crowdsec.Manager crowdsec *crowdsec.Manager
vpn *wireguard.Manager vpn *wireguard.Manager
certbot *certbot.Manager certbot *certbot.Manager
services *services.Manager // Service registration manager domains *domains.Manager // Domain registration manager
sseManager *sse.Manager // SSE manager for real-time events sseManager *sse.Manager // SSE manager for real-time events
port int // Running API port (for config-driven routes) port int // Running API port (for config-driven routes)
} }
@@ -76,13 +77,13 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
crowdsec: crowdsec.NewManager(), crowdsec: crowdsec.NewManager(),
vpn: wireguard.NewManager(dataDir, vpnConfigPath), vpn: wireguard.NewManager(dataDir, vpnConfigPath),
certbot: certbot.NewManager(""), certbot: certbot.NewManager(""),
services: services.NewManager(dataDir), domains: domains.NewManager(dataDir),
sseManager: sseManager, sseManager: sseManager,
} }
// Wire up service registration reconciliation: when services change, // Wire up domain registration reconciliation: when domains change,
// regenerate dnsmasq DNS entries and HAProxy routes. // regenerate dnsmasq DNS entries and HAProxy routes.
api.services.SetReconcileFn(api.reconcileNetworking) api.domains.SetReconcileFn(api.reconcileNetworking)
return api, nil return api, nil
} }
@@ -107,30 +108,49 @@ func envOrDefault(key, defaultVal string) string {
return defaultVal return defaultVal
} }
// StartDDNS loads DDNS config and starts the background goroutine. // StartDDNS launches the DDNS background goroutine with a params callback
// that reads fresh state (token, public domains, interval) on each tick.
func (api *API) StartDDNS(ctx gocontext.Context) { func (api *API) StartDDNS(ctx gocontext.Context) {
api.ctx = ctx api.ctx = ctx
api.reloadDDNSIfEnabled() api.ddns.Start(ctx, api.ddnsParams)
} }
// reloadDDNSIfEnabled re-reads DDNS config and secrets and restarts the DDNS goroutine. // ddnsParams returns the current DDNS parameters derived from runtime state.
// Called by the DDNS runner on each tick — always returns fresh values.
func (api *API) ddnsParams() ddns.Params {
state, err := config.LoadState(api.statePath())
if err != nil || !state.Cloud.DDNS.Enabled {
return ddns.Params{}
}
doms, _ := api.domains.List()
var records []string
for _, dom := range doms {
if dom.Public && dom.DomainName != "" {
records = append(records, dom.DomainName)
}
}
return ddns.Params{
APIToken: api.getCloudflareToken(),
Records: records,
IntervalMinutes: state.Cloud.DDNS.IntervalMinutes,
}
}
// reloadDDNSIfEnabled starts the runner if DDNS is enabled, or stops it if disabled.
// When already running, triggers an immediate check with fresh params.
func (api *API) reloadDDNSIfEnabled() { func (api *API) reloadDDNSIfEnabled() {
globalCfg, err := config.LoadState(api.statePath()) state, _ := config.LoadState(api.statePath())
if err != nil || !globalCfg.Cloud.DDNS.Enabled { if state == nil || !state.Cloud.DDNS.Enabled {
api.ddns.Stop() api.ddns.Stop()
return return
} }
if !api.ddns.GetStatus().Enabled {
apiToken := api.getCloudflareToken() api.ddns.Start(api.ctx, api.ddnsParams)
} else {
cfg := ddns.Config{ api.ddns.Trigger()
Enabled: true,
APIToken: apiToken,
Records: globalCfg.Cloud.DDNS.Records,
IntervalMinutes: globalCfg.Cloud.DDNS.IntervalMinutes,
} }
api.ddns.Start(api.ctx, cfg)
} }
// StartCentralStatusBroadcaster starts periodic broadcasting of central status // StartCentralStatusBroadcaster starts periodic broadcasting of central status
@@ -151,9 +171,9 @@ func (api *API) StartCentralStatusBroadcaster(startTime time.Time) {
func (api *API) RegisterRoutes(r *mux.Router) { func (api *API) RegisterRoutes(r *mux.Router) {
r.Use(RequestLoggingMiddleware) r.Use(RequestLoggingMiddleware)
// Global Configuration // Global State (runtime settings — operator, DDNS, DHCP, etc.)
r.HandleFunc("/api/v1/config", api.GetGlobalConfig).Methods("GET") r.HandleFunc("/api/v1/state", api.GetState).Methods("GET")
r.HandleFunc("/api/v1/config", api.UpdateGlobalConfig).Methods("PUT") r.HandleFunc("/api/v1/state", api.UpdateState).Methods("PUT")
// Global Secrets // Global Secrets
r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET") r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET")
@@ -228,13 +248,13 @@ func (api *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET") r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET")
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST") r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST")
// Service registration — domain is the unique key // Domain registration — domain is the unique key
r.HandleFunc("/api/v1/services", api.ServicesListAll).Methods("GET") r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET")
r.HandleFunc("/api/v1/services", api.ServicesRegister).Methods("POST") r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST")
r.HandleFunc("/api/v1/services/deregister", api.ServicesDeregisterBySource).Methods("DELETE") r.HandleFunc("/api/v1/domains/deregister", api.DomainsDeregisterBySource).Methods("DELETE")
r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesGet).Methods("GET") r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsGet).Methods("GET")
r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesUpdate).Methods("PATCH") r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsUpdate).Methods("PATCH")
r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesDeregister).Methods("DELETE") r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsDeregister).Methods("DELETE")
// SSE events // SSE events
r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET") r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET")
@@ -242,10 +262,9 @@ func (api *API) RegisterRoutes(r *mux.Router) {
// --- Global Config/Secrets handlers --- // --- Global Config/Secrets handlers ---
// GetGlobalConfig returns the global state wrapped in { configured, config }. // GetState returns the global state wrapped in { configured, state }.
func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) { func (api *API) GetState(w http.ResponseWriter, r *http.Request) {
configPath := api.statePath() data, err := os.ReadFile(api.statePath())
data, err := os.ReadFile(configPath)
if err != nil { if err != nil {
respondJSON(w, http.StatusOK, map[string]any{ respondJSON(w, http.StatusOK, map[string]any{
"configured": false, "configured": false,
@@ -253,20 +272,20 @@ func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
var configMap map[string]any var stateMap map[string]any
if err := yaml.Unmarshal(data, &configMap); err != nil { if err := yaml.Unmarshal(data, &stateMap); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to parse config") respondError(w, http.StatusInternalServerError, "Failed to parse state")
return return
} }
respondJSON(w, http.StatusOK, map[string]any{ respondJSON(w, http.StatusOK, map[string]any{
"configured": true, "configured": true,
"config": configMap, "state": stateMap,
}) })
} }
// UpdateGlobalConfig updates the global state // UpdateState updates the global state
func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) { func (api *API) UpdateState(w http.ResponseWriter, r *http.Request) {
configPath := api.statePath() configPath := api.statePath()
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
@@ -320,7 +339,7 @@ func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
// Reload DDNS and re-register Central's service if config changed // Reload DDNS and re-register Central's service if config changed
go api.reloadDDNSIfEnabled() go api.reloadDDNSIfEnabled()
go api.EnsureCentralService() go api.EnsureCentralDomain()
respondMessage(w, http.StatusOK, "Config updated successfully") respondMessage(w, http.StatusOK, "Config updated successfully")
} }
@@ -380,9 +399,32 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime
"uptime": uptime.String(), "uptime": uptime.String(),
"uptimeSeconds": int(uptime.Seconds()), "uptimeSeconds": int(uptime.Seconds()),
"dataDir": dataDir, "dataDir": dataDir,
"daemons": getDaemonStatus(),
}) })
} }
// getDaemonStatus checks whether each managed daemon is active.
func getDaemonStatus() map[string]map[string]bool {
daemons := map[string]string{
"dnsmasq": "dnsmasq.service",
"haproxy": "haproxy.service",
"wireguard": "wg-quick@wg0.service",
"crowdsec": "crowdsec.service",
}
result := make(map[string]map[string]bool, len(daemons)+1)
for name, unit := range daemons {
err := exec.Command("systemctl", "is-active", "--quiet", unit).Run()
result[name] = map[string]bool{"active": err == nil}
}
// nftables has no persistent service — check if the wild-cloud table exists
err := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run()
result["nftables"] = map[string]bool{"active": err == nil}
return result
}
// DaemonRestart triggers a graceful self-restart via SIGTERM // DaemonRestart triggers a graceful self-restart via SIGTERM
func (api *API) DaemonRestart(w http.ResponseWriter, r *http.Request) { func (api *API) DaemonRestart(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusAccepted, map[string]any{"message": "Restarting Wild Central..."}) respondJSON(w, http.StatusAccepted, map[string]any{"message": "Restarting Wild Central..."})

View File

@@ -8,7 +8,7 @@ import (
"github.com/wild-cloud/wild-central/internal/certbot" "github.com/wild-cloud/wild-central/internal/certbot"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/services" "github.com/wild-cloud/wild-central/internal/domains"
) )
// CertStatus returns TLS certificate status for all relevant domains: // CertStatus returns TLS certificate status for all relevant domains:
@@ -24,31 +24,30 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
cfToken := api.getCloudflareToken() cfToken := api.getCloudflareToken()
// Gather cert statuses for all registered services that need TLS termination // Gather cert statuses for all registered domains that need TLS termination
certs := []map[string]any{} certs := []map[string]any{}
seen := map[string]bool{} seen := map[string]bool{}
svcs, _ := api.services.List() doms, _ := api.domains.List()
for _, svc := range svcs { for _, dom := range doms {
if svc.TLS != services.TLSTerminate || svc.Domain == "" { if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
continue continue
} }
if seen[svc.Domain] { if seen[dom.DomainName] {
continue continue
} }
seen[svc.Domain] = true seen[dom.DomainName] = true
status := api.certbot.GetStatus(svc.Domain) status := api.certbot.GetStatus(dom.DomainName)
entry := map[string]any{ entry := map[string]any{
"domain": svc.Domain, "domain": dom.DomainName,
"service": svc.Domain, "source": dom.Source,
"source": svc.Source,
"cert": status, "cert": status,
} }
// Check if covered by a wildcard cert // Check if covered by a wildcard cert
parts := strings.SplitN(svc.Domain, ".", 2) parts := strings.SplitN(dom.DomainName, ".", 2)
if len(parts) == 2 && !status.Exists { if len(parts) == 2 && !status.Exists {
wildcardBase := parts[1] wildcardBase := parts[1]
wildcardStatus := api.certbot.GetStatus(wildcardBase) wildcardStatus := api.certbot.GetStatus(wildcardBase)

View File

@@ -13,7 +13,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) { func TestGetState_ReturnsWrappedResponse(t *testing.T) {
api, tmpDir := setupTestAPI(t) api, tmpDir := setupTestAPI(t)
// Write a config with a central domain // Write a config with a central domain
@@ -30,10 +30,10 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) {
data, _ := yaml.Marshal(cfg) data, _ := yaml.Marshal(cfg)
storage.WriteFile(filepath.Join(tmpDir, "state.yaml"), data, 0644) storage.WriteFile(filepath.Join(tmpDir, "state.yaml"), data, 0644)
req := httptest.NewRequest("GET", "/api/v1/config", nil) req := httptest.NewRequest("GET", "/api/v1/state", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
api.GetGlobalConfig(w, req) api.GetState(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
@@ -42,33 +42,33 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) {
var resp map[string]any var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp) json.Unmarshal(w.Body.Bytes(), &resp)
// Must have "configured" and "config" keys // Must have "configured" and "state" keys
if resp["configured"] != true { if resp["configured"] != true {
t.Errorf("expected configured=true, got %v", resp["configured"]) t.Errorf("expected configured=true, got %v", resp["configured"])
} }
config, ok := resp["config"].(map[string]any) state, ok := resp["state"].(map[string]any)
if !ok { if !ok {
t.Fatalf("expected config to be a map, got %T", resp["config"]) t.Fatalf("expected state to be a map, got %T", resp["state"])
} }
// Verify nested structure is preserved // Verify nested structure is preserved
cloud, _ := config["cloud"].(map[string]any) cloud, _ := state["cloud"].(map[string]any)
central, _ := cloud["central"].(map[string]any) central, _ := cloud["central"].(map[string]any)
if central["domain"] != "central.example.com" { if central["domain"] != "central.example.com" {
t.Errorf("expected domain central.example.com, got %v", central["domain"]) t.Errorf("expected domain central.example.com, got %v", central["domain"])
} }
} }
func TestGetGlobalConfig_EmptyReturnsNotConfigured(t *testing.T) { func TestGetState_EmptyReturnsNotConfigured(t *testing.T) {
api, tmpDir := setupTestAPI(t) api, tmpDir := setupTestAPI(t)
// Ensure no state file exists // Ensure no state file exists
os.Remove(filepath.Join(tmpDir, "state.yaml")) os.Remove(filepath.Join(tmpDir, "state.yaml"))
req := httptest.NewRequest("GET", "/api/v1/config", nil) req := httptest.NewRequest("GET", "/api/v1/state", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
api.GetGlobalConfig(w, req) api.GetState(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code) t.Fatalf("expected 200, got %d", w.Code)
@@ -146,7 +146,7 @@ func TestGetGlobalSecrets_RawShowsValues(t *testing.T) {
} }
} }
func TestUpdateGlobalConfig(t *testing.T) { func TestUpdateState(t *testing.T) {
api, tmpDir := setupTestAPI(t) api, tmpDir := setupTestAPI(t)
update := map[string]any{ update := map[string]any{
@@ -158,10 +158,10 @@ func TestUpdateGlobalConfig(t *testing.T) {
} }
body, _ := json.Marshal(update) body, _ := json.Marshal(update)
req := httptest.NewRequest("PUT", "/api/v1/config", bytes.NewReader(body)) req := httptest.NewRequest("PUT", "/api/v1/state", bytes.NewReader(body))
w := httptest.NewRecorder() w := httptest.NewRecorder()
api.UpdateGlobalConfig(w, req) api.UpdateState(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())

View File

@@ -16,13 +16,14 @@ func (api *API) DDNSStatus(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, api.ddns.GetStatus()) respondJSON(w, http.StatusOK, api.ddns.GetStatus())
} }
// DDNSTrigger forces an immediate DDNS IP check and update // DDNSTrigger forces an immediate DDNS check. The runner reads fresh params
// (token, records) via its callback, so updated tokens take effect immediately.
func (api *API) DDNSTrigger(w http.ResponseWriter, r *http.Request) { func (api *API) DDNSTrigger(w http.ResponseWriter, r *http.Request) {
if api.ddns == nil { if api.ddns == nil {
respondError(w, http.StatusServiceUnavailable, "DDNS not configured") respondError(w, http.StatusServiceUnavailable, "DDNS not configured")
return return
} }
api.ddns.Trigger() api.reloadDDNSIfEnabled()
respondJSON(w, http.StatusOK, map[string]string{ respondJSON(w, http.StatusOK, map[string]string{
"message": "DDNS update triggered", "message": "DDNS update triggered",
}) })

View File

@@ -31,7 +31,7 @@ func setupTestDnsmasq(t *testing.T) (*API, string) {
func TestDnsmasqGenerate(t *testing.T) { func TestDnsmasqGenerate(t *testing.T) {
api, tmpDir := setupTestDnsmasq(t) api, tmpDir := setupTestDnsmasq(t)
globalConfig := config.GlobalConfig{} globalConfig := config.State{}
globalConfig.Cloud.Central.Domain = "central.example.com" globalConfig.Cloud.Central.Domain = "central.example.com"
configPath := filepath.Join(tmpDir, "state.yaml") configPath := filepath.Join(tmpDir, "state.yaml")
configData, _ := yaml.Marshal(globalConfig) configData, _ := yaml.Marshal(globalConfig)

View File

@@ -0,0 +1,132 @@
package v1
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/domains"
)
// DomainsListAll lists all registered domains.
func (api *API) DomainsListAll(w http.ResponseWriter, r *http.Request) {
doms, err := api.domains.List()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list domains: %v", err))
return
}
if doms == nil {
doms = []domains.Domain{}
}
respondJSON(w, http.StatusOK, map[string]any{
"domains": doms,
})
}
// DomainsGet retrieves a domain by its domain name.
func (api *API) DomainsGet(w http.ResponseWriter, r *http.Request) {
domain := mux.Vars(r)["domain"]
dom, err := api.domains.Get(domain)
if err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Domain not found: %v", err))
return
}
respondJSON(w, http.StatusOK, dom)
}
// DomainsRegister creates or updates a domain registration.
func (api *API) DomainsRegister(w http.ResponseWriter, r *http.Request) {
var dom domains.Domain
if err := json.NewDecoder(r.Body).Decode(&dom); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err))
return
}
// Validate routes
for i, r := range dom.Routes {
for _, p := range r.Paths {
if !strings.HasPrefix(p, "/") {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: path prefix must start with /: %q", i, p))
return
}
}
for _, cidr := range r.IPAllow {
if _, _, err := net.ParseCIDR(cidr); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: invalid CIDR in ipAllow: %q", i, cidr))
return
}
}
}
if err := api.domains.Register(dom); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to register domain: %v", err))
return
}
// Return the stored version (has defaults applied)
stored, _ := api.domains.Get(dom.DomainName)
respondJSON(w, http.StatusCreated, map[string]any{
"message": fmt.Sprintf("Domain %q registered", dom.DomainName),
"domain": stored,
})
}
// DomainsUpdate applies partial updates to a domain.
func (api *API) DomainsUpdate(w http.ResponseWriter, r *http.Request) {
domain := mux.Vars(r)["domain"]
var updates map[string]any
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err))
return
}
if err := api.domains.Update(domain, updates); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to update domain: %v", err))
return
}
dom, _ := api.domains.Get(domain)
respondJSON(w, http.StatusOK, map[string]any{
"message": fmt.Sprintf("Domain %q updated", domain),
"domain": dom,
})
}
// DomainsDeregister removes a domain registration.
func (api *API) DomainsDeregister(w http.ResponseWriter, r *http.Request) {
domain := mux.Vars(r)["domain"]
if err := api.domains.Deregister(domain); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to deregister domain: %v", err))
return
}
respondMessage(w, http.StatusOK, fmt.Sprintf("Domain %q deregistered", domain))
}
// DomainsDeregisterBySource removes all domains from a source with a given backend.
func (api *API) DomainsDeregisterBySource(w http.ResponseWriter, r *http.Request) {
source := r.URL.Query().Get("source")
backend := r.URL.Query().Get("backend")
if source == "" {
respondError(w, http.StatusBadRequest, "source query parameter is required")
return
}
if err := api.domains.DeregisterBySource(source, backend); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to deregister: %v", err))
return
}
respondMessage(w, http.StatusOK, fmt.Sprintf("Domains from %q deregistered", source))
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
func TestServicesRegister(t *testing.T) { func TestDomainsRegister(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
@@ -22,9 +22,9 @@ func TestServicesRegister(t *testing.T) {
}, },
}) })
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
w := httptest.NewRecorder() w := httptest.NewRecorder()
api.ServicesRegister(w, req) api.DomainsRegister(w, req)
if w.Code != http.StatusCreated { if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
@@ -33,19 +33,19 @@ func TestServicesRegister(t *testing.T) {
var resp map[string]any var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp) json.Unmarshal(w.Body.Bytes(), &resp)
svc := resp["service"].(map[string]any) dom := resp["domain"].(map[string]any)
if svc["domain"] != "my-api.payne.io" { if dom["domain"] != "my-api.payne.io" {
t.Errorf("expected domain my-api.payne.io, got %v", svc["domain"]) t.Errorf("expected domain my-api.payne.io, got %v", dom["domain"])
} }
if svc["tls"] != "terminate" { if dom["tls"] != "terminate" {
t.Errorf("expected tls=terminate default for http, got %v", svc["tls"]) t.Errorf("expected tls=terminate default for http, got %v", dom["tls"])
} }
if svc["source"] != "wild-works" { if dom["source"] != "wild-works" {
t.Errorf("expected source wild-works, got %v", svc["source"]) t.Errorf("expected source wild-works, got %v", dom["source"])
} }
} }
func TestServicesRegister_TCPPassthrough(t *testing.T) { func TestDomainsRegister_TCPPassthrough(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
@@ -59,9 +59,9 @@ func TestServicesRegister_TCPPassthrough(t *testing.T) {
"public": true, "public": true,
}) })
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
w := httptest.NewRecorder() w := httptest.NewRecorder()
api.ServicesRegister(w, req) api.DomainsRegister(w, req)
if w.Code != http.StatusCreated { if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
@@ -70,21 +70,21 @@ func TestServicesRegister_TCPPassthrough(t *testing.T) {
var resp map[string]any var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp) json.Unmarshal(w.Body.Bytes(), &resp)
svc := resp["service"].(map[string]any) dom := resp["domain"].(map[string]any)
if svc["tls"] != "passthrough" { if dom["tls"] != "passthrough" {
t.Errorf("expected tls=passthrough for tcp-passthrough, got %v", svc["tls"]) t.Errorf("expected tls=passthrough for tcp-passthrough, got %v", dom["tls"])
} }
if svc["subdomains"] != true { if dom["subdomains"] != true {
t.Errorf("expected subdomains=true, got %v", svc["subdomains"]) t.Errorf("expected subdomains=true, got %v", dom["subdomains"])
} }
} }
func TestServicesListAll_Empty(t *testing.T) { func TestDomainsListAll_Empty(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
req := httptest.NewRequest("GET", "/api/v1/services", nil) req := httptest.NewRequest("GET", "/api/v1/domains", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
api.ServicesListAll(w, req) api.DomainsListAll(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code) t.Fatalf("expected 200, got %d", w.Code)
@@ -92,13 +92,13 @@ func TestServicesListAll_Empty(t *testing.T) {
var resp map[string]any var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp) json.Unmarshal(w.Body.Bytes(), &resp)
svcs := resp["services"].([]any) doms := resp["domains"].([]any)
if len(svcs) != 0 { if len(doms) != 0 {
t.Errorf("expected empty, got %d", len(svcs)) t.Errorf("expected empty, got %d", len(doms))
} }
} }
func TestServicesGet(t *testing.T) { func TestDomainsGet(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
// Register // Register
@@ -107,41 +107,41 @@ func TestServicesGet(t *testing.T) {
"source": "test", "source": "test",
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
}) })
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
w := httptest.NewRecorder() w := httptest.NewRecorder()
api.ServicesRegister(w, req) api.DomainsRegister(w, req)
// Get by domain // Get by domain
req = httptest.NewRequest("GET", "/api/v1/services/get-test.example.com", nil) req = httptest.NewRequest("GET", "/api/v1/domains/get-test.example.com", nil)
req = mux.SetURLVars(req, map[string]string{"domain": "get-test.example.com"}) req = mux.SetURLVars(req, map[string]string{"domain": "get-test.example.com"})
w = httptest.NewRecorder() w = httptest.NewRecorder()
api.ServicesGet(w, req) api.DomainsGet(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
} }
var svc map[string]any var dom map[string]any
json.Unmarshal(w.Body.Bytes(), &svc) json.Unmarshal(w.Body.Bytes(), &dom)
if svc["domain"] != "get-test.example.com" { if dom["domain"] != "get-test.example.com" {
t.Errorf("expected domain get-test.example.com, got %v", svc["domain"]) t.Errorf("expected domain get-test.example.com, got %v", dom["domain"])
} }
} }
func TestServicesGet_NotFound(t *testing.T) { func TestDomainsGet_NotFound(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
req := httptest.NewRequest("GET", "/api/v1/services/nonexistent.com", nil) req := httptest.NewRequest("GET", "/api/v1/domains/nonexistent.com", nil)
req = mux.SetURLVars(req, map[string]string{"domain": "nonexistent.com"}) req = mux.SetURLVars(req, map[string]string{"domain": "nonexistent.com"})
w := httptest.NewRecorder() w := httptest.NewRecorder()
api.ServicesGet(w, req) api.DomainsGet(w, req)
if w.Code != http.StatusNotFound { if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code) t.Errorf("expected 404, got %d", w.Code)
} }
} }
func TestServicesUpdate(t *testing.T) { func TestDomainsUpdate(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
// Register // Register
@@ -150,16 +150,16 @@ func TestServicesUpdate(t *testing.T) {
"source": "test", "source": "test",
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
}) })
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
w := httptest.NewRecorder() w := httptest.NewRecorder()
api.ServicesRegister(w, req) api.DomainsRegister(w, req)
// Update public to true // Update public to true
update, _ := json.Marshal(map[string]any{"public": true}) update, _ := json.Marshal(map[string]any{"public": true})
req = httptest.NewRequest("PATCH", "/api/v1/services/update-test.example.com", bytes.NewReader(update)) req = httptest.NewRequest("PATCH", "/api/v1/domains/update-test.example.com", bytes.NewReader(update))
req = mux.SetURLVars(req, map[string]string{"domain": "update-test.example.com"}) req = mux.SetURLVars(req, map[string]string{"domain": "update-test.example.com"})
w = httptest.NewRecorder() w = httptest.NewRecorder()
api.ServicesUpdate(w, req) api.DomainsUpdate(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
@@ -167,13 +167,13 @@ func TestServicesUpdate(t *testing.T) {
var resp map[string]any var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp) json.Unmarshal(w.Body.Bytes(), &resp)
svc := resp["service"].(map[string]any) dom := resp["domain"].(map[string]any)
if svc["public"] != true { if dom["public"] != true {
t.Errorf("expected public=true, got %v", svc["public"]) t.Errorf("expected public=true, got %v", dom["public"])
} }
} }
func TestServicesDeregister(t *testing.T) { func TestDomainsDeregister(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
// Register // Register
@@ -182,40 +182,40 @@ func TestServicesDeregister(t *testing.T) {
"source": "test", "source": "test",
"backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"},
}) })
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
w := httptest.NewRecorder() w := httptest.NewRecorder()
api.ServicesRegister(w, req) api.DomainsRegister(w, req)
// Delete // Delete
req = httptest.NewRequest("DELETE", "/api/v1/services/delete-me.example.com", nil) req = httptest.NewRequest("DELETE", "/api/v1/domains/delete-me.example.com", nil)
req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"}) req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"})
w = httptest.NewRecorder() w = httptest.NewRecorder()
api.ServicesDeregister(w, req) api.DomainsDeregister(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code) t.Fatalf("expected 200, got %d", w.Code)
} }
// Verify gone // Verify gone
req = httptest.NewRequest("GET", "/api/v1/services/delete-me.example.com", nil) req = httptest.NewRequest("GET", "/api/v1/domains/delete-me.example.com", nil)
req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"}) req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"})
w = httptest.NewRecorder() w = httptest.NewRecorder()
api.ServicesGet(w, req) api.DomainsGet(w, req)
if w.Code != http.StatusNotFound { if w.Code != http.StatusNotFound {
t.Errorf("expected 404 after delete, got %d", w.Code) t.Errorf("expected 404 after delete, got %d", w.Code)
} }
} }
func TestServicesRegister_Validation(t *testing.T) { func TestDomainsRegister_Validation(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
// Missing domain // Missing domain
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"backend": map[string]any{"address": "127.0.0.1:80", "type": "http"}, "backend": map[string]any{"address": "127.0.0.1:80", "type": "http"},
}) })
req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body))
w := httptest.NewRecorder() w := httptest.NewRecorder()
api.ServicesRegister(w, req) api.DomainsRegister(w, req)
if w.Code != http.StatusBadRequest { if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing domain, got %d", w.Code) t.Errorf("expected 400 for missing domain, got %d", w.Code)
} }

View File

@@ -110,7 +110,7 @@ func TestHaproxyStats_WhenSocketMissing(t *testing.T) {
} }
} }
func TestHaproxyGenerate_MissingGlobalConfig(t *testing.T) { func TestHaproxyGenerate_MissingState(t *testing.T) {
api, tmpDir := setupTestHaproxy(t) api, tmpDir := setupTestHaproxy(t)
os.Remove(filepath.Join(tmpDir, "state.yaml")) os.Remove(filepath.Join(tmpDir, "state.yaml"))

View File

@@ -53,7 +53,7 @@ func (api *API) vpnAutoUDPPorts() []int {
// syncNftablesOnly regenerates and applies nftables rules from the current global config // syncNftablesOnly regenerates and applies nftables rules from the current global config
// without touching HAProxy. Called when firewall settings change independently. // without touching HAProxy. Called when firewall settings change independently.
// Runs errors are logged only — the caller does not wait for completion. // Runs errors are logged only — the caller does not wait for completion.
func (api *API) syncNftablesOnly(globalCfg *config.GlobalConfig) { func (api *API) syncNftablesOnly(globalCfg *config.State) {
nftCfg := globalCfg.Cloud.Nftables nftCfg := globalCfg.Cloud.Nftables
// Explicitly disabled: flush the wild-cloud table // Explicitly disabled: flush the wild-cloud table

View File

@@ -8,55 +8,53 @@ import (
"testing" "testing"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/services"
) )
// TestReconciliation_HAProxyConfigFromServices verifies that the reconciliation // TestReconciliation_HAProxyConfigFromDomains verifies that the reconciliation
// logic correctly maps registered services to HAProxy configuration. This // logic correctly maps registered domains to HAProxy configuration. This
// replicates the route-building logic from reconcileNetworking() and asserts // replicates the route-building logic from reconcileNetworking() and asserts
// on the generated config without requiring haproxy or dnsmasq binaries. // on the generated config without requiring haproxy or dnsmasq binaries.
func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
// Register a mix of services like a real deployment // Register a mix of domains like a real deployment
testServices := []services.Service{ testDomains := []domains.Domain{
{ {
Domain: "cloud.payne.io", DomainName: "cloud.payne.io",
Source: "wild-cloud", Source: "wild-cloud",
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: true, Subdomains: true,
Public: true, Public: true,
}, },
{ {
Domain: "payne.io", DomainName: "payne.io",
Source: "wild-cloud", Source: "wild-cloud",
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: false, Subdomains: false,
Public: true, Public: true,
}, },
{ {
Domain: "wild-cloud.payne.io", DomainName: "wild-cloud.payne.io",
Source: "wild-works", Source: "wild-works",
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP}, Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP},
// Public defaults to false
}, },
{ {
Domain: "my-api.payne.io", DomainName: "my-api.payne.io",
Source: "wild-works", Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP, Health: "/health"}, Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP, Health: "/health"},
// Public defaults to false
}, },
} }
for _, svc := range testServices { for _, dom := range testDomains {
if err := api.services.Register(svc); err != nil { if err := api.domains.Register(dom); err != nil {
t.Fatalf("Register %s failed: %v", svc.Domain, err) t.Fatalf("Register %s failed: %v", dom.DomainName, err)
} }
} }
// Replicate reconcileNetworking route-building logic // Replicate reconcileNetworking route-building logic
svcs, err := api.services.List() doms, err := api.domains.List()
if err != nil { if err != nil {
t.Fatalf("List failed: %v", err) t.Fatalf("List failed: %v", err)
} }
@@ -64,50 +62,50 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
var instanceRoutes []haproxy.InstanceRoute var instanceRoutes []haproxy.InstanceRoute
var httpRoutes []haproxy.HTTPRoute var httpRoutes []haproxy.HTTPRoute
for _, svc := range svcs { for _, dom := range doms {
switch svc.Backend.Type { switch dom.Backend.Type {
case services.BackendTCPPassthrough: case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{ instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
Name: svc.Domain, Name: dom.DomainName,
Domain: svc.Domain, Domain: dom.DomainName,
BackendIP: extractHost(svc.Backend.Address), BackendIP: extractHost(dom.Backend.Address),
Subdomains: svc.Subdomains, Subdomains: dom.Subdomains,
}) })
case services.BackendHTTP: case domains.BackendHTTP:
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Domain, Name: dom.DomainName,
Domain: svc.Domain, Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{ Routes: []haproxy.HTTPRouteBackend{{
Backend: svc.Backend.Address, Backend: dom.Backend.Address,
HealthPath: svc.Backend.Health, HealthPath: dom.Backend.Health,
}}, }},
}) })
} }
} }
// Central registers itself as a service; add it to the test data // Central registers itself as a domain; add it to the test data
if err := api.services.Register(services.Service{ if err := api.domains.Register(domains.Domain{
Domain: "central.payne.io", DomainName: "central.payne.io",
Source: "central", Source: "central",
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP}, Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP},
TLS: services.TLSTerminate, TLS: domains.TLSTerminate,
}); err != nil { }); err != nil {
t.Fatalf("Register central failed: %v", err) t.Fatalf("Register central failed: %v", err)
} }
// Re-list to pick up Central // Re-list to pick up Central
svcs, err = api.services.List() doms, err = api.domains.List()
if err != nil { if err != nil {
t.Fatalf("List failed: %v", err) t.Fatalf("List failed: %v", err)
} }
httpRoutes = nil httpRoutes = nil
for _, svc := range svcs { for _, dom := range doms {
if svc.Backend.Type == services.BackendHTTP { if dom.Backend.Type == domains.BackendHTTP {
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Domain, Name: dom.DomainName,
Domain: svc.Domain, Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{ Routes: []haproxy.HTTPRouteBackend{{
Backend: svc.Backend.Address, Backend: dom.Backend.Address,
HealthPath: svc.Backend.Health, HealthPath: dom.Backend.Health,
}}, }},
}) })
} }
@@ -120,7 +118,7 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
// --- Assertions on HAProxy config --- // --- Assertions on HAProxy config ---
// L7 services have exact-match ACLs in the SNI frontend // L7 domains have exact-match ACLs in the SNI frontend
if !strings.Contains(cfg, "acl is_l7_central_payne_io req_ssl_sni -m str central.payne.io") { if !strings.Contains(cfg, "acl is_l7_central_payne_io req_ssl_sni -m str central.payne.io") {
t.Errorf("expected L7 exact-match ACL for central.payne.io:\n%s", cfg) t.Errorf("expected L7 exact-match ACL for central.payne.io:\n%s", cfg)
} }
@@ -128,12 +126,12 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
t.Errorf("expected L7 exact-match ACL for wild-cloud.payne.io:\n%s", cfg) t.Errorf("expected L7 exact-match ACL for wild-cloud.payne.io:\n%s", cfg)
} }
// L4 services with subdomains:true have wildcard ACLs // L4 domains with subdomains:true have wildcard ACLs
if !strings.Contains(cfg, "req_ssl_sni -m end .cloud.payne.io") { if !strings.Contains(cfg, "req_ssl_sni -m end .cloud.payne.io") {
t.Errorf("expected L4 wildcard ACL for cloud.payne.io (subdomains:true):\n%s", cfg) t.Errorf("expected L4 wildcard ACL for cloud.payne.io (subdomains:true):\n%s", cfg)
} }
// L4 services with subdomains:false have exact-match only // L4 domains with subdomains:false have exact-match only
if !strings.Contains(cfg, "req_ssl_sni -m str payne.io") { if !strings.Contains(cfg, "req_ssl_sni -m str payne.io") {
t.Errorf("expected L4 exact-match ACL for payne.io:\n%s", cfg) t.Errorf("expected L4 exact-match ACL for payne.io:\n%s", cfg)
} }
@@ -168,72 +166,71 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
} }
} }
// TestReconciliation_DnsmasqConfigFromServices verifies that reconciliation // TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation
// correctly generates dnsmasq config from registered services, including // correctly generates dnsmasq config from registered domains, including
// the critical reach-based local=/ directives. // the critical reach-based local=/ directives.
func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) { func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
centralIP := "192.168.8.151" centralIP := "192.168.8.151"
// Register services with different reach and backend types // Register domains with different reach and backend types
testServices := []services.Service{ testDomains := []domains.Domain{
{ {
// tcp-passthrough, public → DNS points to k8s LB IP, no local=/ // tcp-passthrough, public → DNS points to k8s LB IP, no local=/
Domain: "cloud.payne.io", DomainName: "cloud.payne.io",
Source: "wild-cloud", Source: "wild-cloud",
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: true, Subdomains: true,
Public: true, Public: true,
}, },
{ {
// http, internal → DNS points to Central IP, has local=/ // http, internal → DNS points to Central IP, has local=/
Domain: "my-api.payne.io", DomainName: "my-api.payne.io",
Source: "wild-works", Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP}, Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
// Public defaults to false
}, },
{ {
// http, public → DNS points to Central IP, NO local=/ // http, public → DNS points to Central IP, NO local=/
Domain: "public-app.payne.io", DomainName: "public-app.payne.io",
Source: "wild-works", Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:8080", Type: services.BackendHTTP}, Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP},
Public: true, Public: true,
}, },
} }
for _, svc := range testServices { for _, dom := range testDomains {
if err := api.services.Register(svc); err != nil { if err := api.domains.Register(dom); err != nil {
t.Fatalf("Register %s failed: %v", svc.Domain, err) t.Fatalf("Register %s failed: %v", dom.DomainName, err)
} }
} }
// Build dnsmasq instance configs the same way reconcileNetworking does // Build dnsmasq instance configs the same way reconcileNetworking does
svcs, err := api.services.List() doms, err := api.domains.List()
if err != nil { if err != nil {
t.Fatalf("List failed: %v", err) t.Fatalf("List failed: %v", err)
} }
globalCfg := &config.GlobalConfig{} globalCfg := &config.State{}
var instanceConfigs []config.InstanceConfig var instanceConfigs []config.InstanceConfig
for _, svc := range svcs { for _, dom := range doms {
if svc.Domain == "" { if dom.DomainName == "" {
continue continue
} }
dnsIP := centralIP dnsIP := centralIP
if svc.Backend.Type == services.BackendTCPPassthrough { if dom.Backend.Type == domains.BackendTCPPassthrough {
dnsIP = extractHost(svc.Backend.Address) dnsIP = extractHost(dom.Backend.Address)
} }
ic := config.InstanceConfig{} ic := config.InstanceConfig{}
ic.Cluster.LoadBalancerIp = dnsIP ic.Cluster.LoadBalancerIp = dnsIP
if !svc.Public { if !dom.Public {
ic.Cloud.InternalDomain = svc.Domain ic.Cloud.InternalDomain = dom.DomainName
} else { } else {
ic.Cloud.Domain = svc.Domain ic.Cloud.Domain = dom.DomainName
} }
instanceConfigs = append(instanceConfigs, ic) instanceConfigs = append(instanceConfigs, ic)
@@ -243,69 +240,67 @@ func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) {
// --- TCP passthrough (public) → backend IP --- // --- TCP passthrough (public) → backend IP ---
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") { if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("tcp-passthrough service must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg) t.Errorf("tcp-passthrough domain must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg)
} }
// --- HTTP services → Central IP --- // --- HTTP domains → Central IP ---
if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") { if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") {
t.Errorf("http/internal service must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) t.Errorf("http/internal domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
} }
if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") { if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") {
t.Errorf("http/public service must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
} }
// --- reach:internal → local=/ present --- // --- internal → local=/ present ---
if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") { if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") {
t.Errorf("reach:internal service must have local=/ entry:\n%s", dnsmasqCfg) t.Errorf("internal domain must have local=/ entry:\n%s", dnsmasqCfg)
} }
// --- reach:public → NO local=/ --- // --- public → NO local=/ ---
if strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") { if strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
t.Errorf("reach:public service must NOT have local=/ entry:\n%s", dnsmasqCfg) t.Errorf("public domain must NOT have local=/ entry:\n%s", dnsmasqCfg)
} }
if strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") { if strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
t.Errorf("reach:public service must NOT have local=/ entry:\n%s", dnsmasqCfg) t.Errorf("public domain must NOT have local=/ entry:\n%s", dnsmasqCfg)
} }
} }
// TestReconciliation_CentralDomainInHAProxy verifies that Central, registered // TestReconciliation_CentralDomainInHAProxy verifies that Central, registered
// as a service, produces correct HAProxy L7 routes. // as a domain, produces correct HAProxy L7 routes.
func TestReconciliation_CentralDomainInHAProxy(t *testing.T) { func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
centralPort := 15055 centralPort := 15055
// Register Central as a service (as EnsureCentralService does) // Register Central as a domain (as EnsureCentralDomain does)
if err := api.services.Register(services.Service{ if err := api.domains.Register(domains.Domain{
Domain: "central.payne.io", DomainName: "central.payne.io",
Source: "central", Source: "central",
Backend: services.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: services.BackendHTTP}, Backend: domains.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: domains.BackendHTTP},
// Public defaults to false TLS: domains.TLSTerminate,
TLS: services.TLSTerminate,
}); err != nil { }); err != nil {
t.Fatalf("Register central failed: %v", err) t.Fatalf("Register central failed: %v", err)
} }
// Register another HTTP service // Register another HTTP domain
if err := api.services.Register(services.Service{ if err := api.domains.Register(domains.Domain{
Domain: "my-app.payne.io", DomainName: "my-app.payne.io",
Source: "wild-works", Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP}, Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
// Public defaults to false
}); err != nil { }); err != nil {
t.Fatalf("Register failed: %v", err) t.Fatalf("Register failed: %v", err)
} }
svcs, _ := api.services.List() doms, _ := api.domains.List()
var httpRoutes []haproxy.HTTPRoute var httpRoutes []haproxy.HTTPRoute
for _, svc := range svcs { for _, dom := range doms {
if svc.Backend.Type == services.BackendHTTP { if dom.Backend.Type == domains.BackendHTTP {
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Domain, Name: dom.DomainName,
Domain: svc.Domain, Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{ Routes: []haproxy.HTTPRouteBackend{{
Backend: svc.Backend.Address, Backend: dom.Backend.Address,
}}, }},
}) })
} }
@@ -330,39 +325,39 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
} }
// TestReconciliation_TCPPassthroughDNSTarget verifies that tcp-passthrough // TestReconciliation_TCPPassthroughDNSTarget verifies that tcp-passthrough
// services get DNS pointing to the backend IP, not Central's IP. // domains get DNS pointing to the backend IP, not Central's IP.
func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) { func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
centralIP := "192.168.8.151" centralIP := "192.168.8.151"
// Register a k8s instance with tcp-passthrough // Register a k8s instance with tcp-passthrough
if err := api.services.Register(services.Service{ if err := api.domains.Register(domains.Domain{
Domain: "cloud.payne.io", DomainName: "cloud.payne.io",
Source: "wild-cloud", Source: "wild-cloud",
Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
Subdomains: true, Subdomains: true,
Public: true, Public: true,
}); err != nil { }); err != nil {
t.Fatalf("Register failed: %v", err) t.Fatalf("Register failed: %v", err)
} }
svcs, _ := api.services.List() doms, _ := api.domains.List()
globalCfg := &config.GlobalConfig{} globalCfg := &config.State{}
var instanceConfigs []config.InstanceConfig var instanceConfigs []config.InstanceConfig
for _, svc := range svcs { for _, dom := range doms {
dnsIP := centralIP dnsIP := centralIP
if svc.Backend.Type == services.BackendTCPPassthrough { if dom.Backend.Type == domains.BackendTCPPassthrough {
dnsIP = extractHost(svc.Backend.Address) dnsIP = extractHost(dom.Backend.Address)
} }
ic := config.InstanceConfig{} ic := config.InstanceConfig{}
ic.Cluster.LoadBalancerIp = dnsIP ic.Cluster.LoadBalancerIp = dnsIP
if !svc.Public { if !dom.Public {
ic.Cloud.InternalDomain = svc.Domain ic.Cloud.InternalDomain = dom.DomainName
} else { } else {
ic.Cloud.Domain = svc.Domain ic.Cloud.Domain = dom.DomainName
} }
instanceConfigs = append(instanceConfigs, ic) instanceConfigs = append(instanceConfigs, ic)
} }
@@ -378,6 +373,104 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
} }
} }
// TestReconciliation_DNSOnlyHasNoDNSButNoProxy verifies that dns-only domains
// get DNS entries but no HAProxy routes.
func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register a dns-only domain (e.g. for SSH access)
if err := api.domains.Register(domains.Domain{
DomainName: "dev.payne.io",
Source: "manual",
Backend: domains.Backend{Address: "192.168.8.222", Type: domains.BackendDNSOnly},
Public: true,
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
// Also register a normal HTTP domain to verify it still works
if err := api.domains.Register(domains.Domain{
DomainName: "app.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "127.0.0.1:8080", Type: domains.BackendHTTP},
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
doms, _ := api.domains.List()
// Build HAProxy routes — dns-only should be absent
var instanceRoutes []haproxy.InstanceRoute
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.Backend.Type {
case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.Backend.Address),
})
case domains.BackendDNSOnly:
// dns-only: no gateway routing
case domains.BackendHTTP:
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{Backend: dom.Backend.Address}},
})
}
}
cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
HTTPRoutes: httpRoutes,
CertsDir: "/etc/haproxy/certs/",
})
// dns-only domain must NOT appear in HAProxy config
if strings.Contains(cfg, "dev.payne.io") {
t.Errorf("dns-only domain must NOT appear in HAProxy config:\n%s", cfg)
}
// HTTP domain should appear
if !strings.Contains(cfg, "app.payne.io") {
t.Errorf("http domain should appear in HAProxy config:\n%s", cfg)
}
// Build DNS config — dns-only should point to its backend IP
globalCfg := &config.State{}
var instanceConfigs []config.InstanceConfig
for _, dom := range doms {
dnsIP := centralIP
bt := dom.Backend.Type
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
dnsIP = extractHost(dom.Backend.Address)
}
ic := config.InstanceConfig{}
ic.Cluster.LoadBalancerIp = dnsIP
if !dom.Public {
ic.Cloud.InternalDomain = dom.DomainName
} else {
ic.Cloud.Domain = dom.DomainName
}
instanceConfigs = append(instanceConfigs, ic)
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs)
// dns-only domain should resolve to its backend IP (192.168.8.222)
if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") {
t.Errorf("dns-only domain must point DNS to backend IP:\n%s", dnsmasqCfg)
}
// HTTP domain should resolve to Central IP
if !strings.Contains(dnsmasqCfg, "address=/app.payne.io/192.168.8.151") {
t.Errorf("http domain must point DNS to Central IP:\n%s", dnsmasqCfg)
}
}
// writeTestConfig writes a minimal global config with the given Central domain. // writeTestConfig writes a minimal global config with the given Central domain.
func writeTestConfig(t *testing.T, dataDir, centralDomain string) { func writeTestConfig(t *testing.T, dataDir, centralDomain string) {
t.Helper() t.Helper()
@@ -388,85 +481,85 @@ func writeTestConfig(t *testing.T, dataDir, centralDomain string) {
} }
} }
func TestEnsureCentralService_RegistersWhenDomainConfigured(t *testing.T) { func TestEnsureCentralDomain_RegistersWhenDomainConfigured(t *testing.T) {
api, dataDir := setupTestAPI(t) api, dataDir := setupTestAPI(t)
api.SetPort(15055) api.SetPort(15055)
writeTestConfig(t, dataDir, "central.example.com") writeTestConfig(t, dataDir, "central.example.com")
api.EnsureCentralService() api.EnsureCentralDomain()
svc, err := api.services.Get("central.example.com") dom, err := api.domains.Get("central.example.com")
if err != nil { if err != nil {
t.Fatalf("expected Central service to be registered: %v", err) t.Fatalf("expected Central domain to be registered: %v", err)
} }
if svc.Source != "central" { if dom.Source != "central" {
t.Errorf("source = %q, want %q", svc.Source, "central") t.Errorf("source = %q, want %q", dom.Source, "central")
} }
if svc.Backend.Address != "127.0.0.1:15055" { if dom.Backend.Address != "127.0.0.1:15055" {
t.Errorf("backend = %q, want %q", svc.Backend.Address, "127.0.0.1:15055") t.Errorf("backend = %q, want %q", dom.Backend.Address, "127.0.0.1:15055")
} }
if svc.TLS != services.TLSTerminate { if dom.TLS != domains.TLSTerminate {
t.Errorf("tls = %q, want %q", svc.TLS, services.TLSTerminate) t.Errorf("tls = %q, want %q", dom.TLS, domains.TLSTerminate)
} }
} }
func TestEnsureCentralService_Idempotent(t *testing.T) { func TestEnsureCentralDomain_Idempotent(t *testing.T) {
api, dataDir := setupTestAPI(t) api, dataDir := setupTestAPI(t)
api.SetPort(15055) api.SetPort(15055)
writeTestConfig(t, dataDir, "central.example.com") writeTestConfig(t, dataDir, "central.example.com")
api.EnsureCentralService() api.EnsureCentralDomain()
api.EnsureCentralService() // second call should be a no-op api.EnsureCentralDomain() // second call should be a no-op
svcs, _ := api.services.List() doms, _ := api.domains.List()
count := 0 count := 0
for _, s := range svcs { for _, d := range doms {
if s.Source == "central" { if d.Source == "central" {
count++ count++
} }
} }
if count != 1 { if count != 1 {
t.Errorf("expected 1 central service, got %d", count) t.Errorf("expected 1 central domain, got %d", count)
} }
} }
func TestEnsureCentralService_CleansUpOnDomainChange(t *testing.T) { func TestEnsureCentralDomain_CleansUpOnDomainChange(t *testing.T) {
api, dataDir := setupTestAPI(t) api, dataDir := setupTestAPI(t)
api.SetPort(15055) api.SetPort(15055)
// Register with old domain // Register with old domain
writeTestConfig(t, dataDir, "old.example.com") writeTestConfig(t, dataDir, "old.example.com")
api.EnsureCentralService() api.EnsureCentralDomain()
// Change domain // Change domain
writeTestConfig(t, dataDir, "new.example.com") writeTestConfig(t, dataDir, "new.example.com")
api.EnsureCentralService() api.EnsureCentralDomain()
// Old domain should be gone // Old domain should be gone
if _, err := api.services.Get("old.example.com"); err == nil { if _, err := api.domains.Get("old.example.com"); err == nil {
t.Error("old Central service should have been deregistered") t.Error("old Central domain should have been deregistered")
} }
// New domain should exist // New domain should exist
svc, err := api.services.Get("new.example.com") dom, err := api.domains.Get("new.example.com")
if err != nil { if err != nil {
t.Fatalf("new Central service should be registered: %v", err) t.Fatalf("new Central domain should be registered: %v", err)
} }
if svc.Source != "central" { if dom.Source != "central" {
t.Errorf("source = %q, want %q", svc.Source, "central") t.Errorf("source = %q, want %q", dom.Source, "central")
} }
} }
func TestEnsureCentralService_NoDomainIsNoop(t *testing.T) { func TestEnsureCentralDomain_NoDomainIsNoop(t *testing.T) {
api, _ := setupTestAPI(t) api, _ := setupTestAPI(t)
api.SetPort(15055) api.SetPort(15055)
// Default config has no Central domain // Default config has no Central domain
api.EnsureCentralService() api.EnsureCentralDomain()
svcs, _ := api.services.List() doms, _ := api.domains.List()
for _, s := range svcs { for _, d := range doms {
if s.Source == "central" { if d.Source == "central" {
t.Error("no Central service should be registered when domain is empty") t.Error("no Central domain should be registered when domain is empty")
} }
} }
} }

View File

@@ -1,132 +0,0 @@
package v1
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/services"
)
// ServicesListAll lists all registered services.
func (api *API) ServicesListAll(w http.ResponseWriter, r *http.Request) {
svcs, err := api.services.List()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list services: %v", err))
return
}
if svcs == nil {
svcs = []services.Service{}
}
respondJSON(w, http.StatusOK, map[string]any{
"services": svcs,
})
}
// ServicesGet retrieves a service by domain.
func (api *API) ServicesGet(w http.ResponseWriter, r *http.Request) {
domain := mux.Vars(r)["domain"]
svc, err := api.services.Get(domain)
if err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Service not found: %v", err))
return
}
respondJSON(w, http.StatusOK, svc)
}
// ServicesRegister creates or updates a service registration.
func (api *API) ServicesRegister(w http.ResponseWriter, r *http.Request) {
var svc services.Service
if err := json.NewDecoder(r.Body).Decode(&svc); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err))
return
}
// Validate routes
for i, r := range svc.Routes {
for _, p := range r.Paths {
if !strings.HasPrefix(p, "/") {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: path prefix must start with /: %q", i, p))
return
}
}
for _, cidr := range r.IPAllow {
if _, _, err := net.ParseCIDR(cidr); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: invalid CIDR in ipAllow: %q", i, cidr))
return
}
}
}
if err := api.services.Register(svc); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to register service: %v", err))
return
}
// Return the stored version (has defaults applied)
stored, _ := api.services.Get(svc.Domain)
respondJSON(w, http.StatusCreated, map[string]any{
"message": fmt.Sprintf("Service %q registered", svc.Domain),
"service": stored,
})
}
// ServicesUpdate applies partial updates to a service.
func (api *API) ServicesUpdate(w http.ResponseWriter, r *http.Request) {
domain := mux.Vars(r)["domain"]
var updates map[string]any
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err))
return
}
if err := api.services.Update(domain, updates); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to update service: %v", err))
return
}
svc, _ := api.services.Get(domain)
respondJSON(w, http.StatusOK, map[string]any{
"message": fmt.Sprintf("Service %q updated", domain),
"service": svc,
})
}
// ServicesDeregister removes a service registration.
func (api *API) ServicesDeregister(w http.ResponseWriter, r *http.Request) {
domain := mux.Vars(r)["domain"]
if err := api.services.Deregister(domain); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to deregister service: %v", err))
return
}
respondMessage(w, http.StatusOK, fmt.Sprintf("Service %q deregistered", domain))
}
// ServicesDeregisterBySource removes all services from a source with a given backend.
func (api *API) ServicesDeregisterBySource(w http.ResponseWriter, r *http.Request) {
source := r.URL.Query().Get("source")
backend := r.URL.Query().Get("backend")
if source == "" {
respondError(w, http.StatusBadRequest, "source query parameter is required")
return
}
if err := api.services.DeregisterBySource(source, backend); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to deregister: %v", err))
return
}
respondMessage(w, http.StatusOK, fmt.Sprintf("Services from %q deregistered", source))
}

View File

@@ -9,23 +9,23 @@ import (
"github.com/wild-cloud/wild-central/internal/certbot" "github.com/wild-cloud/wild-central/internal/certbot"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/network" "github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/services"
"github.com/wild-cloud/wild-central/internal/sse" "github.com/wild-cloud/wild-central/internal/sse"
) )
// EnsureCentralService registers (or updates) Central's own domain as a service // EnsureCentralDomain registers (or updates) Central's own domain as a domain
// so it participates in DNS, HAProxy routing, and TLS cert tracking like any // so it participates in DNS, HAProxy routing, and TLS cert tracking like any
// other service. Called on startup and when global config changes. // other domain. Called on startup and when global config changes.
func (api *API) EnsureCentralService() { func (api *API) EnsureCentralDomain() {
centralDomain := api.getCentralDomain() centralDomain := api.getCentralDomain()
port := api.getRunningPort() port := api.getRunningPort()
backend := fmt.Sprintf("127.0.0.1:%d", port) backend := fmt.Sprintf("127.0.0.1:%d", port)
// Check if the current registration already matches — skip if so. // Check if the current registration already matches — skip if so.
if centralDomain != "" { if centralDomain != "" {
if existing, _ := api.services.Get(centralDomain); existing != nil && if existing, _ := api.domains.Get(centralDomain); existing != nil &&
existing.Source == "central" && existing.Source == "central" &&
existing.Backend.Address == backend { existing.Backend.Address == backend {
return return
@@ -33,10 +33,10 @@ func (api *API) EnsureCentralService() {
} }
// Clean up stale Central registrations (e.g. domain changed). // Clean up stale Central registrations (e.g. domain changed).
svcs, _ := api.services.List() doms, _ := api.domains.List()
for _, svc := range svcs { for _, dom := range doms {
if svc.Source == "central" && svc.Domain != centralDomain { if dom.Source == "central" && dom.DomainName != centralDomain {
_ = api.services.Deregister(svc.Domain) _ = api.domains.Deregister(dom.DomainName)
} }
} }
@@ -44,55 +44,56 @@ func (api *API) EnsureCentralService() {
return return
} }
_ = api.services.Register(services.Service{ _ = api.domains.Register(domains.Domain{
Domain: centralDomain, DomainName: centralDomain,
Source: "central", Source: "central",
Backend: services.Backend{ Backend: domains.Backend{
Address: backend, Address: backend,
Type: services.BackendHTTP, Type: domains.BackendHTTP,
}, },
// Public defaults to false (private) TLS: domains.TLSTerminate,
TLS: services.TLSTerminate,
}) })
} }
// Reconcile runs networking reconciliation immediately. Called on startup // Reconcile runs networking reconciliation immediately. Called on startup
// and automatically whenever a service is registered/updated/deregistered. // and automatically whenever a domain is registered/updated/deregistered.
func (api *API) Reconcile() { func (api *API) Reconcile() {
api.reconcileNetworking() api.reconcileNetworking()
} }
// reconcileNetworking reads all registered services and regenerates // reconcileNetworking reads all registered domains and regenerates
// dnsmasq DNS entries and HAProxy routes to match. // dnsmasq DNS entries and HAProxy routes to match.
func (api *API) reconcileNetworking() { func (api *API) reconcileNetworking() {
svcs, err := api.services.List() doms, err := api.domains.List()
if err != nil { if err != nil {
slog.Error("reconcile: failed to list services", "error", err) slog.Error("reconcile: failed to list domains", "error", err)
return return
} }
globalCfg, err := config.LoadState(api.statePath()) globalCfg, err := config.LoadState(api.statePath())
if err != nil { if err != nil {
slog.Warn("reconcile: failed to load state, using empty", "error", err) slog.Warn("reconcile: failed to load state, using empty", "error", err)
globalCfg = &config.GlobalConfig{} globalCfg = &config.State{}
} }
// Build HAProxy routes from registered services // Build HAProxy routes from registered domains
var instanceRoutes []haproxy.InstanceRoute var instanceRoutes []haproxy.InstanceRoute
var httpRoutes []haproxy.HTTPRoute var httpRoutes []haproxy.HTTPRoute
for _, svc := range svcs { for _, dom := range doms {
switch svc.EffectiveBackendType() { switch dom.EffectiveBackendType() {
case services.BackendTCPPassthrough: case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{ instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
Name: svc.Domain, Name: dom.DomainName,
Domain: svc.Domain, Domain: dom.DomainName,
BackendIP: extractHost(svc.EffectiveBackendAddress()), BackendIP: extractHost(dom.EffectiveBackendAddress()),
Subdomains: svc.Subdomains, Subdomains: dom.Subdomains,
}) })
case services.BackendHTTP: case domains.BackendDNSOnly:
// dns-only: no gateway routing, just DNS resolution
case domains.BackendHTTP:
var routeBackends []haproxy.HTTPRouteBackend var routeBackends []haproxy.HTTPRouteBackend
for _, r := range svc.EffectiveRoutes() { for _, r := range dom.EffectiveRoutes() {
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{ routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{
Paths: r.Paths, Paths: r.Paths,
Backend: r.Backend.Address, Backend: r.Backend.Address,
@@ -102,15 +103,15 @@ func (api *API) reconcileNetworking() {
}) })
} }
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Domain, Name: dom.DomainName,
Domain: svc.Domain, Domain: dom.DomainName,
Subdomains: svc.Subdomains, Subdomains: dom.Subdomains,
Routes: routeBackends, Routes: routeBackends,
}) })
} }
} }
// Only include L7 HTTP routes for services that have a cert. // Only include L7 HTTP routes for domains that have a cert.
certsDir := "/etc/haproxy/certs/" certsDir := "/etc/haproxy/certs/"
var activeHTTPRoutes []haproxy.HTTPRoute var activeHTTPRoutes []haproxy.HTTPRoute
for _, route := range httpRoutes { for _, route := range httpRoutes {
@@ -129,10 +130,10 @@ func (api *API) reconcileNetworking() {
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, genOpts) haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, genOpts)
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil { if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
// Validation failed — identify and exclude broken services, then retry // Validation failed — identify and exclude broken domains, then retry
brokenDomains := haproxy.FindBrokenServices(haproxyCfg, haproxy.ParseValidationErrors(err.Error())) brokenDomains := haproxy.FindBrokenServices(haproxyCfg, haproxy.ParseValidationErrors(err.Error()))
if len(brokenDomains) > 0 { if len(brokenDomains) > 0 {
slog.Error("reconcile: excluding broken services and retrying", slog.Error("reconcile: excluding broken domains and retrying",
"broken", brokenDomains, "error", err) "broken", brokenDomains, "error", err)
exclude := map[string]bool{} exclude := map[string]bool{}
@@ -161,22 +162,22 @@ func (api *API) reconcileNetworking() {
if err := api.haproxy.ReloadService(); err != nil { if err := api.haproxy.ReloadService(); err != nil {
slog.Warn("reconcile: failed to reload HAProxy", "error", err) slog.Warn("reconcile: failed to reload HAProxy", "error", err)
} else { } else {
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken services)") api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken domains)")
} }
} }
} else { } else {
slog.Error("reconcile: failed to write HAProxy config (no broken services identified)", "error", err) slog.Error("reconcile: failed to write HAProxy config (no broken domains identified)", "error", err)
} }
} else { } else {
if err := api.haproxy.ReloadService(); err != nil { if err := api.haproxy.ReloadService(); err != nil {
slog.Warn("reconcile: failed to reload HAProxy", "error", err) slog.Warn("reconcile: failed to reload HAProxy", "error", err)
} else { } else {
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered services") api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered domains")
} }
} }
// Generate dnsmasq DNS entries from registered services. // Generate dnsmasq DNS entries from registered domains.
// DNS target IP depends on service type: // DNS target IP depends on domain type:
// tcp-passthrough (k8s): DNS → k8s LB IP (LAN traffic goes direct to k8s) // tcp-passthrough (k8s): DNS → k8s LB IP (LAN traffic goes direct to k8s)
// http/static: DNS → Central IP (HAProxy terminates TLS) // http/static: DNS → Central IP (HAProxy terminates TLS)
// Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected // Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected
@@ -190,26 +191,27 @@ func (api *API) reconcileNetworking() {
} }
var instanceConfigs []config.InstanceConfig var instanceConfigs []config.InstanceConfig
for _, svc := range svcs { for _, dom := range doms {
if svc.Domain == "" { if dom.DomainName == "" {
continue continue
} }
// Choose the DNS target IP based on service type // Choose the DNS target IP based on domain type
dnsIP := centralIP dnsIP := centralIP
if svc.EffectiveBackendType() == services.BackendTCPPassthrough { bt := dom.EffectiveBackendType()
// k8s instances: LAN clients connect directly to the k8s LB if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
dnsIP = extractHost(svc.EffectiveBackendAddress()) // tcp-passthrough/dns-only: DNS resolves to the backend IP directly
dnsIP = extractHost(dom.EffectiveBackendAddress())
} }
ic := config.InstanceConfig{} ic := config.InstanceConfig{}
ic.Cluster.LoadBalancerIp = dnsIP ic.Cluster.LoadBalancerIp = dnsIP
if !svc.Public { if !dom.Public {
// Internal-only: local=/ prevents upstream DNS forwarding // Internal-only: local=/ prevents upstream DNS forwarding
ic.Cloud.InternalDomain = svc.Domain ic.Cloud.InternalDomain = dom.DomainName
} else { } else {
ic.Cloud.Domain = svc.Domain ic.Cloud.Domain = dom.DomainName
} }
instanceConfigs = append(instanceConfigs, ic) instanceConfigs = append(instanceConfigs, ic)
@@ -218,51 +220,55 @@ func (api *API) reconcileNetworking() {
if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil { if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil {
slog.Error("reconcile: failed to update dnsmasq", "error", err) slog.Error("reconcile: failed to update dnsmasq", "error", err)
} else { } else {
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered services") api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains")
} }
// Ensure TLS certs exist for HTTP services (where Central terminates TLS). // Ensure TLS certs exist for HTTP domains (where Central terminates TLS).
// For services under the gateway domain, a wildcard cert covers them all. // For domains under the gateway domain, a wildcard cert covers them all.
// For others, provision individual certs. // For others, provision individual certs.
if len(httpRoutes) > 0 { if len(httpRoutes) > 0 {
api.ensureTLSCerts(globalCfg, svcs) api.ensureTLSCerts(globalCfg, doms)
} }
// Nudge DDNS to pick up any domain changes (public toggle, new domains).
// The runner reads fresh params on each check via its callback.
api.ddns.Trigger()
slog.Info("reconcile: networking updated", slog.Info("reconcile: networking updated",
"services", len(svcs), "domains", len(doms),
"l4Routes", len(instanceRoutes), "l4Routes", len(instanceRoutes),
"l7Routes", len(httpRoutes), "l7Routes", len(httpRoutes),
) )
} }
// ensureTLSCerts logs which certificates are missing for registered services. // ensureTLSCerts logs which certificates are missing for registered domains.
// Does NOT auto-provision — cert provisioning is user-initiated via the // Does NOT auto-provision — cert provisioning is user-initiated via the
// Certificates page. This just logs warnings so the operator knows what's needed. // Certificates page. This just logs warnings so the operator knows what's needed.
func (api *API) ensureTLSCerts(globalCfg *config.GlobalConfig, svcs []services.Service) { func (api *API) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) {
gatewayDomain := "" gatewayDomain := ""
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 { if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1] gatewayDomain = parts[1]
} }
for _, svc := range svcs { for _, dom := range doms {
if svc.TLS != services.TLSTerminate || svc.Domain == "" { if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
continue continue
} }
// Check if covered by wildcard // Check if covered by wildcard
if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) { if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
wildcardCert := certbot.HAProxyCertPath(gatewayDomain) wildcardCert := certbot.HAProxyCertPath(gatewayDomain)
if _, err := os.Stat(wildcardCert); err != nil { if _, err := os.Stat(wildcardCert); err != nil {
slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page", slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page",
"domain", svc.Domain, "needed", "*."+gatewayDomain) "domain", dom.DomainName, "needed", "*."+gatewayDomain)
} }
continue continue
} }
// Check individual cert // Check individual cert
if _, err := os.Stat(certbot.HAProxyCertPath(svc.Domain)); err != nil { if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil {
slog.Warn("reconcile/tls: no cert for service — provision via Certificates page", slog.Warn("reconcile/tls: no cert for domain — provision via Certificates page",
"domain", svc.Domain) "domain", dom.DomainName)
} }
} }
} }
@@ -290,9 +296,6 @@ func hasCertForDomain(certsDir, domain string) bool {
} }
for _, e := range entries { for _, e := range entries {
if strings.HasSuffix(e.Name(), ".pem") { if strings.HasSuffix(e.Name(), ".pem") {
// There's at least one cert — HAProxy can start with the dir bind.
// The specific domain may not have its own cert but HAProxy won't crash.
// It will serve whichever cert matches best (or the first one as default).
return true return true
} }
} }
@@ -324,7 +327,7 @@ func (api *API) broadcastDnsmasqEvent(eventType string, message string) {
event := &sse.Event{ event := &sse.Event{
ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()), ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()),
Type: eventType, Type: eventType,
InstanceName: "global", // dnsmasq is global/central-level, not instance-specific InstanceName: "global",
Timestamp: time.Now(), Timestamp: time.Now(),
Data: map[string]any{ Data: map[string]any{
"message": message, "message": message,

View File

@@ -79,8 +79,10 @@ type DHCPStaticLease struct {
Hostname string `yaml:"hostname,omitempty" json:"hostname,omitempty"` Hostname string `yaml:"hostname,omitempty" json:"hostname,omitempty"`
} }
// GlobalConfig represents the main configuration structure // State represents Wild Central's runtime state — operator settings, networking
type GlobalConfig struct { // configuration, DDNS, DHCP, etc. Persisted in {dataDir}/state.yaml and mutated
// via the API. This is NOT boot-time config (which comes from env vars).
type State struct {
Operator struct { Operator struct {
Email string `yaml:"email,omitempty" json:"email,omitempty"` Email string `yaml:"email,omitempty" json:"email,omitempty"`
} `yaml:"operator,omitempty" json:"operator,omitempty"` } `yaml:"operator,omitempty" json:"operator,omitempty"`
@@ -111,20 +113,19 @@ type GlobalConfig struct {
DDNS struct { DDNS struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
Records []string `yaml:"records,omitempty" json:"records,omitempty"`
IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"` IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"`
} `yaml:"ddns,omitempty" json:"ddns,omitempty"` } `yaml:"ddns,omitempty" json:"ddns,omitempty"`
} `yaml:"cloud,omitempty" json:"cloud,omitempty"` } `yaml:"cloud,omitempty" json:"cloud,omitempty"`
} }
// LoadState loads state from the specified path // LoadState loads state from the specified path
func LoadState(configPath string) (*GlobalConfig, error) { func LoadState(configPath string) (*State, 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)
} }
config := &GlobalConfig{} config := &State{}
if err := yaml.Unmarshal(data, config); err != nil { if err := yaml.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err) return nil, fmt.Errorf("parsing config file: %w", err)
} }
@@ -133,7 +134,7 @@ func LoadState(configPath string) (*GlobalConfig, error) {
} }
// SaveState saves the state to the specified path // SaveState saves the state to the specified path
func SaveState(config *GlobalConfig, configPath string) error { func SaveState(config *State, 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)
@@ -148,7 +149,7 @@ func SaveState(config *GlobalConfig, configPath string) error {
} }
// IsEmpty checks if the configuration is empty or uninitialized // IsEmpty checks if the configuration is empty or uninitialized
func (c *GlobalConfig) IsEmpty() bool { func (c *State) IsEmpty() bool {
if c == nil { if c == nil {
return true return true
} }

View File

@@ -14,7 +14,7 @@ func TestLoadState(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
stateYAML string stateYAML string
verify func(t *testing.T, config *GlobalConfig) verify func(t *testing.T, config *State)
wantErr bool wantErr bool
}{ }{
{ {
@@ -25,7 +25,7 @@ cloud:
central: central:
domain: "central.example.com" domain: "central.example.com"
`, `,
verify: func(t *testing.T, config *GlobalConfig) { verify: func(t *testing.T, config *State) {
if config.Operator.Email != "admin@example.com" { if config.Operator.Email != "admin@example.com" {
t.Error("operator email not loaded correctly") t.Error("operator email not loaded correctly")
} }
@@ -40,7 +40,7 @@ cloud:
stateYAML: `operator: stateYAML: `operator:
email: "admin@example.com" email: "admin@example.com"
`, `,
verify: func(t *testing.T, config *GlobalConfig) { verify: func(t *testing.T, config *State) {
if config.Operator.Email != "admin@example.com" { if config.Operator.Email != "admin@example.com" {
t.Error("operator email not loaded correctly") t.Error("operator email not loaded correctly")
} }
@@ -50,7 +50,7 @@ cloud:
{ {
name: "loads empty state", name: "loads empty state",
stateYAML: "{}\n", stateYAML: "{}\n",
verify: func(t *testing.T, config *GlobalConfig) { verify: func(t *testing.T, config *State) {
if config.Operator.Email != "" { if config.Operator.Email != "" {
t.Error("expected empty operator email") t.Error("expected empty operator email")
} }
@@ -139,13 +139,13 @@ func TestLoadState_Errors(t *testing.T) {
func TestSaveState(t *testing.T) { func TestSaveState(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
config *GlobalConfig config *State
verify func(t *testing.T, statePath string) verify func(t *testing.T, statePath string)
}{ }{
{ {
name: "saves complete state", name: "saves complete state",
config: func() *GlobalConfig { config: func() *State {
cfg := &GlobalConfig{} cfg := &State{}
cfg.Operator.Email = "admin@example.com" cfg.Operator.Email = "admin@example.com"
cfg.Cloud.Central.Domain = "central.example.com" cfg.Cloud.Central.Domain = "central.example.com"
return cfg return cfg
@@ -166,7 +166,7 @@ func TestSaveState(t *testing.T) {
}, },
{ {
name: "saves empty state", name: "saves empty state",
config: &GlobalConfig{}, config: &State{},
verify: func(t *testing.T, statePath string) { verify: func(t *testing.T, statePath string) {
if _, err := os.Stat(statePath); os.IsNotExist(err) { if _, err := os.Stat(statePath); os.IsNotExist(err) {
t.Error("state file not created") t.Error("state file not created")
@@ -221,7 +221,7 @@ func TestSaveState_CreatesDirectory(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
statePath := filepath.Join(tempDir, "nested", "dirs", "state.yaml") statePath := filepath.Join(tempDir, "nested", "dirs", "state.yaml")
config := &GlobalConfig{} config := &State{}
err := SaveState(config, statePath) err := SaveState(config, statePath)
if err != nil { if err != nil {
t.Fatalf("SaveState failed: %v", err) t.Fatalf("SaveState failed: %v", err)
@@ -238,11 +238,11 @@ func TestSaveState_CreatesDirectory(t *testing.T) {
} }
} }
// Test: GlobalConfig.IsEmpty checks if config is empty // Test: State.IsEmpty checks if config is empty
func TestGlobalConfig_IsEmpty(t *testing.T) { func TestState_IsEmpty(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
config *GlobalConfig config *State
want bool want bool
}{ }{
{ {
@@ -252,13 +252,13 @@ func TestGlobalConfig_IsEmpty(t *testing.T) {
}, },
{ {
name: "default config is empty", name: "default config is empty",
config: &GlobalConfig{}, config: &State{},
want: true, want: true,
}, },
{ {
name: "config with only central domain is not empty", name: "config with only central domain is not empty",
config: func() *GlobalConfig { config: func() *State {
cfg := &GlobalConfig{} cfg := &State{}
cfg.Cloud.Central.Domain = "central.example.com" cfg.Cloud.Central.Domain = "central.example.com"
return cfg return cfg
}(), }(),
@@ -266,8 +266,8 @@ func TestGlobalConfig_IsEmpty(t *testing.T) {
}, },
{ {
name: "config with only operator email is not empty", name: "config with only operator email is not empty",
config: func() *GlobalConfig { config: func() *State {
cfg := &GlobalConfig{} cfg := &State{}
cfg.Operator.Email = "admin@example.com" cfg.Operator.Email = "admin@example.com"
return cfg return cfg
}(), }(),
@@ -275,8 +275,8 @@ func TestGlobalConfig_IsEmpty(t *testing.T) {
}, },
{ {
name: "config with all fields is not empty", name: "config with all fields is not empty",
config: func() *GlobalConfig { config: func() *State {
cfg := &GlobalConfig{} cfg := &State{}
cfg.Cloud.Central.Domain = "central.example.com" cfg.Cloud.Central.Domain = "central.example.com"
cfg.Operator.Email = "admin@example.com" cfg.Operator.Email = "admin@example.com"
return cfg return cfg
@@ -477,12 +477,12 @@ func TestSaveCloudConfig(t *testing.T) {
} }
// Test: Round-trip save and load preserves data // Test: Round-trip save and load preserves data
func TestGlobalConfig_RoundTrip(t *testing.T) { func TestState_RoundTrip(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
statePath := filepath.Join(tempDir, "state.yaml") statePath := filepath.Join(tempDir, "state.yaml")
// Create config with all fields // Create config with all fields
original := &GlobalConfig{} original := &State{}
original.Operator.Email = "admin@example.com" original.Operator.Email = "admin@example.com"
original.Cloud.Central.Domain = "central.example.com" original.Cloud.Central.Domain = "central.example.com"

View File

@@ -10,7 +10,7 @@ import (
func TestExtraPortsJSONDecode_Legacy(t *testing.T) { func TestExtraPortsJSONDecode_Legacy(t *testing.T) {
// Legacy format: plain integer array // Legacy format: plain integer array
input := `{"cloud":{"nftables":{"extraPorts":[22]}}}` input := `{"cloud":{"nftables":{"extraPorts":[22]}}}`
var cfg GlobalConfig var cfg State
if err := json.Unmarshal([]byte(input), &cfg); err != nil { if err := json.Unmarshal([]byte(input), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err) t.Fatalf("unmarshal: %v", err)
} }
@@ -22,7 +22,7 @@ func TestExtraPortsJSONDecode_Legacy(t *testing.T) {
func TestExtraPortsJSONDecode_Structured(t *testing.T) { func TestExtraPortsJSONDecode_Structured(t *testing.T) {
// New structured format // New structured format
input := `{"cloud":{"nftables":{"extraPorts":[{"port":22,"protocol":"tcp","label":"SSH"},{"port":5353,"protocol":"udp"}]}}}` input := `{"cloud":{"nftables":{"extraPorts":[{"port":22,"protocol":"tcp","label":"SSH"},{"port":5353,"protocol":"udp"}]}}}`
var cfg GlobalConfig var cfg State
if err := json.Unmarshal([]byte(input), &cfg); err != nil { if err := json.Unmarshal([]byte(input), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err) t.Fatalf("unmarshal: %v", err)
} }
@@ -38,7 +38,7 @@ func TestExtraPortsJSONDecode_Structured(t *testing.T) {
} }
func TestExtraPortsJSONEncode(t *testing.T) { func TestExtraPortsJSONEncode(t *testing.T) {
var cfg GlobalConfig var cfg State
cfg.Cloud.Nftables.ExtraPorts = []ExtraPort{{Port: 22, Protocol: "tcp", Label: "SSH"}} cfg.Cloud.Nftables.ExtraPorts = []ExtraPort{{Port: 22, Protocol: "tcp", Label: "SSH"}}
out, err := json.Marshal(cfg) out, err := json.Marshal(cfg)
if err != nil { if err != nil {
@@ -68,7 +68,7 @@ func TestExtraPortsJSONEncode(t *testing.T) {
func TestExtraPortsYAML_LegacyRoundTrip(t *testing.T) { func TestExtraPortsYAML_LegacyRoundTrip(t *testing.T) {
// Legacy YAML format with plain integers // Legacy YAML format with plain integers
input := "cloud:\n nftables:\n extraPorts:\n - 22\n - 8080\n" input := "cloud:\n nftables:\n extraPorts:\n - 22\n - 8080\n"
var cfg GlobalConfig var cfg State
if err := yaml.Unmarshal([]byte(input), &cfg); err != nil { if err := yaml.Unmarshal([]byte(input), &cfg); err != nil {
t.Fatalf("yaml unmarshal: %v", err) t.Fatalf("yaml unmarshal: %v", err)
} }
@@ -82,7 +82,7 @@ func TestExtraPortsYAML_LegacyRoundTrip(t *testing.T) {
func TestExtraPortsYAML_StructuredRoundTrip(t *testing.T) { func TestExtraPortsYAML_StructuredRoundTrip(t *testing.T) {
input := "cloud:\n nftables:\n extraPorts:\n - port: 22\n protocol: tcp\n label: SSH\n - port: 5353\n protocol: udp\n" input := "cloud:\n nftables:\n extraPorts:\n - port: 22\n protocol: tcp\n label: SSH\n - port: 5353\n protocol: udp\n"
var cfg GlobalConfig var cfg State
if err := yaml.Unmarshal([]byte(input), &cfg); err != nil { if err := yaml.Unmarshal([]byte(input), &cfg); err != nil {
t.Fatalf("yaml unmarshal: %v", err) t.Fatalf("yaml unmarshal: %v", err)
} }

View File

@@ -207,7 +207,7 @@ func (m *Manager) AddDecision(ip, decType, reason, duration string) error {
// DeleteDecision removes a ban decision by numeric ID // DeleteDecision removes a ban decision by numeric ID
func (m *Manager) DeleteDecision(id int) error { func (m *Manager) DeleteDecision(id int) error {
if _, err := runCscli("decisions", "delete", "-i", fmt.Sprintf("%d", id)); err != nil { if _, err := runCscli("decisions", "delete", "--id", fmt.Sprintf("%d", id)); err != nil {
return fmt.Errorf("cscli decisions delete: %w", err) return fmt.Errorf("cscli decisions delete: %w", err)
} }
return nil return nil

View File

@@ -13,14 +13,18 @@ import (
"time" "time"
) )
// Config holds the DDNS configuration // Params holds the runtime parameters for DDNS sync — derived from system state
type Config struct { // (public domains, API token, interval) on each tick via ParamsFunc.
Enabled bool type Params struct {
APIToken string APIToken string
Records []string Records []string
IntervalMinutes int IntervalMinutes int
} }
// ParamsFunc returns the current DDNS parameters. Called on each tick so the
// runner always has fresh state (new tokens, new public domains, etc.).
type ParamsFunc func() Params
// RecordStatus holds the last-known sync result for a single DNS record. // RecordStatus holds the last-known sync result for a single DNS record.
type RecordStatus struct { type RecordStatus struct {
Name string `json:"name"` Name string `json:"name"`
@@ -39,12 +43,15 @@ type Status struct {
Records []RecordStatus `json:"records,omitempty"` Records []RecordStatus `json:"records,omitempty"`
} }
// Runner manages the DDNS background goroutine and exposes current state // Runner manages the DDNS background goroutine and exposes current state.
// The runner pulls fresh parameters via ParamsFunc on each tick, so token
// and record changes take effect without restarting the goroutine.
type Runner struct { type Runner struct {
mu sync.RWMutex mu sync.RWMutex
status Status status Status
trigger chan struct{} trigger chan struct{}
cancel context.CancelFunc cancel context.CancelFunc
paramsFn ParamsFunc
ipFetcher func() (string, error) // injectable for tests ipFetcher func() (string, error) // injectable for tests
} }
@@ -56,27 +63,30 @@ func NewRunner() *Runner {
} }
} }
// Start launches or restarts the DDNS goroutine with the given config. // Start launches the DDNS goroutine with a params callback.
// Safe to call multiple times — cancels the previous run first. // Safe to call multiple times — cancels the previous run first.
// Enabled is set to true here (not inside Run) to eliminate the race where func (rn *Runner) Start(parentCtx context.Context, paramsFn ParamsFunc) {
// the old goroutine's exit could clear Enabled after the new goroutine starts.
func (rn *Runner) Start(parentCtx context.Context, cfg Config) {
rn.mu.Lock() rn.mu.Lock()
if rn.cancel != nil { if rn.cancel != nil {
rn.cancel() rn.cancel()
rn.cancel = nil rn.cancel = nil
} }
if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 { rn.paramsFn = paramsFn
// Validate initial params
p := paramsFn()
if p.APIToken == "" || len(p.Records) == 0 {
rn.status.Enabled = false rn.status.Enabled = false
rn.mu.Unlock() rn.mu.Unlock()
return return
} }
ctx, cancel := context.WithCancel(parentCtx) ctx, cancel := context.WithCancel(parentCtx)
rn.cancel = cancel rn.cancel = cancel
rn.status.Enabled = true rn.status.Enabled = true
rn.status.CurrentIP = "" // clear so first check always syncs all records
rn.mu.Unlock() rn.mu.Unlock()
go rn.Run(ctx, cfg)
go rn.run(ctx)
} }
// Stop cancels the running DDNS goroutine if one is active. // Stop cancels the running DDNS goroutine if one is active.
@@ -97,7 +107,7 @@ func (rn *Runner) GetStatus() Status {
return rn.status return rn.status
} }
// Trigger requests an immediate IP check and update (non-blocking) // Trigger requests an immediate check with fresh params (non-blocking)
func (rn *Runner) Trigger() { func (rn *Runner) Trigger() {
select { select {
case rn.trigger <- struct{}{}: case rn.trigger <- struct{}{}:
@@ -105,23 +115,19 @@ func (rn *Runner) Trigger() {
} }
} }
// Run starts the DDNS polling loop. It exits when ctx is cancelled. // run is the DDNS polling loop. Calls paramsFn on each tick for fresh state.
// Enabled state is managed by Start/Stop, not this function. func (rn *Runner) run(ctx context.Context) {
func (rn *Runner) Run(ctx context.Context, cfg Config) { p := rn.paramsFn()
if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 {
slog.Info("DDNS disabled or not configured", "component", "ddns")
return
}
interval := time.Duration(cfg.IntervalMinutes) * time.Minute interval := time.Duration(p.IntervalMinutes) * time.Minute
if interval <= 0 { if interval <= 0 {
interval = 5 * time.Minute interval = 5 * time.Minute
} }
slog.Info("DDNS started", "component", "ddns", "records", cfg.Records, "interval", interval) slog.Info("DDNS started", "component", "ddns", "records", p.Records, "interval", interval)
// Check immediately on start // Check immediately on start
rn.checkAndUpdate(cfg) rn.checkAndUpdate()
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
@@ -132,21 +138,27 @@ func (rn *Runner) Run(ctx context.Context, cfg Config) {
slog.Info("DDNS stopped", "component", "ddns") slog.Info("DDNS stopped", "component", "ddns")
return return
case <-ticker.C: case <-ticker.C:
rn.checkAndUpdate(cfg) rn.checkAndUpdate()
case <-rn.trigger: case <-rn.trigger:
rn.checkAndUpdate(cfg) rn.checkAndUpdate()
} }
} }
} }
// checkAndUpdate fetches the current public IP and reconciles all records. // checkAndUpdate fetches fresh params, the current public IP, and syncs all records.
// Records are always verified against Cloudflare, not just when the IP changes, // The updateCloudflareRecord function short-circuits when the A record already matches,
// so external drift (e.g. a router DDNS overwriting with a CNAME) is self-healed. // so there's no wasted API calls when nothing has changed.
func (rn *Runner) checkAndUpdate(cfg Config) { func (rn *Runner) checkAndUpdate() {
rn.mu.Lock() rn.mu.Lock()
rn.status.LastChecked = time.Now() rn.status.LastChecked = time.Now()
rn.mu.Unlock() rn.mu.Unlock()
p := rn.paramsFn()
if p.APIToken == "" || len(p.Records) == 0 {
slog.Debug("DDNS: no token or records, skipping", "component", "ddns")
return
}
ip, err := rn.ipFetcher() ip, err := rn.ipFetcher()
if err != nil { if err != nil {
rn.mu.Lock() rn.mu.Lock()
@@ -156,21 +168,12 @@ func (rn *Runner) checkAndUpdate(cfg Config) {
return return
} }
rn.mu.RLock() slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.Records)
ipChanged := rn.status.CurrentIP != ip
rn.mu.RUnlock()
if !ipChanged {
slog.Debug("DDNS: IP unchanged, skipping update", "component", "ddns", "ip", ip)
return
}
slog.Info("DDNS: IP changed, updating records", "component", "ddns", "newIP", ip, "records", cfg.Records)
var lastErr string var lastErr string
records := make([]RecordStatus, 0, len(cfg.Records)) records := make([]RecordStatus, 0, len(p.Records))
for _, record := range cfg.Records { for _, record := range p.Records {
if err := updateCloudflareRecord(cfg.APIToken, record, ip); err != nil { if err := updateCloudflareRecord(p.APIToken, record, ip); err != nil {
lastErr = fmt.Sprintf("updating %s: %v", record, err) lastErr = fmt.Sprintf("updating %s: %v", record, err)
slog.Error("DDNS: failed to update record", "component", "ddns", "record", record, "error", err) slog.Error("DDNS: failed to update record", "component", "ddns", "record", record, "error", err)
records = append(records, RecordStatus{Name: record, OK: false, Error: err.Error()}) records = append(records, RecordStatus{Name: record, OK: false, Error: err.Error()})

View File

@@ -36,78 +36,12 @@ func TestTrigger_NonBlocking(t *testing.T) {
} }
} }
func TestRun_ExitsOnContextCancel(t *testing.T) { func staticParams(token string, records []string) ParamsFunc {
rn := NewRunner() return func() Params {
ctx, cancel := context.WithCancel(context.Background()) return Params{APIToken: token, Records: records, IntervalMinutes: 5}
done := make(chan struct{})
go func() {
rn.Run(ctx, Config{}) // not enabled — returns immediately
close(done)
}()
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Error("Run did not exit after context cancel")
} }
} }
func TestRun_DisabledConfig_ExitsImmediately(t *testing.T) {
rn := NewRunner()
ctx := context.Background()
done := make(chan struct{})
go func() {
rn.Run(ctx, Config{Enabled: false})
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Error("Run should exit immediately when disabled")
}
}
func TestRun_NoToken_ExitsImmediately(t *testing.T) {
rn := NewRunner()
ctx := context.Background()
done := make(chan struct{})
go func() {
rn.Run(ctx, Config{Enabled: true, APIToken: "", Records: []string{"cloud.example.com"}})
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Error("Run should exit immediately when no API token")
}
}
func TestRun_NoRecords_ExitsImmediately(t *testing.T) {
rn := NewRunner()
ctx := context.Background()
done := make(chan struct{})
go func() {
rn.Run(ctx, Config{Enabled: true, APIToken: "token", Records: nil})
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Error("Run should exit immediately when no records configured")
}
}
// TestStart_SetsEnabledStatus verifies Enabled=true after Start, false after Stop.
// Uses Start (not Run directly) since Start owns the Enabled lifecycle.
func TestStart_SetsEnabledStatus(t *testing.T) { func TestStart_SetsEnabledStatus(t *testing.T) {
rn := NewRunner() rn := NewRunner()
started := make(chan struct{}, 1) started := make(chan struct{}, 1)
@@ -119,8 +53,7 @@ func TestStart_SetsEnabledStatus(t *testing.T) {
return "1.2.3.4", nil return "1.2.3.4", nil
} }
ctx := context.Background() rn.Start(context.Background(), staticParams("tok", []string{"a.example.com"}))
rn.Start(ctx, Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}})
select { select {
case <-started: case <-started:
@@ -129,7 +62,7 @@ func TestStart_SetsEnabledStatus(t *testing.T) {
} }
if !rn.GetStatus().Enabled { if !rn.GetStatus().Enabled {
t.Error("expected Enabled=true after Start with valid config") t.Error("expected Enabled=true after Start with valid params")
} }
rn.Stop() rn.Stop()
@@ -139,23 +72,20 @@ func TestStart_SetsEnabledStatus(t *testing.T) {
} }
} }
// TestStart_InvalidConfig_SetsDisabled verifies Enabled=false when Start is called with bad config. func TestStart_InvalidParams_SetsDisabled(t *testing.T) {
func TestStart_InvalidConfig_SetsDisabled(t *testing.T) { for _, pfn := range []ParamsFunc{
for _, cfg := range []Config{ func() Params { return Params{} },
{Enabled: false}, func() Params { return Params{APIToken: ""} },
{Enabled: true, APIToken: ""}, func() Params { return Params{APIToken: "tok", Records: nil} },
{Enabled: true, APIToken: "tok", Records: nil},
} { } {
rn := NewRunner() rn := NewRunner()
rn.Start(context.Background(), cfg) rn.Start(context.Background(), pfn)
if rn.GetStatus().Enabled { if rn.GetStatus().Enabled {
t.Errorf("expected Enabled=false for config %+v", cfg) t.Errorf("expected Enabled=false for empty params")
} }
} }
} }
// TestStart_Restart_KeepsEnabled verifies that calling Start twice does not
// clear Enabled — the race where the old goroutine's exit sets Enabled=false.
func TestStart_Restart_KeepsEnabled(t *testing.T) { func TestStart_Restart_KeepsEnabled(t *testing.T) {
rn := NewRunner() rn := NewRunner()
reached := make(chan struct{}, 2) reached := make(chan struct{}, 2)
@@ -167,11 +97,9 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
return "1.2.3.4", nil return "1.2.3.4", nil
} }
ctx := context.Background() pfn := staticParams("tok", []string{"a.example.com"})
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}} rn.Start(context.Background(), pfn)
rn.Start(ctx, cfg)
// Wait for first goroutine to actually start
select { select {
case <-reached: case <-reached:
case <-time.After(time.Second): case <-time.After(time.Second):
@@ -179,9 +107,8 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
} }
// Restart — old goroutine is cancelled, new one starts // Restart — old goroutine is cancelled, new one starts
rn.Start(ctx, cfg) rn.Start(context.Background(), pfn)
// Enabled must still be true regardless of old goroutine's exit timing
if !rn.GetStatus().Enabled { if !rn.GetStatus().Enabled {
t.Error("expected Enabled=true after restart") t.Error("expected Enabled=true after restart")
} }
@@ -189,8 +116,6 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
rn.Stop() rn.Stop()
} }
// TestStop_ClearsEnabled verifies Stop sets Enabled=false.
func TestStop_ClearsEnabled(t *testing.T) { func TestStop_ClearsEnabled(t *testing.T) {
rn := NewRunner() rn := NewRunner()
rn.mu.Lock() rn.mu.Lock()
@@ -204,7 +129,6 @@ func TestStop_ClearsEnabled(t *testing.T) {
} }
} }
// TestStart_CancelsAndRestarts verifies calling Start twice cancels the previous goroutine.
func TestStart_CancelsAndRestarts(t *testing.T) { func TestStart_CancelsAndRestarts(t *testing.T) {
rn := NewRunner() rn := NewRunner()
@@ -219,10 +143,8 @@ func TestStart_CancelsAndRestarts(t *testing.T) {
return "1.2.3.4", nil return "1.2.3.4", nil
} }
ctx := context.Background() pfn := staticParams("tok", []string{"a.example.com"})
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}} rn.Start(context.Background(), pfn)
rn.Start(ctx, cfg)
select { select {
case <-firstReady: case <-firstReady:
@@ -230,13 +152,12 @@ func TestStart_CancelsAndRestarts(t *testing.T) {
t.Fatal("first goroutine did not start") t.Fatal("first goroutine did not start")
} }
// Unblock the first goroutine so the second Start can cancel it
close(block) close(block)
// Second Start with disabled config must not deadlock // Second Start with disabled params must not deadlock
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
rn.Start(ctx, Config{Enabled: false}) rn.Start(context.Background(), func() Params { return Params{} })
close(done) close(done)
}() }()
@@ -247,59 +168,45 @@ func TestStart_CancelsAndRestarts(t *testing.T) {
} }
} }
// TestCheckAndUpdate_UpdatesCurrentIP verifies CurrentIP is set after IP fetch.
// Note: this calls updateCloudflareRecord which fails with a fake token,
// but CurrentIP is still updated to reflect the fetched IP.
func TestCheckAndUpdate_UpdatesCurrentIP(t *testing.T) { func TestCheckAndUpdate_UpdatesCurrentIP(t *testing.T) {
rn := NewRunner() rn := NewRunner()
rn.ipFetcher = func() (string, error) { return "5.6.7.8", nil } rn.ipFetcher = func() (string, error) { return "5.6.7.8", nil }
rn.paramsFn = staticParams("fake", []string{"a.example.com"})
rn.checkAndUpdate(Config{APIToken: "fake", Records: []string{"a.example.com"}}) rn.checkAndUpdate()
if rn.GetStatus().CurrentIP != "5.6.7.8" { if rn.GetStatus().CurrentIP != "5.6.7.8" {
t.Errorf("expected CurrentIP=5.6.7.8, got %q", rn.GetStatus().CurrentIP) t.Errorf("expected CurrentIP=5.6.7.8, got %q", rn.GetStatus().CurrentIP)
} }
} }
// TestStart_ClearsCurrentIP verifies that Start resets CurrentIP so new records are func TestCheckAndUpdate_SkipsWhenNoParams(t *testing.T) {
// always synced on the first check, even when the public IP hasn't changed.
// This catches the bug where adding a new record (e.g. dev.payne.io) would be
// skipped because currentIP already matched the fetched IP.
func TestStart_ClearsCurrentIP(t *testing.T) {
rn := NewRunner() rn := NewRunner()
rn.mu.Lock() rn.ipFetcher = func() (string, error) { return "1.2.3.4", nil }
rn.status.CurrentIP = "1.2.3.4" // simulates a previously running goroutine rn.paramsFn = func() Params { return Params{} }
rn.mu.Unlock()
rn.Start(context.Background(), Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}) rn.checkAndUpdate()
rn.Stop()
if rn.GetStatus().CurrentIP != "" { if rn.GetStatus().CurrentIP != "" {
t.Error("Start should clear CurrentIP so the first check syncs all records") t.Error("expected empty CurrentIP when params have no token/records")
} }
} }
// TestCheckAndUpdate_SkipsWhenIPUnchanged verifies no Cloudflare call when IP matches. func TestCheckAndUpdate_ParamsFuncCalledEachTime(t *testing.T) {
func TestCheckAndUpdate_SkipsWhenIPUnchanged(t *testing.T) {
rn := NewRunner() rn := NewRunner()
rn.mu.Lock() rn.ipFetcher = func() (string, error) { return "1.2.3.4", nil }
rn.status.CurrentIP = "9.9.9.9"
rn.mu.Unlock()
calls := 0 calls := 0
rn.ipFetcher = func() (string, error) { rn.paramsFn = func() Params {
calls++ calls++
return "9.9.9.9", nil return Params{APIToken: "fake", Records: []string{"a.example.com"}}
} }
prevUpdated := rn.GetStatus().LastUpdated rn.checkAndUpdate()
rn.checkAndUpdate(Config{APIToken: "tok", Records: []string{"a.example.com"}}) rn.checkAndUpdate()
if rn.GetStatus().LastUpdated != prevUpdated { if calls != 2 {
t.Error("LastUpdated should not change when IP is unchanged") t.Errorf("expected paramsFn called 2 times, got %d", calls)
}
if calls != 1 {
t.Errorf("expected 1 ipFetcher call, got %d", calls)
} }
} }

View File

@@ -35,7 +35,7 @@ func (g *ConfigGenerator) GetConfigPath() string {
// Generate creates a dnsmasq configuration from the app config // Generate creates a dnsmasq configuration from the app config
// It auto-detects the Wild Central server's IP address // It auto-detects the Wild Central server's IP address
func (g *ConfigGenerator) Generate(cfg *config.GlobalConfig, clouds []config.InstanceConfig) string { func (g *ConfigGenerator) Generate(cfg *config.State, clouds []config.InstanceConfig) string {
// Use configured dnsmasq IP (bound to eth0), falling back to auto-detect. // Use configured dnsmasq IP (bound to eth0), falling back to auto-detect.
// Auto-detect can pick wlan0 if that's the default route, which is wrong // Auto-detect can pick wlan0 if that's the default route, which is wrong
// when dnsmasq is only listening on eth0. // when dnsmasq is only listening on eth0.
@@ -104,7 +104,7 @@ log-dhcp
} }
// WriteConfig writes the dnsmasq configuration to the specified path // WriteConfig writes the dnsmasq configuration to the specified path
func (g *ConfigGenerator) WriteConfig(cfg *config.GlobalConfig, clouds []config.InstanceConfig, configPath string) error { func (g *ConfigGenerator) WriteConfig(cfg *config.State, clouds []config.InstanceConfig, configPath string) error {
configContent := g.Generate(cfg, clouds) configContent := g.Generate(cfg, clouds)
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath) slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath)
@@ -212,7 +212,7 @@ func (g *ConfigGenerator) ReadConfig() (string, error) {
} }
// UpdateConfig regenerates and writes the dnsmasq configuration for all instances // UpdateConfig regenerates and writes the dnsmasq configuration for all instances
func (g *ConfigGenerator) UpdateConfig(cfg *config.GlobalConfig, instances []config.InstanceConfig, restart bool) error { func (g *ConfigGenerator) UpdateConfig(cfg *config.State, instances []config.InstanceConfig, restart bool) error {
// Generate fresh config from scratch // Generate fresh config from scratch
configContent := g.Generate(cfg, instances) configContent := g.Generate(cfg, instances)

View File

@@ -18,7 +18,7 @@ const (
// GenerateMainConfig creates the main dnsmasq configuration with global settings // GenerateMainConfig creates the main dnsmasq configuration with global settings
// and a conf-dir directive to include per-instance configs // and a conf-dir directive to include per-instance configs
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.GlobalConfig) string { func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
// Get the Wild Central IP address // Get the Wild Central IP address
dnsIP, err := network.GetWildCentralIP() dnsIP, err := network.GetWildCentralIP()
if err != nil { if err != nil {
@@ -264,7 +264,7 @@ func (g *ConfigGenerator) ReloadService() error {
} }
// UpdateToModularConfig migrates from monolithic to modular configuration // UpdateToModularConfig migrates from monolithic to modular configuration
func (g *ConfigGenerator) UpdateToModularConfig(cfg *config.GlobalConfig, instanceNames []string, instances []config.InstanceConfig) error { func (g *ConfigGenerator) UpdateToModularConfig(cfg *config.State, instanceNames []string, instances []config.InstanceConfig) error {
slog.Info("migrating to modular configuration", "component", "dnsmasq") slog.Info("migrating to modular configuration", "component", "dnsmasq")
// Ensure instance directory exists // Ensure instance directory exists

View File

@@ -191,7 +191,7 @@ func TestGenerateMainConfig(t *testing.T) {
// Test: GenerateMainConfig with DHCP disabled does not include dhcp-range // Test: GenerateMainConfig with DHCP disabled does not include dhcp-range
func TestGenerateMainConfig_DHCPDisabled(t *testing.T) { func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{} cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = false cfg.Cloud.Dnsmasq.DHCP.Enabled = false
out := g.GenerateMainConfig(cfg) out := g.GenerateMainConfig(cfg)
@@ -204,7 +204,7 @@ func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
// Test: GenerateMainConfig with DHCP enabled includes dhcp-range // Test: GenerateMainConfig with DHCP enabled includes dhcp-range
func TestGenerateMainConfig_DHCPEnabled(t *testing.T) { func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{} cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
@@ -224,7 +224,7 @@ func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
// Test: DHCP without gateway omits dhcp-option=3 // Test: DHCP without gateway omits dhcp-option=3
func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) { func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{} cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
@@ -240,7 +240,7 @@ func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
// Test: DHCP explicit gateway is used // Test: DHCP explicit gateway is used
func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) { func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{} cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
@@ -256,7 +256,7 @@ func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
// Test: DHCP static leases appear in output // Test: DHCP static leases appear in output
func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) { func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{} cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
@@ -278,7 +278,7 @@ func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
// Test: DHCP lease time defaults to 24h when not specified // Test: DHCP lease time defaults to 24h when not specified
func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) { func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{} cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
@@ -310,7 +310,7 @@ func TestNewConfigGenerator_DefaultPath(t *testing.T) {
// Test: Service registration — domain set but no internal domain // Test: Service registration — domain set but no internal domain
func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) { func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{} globalCfg := &config.State{}
// Service registration: only domain + backend IP, no internal domain // Service registration: only domain + backend IP, no internal domain
inst := instanceWith("my-api.payne.io", "", "192.168.8.151") inst := instanceWith("my-api.payne.io", "", "192.168.8.151")
@@ -351,7 +351,7 @@ func TestGenerateInstanceConfig_ServiceWithoutInternalDomain(t *testing.T) {
// Test: reach:internal → local=/ directive present (prevents upstream DNS forwarding) // Test: reach:internal → local=/ directive present (prevents upstream DNS forwarding)
func TestGenerate_ReachInternal_HasLocal(t *testing.T) { func TestGenerate_ReachInternal_HasLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{} globalCfg := &config.State{}
// Simulate reconciliation for an internal-reach service: // Simulate reconciliation for an internal-reach service:
// InternalDomain is set (reach:internal), Domain is empty (reach:public) // InternalDomain is set (reach:internal), Domain is empty (reach:public)
@@ -370,7 +370,7 @@ func TestGenerate_ReachInternal_HasLocal(t *testing.T) {
// Test: reach:public → no local=/ directive (allows upstream DNS forwarding) // Test: reach:public → no local=/ directive (allows upstream DNS forwarding)
func TestGenerate_ReachPublic_NoLocal(t *testing.T) { func TestGenerate_ReachPublic_NoLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{} globalCfg := &config.State{}
// Simulate reconciliation for a public-reach service: // Simulate reconciliation for a public-reach service:
// Domain is set (reach:public), InternalDomain is empty // Domain is set (reach:public), InternalDomain is empty
@@ -389,7 +389,7 @@ func TestGenerate_ReachPublic_NoLocal(t *testing.T) {
// Test: InternalDomain field → local=/ entry for LAN-only resolution // Test: InternalDomain field → local=/ entry for LAN-only resolution
func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) { func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{} globalCfg := &config.State{}
// Instance with only an internal domain (no external domain) // Instance with only an internal domain (no external domain)
inst := instanceWith("", "internal.cloud.payne.io", "192.168.8.240") inst := instanceWith("", "internal.cloud.payne.io", "192.168.8.240")
@@ -411,7 +411,7 @@ func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) {
// Test: Mix of instances with and without internal domains // Test: Mix of instances with and without internal domains
func TestGenerate_MixedServicesAndInstances(t *testing.T) { func TestGenerate_MixedServicesAndInstances(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{} globalCfg := &config.State{}
instances := []config.InstanceConfig{ instances := []config.InstanceConfig{
instanceWith("cloud.payne.io", "internal.cloud.payne.io", "192.168.8.240"), // k8s instance instanceWith("cloud.payne.io", "internal.cloud.payne.io", "192.168.8.240"), // k8s instance
@@ -438,7 +438,7 @@ func TestGenerate_MixedServicesAndInstances(t *testing.T) {
// Test: empty instances list produces base config with no address= entries // Test: empty instances list produces base config with no address= entries
func TestGenerate_EmptyInstances(t *testing.T) { func TestGenerate_EmptyInstances(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{} globalCfg := &config.State{}
out := g.Generate(globalCfg, []config.InstanceConfig{}) out := g.Generate(globalCfg, []config.InstanceConfig{})
@@ -462,7 +462,7 @@ func TestGenerate_EmptyInstances(t *testing.T) {
// Test: multiple instances each get their own entries // Test: multiple instances each get their own entries
func TestGenerate_MultipleInstances(t *testing.T) { func TestGenerate_MultipleInstances(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{} globalCfg := &config.State{}
instances := []config.InstanceConfig{ instances := []config.InstanceConfig{
instanceWith("cloud1.example.com", "internal1.example.com", "10.0.0.1"), instanceWith("cloud1.example.com", "internal1.example.com", "10.0.0.1"),
@@ -504,7 +504,7 @@ func TestGenerate_NilConfig(t *testing.T) {
// Test: when cfg.Cloud.Dnsmasq.IP is set, uses that instead of auto-detect // Test: when cfg.Cloud.Dnsmasq.IP is set, uses that instead of auto-detect
func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) { func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{} cfg := &config.State{}
cfg.Cloud.Dnsmasq.IP = "192.168.8.100" cfg.Cloud.Dnsmasq.IP = "192.168.8.100"
out := g.Generate(cfg, []config.InstanceConfig{}) out := g.Generate(cfg, []config.InstanceConfig{})
@@ -517,7 +517,7 @@ func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
// Test: verify no address=// or local=// entries appear in any test output // Test: verify no address=// or local=// entries appear in any test output
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) { func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{} cfg := &config.State{}
cases := []struct { cases := []struct {
name string name string

View File

@@ -1,10 +1,10 @@
// Package services manages service registrations from Wild Cloud, Wild Works, // Package domains manages domain registrations from Wild Cloud, Wild Works,
// and manual entries. Services register with Central to get DNS, gateway // and manual entries. Domains register with Central to get DNS, gateway
// routing, TLS certificates, and optional public exposure via DDNS. // routing, TLS certificates, and optional public exposure via DDNS.
// //
// Each registration is keyed by its domain — one registration per domain. // Each registration is keyed by its domain — one registration per domain.
// Central registers its own UI domain here on startup (source: "central"). // Central registers its own UI domain here on startup (source: "central").
package services package domains
import ( import (
"fmt" "fmt"
@@ -17,36 +17,38 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// BackendType describes how the gateway handles traffic for this service. // BackendType describes how the gateway handles traffic for this domain.
type BackendType string type BackendType string
const ( const (
BackendTCPPassthrough BackendType = "tcp-passthrough" // L4 SNI passthrough (k8s) BackendTCPPassthrough BackendType = "tcp-passthrough" // L4 SNI passthrough (k8s)
BackendHTTP BackendType = "http" // L7 HTTP reverse proxy BackendHTTP BackendType = "http" // L7 HTTP reverse proxy
BackendDNSOnly BackendType = "dns-only" // DNS resolution only, no gateway
) )
// TLSMode describes how TLS is handled for the service. // TLSMode describes how TLS is handled for the domain.
type TLSMode string type TLSMode string
const ( const (
TLSTerminate TLSMode = "terminate" // Central terminates TLS TLSTerminate TLSMode = "terminate" // Central terminates TLS
TLSPassthrough TLSMode = "passthrough" // Backend handles TLS (k8s traefik) TLSPassthrough TLSMode = "passthrough" // Backend handles TLS (k8s traefik)
TLSNone TLSMode = "none" // No TLS (dns-only)
) )
// Backend describes the target for a service. // Backend describes the target for a domain.
type Backend struct { type Backend struct {
Address string `yaml:"address" json:"address"` // host:port Address string `yaml:"address" json:"address"` // host:port
Type BackendType `yaml:"type" json:"type"` // tcp-passthrough or http Type BackendType `yaml:"type" json:"type"` // tcp-passthrough or http
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. // HeaderConfig describes custom request/response headers for L7 HTTP domains.
type HeaderConfig struct { type HeaderConfig struct {
Request map[string]string `yaml:"request,omitempty" json:"request,omitempty"` Request map[string]string `yaml:"request,omitempty" json:"request,omitempty"`
Response map[string]string `yaml:"response,omitempty" json:"response,omitempty"` Response map[string]string `yaml:"response,omitempty" json:"response,omitempty"`
} }
// Route describes one path→backend mapping within a service. // Route describes one path→backend mapping within a domain.
// Routes are evaluated in order — first match wins. // Routes are evaluated in order — first match wins.
// A route with empty Paths is a catch-all (should be last). // A route with empty Paths is a catch-all (should be last).
type Route struct { type Route struct {
@@ -56,12 +58,12 @@ type Route struct {
IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist
} }
// Service represents a registered service. The Domain is the unique key. // Domain represents a registered domain. The Domain field is the unique key.
// //
// Simple services use Backend directly. Multi-backend services use Routes // Simple domains use Backend directly. Multi-backend domains use Routes
// for path-based splitting (Backend is ignored when Routes is present). // for path-based splitting (Backend is ignored when Routes is present).
type Service struct { type Domain struct {
Domain string `yaml:"domain" json:"domain"` // FQDN — unique key DomainName 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,omitempty" json:"backend,omitempty"` // single backend (simple case) 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
@@ -73,58 +75,71 @@ type Service struct {
Routes []Route `yaml:"routes,omitempty" json:"routes,omitempty"` Routes []Route `yaml:"routes,omitempty" json:"routes,omitempty"`
} }
// EffectiveRoutes returns the service's routing as a normalized []Route. // EffectiveRoutes returns the domain's routing as a normalized []Route.
// Simple services (Backend, no Routes) are wrapped in a single-element slice. // Simple domains (Backend, no Routes) are wrapped in a single-element slice.
func (s *Service) EffectiveRoutes() []Route { func (d *Domain) EffectiveRoutes() []Route {
if len(s.Routes) > 0 { if len(d.Routes) > 0 {
return s.Routes return d.Routes
} }
if s.Backend.Address == "" { if d.Backend.Address == "" {
return nil return nil
} }
return []Route{{Backend: s.Backend}} return []Route{{Backend: d.Backend}}
} }
// EffectiveBackendAddress returns the primary backend address for DNS/DDNS purposes. // EffectiveBackendAddress returns the primary backend address for DNS/DDNS purposes.
// For multi-route services, returns the first route's backend. // For multi-route domains, returns the first route's backend.
func (s *Service) EffectiveBackendAddress() string { func (d *Domain) EffectiveBackendAddress() string {
if len(s.Routes) > 0 { if len(d.Routes) > 0 {
return s.Routes[0].Backend.Address return d.Routes[0].Backend.Address
} }
return s.Backend.Address return d.Backend.Address
} }
// EffectiveBackendType returns the backend type for routing decisions. // EffectiveBackendType returns the backend type for routing decisions.
func (s *Service) EffectiveBackendType() BackendType { func (d *Domain) EffectiveBackendType() BackendType {
if len(s.Routes) > 0 { if len(d.Routes) > 0 {
return s.Routes[0].Backend.Type return d.Routes[0].Backend.Type
} }
return s.Backend.Type return d.Backend.Type
} }
// Manager handles service registration CRUD and triggers networking reconciliation. // Manager handles domain registration CRUD and triggers networking reconciliation.
type Manager struct { type Manager struct {
dataDir string dataDir string
mu sync.RWMutex mu sync.RWMutex
reconcileFn func() reconcileFn func()
} }
// NewManager creates a new service registration manager. // NewManager creates a new domain registration manager.
func NewManager(dataDir string) *Manager { func NewManager(dataDir string) *Manager {
servicesDir := filepath.Join(dataDir, "services") domainsDir := filepath.Join(dataDir, "domains")
if err := os.MkdirAll(servicesDir, 0755); err != nil { oldServicesDir := filepath.Join(dataDir, "services")
slog.Warn("failed to create services directory", "path", servicesDir, "error", err)
// Migrate from old "services" directory if it exists
if _, err := os.Stat(oldServicesDir); err == nil {
if _, err := os.Stat(domainsDir); os.IsNotExist(err) {
if err := os.Rename(oldServicesDir, domainsDir); err != nil {
slog.Warn("failed to migrate services dir to domains", "error", err)
} else {
slog.Info("migrated data directory", "from", oldServicesDir, "to", domainsDir)
}
}
}
if err := os.MkdirAll(domainsDir, 0755); err != nil {
slog.Warn("failed to create domains directory", "path", domainsDir, "error", err)
} }
return &Manager{dataDir: dataDir} return &Manager{dataDir: dataDir}
} }
// SetReconcileFn sets the function called after service changes to update networking. // SetReconcileFn sets the function called after domain changes to update networking.
func (m *Manager) SetReconcileFn(fn func()) { func (m *Manager) SetReconcileFn(fn func()) {
m.reconcileFn = fn m.reconcileFn = fn
} }
func (m *Manager) servicesDir() string { func (m *Manager) domainsDir() string {
return filepath.Join(m.dataDir, "services") return filepath.Join(m.dataDir, "domains")
} }
// domainToFilename converts a domain to a filesystem-safe filename. // domainToFilename converts a domain to a filesystem-safe filename.
@@ -132,31 +147,31 @@ func domainToFilename(domain string) string {
return strings.ReplaceAll(domain, ".", "_") + ".yaml" return strings.ReplaceAll(domain, ".", "_") + ".yaml"
} }
func (m *Manager) servicePath(domain string) string { func (m *Manager) domainPath(domain string) string {
return filepath.Join(m.servicesDir(), domainToFilename(domain)) return filepath.Join(m.domainsDir(), domainToFilename(domain))
} }
// Register creates or updates a service registration. // Register creates or updates a domain registration.
func (m *Manager) Register(svc Service) error { func (m *Manager) Register(dom Domain) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if svc.Domain == "" { if dom.DomainName == "" {
return fmt.Errorf("domain is required") return fmt.Errorf("domain is required")
} }
hasBackend := svc.Backend.Address != "" hasBackend := dom.Backend.Address != ""
hasRoutes := len(svc.Routes) > 0 hasRoutes := len(dom.Routes) > 0
if hasBackend && hasRoutes { if hasBackend && hasRoutes {
return fmt.Errorf("service must use backend or routes, not both") return fmt.Errorf("domain must use backend or routes, not both")
} }
if !hasBackend && !hasRoutes { if !hasBackend && !hasRoutes {
return fmt.Errorf("backend address or routes required") return fmt.Errorf("backend address or routes required")
} }
if hasRoutes { if hasRoutes {
for i, r := range svc.Routes { for i, r := range dom.Routes {
if r.Backend.Address == "" { if r.Backend.Address == "" {
return fmt.Errorf("route %d: backend address is required", i) return fmt.Errorf("route %d: backend address is required", i)
} }
@@ -165,36 +180,38 @@ func (m *Manager) Register(svc Service) error {
} }
} }
} else { } else {
if svc.Backend.Type == "" { if dom.Backend.Type == "" {
return fmt.Errorf("backend type is required") return fmt.Errorf("backend type is required")
} }
} }
// Default TLS mode based on backend type // Default TLS mode based on backend type
if svc.TLS == "" { if dom.TLS == "" {
bt := svc.EffectiveBackendType() switch dom.EffectiveBackendType() {
if bt == BackendTCPPassthrough { case BackendTCPPassthrough:
svc.TLS = TLSPassthrough dom.TLS = TLSPassthrough
} else { case BackendDNSOnly:
svc.TLS = TLSTerminate dom.TLS = TLSNone
default:
dom.TLS = TLSTerminate
} }
} }
// Default source // Default source
if svc.Source == "" { if dom.Source == "" {
svc.Source = "manual" dom.Source = "manual"
} }
data, err := yaml.Marshal(svc) data, err := yaml.Marshal(dom)
if err != nil { if err != nil {
return fmt.Errorf("marshaling service: %w", err) return fmt.Errorf("marshaling domain: %w", err)
} }
if err := os.WriteFile(m.servicePath(svc.Domain), data, 0644); err != nil { if err := os.WriteFile(m.domainPath(dom.DomainName), data, 0644); err != nil {
return fmt.Errorf("writing service file: %w", err) return fmt.Errorf("writing domain file: %w", err)
} }
slog.Info("service registered", "domain", svc.Domain, "public", svc.Public, "source", svc.Source, "subdomains", svc.Subdomains) slog.Info("domain registered", "domain", dom.DomainName, "public", dom.Public, "source", dom.Source, "subdomains", dom.Subdomains)
if m.reconcileFn != nil { if m.reconcileFn != nil {
go func() { go func() {
@@ -210,21 +227,21 @@ func (m *Manager) Register(svc Service) error {
return nil return nil
} }
// Deregister removes a service registration by domain. // Deregister removes a domain registration by domain name.
func (m *Manager) Deregister(domain string) error { func (m *Manager) Deregister(domain string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
path := m.servicePath(domain) path := m.domainPath(domain)
if _, err := os.Stat(path); os.IsNotExist(err) { if _, err := os.Stat(path); os.IsNotExist(err) {
return fmt.Errorf("service %q not found", domain) return fmt.Errorf("domain %q not found", domain)
} }
if err := os.Remove(path); err != nil { if err := os.Remove(path); err != nil {
return fmt.Errorf("removing service file: %w", err) return fmt.Errorf("removing domain file: %w", err)
} }
slog.Info("service deregistered", "domain", domain) slog.Info("domain deregistered", "domain", domain)
if m.reconcileFn != nil { if m.reconcileFn != nil {
go m.reconcileFn() go m.reconcileFn()
@@ -233,76 +250,76 @@ func (m *Manager) Deregister(domain string) error {
return nil return nil
} }
// Get retrieves a service registration by domain. // Get retrieves a domain registration by domain name.
func (m *Manager) Get(domain string) (*Service, error) { func (m *Manager) Get(domain string) (*Domain, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
data, err := os.ReadFile(m.servicePath(domain)) data, err := os.ReadFile(m.domainPath(domain))
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, fmt.Errorf("service %q not found", domain) return nil, fmt.Errorf("domain %q not found", domain)
} }
return nil, fmt.Errorf("reading service file: %w", err) return nil, fmt.Errorf("reading domain file: %w", err)
} }
var svc Service var dom Domain
if err := yaml.Unmarshal(data, &svc); err != nil { if err := yaml.Unmarshal(data, &dom); err != nil {
return nil, fmt.Errorf("parsing service file: %w", err) return nil, fmt.Errorf("parsing domain file: %w", err)
} }
return &svc, nil return &dom, nil
} }
// List returns all registered services. // List returns all registered domains.
func (m *Manager) List() ([]Service, error) { func (m *Manager) List() ([]Domain, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
entries, err := os.ReadDir(m.servicesDir()) entries, err := os.ReadDir(m.domainsDir())
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("reading services directory: %w", err) return nil, fmt.Errorf("reading domains directory: %w", err)
} }
var services []Service var doms []Domain
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
continue continue
} }
data, err := os.ReadFile(filepath.Join(m.servicesDir(), entry.Name())) data, err := os.ReadFile(filepath.Join(m.domainsDir(), entry.Name()))
if err != nil { if err != nil {
slog.Warn("failed to read service file", "file", entry.Name(), "error", err) slog.Warn("failed to read domain file", "file", entry.Name(), "error", err)
continue continue
} }
var svc Service var dom Domain
if err := yaml.Unmarshal(data, &svc); err != nil { if err := yaml.Unmarshal(data, &dom); err != nil {
slog.Warn("failed to parse service file", "file", entry.Name(), "error", err) slog.Warn("failed to parse domain file", "file", entry.Name(), "error", err)
continue continue
} }
services = append(services, svc) doms = append(doms, dom)
} }
return services, nil return doms, nil
} }
// DeregisterBySource removes all registrations from a given source that match // DeregisterBySource removes all registrations from a given source that match
// a backend address. Used by consumers to clean up before re-registering. // a backend address. Used by consumers to clean up before re-registering.
func (m *Manager) DeregisterBySource(source, backendAddress string) error { func (m *Manager) DeregisterBySource(source, backendAddress string) error {
svcs, err := m.List() doms, err := m.List()
if err != nil { if err != nil {
return err return err
} }
for _, svc := range svcs { for _, dom := range doms {
if svc.Source == source && svc.EffectiveBackendAddress() == backendAddress { if dom.Source == source && dom.EffectiveBackendAddress() == backendAddress {
if err := m.Deregister(svc.Domain); err != nil { if err := m.Deregister(dom.DomainName); err != nil {
slog.Warn("failed to deregister service during cleanup", "domain", svc.Domain, "error", err) slog.Warn("failed to deregister domain during cleanup", "domain", dom.DomainName, "error", err)
} }
} }
} }
@@ -310,32 +327,33 @@ func (m *Manager) DeregisterBySource(source, backendAddress string) error {
return nil return nil
} }
// Update applies partial updates to a service registration. // Update applies partial updates to a domain registration.
func (m *Manager) Update(domain string, updates map[string]any) error { func (m *Manager) Update(domain string, updates map[string]any) error {
svc, err := m.Get(domain) dom, err := m.Get(domain)
if err != nil { if err != nil {
return err return err
} }
if public, ok := updates["public"].(bool); ok { if public, ok := updates["public"].(bool); ok {
svc.Public = public dom.Public = public
} }
if sub, ok := updates["subdomains"].(bool); ok { if sub, ok := updates["subdomains"].(bool); ok {
svc.Subdomains = sub dom.Subdomains = sub
} }
if backend, ok := updates["backend"].(map[string]any); ok { if backend, ok := updates["backend"].(map[string]any); ok {
if addr, ok := backend["address"].(string); ok { if addr, ok := backend["address"].(string); ok {
svc.Backend.Address = addr dom.Backend.Address = addr
} }
if typ, ok := backend["type"].(string); ok { if typ, ok := backend["type"].(string); ok {
svc.Backend.Type = BackendType(typ) dom.Backend.Type = BackendType(typ)
} }
if health, ok := backend["health"].(string); ok { if health, ok := backend["health"].(string); ok {
svc.Backend.Health = health dom.Backend.Health = health
} }
} }
// Routes is a full replacement — partial route updates are too complex if tls, ok := updates["tls"].(string); ok {
// to express via map[string]any. Re-register the full service instead. dom.TLS = TLSMode(tls)
}
return m.Register(*svc) return m.Register(*dom)
} }

View File

@@ -1,4 +1,4 @@
package services package domains
import ( import (
"os" "os"
@@ -9,17 +9,16 @@ import (
func TestRegisterAndGet(t *testing.T) { func TestRegisterAndGet(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
svc := Service{ dom := Domain{
Domain: "my-api.payne.io", DomainName: "my-api.payne.io",
Source: "wild-works", Source: "wild-works",
Backend: Backend{ Backend: Backend{
Address: "192.168.8.60:9001", Address: "192.168.8.60:9001",
Type: BackendHTTP, Type: BackendHTTP,
}, },
// Public defaults to false
} }
if err := mgr.Register(svc); err != nil { if err := mgr.Register(dom); err != nil {
t.Fatalf("Register failed: %v", err) t.Fatalf("Register failed: %v", err)
} }
@@ -28,8 +27,8 @@ func TestRegisterAndGet(t *testing.T) {
t.Fatalf("Get failed: %v", err) t.Fatalf("Get failed: %v", err)
} }
if got.Domain != "my-api.payne.io" { if got.DomainName != "my-api.payne.io" {
t.Errorf("expected domain my-api.payne.io, got %s", got.Domain) t.Errorf("expected domain my-api.payne.io, got %s", got.DomainName)
} }
if got.Backend.Address != "192.168.8.60:9001" { if got.Backend.Address != "192.168.8.60:9001" {
t.Errorf("expected backend 192.168.8.60:9001, got %s", got.Backend.Address) t.Errorf("expected backend 192.168.8.60:9001, got %s", got.Backend.Address)
@@ -45,8 +44,8 @@ func TestRegisterAndGet(t *testing.T) {
func TestRegisterTCPPassthrough(t *testing.T) { func TestRegisterTCPPassthrough(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
svc := Service{ dom := Domain{
Domain: "cloud.payne.io", DomainName: "cloud.payne.io",
Source: "wild-cloud", Source: "wild-cloud",
Backend: Backend{ Backend: Backend{
Address: "192.168.8.240:443", Address: "192.168.8.240:443",
@@ -56,7 +55,7 @@ func TestRegisterTCPPassthrough(t *testing.T) {
Public: true, Public: true,
} }
if err := mgr.Register(svc); err != nil { if err := mgr.Register(dom); err != nil {
t.Fatalf("Register failed: %v", err) t.Fatalf("Register failed: %v", err)
} }
@@ -76,37 +75,35 @@ func TestRegisterTCPPassthrough(t *testing.T) {
} }
} }
func TestListServices(t *testing.T) { func TestListDomains(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
for _, domain := range []string{"a.example.com", "b.example.com", "c.example.com"} { for _, domain := range []string{"a.example.com", "b.example.com", "c.example.com"} {
if err := mgr.Register(Service{ if err := mgr.Register(Domain{
Domain: domain, DomainName: domain,
Source: "test", Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
}); err != nil { }); err != nil {
t.Fatalf("Register %s failed: %v", domain, err) t.Fatalf("Register %s failed: %v", domain, err)
} }
} }
svcs, err := mgr.List() doms, err := mgr.List()
if err != nil { if err != nil {
t.Fatalf("List failed: %v", err) t.Fatalf("List failed: %v", err)
} }
if len(svcs) != 3 { if len(doms) != 3 {
t.Errorf("expected 3 services, got %d", len(svcs)) t.Errorf("expected 3 domains, got %d", len(doms))
} }
} }
func TestDeregister(t *testing.T) { func TestDeregister(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
if err := mgr.Register(Service{ if err := mgr.Register(Domain{
Domain: "temp.example.com", DomainName: "temp.example.com",
Source: "test", Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
}); err != nil { }); err != nil {
t.Fatalf("Register failed: %v", err) t.Fatalf("Register failed: %v", err)
} }
@@ -123,45 +120,43 @@ func TestDeregister(t *testing.T) {
func TestDeregisterBySource(t *testing.T) { func TestDeregisterBySource(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
// Register 3 services from wild-cloud with same backend // Register 3 domains from wild-cloud with same backend
for _, domain := range []string{"cloud.payne.io", "internal.cloud.payne.io", "payne.io"} { for _, domain := range []string{"cloud.payne.io", "internal.cloud.payne.io", "payne.io"} {
mgr.Register(Service{ mgr.Register(Domain{
Domain: domain, DomainName: domain,
Source: "wild-cloud", Source: "wild-cloud",
Backend: Backend{Address: "192.168.8.240:443", Type: BackendTCPPassthrough}, Backend: Backend{Address: "192.168.8.240:443", Type: BackendTCPPassthrough},
Public: true, Public: true,
}) })
} }
// Register 1 service from wild-works (different source) // Register 1 domain from wild-works (different source)
mgr.Register(Service{ mgr.Register(Domain{
Domain: "my-api.payne.io", DomainName: "my-api.payne.io",
Source: "wild-works", Source: "wild-works",
Backend: Backend{Address: "127.0.0.1:9001", Type: BackendHTTP}, Backend: Backend{Address: "127.0.0.1:9001", Type: BackendHTTP},
// Public defaults to false
}) })
// Deregister all wild-cloud services with backend 192.168.8.240:443 // Deregister all wild-cloud domains with backend 192.168.8.240:443
if err := mgr.DeregisterBySource("wild-cloud", "192.168.8.240:443"); err != nil { if err := mgr.DeregisterBySource("wild-cloud", "192.168.8.240:443"); err != nil {
t.Fatalf("DeregisterBySource failed: %v", err) t.Fatalf("DeregisterBySource failed: %v", err)
} }
svcs, _ := mgr.List() doms, _ := mgr.List()
if len(svcs) != 1 { if len(doms) != 1 {
t.Errorf("expected 1 remaining service, got %d", len(svcs)) t.Errorf("expected 1 remaining domain, got %d", len(doms))
} }
if svcs[0].Domain != "my-api.payne.io" { if doms[0].DomainName != "my-api.payne.io" {
t.Errorf("expected wild-works service to remain, got %s", svcs[0].Domain) t.Errorf("expected wild-works domain to remain, got %s", doms[0].DomainName)
} }
} }
func TestUpdate(t *testing.T) { func TestUpdate(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
mgr.Register(Service{ mgr.Register(Domain{
Domain: "updatable.example.com", DomainName: "updatable.example.com",
Source: "test", Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
}) })
if err := mgr.Update("updatable.example.com", map[string]any{ if err := mgr.Update("updatable.example.com", map[string]any{
@@ -184,17 +179,17 @@ func TestRegisterValidation(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
// Missing domain // Missing domain
if err := mgr.Register(Service{Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}}); err == nil { if err := mgr.Register(Domain{Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}}); err == nil {
t.Error("expected error for missing domain") t.Error("expected error for missing domain")
} }
// Missing backend address // Missing backend address
if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Type: BackendHTTP}}); err == nil { if err := mgr.Register(Domain{DomainName: "x.com", Backend: Backend{Type: BackendHTTP}}); err == nil {
t.Error("expected error for missing backend address") t.Error("expected error for missing backend address")
} }
// Missing backend type // Missing backend type
if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Address: "127.0.0.1:80"}}); err == nil { if err := mgr.Register(Domain{DomainName: "x.com", Backend: Backend{Address: "127.0.0.1:80"}}); err == nil {
t.Error("expected error for missing backend type") t.Error("expected error for missing backend type")
} }
} }
@@ -202,10 +197,9 @@ func TestRegisterValidation(t *testing.T) {
func TestDefaultSource(t *testing.T) { func TestDefaultSource(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
mgr.Register(Service{ mgr.Register(Domain{
Domain: "test.com", DomainName: "test.com",
Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}, Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP},
// Public defaults to false
}) })
got, _ := mgr.Get("test.com") got, _ := mgr.Get("test.com")
@@ -217,31 +211,30 @@ func TestDefaultSource(t *testing.T) {
func TestRegisterIdempotent(t *testing.T) { func TestRegisterIdempotent(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
svc := Service{ dom := Domain{
Domain: "idempotent.example.com", DomainName: "idempotent.example.com",
Source: "test", Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
} }
// Register twice with same domain // Register twice with same domain
if err := mgr.Register(svc); err != nil { if err := mgr.Register(dom); err != nil {
t.Fatalf("first Register failed: %v", err) t.Fatalf("first Register failed: %v", err)
} }
// Update the backend address on second registration // Update the backend address on second registration
svc.Backend.Address = "127.0.0.1:9090" dom.Backend.Address = "127.0.0.1:9090"
if err := mgr.Register(svc); err != nil { if err := mgr.Register(dom); err != nil {
t.Fatalf("second Register failed: %v", err) t.Fatalf("second Register failed: %v", err)
} }
// Should still have only one service // Should still have only one domain
svcs, err := mgr.List() doms, err := mgr.List()
if err != nil { if err != nil {
t.Fatalf("List failed: %v", err) t.Fatalf("List failed: %v", err)
} }
if len(svcs) != 1 { if len(doms) != 1 {
t.Errorf("expected 1 service after idempotent register, got %d", len(svcs)) t.Errorf("expected 1 domain after idempotent register, got %d", len(doms))
} }
// Should have the updated address // Should have the updated address
@@ -257,50 +250,48 @@ func TestRegisterIdempotent(t *testing.T) {
func TestListNoDuplicates(t *testing.T) { func TestListNoDuplicates(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
domains := []string{ domainNames := []string{
"alpha.example.com", "alpha.example.com",
"beta.example.com", "beta.example.com",
"gamma.example.com", "gamma.example.com",
} }
// Register each domain // Register each domain
for _, domain := range domains { for _, name := range domainNames {
if err := mgr.Register(Service{ if err := mgr.Register(Domain{
Domain: domain, DomainName: name,
Source: "test", Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
}); err != nil { }); err != nil {
t.Fatalf("Register %s failed: %v", domain, err) t.Fatalf("Register %s failed: %v", name, err)
} }
} }
// Re-register one domain to verify no duplication // Re-register one domain to verify no duplication
if err := mgr.Register(Service{ if err := mgr.Register(Domain{
Domain: "beta.example.com", DomainName: "beta.example.com",
Source: "test", Source: "test",
Backend: Backend{Address: "127.0.0.1:9090", Type: BackendHTTP}, Backend: Backend{Address: "127.0.0.1:9090", Type: BackendHTTP},
// Public defaults to false
}); err != nil { }); err != nil {
t.Fatalf("Re-register beta failed: %v", err) t.Fatalf("Re-register beta failed: %v", err)
} }
svcs, err := mgr.List() doms, err := mgr.List()
if err != nil { if err != nil {
t.Fatalf("List failed: %v", err) t.Fatalf("List failed: %v", err)
} }
if len(svcs) != 3 { if len(doms) != 3 {
t.Errorf("expected 3 unique services, got %d", len(svcs)) t.Errorf("expected 3 unique domains, got %d", len(doms))
} }
// Verify no duplicate domains // Verify no duplicate domains
seen := make(map[string]bool) seen := make(map[string]bool)
for _, svc := range svcs { for _, dom := range doms {
if seen[svc.Domain] { if seen[dom.DomainName] {
t.Errorf("duplicate domain in List(): %s", svc.Domain) t.Errorf("duplicate domain in List(): %s", dom.DomainName)
} }
seen[svc.Domain] = true seen[dom.DomainName] = true
} }
} }
@@ -343,18 +334,16 @@ func TestDeregister_NotFound(t *testing.T) {
func TestDeregisterBySource_NoMatch(t *testing.T) { func TestDeregisterBySource_NoMatch(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
// Register some services mgr.Register(Domain{
mgr.Register(Service{ DomainName: "a.example.com",
Domain: "a.example.com",
Source: "wild-cloud", Source: "wild-cloud",
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough}, Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
Public: true, Public: true,
}) })
mgr.Register(Service{ mgr.Register(Domain{
Domain: "b.example.com", DomainName: "b.example.com",
Source: "wild-works", Source: "wild-works",
Backend: Backend{Address: "10.0.0.2:8080", Type: BackendHTTP}, Backend: Backend{Address: "10.0.0.2:8080", Type: BackendHTTP},
// Public defaults to false
}) })
// Deregister with a source that doesn't match anything // Deregister with a source that doesn't match anything
@@ -363,10 +352,10 @@ func TestDeregisterBySource_NoMatch(t *testing.T) {
t.Fatalf("DeregisterBySource with no matches should not error: %v", err) t.Fatalf("DeregisterBySource with no matches should not error: %v", err)
} }
// All services should still be present // All domains should still be present
svcs, _ := mgr.List() doms, _ := mgr.List()
if len(svcs) != 2 { if len(doms) != 2 {
t.Errorf("expected 2 services after no-match deregister, got %d", len(svcs)) t.Errorf("expected 2 domains after no-match deregister, got %d", len(doms))
} }
} }
@@ -374,14 +363,14 @@ func TestDeregisterBySource_PartialMatch(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
// Same source, different backends // Same source, different backends
mgr.Register(Service{ mgr.Register(Domain{
Domain: "a.example.com", DomainName: "a.example.com",
Source: "wild-cloud", Source: "wild-cloud",
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough}, Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
Public: true, Public: true,
}) })
mgr.Register(Service{ mgr.Register(Domain{
Domain: "b.example.com", DomainName: "b.example.com",
Source: "wild-cloud", Source: "wild-cloud",
Backend: Backend{Address: "10.0.0.2:443", Type: BackendTCPPassthrough}, Backend: Backend{Address: "10.0.0.2:443", Type: BackendTCPPassthrough},
Public: true, Public: true,
@@ -393,12 +382,12 @@ func TestDeregisterBySource_PartialMatch(t *testing.T) {
t.Fatalf("DeregisterBySource failed: %v", err) t.Fatalf("DeregisterBySource failed: %v", err)
} }
svcs, _ := mgr.List() doms, _ := mgr.List()
if len(svcs) != 1 { if len(doms) != 1 {
t.Errorf("expected 1 remaining service, got %d", len(svcs)) t.Errorf("expected 1 remaining domain, got %d", len(doms))
} }
if svcs[0].Domain != "b.example.com" { if doms[0].DomainName != "b.example.com" {
t.Errorf("expected b.example.com to remain, got %s", svcs[0].Domain) t.Errorf("expected b.example.com to remain, got %s", doms[0].DomainName)
} }
} }
@@ -417,26 +406,25 @@ func TestRegister_OverwritePreservesFile(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
mgr := NewManager(dir) mgr := NewManager(dir)
svc := Service{ dom := Domain{
Domain: "overwrite.example.com", DomainName: "overwrite.example.com",
Source: "test", Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
} }
// Register first time // Register first time
if err := mgr.Register(svc); err != nil { if err := mgr.Register(dom); err != nil {
t.Fatalf("first Register failed: %v", err) t.Fatalf("first Register failed: %v", err)
} }
// Register again with different backend // Register again with different backend
svc.Backend.Address = "127.0.0.1:9090" dom.Backend.Address = "127.0.0.1:9090"
if err := mgr.Register(svc); err != nil { if err := mgr.Register(dom); err != nil {
t.Fatalf("second Register failed: %v", err) t.Fatalf("second Register failed: %v", err)
} }
// File count should be exactly 1 // File count should be exactly 1
entries, err := os.ReadDir(filepath.Join(dir, "services")) entries, err := os.ReadDir(filepath.Join(dir, "domains"))
if err != nil { if err != nil {
t.Fatalf("ReadDir failed: %v", err) t.Fatalf("ReadDir failed: %v", err)
} }
@@ -454,20 +442,20 @@ func TestRegister_OverwritePreservesFile(t *testing.T) {
func TestList_EmptyDir(t *testing.T) { func TestList_EmptyDir(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
svcs, err := mgr.List() doms, err := mgr.List()
if err != nil { if err != nil {
t.Fatalf("List on empty dir should not error: %v", err) t.Fatalf("List on empty dir should not error: %v", err)
} }
if len(svcs) != 0 { if len(doms) != 0 {
t.Errorf("expected 0 services from empty dir, got %d", len(svcs)) t.Errorf("expected 0 domains from empty dir, got %d", len(doms))
} }
} }
func TestRegisterWithL7Fields(t *testing.T) { func TestRegisterWithL7Fields(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
svc := Service{ dom := Domain{
Domain: "keila.cloud.payne.io", DomainName: "keila.cloud.payne.io",
Source: "wild-cloud", Source: "wild-cloud",
Routes: []Route{{ Routes: []Route{{
Paths: []string{"/.well-known/matrix", "/_matrix"}, Paths: []string{"/.well-known/matrix", "/_matrix"},
@@ -487,7 +475,7 @@ func TestRegisterWithL7Fields(t *testing.T) {
}}, }},
} }
if err := mgr.Register(svc); err != nil { if err := mgr.Register(dom); err != nil {
t.Fatalf("Register failed: %v", err) t.Fatalf("Register failed: %v", err)
} }
@@ -520,8 +508,8 @@ func TestRegisterWithL7Fields(t *testing.T) {
func TestUpdateL7Fields(t *testing.T) { func TestUpdateL7Fields(t *testing.T) {
mgr := NewManager(t.TempDir()) mgr := NewManager(t.TempDir())
mgr.Register(Service{ mgr.Register(Domain{
Domain: "updatable.example.com", DomainName: "updatable.example.com",
Source: "test", Source: "test",
Routes: []Route{{ Routes: []Route{{
Paths: []string{"/api", "/webhook"}, Paths: []string{"/api", "/webhook"},
@@ -555,28 +543,63 @@ func TestList_IgnoresNonYAML(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
mgr := NewManager(dir) mgr := NewManager(dir)
// Register one real service // Register one real domain
mgr.Register(Service{ mgr.Register(Domain{
Domain: "real.example.com", DomainName: "real.example.com",
Source: "test", Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
}) })
// Create non-YAML files in the services directory // Create non-YAML files in the domains directory
servicesDir := filepath.Join(dir, "services") domainsDir := filepath.Join(dir, "domains")
os.WriteFile(filepath.Join(servicesDir, "README.md"), []byte("# ignore me"), 0644) os.WriteFile(filepath.Join(domainsDir, "README.md"), []byte("# ignore me"), 0644)
os.WriteFile(filepath.Join(servicesDir, "notes.txt"), []byte("some notes"), 0644) os.WriteFile(filepath.Join(domainsDir, "notes.txt"), []byte("some notes"), 0644)
os.WriteFile(filepath.Join(servicesDir, ".gitkeep"), []byte(""), 0644) os.WriteFile(filepath.Join(domainsDir, ".gitkeep"), []byte(""), 0644)
svcs, err := mgr.List() doms, err := mgr.List()
if err != nil { if err != nil {
t.Fatalf("List failed: %v", err) t.Fatalf("List failed: %v", err)
} }
if len(svcs) != 1 { if len(doms) != 1 {
t.Errorf("expected 1 service (ignoring non-YAML files), got %d", len(svcs)) t.Errorf("expected 1 domain (ignoring non-YAML files), got %d", len(doms))
} }
if svcs[0].Domain != "real.example.com" { if doms[0].DomainName != "real.example.com" {
t.Errorf("expected real.example.com, got %s", svcs[0].Domain) t.Errorf("expected real.example.com, got %s", doms[0].DomainName)
}
}
func TestRegisterDNSOnly(t *testing.T) {
mgr := NewManager(t.TempDir())
dom := Domain{
DomainName: "dev.payne.io",
Source: "manual",
Backend: Backend{
Address: "192.168.8.222",
Type: BackendDNSOnly,
},
Public: true,
}
if err := mgr.Register(dom); err != nil {
t.Fatalf("Register failed: %v", err)
}
got, err := mgr.Get("dev.payne.io")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got.TLS != TLSNone {
t.Errorf("expected TLS none for dns-only, got %s", got.TLS)
}
if got.Backend.Type != BackendDNSOnly {
t.Errorf("expected backend type dns-only, got %s", got.Backend.Type)
}
if got.Backend.Address != "192.168.8.222" {
t.Errorf("expected address 192.168.8.222, got %s", got.Backend.Address)
}
if !got.Public {
t.Error("expected public=true")
} }
} }

View File

@@ -12,7 +12,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/wild-cloud/wild-central/internal/services" "github.com/wild-cloud/wild-central/internal/domains"
) )
// InstanceRoute represents a Wild Cloud instance's ingress routing configuration // InstanceRoute represents a Wild Cloud instance's ingress routing configuration
@@ -36,7 +36,7 @@ type HTTPRouteBackend struct {
Paths []string // path prefixes (empty = catch-all) Paths []string // path prefixes (empty = catch-all)
Backend string // host:port Backend string // host:port
HealthPath string // optional health check path HealthPath string // optional health check path
Headers *services.HeaderConfig // per-route headers Headers *domains.HeaderConfig // per-route headers
IPAllow []string // per-route CIDR whitelist IPAllow []string // per-route CIDR whitelist
} }

View File

@@ -4,7 +4,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/wild-cloud/wild-central/internal/services" "github.com/wild-cloud/wild-central/internal/domains"
) )
func TestNewManager_DefaultPath(t *testing.T) { func TestNewManager_DefaultPath(t *testing.T) {
@@ -606,7 +606,7 @@ func TestGenerateWithOpts_ResponseHeaders(t *testing.T) {
Domain: "plausible.cloud.payne.io", Domain: "plausible.cloud.payne.io",
Routes: []HTTPRouteBackend{{ Routes: []HTTPRouteBackend{{
Backend: "192.168.8.80:80", Backend: "192.168.8.80:80",
Headers: &services.HeaderConfig{ Headers: &domains.HeaderConfig{
Response: map[string]string{ Response: map[string]string{
"Access-Control-Allow-Private-Network": "true", "Access-Control-Allow-Private-Network": "true",
}, },
@@ -631,7 +631,7 @@ func TestGenerateWithOpts_RequestHeaders(t *testing.T) {
Domain: "api.payne.io", Domain: "api.payne.io",
Routes: []HTTPRouteBackend{{ Routes: []HTTPRouteBackend{{
Backend: "192.168.8.80:80", Backend: "192.168.8.80:80",
Headers: &services.HeaderConfig{ Headers: &domains.HeaderConfig{
Request: map[string]string{ Request: map[string]string{
"X-Forwarded-Proto": "https", "X-Forwarded-Proto": "https",
}, },

View File

@@ -17,7 +17,7 @@ import (
const ( const (
// KV bucket names // KV bucket names
BucketServices = "wild-services" // service registrations BucketDomains = "wild-domains" // domain registrations
BucketPresence = "wild-presence" // node liveness (TTL keys) BucketPresence = "wild-presence" // node liveness (TTL keys)
// Stream names // Stream names
@@ -126,13 +126,13 @@ func (s *Server) ensureBuckets() error {
// Services bucket — no TTL, entries persist until explicitly deleted // Services bucket — no TTL, entries persist until explicitly deleted
_, err := s.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ _, err := s.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: BucketServices, Bucket: BucketDomains,
Description: "Service registrations from Wild Cloud, Wild Works, and manual entries", Description: "Service registrations from Wild Cloud, Wild Works, and manual entries",
}) })
if err != nil { if err != nil {
return fmt.Errorf("services bucket: %w", err) return fmt.Errorf("services bucket: %w", err)
} }
slog.Info("NATS KV bucket ready", "bucket", BucketServices) slog.Info("NATS KV bucket ready", "bucket", BucketDomains)
// Presence bucket — TTL-based, keys expire when nodes stop renewing // Presence bucket — TTL-based, keys expire when nodes stop renewing
_, err = s.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ _, err = s.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{

View File

@@ -36,10 +36,10 @@ func TestBucketsCreated(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
// Verify services bucket exists // Verify domains bucket exists
kv, err := s.js.KeyValue(ctx, BucketServices) kv, err := s.js.KeyValue(ctx, BucketDomains)
if err != nil { if err != nil {
t.Fatalf("services bucket not found: %v", err) t.Fatalf("domains bucket not found: %v", err)
} }
// Write and read a value // Write and read a value

73
internal/tools/yq.go Normal file
View File

@@ -0,0 +1,73 @@
package tools
import (
"bytes"
"fmt"
"os/exec"
"strings"
)
// YQ provides a wrapper around the yq command-line tool
type YQ struct {
yqPath string
}
// NewYQ creates a new YQ wrapper
func NewYQ() *YQ {
path, err := exec.LookPath("yq")
if err != nil {
path = "yq"
}
return &YQ{yqPath: path}
}
// Get retrieves a value from a YAML file using a yq expression
func (y *YQ) Get(filePath, expression string) (string, error) {
cmd := exec.Command(y.yqPath, expression, filePath)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("yq get failed: %w, stderr: %s", err, stderr.String())
}
return strings.TrimSpace(stdout.String()), nil
}
// Set sets a value in a YAML file using a yq expression
func (y *YQ) Set(filePath, expression, value string) error {
if !strings.HasPrefix(expression, ".") {
expression = "." + expression
}
quotedValue := fmt.Sprintf(`"%s"`, strings.ReplaceAll(value, `"`, `\"`))
setExpr := fmt.Sprintf("%s = %s", expression, quotedValue)
cmd := exec.Command(y.yqPath, "-i", setExpr, filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yq set failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// Delete removes a key from a YAML file
func (y *YQ) Delete(filePath, expression string) error {
delExpr := fmt.Sprintf("del(%s)", expression)
cmd := exec.Command(y.yqPath, "-i", delExpr, filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yq delete failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// Validate checks if a YAML file is valid
func (y *YQ) Validate(filePath string) error {
cmd := exec.Command(y.yqPath, "eval", ".", filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yaml validation failed: %w, stderr: %s", err, stderr.String())
}
return nil
}

View File

@@ -30,9 +30,9 @@ type Config struct {
CredentialsDir string `yaml:"credentialsDir" json:"credentialsDir"` CredentialsDir string `yaml:"credentialsDir" json:"credentialsDir"`
} }
// PublicService describes a service that should be exposed through the tunnel. // PublicDomain describes a domain that should be exposed through the tunnel.
type PublicService struct { type PublicDomain struct {
Name string // service name (used as subdomain) Name string // domain name (used as subdomain)
Subdomain string // explicit subdomain override (defaults to Name) Subdomain string // explicit subdomain override (defaults to Name)
} }
@@ -79,7 +79,7 @@ type cloudflaredConfig struct {
// GenerateConfig renders the cloudflared config.yml for the given public services. // GenerateConfig renders the cloudflared config.yml for the given public services.
// Returns the YAML string, or empty string if tunnel is not configured or no services are public. // Returns the YAML string, or empty string if tunnel is not configured or no services are public.
func (m *Manager) GenerateConfig(cfg Config, services []PublicService) string { func (m *Manager) GenerateConfig(cfg Config, services []PublicDomain) string {
if !cfg.Enabled || cfg.TunnelID == "" || cfg.PublicDomain == "" || cfg.GatewayDomain == "" { if !cfg.Enabled || cfg.TunnelID == "" || cfg.PublicDomain == "" || cfg.GatewayDomain == "" {
return "" return ""
} }
@@ -131,7 +131,7 @@ func (m *Manager) GenerateConfig(cfg Config, services []PublicService) string {
} }
// WriteConfig generates and writes the cloudflared config to disk. // WriteConfig generates and writes the cloudflared config to disk.
func (m *Manager) WriteConfig(cfg Config, services []PublicService) error { func (m *Manager) WriteConfig(cfg Config, services []PublicDomain) error {
content := m.GenerateConfig(cfg, services) content := m.GenerateConfig(cfg, services)
if content == "" { if content == "" {
// Remove config if tunnel is disabled or no public services // Remove config if tunnel is disabled or no public services

View File

@@ -20,7 +20,7 @@ func testConfig() Config {
func TestGenerateConfig_Basic(t *testing.T) { func TestGenerateConfig_Basic(t *testing.T) {
m := NewManager(t.TempDir()) m := NewManager(t.TempDir())
services := []PublicService{ services := []PublicDomain{
{Name: "my-api"}, {Name: "my-api"},
{Name: "dashboard"}, {Name: "dashboard"},
} }
@@ -71,7 +71,7 @@ func TestGenerateConfig_Basic(t *testing.T) {
func TestGenerateConfig_SubdomainOverride(t *testing.T) { func TestGenerateConfig_SubdomainOverride(t *testing.T) {
m := NewManager(t.TempDir()) m := NewManager(t.TempDir())
services := []PublicService{ services := []PublicDomain{
{Name: "internal-name", Subdomain: "public-name"}, {Name: "internal-name", Subdomain: "public-name"},
} }
@@ -88,7 +88,7 @@ func TestGenerateConfig_Disabled(t *testing.T) {
cfg := testConfig() cfg := testConfig()
cfg.Enabled = false cfg.Enabled = false
out := m.GenerateConfig(cfg, []PublicService{{Name: "my-api"}}) out := m.GenerateConfig(cfg, []PublicDomain{{Name: "my-api"}})
if out != "" { if out != "" {
t.Errorf("expected empty config when disabled, got:\n%s", out) t.Errorf("expected empty config when disabled, got:\n%s", out)
} }
@@ -107,7 +107,7 @@ func TestGenerateConfig_MissingTunnelID(t *testing.T) {
cfg := testConfig() cfg := testConfig()
cfg.TunnelID = "" cfg.TunnelID = ""
out := m.GenerateConfig(cfg, []PublicService{{Name: "my-api"}}) out := m.GenerateConfig(cfg, []PublicDomain{{Name: "my-api"}})
if out != "" { if out != "" {
t.Errorf("expected empty config with missing tunnel ID, got:\n%s", out) t.Errorf("expected empty config with missing tunnel ID, got:\n%s", out)
} }
@@ -117,7 +117,7 @@ func TestWriteConfig(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
m := NewManager(tmpDir) m := NewManager(tmpDir)
services := []PublicService{{Name: "my-api"}} services := []PublicDomain{{Name: "my-api"}}
if err := m.WriteConfig(testConfig(), services); err != nil { if err := m.WriteConfig(testConfig(), services); err != nil {
t.Fatalf("WriteConfig failed: %v", err) t.Fatalf("WriteConfig failed: %v", err)
} }
@@ -139,14 +139,14 @@ func TestWriteConfig_RemovesWhenDisabled(t *testing.T) {
m := NewManager(tmpDir) m := NewManager(tmpDir)
// Write config first // Write config first
if err := m.WriteConfig(testConfig(), []PublicService{{Name: "my-api"}}); err != nil { if err := m.WriteConfig(testConfig(), []PublicDomain{{Name: "my-api"}}); err != nil {
t.Fatalf("WriteConfig failed: %v", err) t.Fatalf("WriteConfig failed: %v", err)
} }
// Now disable and write again — should remove the file // Now disable and write again — should remove the file
cfg := testConfig() cfg := testConfig()
cfg.Enabled = false cfg.Enabled = false
if err := m.WriteConfig(cfg, []PublicService{{Name: "my-api"}}); err != nil { if err := m.WriteConfig(cfg, []PublicDomain{{Name: "my-api"}}); err != nil {
t.Fatalf("WriteConfig (disable) failed: %v", err) t.Fatalf("WriteConfig (disable) failed: %v", err)
} }

View File

@@ -15,10 +15,12 @@ import {
useSidebar, useSidebar,
} from './ui/sidebar'; } from './ui/sidebar';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useCentralStatus } from '../hooks/useCentralStatus';
export function AppSidebar() { export function AppSidebar() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { state } = useSidebar(); const { state } = useSidebar();
const { data: centralStatus } = useCentralStatus();
const cycleTheme = () => { const cycleTheme = () => {
if (theme === 'light') { if (theme === 'light') {
@@ -52,16 +54,16 @@ export function AppSidebar() {
} }
}; };
const servicesItems = [ const domainsItems = [
{ to: '/central', icon: LayoutDashboard, label: 'Dashboard', end: true }, { to: '/central', icon: LayoutDashboard, label: 'Dashboard', end: true },
{ to: '/central/services', icon: Globe, label: 'Services' }, { to: '/central/domains', icon: Globe, label: 'Domains' },
]; ];
const centralItems = [ const centralItems = [
{ to: '/central/vpn', icon: Lock, label: 'VPN' }, { to: '/central/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
{ to: '/central/firewall', icon: Shield, label: 'Firewall' }, { to: '/central/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec' }, { to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP' }, { to: '/central/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
]; ];
return ( return (
@@ -80,26 +82,33 @@ export function AppSidebar() {
<SidebarContent> <SidebarContent>
{state === 'collapsed' ? ( {state === 'collapsed' ? (
<SidebarMenu> <SidebarMenu>
{[...servicesItems, ...centralItems].map((item) => ( {[...domainsItems, ...centralItems].map((item) => {
const daemon = 'daemon' in item ? (item as any).daemon : undefined;
const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined;
return (
<SidebarMenuItem key={item.to}> <SidebarMenuItem key={item.to}>
<NavLink to={item.to} end={'end' in item ? (item as any).end : undefined}> <NavLink to={item.to} end={'end' in item ? (item as any).end : undefined}>
{({ isActive }) => ( {({ isActive }) => (
<SidebarMenuButton isActive={isActive} tooltip={item.label}> <SidebarMenuButton isActive={isActive} tooltip={item.label}>
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
<span>{item.label}</span> <span>{item.label}</span>
{active !== undefined && (
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
)}
</SidebarMenuButton> </SidebarMenuButton>
)} )}
</NavLink> </NavLink>
</SidebarMenuItem> </SidebarMenuItem>
))} );
})}
</SidebarMenu> </SidebarMenu>
) : ( ) : (
<> <>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Services</SidebarGroupLabel> <SidebarGroupLabel>Domains</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{servicesItems.map(({ to, icon: Icon, label, end }) => ( {domainsItems.map(({ to, icon: Icon, label, end }) => (
<SidebarMenuItem key={to}> <SidebarMenuItem key={to}>
<NavLink to={to} end={end}> <NavLink to={to} end={end}>
{({ isActive }) => ( {({ isActive }) => (
@@ -119,18 +128,24 @@ export function AppSidebar() {
<SidebarGroupLabel>Central</SidebarGroupLabel> <SidebarGroupLabel>Central</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{centralItems.map(({ to, icon: Icon, label }) => ( {centralItems.map(({ to, icon: Icon, label, daemon }) => {
const active = centralStatus?.daemons?.[daemon]?.active;
return (
<SidebarMenuItem key={to}> <SidebarMenuItem key={to}>
<NavLink to={to}> <NavLink to={to}>
{({ isActive }) => ( {({ isActive }) => (
<SidebarMenuButton isActive={isActive}> <SidebarMenuButton isActive={isActive}>
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
{active !== undefined && (
<span className={`ml-auto h-1.5 w-1.5 rounded-full ${active ? 'bg-green-500' : 'bg-red-500'}`} />
)}
</SidebarMenuButton> </SidebarMenuButton>
)} )}
</NavLink> </NavLink>
</SidebarMenuItem> </SidebarMenuItem>
))} );
})}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>

View File

@@ -7,7 +7,7 @@ import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { import {
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2, LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key, Plus, X, RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key,
} from 'lucide-react'; } from 'lucide-react';
import { useCloudflare } from '../hooks/useCloudflare'; import { useCloudflare } from '../hooks/useCloudflare';
import { useDdns } from '../hooks/useDdns'; import { useDdns } from '../hooks/useDdns';
@@ -22,7 +22,7 @@ export function DashboardComponent() {
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus(); const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
const { verification, isLoading: cfLoading } = useCloudflare(); const { verification, isLoading: cfLoading } = useCloudflare();
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns(); const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
const { config: globalConfig, updateConfig } = useConfig(); const { config: globalConfig } = useConfig();
const { config: vpnConfig } = useVpn(); const { config: vpnConfig } = useVpn();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -36,8 +36,6 @@ export function DashboardComponent() {
const [editingToken, setEditingToken] = useState(false); const [editingToken, setEditingToken] = useState(false);
const [tokenValue, setTokenValue] = useState(''); const [tokenValue, setTokenValue] = useState('');
const [addingDdnsRecord, setAddingDdnsRecord] = useState(false);
const [newDdnsRecord, setNewDdnsRecord] = useState('');
const updateSecretsMutation = useMutation({ const updateSecretsMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => secretsApi.update(values), mutationFn: (values: Record<string, unknown>) => secretsApi.update(values),
@@ -260,76 +258,33 @@ export function DashboardComponent() {
</div> </div>
</div> </div>
{/* Record list with remove buttons */} {/* Record list (read-only — derived from public domains) */}
{globalConfig?.cloud?.ddns?.records?.length ? ( {ddnsStatus.records?.length ? (
<div className="space-y-1"> <div className="space-y-1">
{globalConfig.cloud.ddns.records.map((r: string) => { {ddnsStatus.records.map((rs: DdnsRecordStatus) => (
const rs: DdnsRecordStatus | undefined = ddnsStatus.records?.find((s: DdnsRecordStatus) => s.name === r); <div key={rs.name} className="flex items-center gap-2 text-xs">
return ( {rs.ok ? (
<div key={r} className="flex items-center gap-2 text-xs group">
{rs?.ok ? (
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" /> <CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" />
) : rs && !rs.ok ? (
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
) : ( ) : (
<div className="h-3.5 w-3.5 shrink-0" /> <XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
)} )}
<span className="font-mono bg-muted px-2 py-0.5 rounded">{r}</span> <span className="font-mono bg-muted px-2 py-0.5 rounded">{rs.name}</span>
{rs?.ok && rs.ip && ( {rs.ok && rs.ip && (
<span className="text-muted-foreground font-mono">{rs.ip}</span> <span className="text-muted-foreground font-mono">{rs.ip}</span>
)} )}
{rs && !rs.ok && rs.error && ( {!rs.ok && rs.error && (
<span className="text-red-500 truncate" title={rs.error}>{rs.error}</span> <span className="text-red-500 truncate" title={rs.error}>{rs.error}</span>
)} )}
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 ml-auto"
onClick={async () => {
if (!globalConfig) return;
const records = (globalConfig.cloud?.ddns?.records || []).filter((rec: string) => rec !== r);
await updateConfig({
...globalConfig,
cloud: { ...globalConfig.cloud, ddns: { ...globalConfig.cloud?.ddns, records } },
});
}}
>
<X className="h-3 w-3" />
</Button>
</div> </div>
); ))}
})} <p className="text-xs text-muted-foreground mt-1">
Records are derived from public domains. Toggle Public on the Domains page to manage.
</p>
</div> </div>
) : null}
{/* Add DDNS record */}
{!addingDdnsRecord ? (
<Button variant="ghost" size="sm" onClick={() => setAddingDdnsRecord(true)} className="gap-1 text-muted-foreground">
<Plus className="h-3 w-3" />
Add Record
</Button>
) : ( ) : (
<div className="flex gap-2 items-center"> <p className="text-xs text-muted-foreground">
<Input No public domains registered. Set a domain to Public on the Domains page to enable DDNS.
value={newDdnsRecord} </p>
onChange={(e) => setNewDdnsRecord(e.target.value)}
placeholder="dev.payne.io"
className="h-8 text-xs font-mono flex-1"
/>
<Button size="sm" className="h-8" disabled={!newDdnsRecord} onClick={async () => {
if (!globalConfig || !newDdnsRecord) return;
const records = [...(globalConfig.cloud?.ddns?.records || []), newDdnsRecord];
await updateConfig({
...globalConfig,
cloud: { ...globalConfig.cloud, ddns: { ...globalConfig.cloud?.ddns, records } },
});
setNewDdnsRecord('');
setAddingDdnsRecord(false);
}}>Add</Button>
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setAddingDdnsRecord(false); setNewDdnsRecord(''); }}>
<X className="h-3 w-3" />
</Button>
</div>
)} )}
<Button <Button
@@ -345,7 +300,7 @@ export function DashboardComponent() {
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground ml-7"> <p className="text-sm text-muted-foreground ml-7">
DDNS is not enabled. Configure it in the DDNS settings to keep DNS records in sync with your public IP. DDNS is not enabled. Enable it in the global config to keep public domain DNS records in sync with your public IP.
</p> </p>
)} )}
</Card> </Card>
@@ -378,10 +333,11 @@ export function DashboardComponent() {
<div className="space-y-3 ml-7"> <div className="space-y-3 ml-7">
{/* Service badges */} {/* Service badges */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<ServiceBadge name="dnsmasq" /> <ServiceBadge name="dnsmasq" active={centralStatus?.daemons?.dnsmasq?.active} />
<ServiceBadge name="haproxy" /> <ServiceBadge name="haproxy" active={centralStatus?.daemons?.haproxy?.active} />
<ServiceBadge name="wireguard" /> <ServiceBadge name="nftables" active={centralStatus?.daemons?.nftables?.active} />
<ServiceBadge name="crowdsec" /> <ServiceBadge name="wireguard" active={centralStatus?.daemons?.wireguard?.active} />
<ServiceBadge name="crowdsec" active={centralStatus?.daemons?.crowdsec?.active} />
</div> </div>
{/* Version and uptime */} {/* Version and uptime */}
@@ -452,13 +408,12 @@ export function DashboardComponent() {
); );
} }
/** Inline badge showing a service name with a colored dot. Status is informational only. */ /** Inline badge showing a service name with a colored dot reflecting actual daemon status. */
function ServiceBadge({ name }: { name: string }) { function ServiceBadge({ name, active }: { name: string; active?: boolean }) {
// These are presentational — the daemon is running if we can reach the API, const dotColor = active === undefined ? 'bg-gray-400' : active ? 'bg-green-500' : 'bg-red-500';
// and individual service health comes from their own endpoints.
return ( return (
<span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium"> <span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium">
<span className="h-2 w-2 rounded-full bg-green-500" /> <span className={`h-2 w-2 rounded-full ${dotColor}`} />
{name} {name}
</span> </span>
); );

View File

@@ -12,12 +12,13 @@ import {
Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText, Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText,
} from 'lucide-react'; } from 'lucide-react';
import { useHaproxy } from '../hooks/useHaproxy'; import { useHaproxy } from '../hooks/useHaproxy';
import { useServices } from '../hooks/useServices'; import { useDomains } from '../hooks/useDomains';
import { useDdns } from '../hooks/useDdns';
import { useCert } from '../hooks/useCert'; import { useCert } from '../hooks/useCert';
import { usePageHelp } from '../hooks/usePageHelp'; import { usePageHelp } from '../hooks/usePageHelp';
import type { RegisteredService } from '../services/api/networking'; import type { RegisteredDomain } from '../services/api/networking';
import type { CertEntry } from '../services/api/cert'; import type { CertEntry } from '../services/api/cert';
import { servicesApi, haproxyApi } from '../services/api/networking'; import { domainsApi, haproxyApi } from '../services/api/networking';
function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) { function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) {
return ( return (
@@ -30,9 +31,9 @@ function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: st
); );
} }
function getTlsStatus(service: RegisteredService, certDomains: Set<string>): 'ok' | 'error' | 'na' { function getTlsStatus(service: RegisteredDomain, certDomains: Set<string>): 'ok' | 'error' | 'na' {
const backendType = service.routes?.length ? service.routes[0].backend.type : service.backend.type; const backendType = service.routes?.length ? service.routes[0].backend.type : service.backend.type;
if (service.tls === 'passthrough' || backendType === 'tcp-passthrough') return 'na'; if (service.tls === 'passthrough' || service.tls === 'none' || backendType === 'tcp-passthrough' || backendType === 'dns-only') return 'na';
// Check exact match or wildcard coverage // Check exact match or wildcard coverage
if (certDomains.has(service.domain)) return 'ok'; if (certDomains.has(service.domain)) return 'ok';
const dotIdx = service.domain.indexOf('.'); const dotIdx = service.domain.indexOf('.');
@@ -56,21 +57,17 @@ function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined {
return undefined; return undefined;
} }
function hasRoutes(service: RegisteredService): boolean { function hasRoutes(service: RegisteredDomain): boolean {
return (service.routes?.length ?? 0) > 0; return (service.routes?.length ?? 0) > 0;
} }
function getBackendAddress(service: RegisteredService): string {
if (hasRoutes(service)) return service.routes![0].backend.address;
return service.backend.address;
}
function getBackendType(service: RegisteredService): string { function getBackendType(service: RegisteredDomain): string {
if (hasRoutes(service)) return service.routes![0].backend.type; if (hasRoutes(service)) return service.routes![0].backend.type;
return service.backend.type; return service.backend.type;
} }
export function ServicesComponent() { export function DomainsComponent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { const {
@@ -78,8 +75,9 @@ export function ServicesComponent() {
generateData: haproxyGenerateData, generateError: haproxyGenerateError, generateData: haproxyGenerateData, generateError: haproxyGenerateError,
restart: restartHaproxy, isRestarting: isHaproxyRestarting, restart: restartHaproxy, isRestarting: isHaproxyRestarting,
} = useHaproxy(); } = useHaproxy();
const { services, isLoading: isServicesLoading } = useServices(); const { domains, isLoading: isDomainsLoading } = useDomains();
const { status: certStatus, provision: provisionCert, isProvisioning, renew: renewCert, isRenewing } = useCert(); const { status: ddnsStatus, trigger: triggerDdns, isTriggering: isDdnsTriggering } = useDdns();
const { status: certStatus, provision: provisionCert, renew: renewCert } = useCert();
const provisionMutation = useMutation({ const provisionMutation = useMutation({
mutationFn: (domain: string) => provisionCert(domain), mutationFn: (domain: string) => provisionCert(domain),
@@ -103,38 +101,38 @@ export function ServicesComponent() {
const [newBackendAddr, setNewBackendAddr] = useState(''); const [newBackendAddr, setNewBackendAddr] = useState('');
const [newPublic, setNewPublic] = useState(true); const [newPublic, setNewPublic] = useState(true);
const [newSubdomains, setNewSubdomains] = useState(false); const [newSubdomains, setNewSubdomains] = useState(false);
const [newTls, setNewTls] = useState<string>('passthrough'); const [gatewayMode, setGatewayMode] = useState<'none' | 'reverse-proxy' | 'passthrough'>('reverse-proxy');
const certDomains = new Set<string>( const certDomains = new Set<string>(
(certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain) (certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain)
); );
const registerMutation = useMutation({ const registerMutation = useMutation({
mutationFn: (svc: Record<string, unknown>) => servicesApi.register(svc as any), mutationFn: (svc: Record<string, unknown>) => domainsApi.register(svc as any),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] }); queryClient.invalidateQueries({ queryKey: ['domains'] });
setShowAddForm(false); setShowAddForm(false);
setNewDomain(''); setNewBackendAddr(''); setNewPublic(true); setNewSubdomains(false); setNewTls('passthrough'); setNewDomain(''); setNewBackendAddr(''); setNewPublic(true); setNewSubdomains(false); setGatewayMode('reverse-proxy');
}, },
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ domain, updates }: { domain: string; updates: Record<string, unknown> }) => mutationFn: ({ domain, updates }: { domain: string; updates: Record<string, unknown> }) =>
servicesApi.register({ ...(services.find(s => s.domain === domain) ?? {}), ...updates } as any), domainsApi.register({ ...(domains.find(d => d.domain === domain) ?? {}), ...updates } as any),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
}); });
const deregisterMutation = useMutation({ const deregisterMutation = useMutation({
mutationFn: (domain: string) => servicesApi.deregister(domain), mutationFn: (domain: string) => domainsApi.deregister(domain),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['domains'] }),
}); });
usePageHelp({ usePageHelp({
title: 'Services', title: 'Domains',
description: ( description: (
<p className="leading-relaxed"> <p className="leading-relaxed">
Each service is a domain that Wild Central manages. Central provides DNS resolution, Each domain is managed by Wild Central. For each, configure whether it just resolves to an IP
proxy routing, TLS certificates, and public DNS records based on how you configure each service. or routes through Central's gateway for DNS, proxy routing, TLS, and public exposure.
</p> </p>
), ),
}); });
@@ -168,12 +166,12 @@ export function ServicesComponent() {
<Globe className="h-6 w-6 text-primary" /> <Globe className="h-6 w-6 text-primary" />
</div> </div>
<div> <div>
<h2 className="text-2xl font-semibold">Services</h2> <h2 className="text-2xl font-semibold">Domains</h2>
<p className="text-muted-foreground">Registered domains and their networking status</p> <p className="text-muted-foreground">Registered domains and their networking status</p>
</div> </div>
</div> </div>
<Button onClick={() => setShowAddForm(!showAddForm)} className="gap-1"> <Button onClick={() => setShowAddForm(!showAddForm)} className="gap-1">
<Plus className="h-4 w-4" />Add Service <Plus className="h-4 w-4" />Add Domain
</Button> </Button>
</div> </div>
@@ -181,16 +179,32 @@ export function ServicesComponent() {
{showAddForm && ( {showAddForm && (
<Card className="border-dashed border-primary/50"> <Card className="border-dashed border-primary/50">
<CardContent className="p-4 space-y-4"> <CardContent className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div> <div>
<Label className="text-xs">Domain</Label> <Label className="text-xs">Domain</Label>
<Input value={newDomain} onChange={e => setNewDomain(e.target.value)} placeholder="cloud.payne.io" className="mt-1 font-mono" /> <Input value={newDomain} onChange={e => setNewDomain(e.target.value)} placeholder="dev.payne.io" className="mt-1 font-mono" />
</div> </div>
<div> <div>
<Label className="text-xs">Backend (host:port)</Label> <Label className="text-xs mb-2 block">Gateway</Label>
<Input value={newBackendAddr} onChange={e => setNewBackendAddr(e.target.value)} placeholder="192.168.8.240:443" className="mt-1 font-mono" /> <div className="flex gap-1 bg-muted rounded-md p-1">
{([
{ value: 'none' as const, label: 'None' },
{ value: 'reverse-proxy' as const, label: 'Reverse proxy' },
{ value: 'passthrough' as const, label: 'Passthrough' },
] as const).map(opt => (
<button key={opt.value} type="button"
className={`flex-1 text-xs py-1.5 px-2 rounded transition-colors ${gatewayMode === opt.value ? 'bg-background shadow-sm font-medium' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => setGatewayMode(opt.value)}>
{opt.label}
</button>
))}
</div> </div>
</div> </div>
<div>
<Label className="text-xs">{gatewayMode === 'none' ? 'Resolves to (IP)' : 'Backend (host:port)'}</Label>
<Input value={newBackendAddr} onChange={e => setNewBackendAddr(e.target.value)}
placeholder={gatewayMode === 'none' ? '192.168.8.222' : '192.168.8.240:443'}
className="mt-1 font-mono" />
</div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<Switch checked={newPublic} onCheckedChange={setNewPublic} /> <Switch checked={newPublic} onCheckedChange={setNewPublic} />
@@ -200,19 +214,19 @@ export function ServicesComponent() {
<Switch checked={newSubdomains} onCheckedChange={setNewSubdomains} /> <Switch checked={newSubdomains} onCheckedChange={setNewSubdomains} />
Include subdomains Include subdomains
</label> </label>
<label className="flex items-center gap-2 text-sm">
<Switch checked={newTls === 'terminate'} onCheckedChange={v => setNewTls(v ? 'terminate' : 'passthrough')} />
Central handles TLS
</label>
</div> </div>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={() => setShowAddForm(false)}><X className="h-3 w-3 mr-1" />Cancel</Button> <Button variant="outline" size="sm" onClick={() => setShowAddForm(false)}><X className="h-3 w-3 mr-1" />Cancel</Button>
<Button size="sm" disabled={!newDomain || !newBackendAddr || registerMutation.isPending} <Button size="sm" disabled={!newDomain || !newBackendAddr || registerMutation.isPending}
onClick={() => registerMutation.mutate({ onClick={() => {
const backendType = gatewayMode === 'none' ? 'dns-only' : gatewayMode === 'passthrough' ? 'tcp-passthrough' : 'http';
const tls = gatewayMode === 'none' ? 'none' : gatewayMode === 'passthrough' ? 'passthrough' : 'terminate';
registerMutation.mutate({
domain: newDomain, domain: newDomain,
backend: { address: newBackendAddr, type: newTls === 'passthrough' ? 'tcp-passthrough' : 'http' }, backend: { address: newBackendAddr, type: backendType },
public: newPublic, subdomains: newSubdomains, tls: newTls, source: 'manual', public: newPublic, subdomains: newSubdomains, tls, source: 'manual',
})}> });
}}>
{registerMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />} {registerMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />}
Register Register
</Button> </Button>
@@ -222,28 +236,32 @@ export function ServicesComponent() {
)} )}
{/* Loading */} {/* Loading */}
{isServicesLoading && ( {isDomainsLoading && (
<Card className="p-8 text-center"> <Card className="p-8 text-center">
<Loader2 className="h-8 w-8 text-primary mx-auto mb-2 animate-spin" /> <Loader2 className="h-8 w-8 text-primary mx-auto mb-2 animate-spin" />
<p className="text-sm text-muted-foreground">Loading services...</p> <p className="text-sm text-muted-foreground">Loading domains...</p>
</Card> </Card>
)} )}
{/* Empty */} {/* Empty */}
{!isServicesLoading && services.length === 0 && ( {!isDomainsLoading && domains.length === 0 && (
<Card className="p-8 text-center"> <Card className="p-8 text-center">
<Globe className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> <Globe className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Services</h3> <h3 className="text-lg font-medium mb-2">No Domains</h3>
<p className="text-muted-foreground">Services appear when Wild Cloud or Wild Works registers domains, or add one manually.</p> <p className="text-muted-foreground">Domains appear when Wild Cloud or Wild Works registers them, or add one manually.</p>
</Card> </Card>
)} )}
{/* Service Cards */} {/* Domain Cards */}
{services.map(service => { {domains.map(service => {
const isExpanded = expandedDomains.has(service.domain); const isExpanded = expandedDomains.has(service.domain);
const backendType = getBackendType(service);
const isDnsOnly = backendType === 'dns-only';
const isPassthrough = service.tls === 'passthrough' || backendType === 'tcp-passthrough';
const hasGateway = !isDnsOnly;
const tlsStatus = getTlsStatus(service, certDomains); const tlsStatus = getTlsStatus(service, certDomains);
const ddnsStatus2 = service.public ? 'ok' as const : 'na' as const; const ddnsRecord = ddnsStatus?.records?.find(r => r.name === service.domain);
const isPassthrough = service.tls === 'passthrough' || getBackendType(service) === 'tcp-passthrough'; const ddnsStatus2: 'ok' | 'error' | 'na' = !service.public ? 'na' : ddnsRecord?.ok ? 'ok' : ddnsRecord ? 'error' : 'na';
const routeCount = service.routes?.length ?? 0; const routeCount = service.routes?.length ?? 0;
return ( return (
@@ -261,8 +279,8 @@ export function ServicesComponent() {
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<StatusDot status="ok" label="DNS" /> <StatusDot status="ok" label="DNS" />
<StatusDot status="ok" label="Proxy" /> {hasGateway && <StatusDot status="ok" label="Gateway" />}
<StatusDot status={tlsStatus} label="TLS" /> {hasGateway && !isPassthrough && <StatusDot status={tlsStatus} label="TLS" />}
<StatusDot status={ddnsStatus2} label="DDNS" /> <StatusDot status={ddnsStatus2} label="DDNS" />
</div> </div>
</div> </div>
@@ -275,7 +293,7 @@ export function ServicesComponent() {
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2"> <div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2">
{!hasRoutes(service) && ( {!hasRoutes(service) && (
<div> <div>
<Label className="text-xs text-muted-foreground">Backend</Label> <Label className="text-xs text-muted-foreground">{isDnsOnly ? 'Resolves to' : 'Backend'}</Label>
<Input <Input
defaultValue={service.backend.address} defaultValue={service.backend.address}
className="mt-0.5 h-8 font-mono text-sm w-48" className="mt-0.5 h-8 font-mono text-sm w-48"
@@ -310,6 +328,7 @@ export function ServicesComponent() {
<Label className="text-sm">Subdomains</Label> <Label className="text-sm">Subdomains</Label>
</div> </div>
{hasGateway && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
checked={!isPassthrough} checked={!isPassthrough}
@@ -333,6 +352,7 @@ export function ServicesComponent() {
/> />
<Label className="text-sm">{isPassthrough ? 'TLS Passthrough' : 'Central TLS'}</Label> <Label className="text-sm">{isPassthrough ? 'TLS Passthrough' : 'Central TLS'}</Label>
</div> </div>
)}
</div> </div>
{/* Routes detail */} {/* Routes detail */}
@@ -355,8 +375,8 @@ export function ServicesComponent() {
if (val && val !== route.backend.address) { if (val && val !== route.backend.address) {
const updatedRoutes = [...service.routes!]; const updatedRoutes = [...service.routes!];
updatedRoutes[i] = { ...route, backend: { ...route.backend, address: val } }; updatedRoutes[i] = { ...route, backend: { ...route.backend, address: val } };
servicesApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any) domainsApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any)
.then(() => queryClient.invalidateQueries({ queryKey: ['services'] })); .then(() => queryClient.invalidateQueries({ queryKey: ['domains'] }));
} }
}} }}
/> />
@@ -401,7 +421,7 @@ export function ServicesComponent() {
)} )}
{/* TLS cert info */} {/* TLS cert info */}
{!isPassthrough && (() => { {hasGateway && !isPassthrough && (() => {
const cert = findCert(service.domain, certStatus?.certs ?? []); const cert = findCert(service.domain, certStatus?.certs ?? []);
if (!cert) { if (!cert) {
const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain; const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain;
@@ -449,6 +469,25 @@ export function ServicesComponent() {
); );
})()} })()}
{/* DDNS error info */}
{ddnsStatus2 === 'error' && ddnsRecord && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span>
DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}.
{' '}Check that your Cloudflare API token has access to this domain's zone.
</span>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs gap-1 shrink-0 ml-2"
disabled={isDdnsTriggering}
onClick={() => triggerDdns()}>
{isDdnsTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
Retry
</Button>
</div>
)}
{/* Source + deregister */} {/* Source + deregister */}
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Source: {service.source}</span> <span>Source: {service.source}</span>

View File

@@ -1,6 +1,10 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../services/api/client'; import { apiClient } from '../services/api/client';
interface DaemonStatus {
active: boolean;
}
interface CentralStatus { interface CentralStatus {
status: string; status: string;
version: string; version: string;
@@ -10,6 +14,7 @@ interface CentralStatus {
appsDir: string; appsDir: string;
setupFiles: string; setupFiles: string;
pendingRestart: boolean; pendingRestart: boolean;
daemons?: Record<string, DaemonStatus>;
} }
/** /**

View File

@@ -1,62 +1,61 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api-legacy'; import { apiService } from '../services/api-legacy';
import type { GlobalConfig, GlobalConfigResponse } from '../types'; import type { CentralState, CentralStateResponse } from '../types';
interface CreateConfigResponse { interface CreateConfigResponse {
status: string; status: string;
} }
/** /**
* Hook for managing Wild Central global configuration * Hook for managing Wild Central runtime state
* Endpoint: /api/v1/config * Endpoint: /api/v1/state
* File: {dataDir}/state.yaml * File: {dataDir}/state.yaml
*/ */
export const useConfig = () => { export const useConfig = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showConfigSetup, setShowConfigSetup] = useState(false); const [showConfigSetup, setShowConfigSetup] = useState(false);
const configQuery = useQuery<GlobalConfigResponse>({ const stateQuery = useQuery<CentralStateResponse>({
queryKey: ['globalConfig'], queryKey: ['state'],
queryFn: () => apiService.getConfig(), queryFn: () => apiService.getConfig(),
}); });
// Update showConfigSetup based on query data // Update showConfigSetup based on query data
useEffect(() => { useEffect(() => {
if (configQuery.data) { if (stateQuery.data) {
setShowConfigSetup(configQuery.data.configured === false); setShowConfigSetup(stateQuery.data.configured === false);
} }
}, [configQuery.data]); }, [stateQuery.data]);
const createConfigMutation = useMutation<CreateConfigResponse, Error, GlobalConfig>({ const createStateMutation = useMutation<CreateConfigResponse, Error, CentralState>({
mutationFn: (config) => apiService.createConfig(config), mutationFn: (state) => apiService.createConfig(state),
onSuccess: () => { onSuccess: () => {
// Invalidate and refetch config after successful creation queryClient.invalidateQueries({ queryKey: ['state'] });
queryClient.invalidateQueries({ queryKey: ['globalConfig'] });
setShowConfigSetup(false); setShowConfigSetup(false);
}, },
}); });
const updateConfigMutation = useMutation<CreateConfigResponse, Error, GlobalConfig>({ const updateStateMutation = useMutation<CreateConfigResponse, Error, CentralState>({
mutationFn: (config) => apiService.updateConfig(config), mutationFn: (state) => apiService.updateConfig(state),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['globalConfig'] }); queryClient.invalidateQueries({ queryKey: ['state'] });
// Immediately re-check daemon status so the pending-restart banner appears // Immediately re-check daemon status so the pending-restart banner appears
queryClient.invalidateQueries({ queryKey: ['central', 'status'] }); queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
}, },
}); });
return { return {
config: configQuery.data?.config || null, config: stateQuery.data?.state || null,
isConfigured: configQuery.data?.configured || false, isConfigured: stateQuery.data?.configured || false,
showConfigSetup, showConfigSetup,
setShowConfigSetup, setShowConfigSetup,
isLoading: configQuery.isLoading, isLoading: stateQuery.isLoading,
isCreating: createConfigMutation.isPending, isCreating: createStateMutation.isPending,
isUpdating: updateConfigMutation.isPending, isUpdating: updateStateMutation.isPending,
error: configQuery.error || createConfigMutation.error || updateConfigMutation.error, error: stateQuery.error || createStateMutation.error || updateStateMutation.error,
createConfig: createConfigMutation.mutate, createConfig: createStateMutation.mutate,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateStateMutation.mutateAsync,
refetch: configQuery.refetch, refetch: stateQuery.refetch,
}; };
}; };

View File

@@ -14,10 +14,12 @@ export const useDdns = () => {
const triggerMutation = useMutation({ const triggerMutation = useMutation({
mutationFn: () => ddnsApi.trigger(), mutationFn: () => ddnsApi.trigger(),
onSuccess: () => { onSuccess: () => {
// Refresh status shortly after trigger // Refresh status after trigger — stagger to catch async completion
for (const delay of [1000, 3000, 6000]) {
setTimeout(() => { setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['ddns', 'status'] }); queryClient.invalidateQueries({ queryKey: ['ddns', 'status'] });
}, 2000); }, delay);
}
}, },
}); });

View File

@@ -1,16 +1,16 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { servicesApi } from '../services/api/networking'; import { domainsApi } from '../services/api/networking';
export const useServices = () => { export const useDomains = () => {
const query = useQuery({ const query = useQuery({
queryKey: ['services'], queryKey: ['domains'],
queryFn: () => servicesApi.list(), queryFn: () => domainsApi.list(),
refetchInterval: 30000, refetchInterval: 30000,
staleTime: 10000, staleTime: 10000,
}); });
return { return {
services: query.data?.services ?? [], domains: query.data?.domains ?? [],
isLoading: query.isLoading, isLoading: query.isLoading,
error: query.error, error: query.error,
}; };

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { DomainsComponent } from '../../components/DomainsComponent';
export function DomainsPage() {
return (
<ErrorBoundary>
<DomainsComponent />
</ErrorBoundary>
);
}

View File

@@ -1,10 +0,0 @@
import { ErrorBoundary } from '../../components';
import { ServicesComponent } from '../../components/ServicesComponent';
export function ServicesPage() {
return (
<ErrorBoundary>
<ServicesComponent />
</ErrorBoundary>
);
}

View File

@@ -3,7 +3,7 @@ import type { RouteObject } from 'react-router';
import { CentralLayout } from './CentralLayout'; import { CentralLayout } from './CentralLayout';
import { NotFoundPage } from './pages/NotFoundPage'; import { NotFoundPage } from './pages/NotFoundPage';
import { DashboardPage } from './pages/DashboardPage'; import { DashboardPage } from './pages/DashboardPage';
import { ServicesPage } from './pages/ServicesPage'; import { DomainsPage } from './pages/DomainsPage';
import { FirewallPage } from './pages/FirewallPage'; import { FirewallPage } from './pages/FirewallPage';
import { VpnPage } from './pages/VpnPage'; import { VpnPage } from './pages/VpnPage';
import { CrowdSecPage } from './pages/CrowdSecPage'; import { CrowdSecPage } from './pages/CrowdSecPage';
@@ -19,7 +19,7 @@ export const routes: RouteObject[] = [
element: <CentralLayout />, element: <CentralLayout />,
children: [ children: [
{ index: true, element: <DashboardPage /> }, { index: true, element: <DashboardPage /> },
{ path: 'services', element: <ServicesPage /> }, { path: 'domains', element: <DomainsPage /> },
{ path: 'firewall', element: <FirewallPage /> }, { path: 'firewall', element: <FirewallPage /> },
{ path: 'vpn', element: <VpnPage /> }, { path: 'vpn', element: <VpnPage /> },
{ path: 'crowdsec', element: <CrowdSecPage /> }, { path: 'crowdsec', element: <CrowdSecPage /> },

View File

@@ -1,7 +1,7 @@
import type { import type {
Status, Status,
GlobalConfig, CentralState,
GlobalConfigResponse, CentralStateResponse,
HealthResponse, HealthResponse,
StatusResponse, StatusResponse,
NetworkInfo, NetworkInfo,
@@ -51,35 +51,35 @@ class ApiService {
// ======================================== // ========================================
// Global Config APIs (Wild Central level) // Global Config APIs (Wild Central level)
// Endpoint: /api/v1/config // Endpoint: /api/v1/state
// ======================================== // ========================================
async getConfig(): Promise<GlobalConfigResponse> { async getConfig(): Promise<CentralStateResponse> {
return this.request<GlobalConfigResponse>('/api/v1/config'); return this.request<CentralStateResponse>('/api/v1/state');
} }
async getConfigYaml(): Promise<string> { async getConfigYaml(): Promise<string> {
return this.requestText('/api/v1/config/yaml'); return this.requestText('/api/v1/state/yaml');
} }
async updateConfigYaml(yamlContent: string): Promise<StatusResponse> { async updateConfigYaml(yamlContent: string): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config/yaml', { return this.request<StatusResponse>('/api/v1/state/yaml', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'text/plain' }, headers: { 'Content-Type': 'text/plain' },
body: yamlContent body: yamlContent
}); });
} }
async createConfig(config: GlobalConfig): Promise<StatusResponse> { async createConfig(config: CentralState): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', { return this.request<StatusResponse>('/api/v1/state', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config) body: JSON.stringify(config)
}); });
} }
async updateConfig(config: GlobalConfig): Promise<StatusResponse> { async updateConfig(config: CentralState): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', { return this.request<StatusResponse>('/api/v1/state', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config) body: JSON.stringify(config)

View File

@@ -1,8 +1,8 @@
export { apiClient, ApiError } from './client'; export { apiClient, ApiError } from './client';
export * from './types'; export * from './types';
export { dnsmasqApi } from './dnsmasq'; export { dnsmasqApi } from './dnsmasq';
export { servicesApi, haproxyApi, ddnsApi, dhcpApi, secretsApi, crowdSecApi } from './networking'; export { domainsApi, haproxyApi, ddnsApi, dhcpApi, secretsApi, crowdSecApi } from './networking';
export type { RegisteredService, HaproxyStatus, HaproxyGenerateResult, DdnsStatus, DhcpLease, DhcpStaticLease, BackendStat, CrowdSecStatus, CrowdSecMachine, CrowdSecBouncer, CrowdSecDecision, CrowdSecProvisionResult } from './networking'; export type { RegisteredDomain, HaproxyStatus, HaproxyGenerateResult, DdnsStatus, DhcpLease, DhcpStaticLease, BackendStat, CrowdSecStatus, CrowdSecMachine, CrowdSecBouncer, CrowdSecDecision } from './networking';
export { vpnApi } from './vpn'; export { vpnApi } from './vpn';
export type { VpnStatus, VpnConfig, VpnPeer, VpnPeerConfig } from './vpn'; export type { VpnStatus, VpnConfig, VpnPeer, VpnPeerConfig } from './vpn';
export { certApi } from './cert'; export { certApi } from './cert';

View File

@@ -1,6 +1,6 @@
import { apiClient } from './client'; import { apiClient } from './client';
// Services // Domains
export interface HeaderConfig { export interface HeaderConfig {
request?: Record<string, string>; request?: Record<string, string>;
@@ -18,7 +18,7 @@ export interface Route {
ipAllow?: string[]; ipAllow?: string[];
} }
export interface RegisteredService { export interface RegisteredDomain {
domain: string; domain: string;
source: string; source: string;
subdomains: boolean; subdomains: boolean;
@@ -32,15 +32,15 @@ export interface RegisteredService {
routes?: Route[]; routes?: Route[];
} }
export const servicesApi = { export const domainsApi = {
async list(): Promise<{ services: RegisteredService[] }> { async list(): Promise<{ domains: RegisteredDomain[] }> {
return apiClient.get('/api/v1/services'); return apiClient.get('/api/v1/domains');
}, },
async register(svc: { domain: string; backend: { address: string; type: string; health?: string }; public?: boolean; subdomains?: boolean; source?: string }): Promise<{ message: string; service: RegisteredService }> { async register(dom: { domain: string; backend: { address: string; type: string; health?: string }; public?: boolean; subdomains?: boolean; source?: string; tls?: string }): Promise<{ message: string; domain: RegisteredDomain }> {
return apiClient.post('/api/v1/services', svc); return apiClient.post('/api/v1/domains', dom);
}, },
async deregister(domain: string): Promise<{ message: string }> { async deregister(domain: string): Promise<{ message: string }> {
return apiClient.delete(`/api/v1/services/${domain}`); return apiClient.delete(`/api/v1/domains/${domain}`);
}, },
}; };

View File

@@ -7,7 +7,7 @@ export interface Status {
// ======================================== // ========================================
// Global Config Types (Wild Central level) // Global Config Types (Wild Central level)
// Endpoint: /api/v1/config // Endpoint: /api/v1/state
// File: {dataDir}/state.yaml // File: {dataDir}/state.yaml
// ======================================== // ========================================
@@ -17,7 +17,7 @@ export interface HAProxyCustomRoute {
backend: string; backend: string;
} }
export interface GlobalConfig { export interface CentralState {
operator?: { operator?: {
email?: string; email?: string;
}; };
@@ -48,15 +48,14 @@ export interface GlobalConfig {
ddns?: { ddns?: {
enabled?: boolean; enabled?: boolean;
provider?: string; provider?: string;
records?: string[];
intervalMinutes?: number; intervalMinutes?: number;
}; };
}; };
} }
export interface GlobalConfigResponse { export interface CentralStateResponse {
configured: boolean; configured: boolean;
config?: GlobalConfig; state?: CentralState;
message?: string; message?: string;
} }