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
- **DDNS** (Cloudflare) — A record + CNAME management
- **Security** (CrowdSec) — intrusion detection
- **NATS JetStream** — coordination bus for service registration, presence, and events
- **Service Registration** — the contract API for Wild Cloud and Wild Works
- **NATS JetStream** — coordination bus for domain registration, presence, and events
- **Domain Registration** — the contract API for Wild Cloud and Wild Works
## Architecture
@@ -21,7 +21,7 @@ Wild Central is a Go service that manages Linux networking daemons via config fi
**Consumers:**
- **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.
@@ -52,23 +52,23 @@ make check # Lint + test
Runtime state is persisted in `{WILD_CENTRAL_DATA_DIR}/`:
- `state.yaml` — operator, domain, firewall, DDNS, DHCP settings (managed via API)
- `secrets.yaml` — API tokens and credentials
- `services/` — per-domain service registration files
- `domains/` — per-domain registration files
- `nats/` — embedded NATS JetStream data
- `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
GET /api/v1/services List all services
GET /api/v1/services/{name} Get service details
PATCH /api/v1/services/{name} Update service
DELETE /api/v1/services/{name} Deregister service
POST /api/v1/domains Register a domain
GET /api/v1/domains List all domains
GET /api/v1/domains/{domain} Get domain details
PATCH /api/v1/domains/{domain} Update domain
DELETE /api/v1/domains/{domain} Deregister domain
```
### Service payload
### Domain payload
```json
{
@@ -87,10 +87,10 @@ DELETE /api/v1/services/{name} Deregister service
### Backend types
- `tcp-passthrough` — L4, SNI routing, backend handles TLS (Wild Cloud k8s)
- `http` — L7, Central terminates TLS with wildcard cert (Wild Works services)
- `static` — L7, static file serving (Wild Works frontends)
- `http` — L7, Central terminates TLS with wildcard cert (Wild Works)
- `dns-only` — DNS resolution only, no gateway routing (SSH, game servers, etc.)
### Public
- `false` (default) — LAN-visible only (DNS + proxy + TLS)
- `false` (default) — LAN-visible only (DNS + gateway + TLS)
- `true` — internet-visible (+ DDNS or tunnel exposure)

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
@@ -25,7 +26,7 @@ import (
"github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/nftables"
"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/storage"
"github.com/wild-cloud/wild-central/internal/wireguard"
@@ -46,7 +47,7 @@ type API struct {
crowdsec *crowdsec.Manager
vpn *wireguard.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
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(),
vpn: wireguard.NewManager(dataDir, vpnConfigPath),
certbot: certbot.NewManager(""),
services: services.NewManager(dataDir),
domains: domains.NewManager(dataDir),
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.
api.services.SetReconcileFn(api.reconcileNetworking)
api.domains.SetReconcileFn(api.reconcileNetworking)
return api, nil
}
@@ -107,30 +108,49 @@ func envOrDefault(key, defaultVal string) string {
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) {
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() {
globalCfg, err := config.LoadState(api.statePath())
if err != nil || !globalCfg.Cloud.DDNS.Enabled {
state, _ := config.LoadState(api.statePath())
if state == nil || !state.Cloud.DDNS.Enabled {
api.ddns.Stop()
return
}
apiToken := api.getCloudflareToken()
cfg := ddns.Config{
Enabled: true,
APIToken: apiToken,
Records: globalCfg.Cloud.DDNS.Records,
IntervalMinutes: globalCfg.Cloud.DDNS.IntervalMinutes,
if !api.ddns.GetStatus().Enabled {
api.ddns.Start(api.ctx, api.ddnsParams)
} else {
api.ddns.Trigger()
}
api.ddns.Start(api.ctx, cfg)
}
// 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) {
r.Use(RequestLoggingMiddleware)
// Global Configuration
r.HandleFunc("/api/v1/config", api.GetGlobalConfig).Methods("GET")
r.HandleFunc("/api/v1/config", api.UpdateGlobalConfig).Methods("PUT")
// Global State (runtime settings — operator, DDNS, DHCP, etc.)
r.HandleFunc("/api/v1/state", api.GetState).Methods("GET")
r.HandleFunc("/api/v1/state", api.UpdateState).Methods("PUT")
// Global Secrets
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.CloudflareSetZoneID).Methods("POST")
// Service registration — domain is the unique key
r.HandleFunc("/api/v1/services", api.ServicesListAll).Methods("GET")
r.HandleFunc("/api/v1/services", api.ServicesRegister).Methods("POST")
r.HandleFunc("/api/v1/services/deregister", api.ServicesDeregisterBySource).Methods("DELETE")
r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesGet).Methods("GET")
r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesUpdate).Methods("PATCH")
r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesDeregister).Methods("DELETE")
// Domain registration — domain is the unique key
r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET")
r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST")
r.HandleFunc("/api/v1/domains/deregister", api.DomainsDeregisterBySource).Methods("DELETE")
r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsGet).Methods("GET")
r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsUpdate).Methods("PATCH")
r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsDeregister).Methods("DELETE")
// SSE events
r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET")
@@ -242,10 +262,9 @@ func (api *API) RegisterRoutes(r *mux.Router) {
// --- Global Config/Secrets handlers ---
// GetGlobalConfig returns the global state wrapped in { configured, config }.
func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) {
configPath := api.statePath()
data, err := os.ReadFile(configPath)
// GetState returns the global state wrapped in { configured, state }.
func (api *API) GetState(w http.ResponseWriter, r *http.Request) {
data, err := os.ReadFile(api.statePath())
if err != nil {
respondJSON(w, http.StatusOK, map[string]any{
"configured": false,
@@ -253,20 +272,20 @@ func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) {
return
}
var configMap map[string]any
if err := yaml.Unmarshal(data, &configMap); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to parse config")
var stateMap map[string]any
if err := yaml.Unmarshal(data, &stateMap); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to parse state")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"configured": true,
"config": configMap,
"state": stateMap,
})
}
// UpdateGlobalConfig updates the global state
func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
// UpdateState updates the global state
func (api *API) UpdateState(w http.ResponseWriter, r *http.Request) {
configPath := api.statePath()
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
go api.reloadDDNSIfEnabled()
go api.EnsureCentralService()
go api.EnsureCentralDomain()
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(),
"uptimeSeconds": int(uptime.Seconds()),
"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
func (api *API) DaemonRestart(w http.ResponseWriter, r *http.Request) {
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/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:
@@ -24,31 +24,30 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
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{}
seen := map[string]bool{}
svcs, _ := api.services.List()
for _, svc := range svcs {
if svc.TLS != services.TLSTerminate || svc.Domain == "" {
doms, _ := api.domains.List()
for _, dom := range doms {
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
continue
}
if seen[svc.Domain] {
if seen[dom.DomainName] {
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{
"domain": svc.Domain,
"service": svc.Domain,
"source": svc.Source,
"cert": status,
"domain": dom.DomainName,
"source": dom.Source,
"cert": status,
}
// 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 {
wildcardBase := parts[1]
wildcardStatus := api.certbot.GetStatus(wildcardBase)

View File

@@ -13,7 +13,7 @@ import (
"gopkg.in/yaml.v3"
)
func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) {
func TestGetState_ReturnsWrappedResponse(t *testing.T) {
api, tmpDir := setupTestAPI(t)
// Write a config with a central domain
@@ -30,10 +30,10 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) {
data, _ := yaml.Marshal(cfg)
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()
api.GetGlobalConfig(w, req)
api.GetState(w, req)
if w.Code != http.StatusOK {
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
json.Unmarshal(w.Body.Bytes(), &resp)
// Must have "configured" and "config" keys
// Must have "configured" and "state" keys
if resp["configured"] != true {
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 {
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
cloud, _ := config["cloud"].(map[string]any)
cloud, _ := state["cloud"].(map[string]any)
central, _ := cloud["central"].(map[string]any)
if central["domain"] != "central.example.com" {
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)
// Ensure no state file exists
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()
api.GetGlobalConfig(w, req)
api.GetState(w, req)
if w.Code != http.StatusOK {
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)
update := map[string]any{
@@ -158,10 +158,10 @@ func TestUpdateGlobalConfig(t *testing.T) {
}
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()
api.UpdateGlobalConfig(w, req)
api.UpdateState(w, req)
if w.Code != http.StatusOK {
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())
}
// 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) {
if api.ddns == nil {
respondError(w, http.StatusServiceUnavailable, "DDNS not configured")
return
}
api.ddns.Trigger()
api.reloadDDNSIfEnabled()
respondJSON(w, http.StatusOK, map[string]string{
"message": "DDNS update triggered",
})

View File

@@ -31,7 +31,7 @@ func setupTestDnsmasq(t *testing.T) (*API, string) {
func TestDnsmasqGenerate(t *testing.T) {
api, tmpDir := setupTestDnsmasq(t)
globalConfig := config.GlobalConfig{}
globalConfig := config.State{}
globalConfig.Cloud.Central.Domain = "central.example.com"
configPath := filepath.Join(tmpDir, "state.yaml")
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"
)
func TestServicesRegister(t *testing.T) {
func TestDomainsRegister(t *testing.T) {
api, _ := setupTestAPI(t)
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()
api.ServicesRegister(w, req)
api.DomainsRegister(w, req)
if w.Code != http.StatusCreated {
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
json.Unmarshal(w.Body.Bytes(), &resp)
svc := resp["service"].(map[string]any)
if svc["domain"] != "my-api.payne.io" {
t.Errorf("expected domain my-api.payne.io, got %v", svc["domain"])
dom := resp["domain"].(map[string]any)
if dom["domain"] != "my-api.payne.io" {
t.Errorf("expected domain my-api.payne.io, got %v", dom["domain"])
}
if svc["tls"] != "terminate" {
t.Errorf("expected tls=terminate default for http, got %v", svc["tls"])
if dom["tls"] != "terminate" {
t.Errorf("expected tls=terminate default for http, got %v", dom["tls"])
}
if svc["source"] != "wild-works" {
t.Errorf("expected source wild-works, got %v", svc["source"])
if dom["source"] != "wild-works" {
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)
body, _ := json.Marshal(map[string]any{
@@ -59,9 +59,9 @@ func TestServicesRegister_TCPPassthrough(t *testing.T) {
"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()
api.ServicesRegister(w, req)
api.DomainsRegister(w, req)
if w.Code != http.StatusCreated {
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
json.Unmarshal(w.Body.Bytes(), &resp)
svc := resp["service"].(map[string]any)
if svc["tls"] != "passthrough" {
t.Errorf("expected tls=passthrough for tcp-passthrough, got %v", svc["tls"])
dom := resp["domain"].(map[string]any)
if dom["tls"] != "passthrough" {
t.Errorf("expected tls=passthrough for tcp-passthrough, got %v", dom["tls"])
}
if svc["subdomains"] != true {
t.Errorf("expected subdomains=true, got %v", svc["subdomains"])
if dom["subdomains"] != true {
t.Errorf("expected subdomains=true, got %v", dom["subdomains"])
}
}
func TestServicesListAll_Empty(t *testing.T) {
func TestDomainsListAll_Empty(t *testing.T) {
api, _ := setupTestAPI(t)
req := httptest.NewRequest("GET", "/api/v1/services", nil)
req := httptest.NewRequest("GET", "/api/v1/domains", nil)
w := httptest.NewRecorder()
api.ServicesListAll(w, req)
api.DomainsListAll(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
@@ -92,13 +92,13 @@ func TestServicesListAll_Empty(t *testing.T) {
var resp map[string]any
json.Unmarshal(w.Body.Bytes(), &resp)
svcs := resp["services"].([]any)
if len(svcs) != 0 {
t.Errorf("expected empty, got %d", len(svcs))
doms := resp["domains"].([]any)
if len(doms) != 0 {
t.Errorf("expected empty, got %d", len(doms))
}
}
func TestServicesGet(t *testing.T) {
func TestDomainsGet(t *testing.T) {
api, _ := setupTestAPI(t)
// Register
@@ -107,41 +107,41 @@ func TestServicesGet(t *testing.T) {
"source": "test",
"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()
api.ServicesRegister(w, req)
api.DomainsRegister(w, req)
// 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"})
w = httptest.NewRecorder()
api.ServicesGet(w, req)
api.DomainsGet(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var svc map[string]any
json.Unmarshal(w.Body.Bytes(), &svc)
if svc["domain"] != "get-test.example.com" {
t.Errorf("expected domain get-test.example.com, got %v", svc["domain"])
var dom map[string]any
json.Unmarshal(w.Body.Bytes(), &dom)
if dom["domain"] != "get-test.example.com" {
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)
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"})
w := httptest.NewRecorder()
api.ServicesGet(w, req)
api.DomainsGet(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestServicesUpdate(t *testing.T) {
func TestDomainsUpdate(t *testing.T) {
api, _ := setupTestAPI(t)
// Register
@@ -150,16 +150,16 @@ func TestServicesUpdate(t *testing.T) {
"source": "test",
"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()
api.ServicesRegister(w, req)
api.DomainsRegister(w, req)
// Update public to 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"})
w = httptest.NewRecorder()
api.ServicesUpdate(w, req)
api.DomainsUpdate(w, req)
if w.Code != http.StatusOK {
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
json.Unmarshal(w.Body.Bytes(), &resp)
svc := resp["service"].(map[string]any)
if svc["public"] != true {
t.Errorf("expected public=true, got %v", svc["public"])
dom := resp["domain"].(map[string]any)
if dom["public"] != true {
t.Errorf("expected public=true, got %v", dom["public"])
}
}
func TestServicesDeregister(t *testing.T) {
func TestDomainsDeregister(t *testing.T) {
api, _ := setupTestAPI(t)
// Register
@@ -182,40 +182,40 @@ func TestServicesDeregister(t *testing.T) {
"source": "test",
"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()
api.ServicesRegister(w, req)
api.DomainsRegister(w, req)
// 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"})
w = httptest.NewRecorder()
api.ServicesDeregister(w, req)
api.DomainsDeregister(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// 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"})
w = httptest.NewRecorder()
api.ServicesGet(w, req)
api.DomainsGet(w, req)
if w.Code != http.StatusNotFound {
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)
// Missing domain
body, _ := json.Marshal(map[string]any{
"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()
api.ServicesRegister(w, req)
api.DomainsRegister(w, req)
if w.Code != http.StatusBadRequest {
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)
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
// without touching HAProxy. Called when firewall settings change independently.
// 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
// Explicitly disabled: flush the wild-cloud table

View File

@@ -8,55 +8,53 @@ import (
"testing"
"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/services"
)
// TestReconciliation_HAProxyConfigFromServices verifies that the reconciliation
// logic correctly maps registered services to HAProxy configuration. This
// TestReconciliation_HAProxyConfigFromDomains verifies that the reconciliation
// logic correctly maps registered domains to HAProxy configuration. This
// replicates the route-building logic from reconcileNetworking() and asserts
// 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)
// Register a mix of services like a real deployment
testServices := []services.Service{
// Register a mix of domains like a real deployment
testDomains := []domains.Domain{
{
Domain: "cloud.payne.io",
DomainName: "cloud.payne.io",
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,
Public: true,
Public: true,
},
{
Domain: "payne.io",
DomainName: "payne.io",
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,
Public: true,
Public: true,
},
{
Domain: "wild-cloud.payne.io",
Source: "wild-works",
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
// Public defaults to false
DomainName: "wild-cloud.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP},
},
{
Domain: "my-api.payne.io",
Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP, Health: "/health"},
// Public defaults to false
DomainName: "my-api.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP, Health: "/health"},
},
}
for _, svc := range testServices {
if err := api.services.Register(svc); err != nil {
t.Fatalf("Register %s failed: %v", svc.Domain, err)
for _, dom := range testDomains {
if err := api.domains.Register(dom); err != nil {
t.Fatalf("Register %s failed: %v", dom.DomainName, err)
}
}
// Replicate reconcileNetworking route-building logic
svcs, err := api.services.List()
doms, err := api.domains.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
@@ -64,50 +62,50 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
var instanceRoutes []haproxy.InstanceRoute
var httpRoutes []haproxy.HTTPRoute
for _, svc := range svcs {
switch svc.Backend.Type {
case services.BackendTCPPassthrough:
for _, dom := range doms {
switch dom.Backend.Type {
case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
Name: svc.Domain,
Domain: svc.Domain,
BackendIP: extractHost(svc.Backend.Address),
Subdomains: svc.Subdomains,
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.Backend.Address),
Subdomains: dom.Subdomains,
})
case services.BackendHTTP:
case domains.BackendHTTP:
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Domain,
Domain: svc.Domain,
Name: dom.DomainName,
Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{
Backend: svc.Backend.Address,
HealthPath: svc.Backend.Health,
Backend: dom.Backend.Address,
HealthPath: dom.Backend.Health,
}},
})
}
}
// Central registers itself as a service; add it to the test data
if err := api.services.Register(services.Service{
Domain: "central.payne.io",
Source: "central",
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
TLS: services.TLSTerminate,
// Central registers itself as a domain; add it to the test data
if err := api.domains.Register(domains.Domain{
DomainName: "central.payne.io",
Source: "central",
Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP},
TLS: domains.TLSTerminate,
}); err != nil {
t.Fatalf("Register central failed: %v", err)
}
// Re-list to pick up Central
svcs, err = api.services.List()
doms, err = api.domains.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
httpRoutes = nil
for _, svc := range svcs {
if svc.Backend.Type == services.BackendHTTP {
for _, dom := range doms {
if dom.Backend.Type == domains.BackendHTTP {
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Domain,
Domain: svc.Domain,
Name: dom.DomainName,
Domain: dom.DomainName,
Routes: []haproxy.HTTPRouteBackend{{
Backend: svc.Backend.Address,
HealthPath: svc.Backend.Health,
Backend: dom.Backend.Address,
HealthPath: dom.Backend.Health,
}},
})
}
@@ -120,7 +118,7 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
// --- 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") {
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)
}
// 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") {
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") {
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
// correctly generates dnsmasq config from registered services, including
// TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation
// correctly generates dnsmasq config from registered domains, including
// the critical reach-based local=/ directives.
func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) {
func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register services with different reach and backend types
testServices := []services.Service{
// Register domains with different reach and backend types
testDomains := []domains.Domain{
{
// tcp-passthrough, public → DNS points to k8s LB IP, no local=/
Domain: "cloud.payne.io",
DomainName: "cloud.payne.io",
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,
Public: true,
Public: true,
},
{
// http, internal → DNS points to Central IP, has local=/
Domain: "my-api.payne.io",
Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP},
// Public defaults to false
DomainName: "my-api.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
},
{
// http, public → DNS points to Central IP, NO local=/
Domain: "public-app.payne.io",
Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:8080", Type: services.BackendHTTP},
Public: true,
DomainName: "public-app.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP},
Public: true,
},
}
for _, svc := range testServices {
if err := api.services.Register(svc); err != nil {
t.Fatalf("Register %s failed: %v", svc.Domain, err)
for _, dom := range testDomains {
if err := api.domains.Register(dom); err != nil {
t.Fatalf("Register %s failed: %v", dom.DomainName, err)
}
}
// Build dnsmasq instance configs the same way reconcileNetworking does
svcs, err := api.services.List()
doms, err := api.domains.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
globalCfg := &config.GlobalConfig{}
globalCfg := &config.State{}
var instanceConfigs []config.InstanceConfig
for _, svc := range svcs {
if svc.Domain == "" {
for _, dom := range doms {
if dom.DomainName == "" {
continue
}
dnsIP := centralIP
if svc.Backend.Type == services.BackendTCPPassthrough {
dnsIP = extractHost(svc.Backend.Address)
if dom.Backend.Type == domains.BackendTCPPassthrough {
dnsIP = extractHost(dom.Backend.Address)
}
ic := config.InstanceConfig{}
ic.Cluster.LoadBalancerIp = dnsIP
if !svc.Public {
ic.Cloud.InternalDomain = svc.Domain
if !dom.Public {
ic.Cloud.InternalDomain = dom.DomainName
} else {
ic.Cloud.Domain = svc.Domain
ic.Cloud.Domain = dom.DomainName
}
instanceConfigs = append(instanceConfigs, ic)
@@ -243,69 +240,67 @@ func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) {
// --- TCP passthrough (public) → backend IP ---
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") {
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") {
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/") {
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/") {
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/") {
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
// as a service, produces correct HAProxy L7 routes.
// as a domain, produces correct HAProxy L7 routes.
func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
api, _ := setupTestAPI(t)
centralPort := 15055
// Register Central as a service (as EnsureCentralService does)
if err := api.services.Register(services.Service{
Domain: "central.payne.io",
Source: "central",
Backend: services.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: services.BackendHTTP},
// Public defaults to false
TLS: services.TLSTerminate,
// Register Central as a domain (as EnsureCentralDomain does)
if err := api.domains.Register(domains.Domain{
DomainName: "central.payne.io",
Source: "central",
Backend: domains.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: domains.BackendHTTP},
TLS: domains.TLSTerminate,
}); err != nil {
t.Fatalf("Register central failed: %v", err)
}
// Register another HTTP service
if err := api.services.Register(services.Service{
Domain: "my-app.payne.io",
Source: "wild-works",
Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP},
// Public defaults to false
// Register another HTTP domain
if err := api.domains.Register(domains.Domain{
DomainName: "my-app.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
svcs, _ := api.services.List()
doms, _ := api.domains.List()
var httpRoutes []haproxy.HTTPRoute
for _, svc := range svcs {
if svc.Backend.Type == services.BackendHTTP {
for _, dom := range doms {
if dom.Backend.Type == domains.BackendHTTP {
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Domain,
Domain: svc.Domain,
Name: dom.DomainName,
Domain: dom.DomainName,
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
// 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) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register a k8s instance with tcp-passthrough
if err := api.services.Register(services.Service{
Domain: "cloud.payne.io",
if err := api.domains.Register(domains.Domain{
DomainName: "cloud.payne.io",
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,
Public: true,
Public: true,
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
svcs, _ := api.services.List()
globalCfg := &config.GlobalConfig{}
doms, _ := api.domains.List()
globalCfg := &config.State{}
var instanceConfigs []config.InstanceConfig
for _, svc := range svcs {
for _, dom := range doms {
dnsIP := centralIP
if svc.Backend.Type == services.BackendTCPPassthrough {
dnsIP = extractHost(svc.Backend.Address)
if dom.Backend.Type == domains.BackendTCPPassthrough {
dnsIP = extractHost(dom.Backend.Address)
}
ic := config.InstanceConfig{}
ic.Cluster.LoadBalancerIp = dnsIP
if !svc.Public {
ic.Cloud.InternalDomain = svc.Domain
if !dom.Public {
ic.Cloud.InternalDomain = dom.DomainName
} else {
ic.Cloud.Domain = svc.Domain
ic.Cloud.Domain = dom.DomainName
}
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.
func writeTestConfig(t *testing.T, dataDir, centralDomain string) {
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.SetPort(15055)
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 {
t.Fatalf("expected Central service to be registered: %v", err)
t.Fatalf("expected Central domain to be registered: %v", err)
}
if svc.Source != "central" {
t.Errorf("source = %q, want %q", svc.Source, "central")
if dom.Source != "central" {
t.Errorf("source = %q, want %q", dom.Source, "central")
}
if svc.Backend.Address != "127.0.0.1:15055" {
t.Errorf("backend = %q, want %q", svc.Backend.Address, "127.0.0.1:15055")
if dom.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 {
t.Errorf("tls = %q, want %q", svc.TLS, services.TLSTerminate)
if dom.TLS != domains.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.SetPort(15055)
writeTestConfig(t, dataDir, "central.example.com")
api.EnsureCentralService()
api.EnsureCentralService() // second call should be a no-op
api.EnsureCentralDomain()
api.EnsureCentralDomain() // second call should be a no-op
svcs, _ := api.services.List()
doms, _ := api.domains.List()
count := 0
for _, s := range svcs {
if s.Source == "central" {
for _, d := range doms {
if d.Source == "central" {
count++
}
}
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.SetPort(15055)
// Register with old domain
writeTestConfig(t, dataDir, "old.example.com")
api.EnsureCentralService()
api.EnsureCentralDomain()
// Change domain
writeTestConfig(t, dataDir, "new.example.com")
api.EnsureCentralService()
api.EnsureCentralDomain()
// Old domain should be gone
if _, err := api.services.Get("old.example.com"); err == nil {
t.Error("old Central service should have been deregistered")
if _, err := api.domains.Get("old.example.com"); err == nil {
t.Error("old Central domain should have been deregistered")
}
// New domain should exist
svc, err := api.services.Get("new.example.com")
dom, err := api.domains.Get("new.example.com")
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" {
t.Errorf("source = %q, want %q", svc.Source, "central")
if dom.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.SetPort(15055)
// Default config has no Central domain
api.EnsureCentralService()
api.EnsureCentralDomain()
svcs, _ := api.services.List()
for _, s := range svcs {
if s.Source == "central" {
t.Error("no Central service should be registered when domain is empty")
doms, _ := api.domains.List()
for _, d := range doms {
if d.Source == "central" {
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/config"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/services"
"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
// other service. Called on startup and when global config changes.
func (api *API) EnsureCentralService() {
// other domain. Called on startup and when global config changes.
func (api *API) EnsureCentralDomain() {
centralDomain := api.getCentralDomain()
port := api.getRunningPort()
backend := fmt.Sprintf("127.0.0.1:%d", port)
// Check if the current registration already matches — skip if so.
if centralDomain != "" {
if existing, _ := api.services.Get(centralDomain); existing != nil &&
if existing, _ := api.domains.Get(centralDomain); existing != nil &&
existing.Source == "central" &&
existing.Backend.Address == backend {
return
@@ -33,10 +33,10 @@ func (api *API) EnsureCentralService() {
}
// Clean up stale Central registrations (e.g. domain changed).
svcs, _ := api.services.List()
for _, svc := range svcs {
if svc.Source == "central" && svc.Domain != centralDomain {
_ = api.services.Deregister(svc.Domain)
doms, _ := api.domains.List()
for _, dom := range doms {
if dom.Source == "central" && dom.DomainName != centralDomain {
_ = api.domains.Deregister(dom.DomainName)
}
}
@@ -44,55 +44,56 @@ func (api *API) EnsureCentralService() {
return
}
_ = api.services.Register(services.Service{
Domain: centralDomain,
Source: "central",
Backend: services.Backend{
_ = api.domains.Register(domains.Domain{
DomainName: centralDomain,
Source: "central",
Backend: domains.Backend{
Address: backend,
Type: services.BackendHTTP,
Type: domains.BackendHTTP,
},
// Public defaults to false (private)
TLS: services.TLSTerminate,
TLS: domains.TLSTerminate,
})
}
// 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() {
api.reconcileNetworking()
}
// reconcileNetworking reads all registered services and regenerates
// reconcileNetworking reads all registered domains and regenerates
// dnsmasq DNS entries and HAProxy routes to match.
func (api *API) reconcileNetworking() {
svcs, err := api.services.List()
doms, err := api.domains.List()
if err != nil {
slog.Error("reconcile: failed to list services", "error", err)
slog.Error("reconcile: failed to list domains", "error", err)
return
}
globalCfg, err := config.LoadState(api.statePath())
if err != nil {
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 httpRoutes []haproxy.HTTPRoute
for _, svc := range svcs {
switch svc.EffectiveBackendType() {
case services.BackendTCPPassthrough:
for _, dom := range doms {
switch dom.EffectiveBackendType() {
case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
Name: svc.Domain,
Domain: svc.Domain,
BackendIP: extractHost(svc.EffectiveBackendAddress()),
Subdomains: svc.Subdomains,
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.EffectiveBackendAddress()),
Subdomains: dom.Subdomains,
})
case services.BackendHTTP:
case domains.BackendDNSOnly:
// dns-only: no gateway routing, just DNS resolution
case domains.BackendHTTP:
var routeBackends []haproxy.HTTPRouteBackend
for _, r := range svc.EffectiveRoutes() {
for _, r := range dom.EffectiveRoutes() {
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{
Paths: r.Paths,
Backend: r.Backend.Address,
@@ -102,15 +103,15 @@ func (api *API) reconcileNetworking() {
})
}
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: svc.Domain,
Domain: svc.Domain,
Subdomains: svc.Subdomains,
Name: dom.DomainName,
Domain: dom.DomainName,
Subdomains: dom.Subdomains,
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/"
var activeHTTPRoutes []haproxy.HTTPRoute
for _, route := range httpRoutes {
@@ -129,10 +130,10 @@ func (api *API) reconcileNetworking() {
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, genOpts)
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()))
if len(brokenDomains) > 0 {
slog.Error("reconcile: excluding broken services and retrying",
slog.Error("reconcile: excluding broken domains and retrying",
"broken", brokenDomains, "error", err)
exclude := map[string]bool{}
@@ -161,22 +162,22 @@ func (api *API) reconcileNetworking() {
if err := api.haproxy.ReloadService(); err != nil {
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
} else {
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken services)")
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken domains)")
}
}
} 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 {
if err := api.haproxy.ReloadService(); err != nil {
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
} 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.
// DNS target IP depends on service type:
// Generate dnsmasq DNS entries from registered domains.
// DNS target IP depends on domain type:
// tcp-passthrough (k8s): DNS → k8s LB IP (LAN traffic goes direct to k8s)
// http/static: DNS → Central IP (HAProxy terminates TLS)
// 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
for _, svc := range svcs {
if svc.Domain == "" {
for _, dom := range doms {
if dom.DomainName == "" {
continue
}
// Choose the DNS target IP based on service type
// Choose the DNS target IP based on domain type
dnsIP := centralIP
if svc.EffectiveBackendType() == services.BackendTCPPassthrough {
// k8s instances: LAN clients connect directly to the k8s LB
dnsIP = extractHost(svc.EffectiveBackendAddress())
bt := dom.EffectiveBackendType()
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
// tcp-passthrough/dns-only: DNS resolves to the backend IP directly
dnsIP = extractHost(dom.EffectiveBackendAddress())
}
ic := config.InstanceConfig{}
ic.Cluster.LoadBalancerIp = dnsIP
if !svc.Public {
if !dom.Public {
// Internal-only: local=/ prevents upstream DNS forwarding
ic.Cloud.InternalDomain = svc.Domain
ic.Cloud.InternalDomain = dom.DomainName
} else {
ic.Cloud.Domain = svc.Domain
ic.Cloud.Domain = dom.DomainName
}
instanceConfigs = append(instanceConfigs, ic)
@@ -218,51 +220,55 @@ func (api *API) reconcileNetworking() {
if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil {
slog.Error("reconcile: failed to update dnsmasq", "error", err)
} 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).
// For services under the gateway domain, a wildcard cert covers them all.
// Ensure TLS certs exist for HTTP domains (where Central terminates TLS).
// For domains under the gateway domain, a wildcard cert covers them all.
// For others, provision individual certs.
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",
"services", len(svcs),
"domains", len(doms),
"l4Routes", len(instanceRoutes),
"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
// 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 := ""
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1]
}
for _, svc := range svcs {
if svc.TLS != services.TLSTerminate || svc.Domain == "" {
for _, dom := range doms {
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
continue
}
// Check if covered by wildcard
if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) {
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
wildcardCert := certbot.HAProxyCertPath(gatewayDomain)
if _, err := os.Stat(wildcardCert); err != nil {
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
}
// Check individual cert
if _, err := os.Stat(certbot.HAProxyCertPath(svc.Domain)); err != nil {
slog.Warn("reconcile/tls: no cert for service — provision via Certificates page",
"domain", svc.Domain)
if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil {
slog.Warn("reconcile/tls: no cert for domain — provision via Certificates page",
"domain", dom.DomainName)
}
}
}
@@ -290,9 +296,6 @@ func hasCertForDomain(certsDir, domain string) bool {
}
for _, e := range entries {
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
}
}
@@ -324,7 +327,7 @@ func (api *API) broadcastDnsmasqEvent(eventType string, message string) {
event := &sse.Event{
ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global", // dnsmasq is global/central-level, not instance-specific
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"message": message,

View File

@@ -79,8 +79,10 @@ type DHCPStaticLease struct {
Hostname string `yaml:"hostname,omitempty" json:"hostname,omitempty"`
}
// GlobalConfig represents the main configuration structure
type GlobalConfig struct {
// State represents Wild Central's runtime state — operator settings, networking
// 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 {
Email string `yaml:"email,omitempty" json:"email,omitempty"`
} `yaml:"operator,omitempty" json:"operator,omitempty"`
@@ -109,22 +111,21 @@ type GlobalConfig struct {
ExtraPorts []ExtraPort `yaml:"extraPorts,omitempty" json:"extraPorts,omitempty"`
} `yaml:"nftables,omitempty" json:"nftables,omitempty"`
DDNS struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,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"`
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"`
} `yaml:"ddns,omitempty" json:"ddns,omitempty"`
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
}
// LoadState loads state from the specified path
func LoadState(configPath string) (*GlobalConfig, error) {
func LoadState(configPath string) (*State, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
}
config := &GlobalConfig{}
config := &State{}
if err := yaml.Unmarshal(data, config); err != nil {
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
func SaveState(config *GlobalConfig, configPath string) error {
func SaveState(config *State, configPath string) error {
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
@@ -148,7 +149,7 @@ func SaveState(config *GlobalConfig, configPath string) error {
}
// IsEmpty checks if the configuration is empty or uninitialized
func (c *GlobalConfig) IsEmpty() bool {
func (c *State) IsEmpty() bool {
if c == nil {
return true
}

View File

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

View File

@@ -10,7 +10,7 @@ import (
func TestExtraPortsJSONDecode_Legacy(t *testing.T) {
// Legacy format: plain integer array
input := `{"cloud":{"nftables":{"extraPorts":[22]}}}`
var cfg GlobalConfig
var cfg State
if err := json.Unmarshal([]byte(input), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
@@ -22,7 +22,7 @@ func TestExtraPortsJSONDecode_Legacy(t *testing.T) {
func TestExtraPortsJSONDecode_Structured(t *testing.T) {
// New structured format
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 {
t.Fatalf("unmarshal: %v", err)
}
@@ -38,7 +38,7 @@ func TestExtraPortsJSONDecode_Structured(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"}}
out, err := json.Marshal(cfg)
if err != nil {
@@ -68,7 +68,7 @@ func TestExtraPortsJSONEncode(t *testing.T) {
func TestExtraPortsYAML_LegacyRoundTrip(t *testing.T) {
// Legacy YAML format with plain integers
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 {
t.Fatalf("yaml unmarshal: %v", err)
}
@@ -82,7 +82,7 @@ func TestExtraPortsYAML_LegacyRoundTrip(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"
var cfg GlobalConfig
var cfg State
if err := yaml.Unmarshal([]byte(input), &cfg); err != nil {
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
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 nil

View File

@@ -13,14 +13,18 @@ import (
"time"
)
// Config holds the DDNS configuration
type Config struct {
Enabled bool
// Params holds the runtime parameters for DDNS sync — derived from system state
// (public domains, API token, interval) on each tick via ParamsFunc.
type Params struct {
APIToken string
Records []string
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.
type RecordStatus struct {
Name string `json:"name"`
@@ -39,13 +43,16 @@ type Status struct {
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 {
mu sync.RWMutex
status Status
trigger chan struct{}
cancel context.CancelFunc
ipFetcher func() (string, error) // injectable for tests
mu sync.RWMutex
status Status
trigger chan struct{}
cancel context.CancelFunc
paramsFn ParamsFunc
ipFetcher func() (string, error) // injectable for tests
}
// NewRunner creates a new DDNS runner
@@ -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.
// Enabled is set to true here (not inside Run) to eliminate the race where
// the old goroutine's exit could clear Enabled after the new goroutine starts.
func (rn *Runner) Start(parentCtx context.Context, cfg Config) {
func (rn *Runner) Start(parentCtx context.Context, paramsFn ParamsFunc) {
rn.mu.Lock()
if rn.cancel != nil {
rn.cancel()
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.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(parentCtx)
rn.cancel = cancel
rn.status.Enabled = true
rn.status.CurrentIP = "" // clear so first check always syncs all records
rn.mu.Unlock()
go rn.Run(ctx, cfg)
go rn.run(ctx)
}
// Stop cancels the running DDNS goroutine if one is active.
@@ -97,7 +107,7 @@ func (rn *Runner) GetStatus() 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() {
select {
case rn.trigger <- struct{}{}:
@@ -105,23 +115,19 @@ func (rn *Runner) Trigger() {
}
}
// Run starts the DDNS polling loop. It exits when ctx is cancelled.
// Enabled state is managed by Start/Stop, not this function.
func (rn *Runner) Run(ctx context.Context, cfg Config) {
if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 {
slog.Info("DDNS disabled or not configured", "component", "ddns")
return
}
// run is the DDNS polling loop. Calls paramsFn on each tick for fresh state.
func (rn *Runner) run(ctx context.Context) {
p := rn.paramsFn()
interval := time.Duration(cfg.IntervalMinutes) * time.Minute
interval := time.Duration(p.IntervalMinutes) * time.Minute
if interval <= 0 {
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
rn.checkAndUpdate(cfg)
rn.checkAndUpdate()
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -132,21 +138,27 @@ func (rn *Runner) Run(ctx context.Context, cfg Config) {
slog.Info("DDNS stopped", "component", "ddns")
return
case <-ticker.C:
rn.checkAndUpdate(cfg)
rn.checkAndUpdate()
case <-rn.trigger:
rn.checkAndUpdate(cfg)
rn.checkAndUpdate()
}
}
}
// checkAndUpdate fetches the current public IP and reconciles all records.
// Records are always verified against Cloudflare, not just when the IP changes,
// so external drift (e.g. a router DDNS overwriting with a CNAME) is self-healed.
func (rn *Runner) checkAndUpdate(cfg Config) {
// checkAndUpdate fetches fresh params, the current public IP, and syncs all records.
// The updateCloudflareRecord function short-circuits when the A record already matches,
// so there's no wasted API calls when nothing has changed.
func (rn *Runner) checkAndUpdate() {
rn.mu.Lock()
rn.status.LastChecked = time.Now()
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()
if err != nil {
rn.mu.Lock()
@@ -156,21 +168,12 @@ func (rn *Runner) checkAndUpdate(cfg Config) {
return
}
rn.mu.RLock()
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)
slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.Records)
var lastErr string
records := make([]RecordStatus, 0, len(cfg.Records))
for _, record := range cfg.Records {
if err := updateCloudflareRecord(cfg.APIToken, record, ip); err != nil {
records := make([]RecordStatus, 0, len(p.Records))
for _, record := range p.Records {
if err := updateCloudflareRecord(p.APIToken, record, ip); err != nil {
lastErr = fmt.Sprintf("updating %s: %v", record, 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()})

View File

@@ -36,78 +36,12 @@ func TestTrigger_NonBlocking(t *testing.T) {
}
}
func TestRun_ExitsOnContextCancel(t *testing.T) {
rn := NewRunner()
ctx, cancel := context.WithCancel(context.Background())
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 staticParams(token string, records []string) ParamsFunc {
return func() Params {
return Params{APIToken: token, Records: records, IntervalMinutes: 5}
}
}
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) {
rn := NewRunner()
started := make(chan struct{}, 1)
@@ -119,8 +53,7 @@ func TestStart_SetsEnabledStatus(t *testing.T) {
return "1.2.3.4", nil
}
ctx := context.Background()
rn.Start(ctx, Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}})
rn.Start(context.Background(), staticParams("tok", []string{"a.example.com"}))
select {
case <-started:
@@ -129,7 +62,7 @@ func TestStart_SetsEnabledStatus(t *testing.T) {
}
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()
@@ -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_InvalidConfig_SetsDisabled(t *testing.T) {
for _, cfg := range []Config{
{Enabled: false},
{Enabled: true, APIToken: ""},
{Enabled: true, APIToken: "tok", Records: nil},
func TestStart_InvalidParams_SetsDisabled(t *testing.T) {
for _, pfn := range []ParamsFunc{
func() Params { return Params{} },
func() Params { return Params{APIToken: ""} },
func() Params { return Params{APIToken: "tok", Records: nil} },
} {
rn := NewRunner()
rn.Start(context.Background(), cfg)
rn.Start(context.Background(), pfn)
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) {
rn := NewRunner()
reached := make(chan struct{}, 2)
@@ -167,11 +97,9 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
return "1.2.3.4", nil
}
ctx := context.Background()
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}
rn.Start(ctx, cfg)
pfn := staticParams("tok", []string{"a.example.com"})
rn.Start(context.Background(), pfn)
// Wait for first goroutine to actually start
select {
case <-reached:
case <-time.After(time.Second):
@@ -179,9 +107,8 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
}
// 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 {
t.Error("expected Enabled=true after restart")
}
@@ -189,8 +116,6 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) {
rn.Stop()
}
// TestStop_ClearsEnabled verifies Stop sets Enabled=false.
func TestStop_ClearsEnabled(t *testing.T) {
rn := NewRunner()
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) {
rn := NewRunner()
@@ -219,10 +143,8 @@ func TestStart_CancelsAndRestarts(t *testing.T) {
return "1.2.3.4", nil
}
ctx := context.Background()
cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}
rn.Start(ctx, cfg)
pfn := staticParams("tok", []string{"a.example.com"})
rn.Start(context.Background(), pfn)
select {
case <-firstReady:
@@ -230,13 +152,12 @@ func TestStart_CancelsAndRestarts(t *testing.T) {
t.Fatal("first goroutine did not start")
}
// Unblock the first goroutine so the second Start can cancel it
close(block)
// Second Start with disabled config must not deadlock
// Second Start with disabled params must not deadlock
done := make(chan struct{})
go func() {
rn.Start(ctx, Config{Enabled: false})
rn.Start(context.Background(), func() Params { return Params{} })
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) {
rn := NewRunner()
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" {
t.Errorf("expected CurrentIP=5.6.7.8, got %q", rn.GetStatus().CurrentIP)
}
}
// TestStart_ClearsCurrentIP verifies that Start resets CurrentIP so new records are
// 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) {
func TestCheckAndUpdate_SkipsWhenNoParams(t *testing.T) {
rn := NewRunner()
rn.mu.Lock()
rn.status.CurrentIP = "1.2.3.4" // simulates a previously running goroutine
rn.mu.Unlock()
rn.ipFetcher = func() (string, error) { return "1.2.3.4", nil }
rn.paramsFn = func() Params { return Params{} }
rn.Start(context.Background(), Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}})
rn.Stop()
rn.checkAndUpdate()
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_SkipsWhenIPUnchanged(t *testing.T) {
func TestCheckAndUpdate_ParamsFuncCalledEachTime(t *testing.T) {
rn := NewRunner()
rn.mu.Lock()
rn.status.CurrentIP = "9.9.9.9"
rn.mu.Unlock()
rn.ipFetcher = func() (string, error) { return "1.2.3.4", nil }
calls := 0
rn.ipFetcher = func() (string, error) {
rn.paramsFn = func() Params {
calls++
return "9.9.9.9", nil
return Params{APIToken: "fake", Records: []string{"a.example.com"}}
}
prevUpdated := rn.GetStatus().LastUpdated
rn.checkAndUpdate(Config{APIToken: "tok", Records: []string{"a.example.com"}})
rn.checkAndUpdate()
rn.checkAndUpdate()
if rn.GetStatus().LastUpdated != prevUpdated {
t.Error("LastUpdated should not change when IP is unchanged")
}
if calls != 1 {
t.Errorf("expected 1 ipFetcher call, got %d", calls)
if calls != 2 {
t.Errorf("expected paramsFn called 2 times, got %d", calls)
}
}

View File

@@ -35,7 +35,7 @@ func (g *ConfigGenerator) GetConfigPath() string {
// Generate creates a dnsmasq configuration from the app config
// 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.
// Auto-detect can pick wlan0 if that's the default route, which is wrong
// when dnsmasq is only listening on eth0.
@@ -104,7 +104,7 @@ log-dhcp
}
// 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)
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
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
configContent := g.Generate(cfg, instances)

View File

@@ -18,7 +18,7 @@ const (
// GenerateMainConfig creates the main dnsmasq configuration with global settings
// 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
dnsIP, err := network.GetWildCentralIP()
if err != nil {
@@ -264,7 +264,7 @@ func (g *ConfigGenerator) ReloadService() error {
}
// 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")
// 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
func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{}
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = false
out := g.GenerateMainConfig(cfg)
@@ -204,7 +204,7 @@ func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
// Test: GenerateMainConfig with DHCP enabled includes dhcp-range
func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{}
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
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
func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{}
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
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
func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{}
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
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
func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{}
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
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
func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{}
cfg := &config.State{}
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
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
func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{}
globalCfg := &config.State{}
// Service registration: only domain + backend IP, no internal domain
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)
func TestGenerate_ReachInternal_HasLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{}
globalCfg := &config.State{}
// Simulate reconciliation for an internal-reach service:
// 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)
func TestGenerate_ReachPublic_NoLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{}
globalCfg := &config.State{}
// Simulate reconciliation for a public-reach service:
// 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
func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{}
globalCfg := &config.State{}
// Instance with only an internal domain (no external domain)
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
func TestGenerate_MixedServicesAndInstances(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{}
globalCfg := &config.State{}
instances := []config.InstanceConfig{
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
func TestGenerate_EmptyInstances(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{}
globalCfg := &config.State{}
out := g.Generate(globalCfg, []config.InstanceConfig{})
@@ -462,7 +462,7 @@ func TestGenerate_EmptyInstances(t *testing.T) {
// Test: multiple instances each get their own entries
func TestGenerate_MultipleInstances(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.GlobalConfig{}
globalCfg := &config.State{}
instances := []config.InstanceConfig{
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
func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{}
cfg := &config.State{}
cfg.Cloud.Dnsmasq.IP = "192.168.8.100"
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
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.GlobalConfig{}
cfg := &config.State{}
cases := []struct {
name string

View File

@@ -1,10 +1,10 @@
// Package services manages service registrations from Wild Cloud, Wild Works,
// and manual entries. Services register with Central to get DNS, gateway
// Package domains manages domain registrations from Wild Cloud, Wild Works,
// and manual entries. Domains register with Central to get DNS, gateway
// routing, TLS certificates, and optional public exposure via DDNS.
//
// Each registration is keyed by its domain — one registration per domain.
// Central registers its own UI domain here on startup (source: "central").
package services
package domains
import (
"fmt"
@@ -17,36 +17,38 @@ import (
"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
const (
BackendTCPPassthrough BackendType = "tcp-passthrough" // L4 SNI passthrough (k8s)
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
const (
TLSTerminate TLSMode = "terminate" // Central terminates TLS
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 {
Address string `yaml:"address" json:"address"` // host:port
Type BackendType `yaml:"type" json:"type"` // tcp-passthrough or http
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 {
Request map[string]string `yaml:"request,omitempty" json:"request,omitempty"`
Response map[string]string `yaml:"response,omitempty" json:"response,omitempty"`
}
// Route describes one path→backend mapping within a service.
// Route describes one path→backend mapping within a domain.
// Routes are evaluated in order — first match wins.
// A route with empty Paths is a catch-all (should be last).
type Route struct {
@@ -56,12 +58,12 @@ type Route struct {
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).
type Service struct {
Domain string `yaml:"domain" json:"domain"` // FQDN — unique key
type Domain struct {
DomainName string `yaml:"domain" json:"domain"` // FQDN — unique key
Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual
Backend Backend `yaml:"backend,omitempty" json:"backend,omitempty"` // single backend (simple case)
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"`
}
// EffectiveRoutes returns the service's routing as a normalized []Route.
// Simple services (Backend, no Routes) are wrapped in a single-element slice.
func (s *Service) EffectiveRoutes() []Route {
if len(s.Routes) > 0 {
return s.Routes
// EffectiveRoutes returns the domain's routing as a normalized []Route.
// Simple domains (Backend, no Routes) are wrapped in a single-element slice.
func (d *Domain) EffectiveRoutes() []Route {
if len(d.Routes) > 0 {
return d.Routes
}
if s.Backend.Address == "" {
if d.Backend.Address == "" {
return nil
}
return []Route{{Backend: s.Backend}}
return []Route{{Backend: d.Backend}}
}
// EffectiveBackendAddress returns the primary backend address for DNS/DDNS purposes.
// For multi-route services, returns the first route's backend.
func (s *Service) EffectiveBackendAddress() string {
if len(s.Routes) > 0 {
return s.Routes[0].Backend.Address
// For multi-route domains, returns the first route's backend.
func (d *Domain) EffectiveBackendAddress() string {
if len(d.Routes) > 0 {
return d.Routes[0].Backend.Address
}
return s.Backend.Address
return d.Backend.Address
}
// EffectiveBackendType returns the backend type for routing decisions.
func (s *Service) EffectiveBackendType() BackendType {
if len(s.Routes) > 0 {
return s.Routes[0].Backend.Type
func (d *Domain) EffectiveBackendType() BackendType {
if len(d.Routes) > 0 {
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 {
dataDir string
mu sync.RWMutex
reconcileFn func()
}
// NewManager creates a new service registration manager.
// NewManager creates a new domain registration manager.
func NewManager(dataDir string) *Manager {
servicesDir := filepath.Join(dataDir, "services")
if err := os.MkdirAll(servicesDir, 0755); err != nil {
slog.Warn("failed to create services directory", "path", servicesDir, "error", err)
domainsDir := filepath.Join(dataDir, "domains")
oldServicesDir := filepath.Join(dataDir, "services")
// 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}
}
// 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()) {
m.reconcileFn = fn
}
func (m *Manager) servicesDir() string {
return filepath.Join(m.dataDir, "services")
func (m *Manager) domainsDir() string {
return filepath.Join(m.dataDir, "domains")
}
// domainToFilename converts a domain to a filesystem-safe filename.
@@ -132,31 +147,31 @@ func domainToFilename(domain string) string {
return strings.ReplaceAll(domain, ".", "_") + ".yaml"
}
func (m *Manager) servicePath(domain string) string {
return filepath.Join(m.servicesDir(), domainToFilename(domain))
func (m *Manager) domainPath(domain string) string {
return filepath.Join(m.domainsDir(), domainToFilename(domain))
}
// Register creates or updates a service registration.
func (m *Manager) Register(svc Service) error {
// Register creates or updates a domain registration.
func (m *Manager) Register(dom Domain) error {
m.mu.Lock()
defer m.mu.Unlock()
if svc.Domain == "" {
if dom.DomainName == "" {
return fmt.Errorf("domain is required")
}
hasBackend := svc.Backend.Address != ""
hasRoutes := len(svc.Routes) > 0
hasBackend := dom.Backend.Address != ""
hasRoutes := len(dom.Routes) > 0
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 {
return fmt.Errorf("backend address or routes required")
}
if hasRoutes {
for i, r := range svc.Routes {
for i, r := range dom.Routes {
if r.Backend.Address == "" {
return fmt.Errorf("route %d: backend address is required", i)
}
@@ -165,36 +180,38 @@ func (m *Manager) Register(svc Service) error {
}
}
} else {
if svc.Backend.Type == "" {
if dom.Backend.Type == "" {
return fmt.Errorf("backend type is required")
}
}
// Default TLS mode based on backend type
if svc.TLS == "" {
bt := svc.EffectiveBackendType()
if bt == BackendTCPPassthrough {
svc.TLS = TLSPassthrough
} else {
svc.TLS = TLSTerminate
if dom.TLS == "" {
switch dom.EffectiveBackendType() {
case BackendTCPPassthrough:
dom.TLS = TLSPassthrough
case BackendDNSOnly:
dom.TLS = TLSNone
default:
dom.TLS = TLSTerminate
}
}
// Default source
if svc.Source == "" {
svc.Source = "manual"
if dom.Source == "" {
dom.Source = "manual"
}
data, err := yaml.Marshal(svc)
data, err := yaml.Marshal(dom)
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 {
return fmt.Errorf("writing service file: %w", err)
if err := os.WriteFile(m.domainPath(dom.DomainName), data, 0644); err != nil {
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 {
go func() {
@@ -210,21 +227,21 @@ func (m *Manager) Register(svc Service) error {
return nil
}
// Deregister removes a service registration by domain.
// Deregister removes a domain registration by domain name.
func (m *Manager) Deregister(domain string) error {
m.mu.Lock()
defer m.mu.Unlock()
path := m.servicePath(domain)
path := m.domainPath(domain)
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 {
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 {
go m.reconcileFn()
@@ -233,76 +250,76 @@ func (m *Manager) Deregister(domain string) error {
return nil
}
// Get retrieves a service registration by domain.
func (m *Manager) Get(domain string) (*Service, error) {
// Get retrieves a domain registration by domain name.
func (m *Manager) Get(domain string) (*Domain, error) {
m.mu.RLock()
defer m.mu.RUnlock()
data, err := os.ReadFile(m.servicePath(domain))
data, err := os.ReadFile(m.domainPath(domain))
if err != nil {
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
if err := yaml.Unmarshal(data, &svc); err != nil {
return nil, fmt.Errorf("parsing service file: %w", err)
var dom Domain
if err := yaml.Unmarshal(data, &dom); err != nil {
return nil, fmt.Errorf("parsing domain file: %w", err)
}
return &svc, nil
return &dom, nil
}
// List returns all registered services.
func (m *Manager) List() ([]Service, error) {
// List returns all registered domains.
func (m *Manager) List() ([]Domain, error) {
m.mu.RLock()
defer m.mu.RUnlock()
entries, err := os.ReadDir(m.servicesDir())
entries, err := os.ReadDir(m.domainsDir())
if err != nil {
if os.IsNotExist(err) {
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 {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
continue
}
data, err := os.ReadFile(filepath.Join(m.servicesDir(), entry.Name()))
data, err := os.ReadFile(filepath.Join(m.domainsDir(), entry.Name()))
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
}
var svc Service
if err := yaml.Unmarshal(data, &svc); err != nil {
slog.Warn("failed to parse service file", "file", entry.Name(), "error", err)
var dom Domain
if err := yaml.Unmarshal(data, &dom); err != nil {
slog.Warn("failed to parse domain file", "file", entry.Name(), "error", err)
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
// a backend address. Used by consumers to clean up before re-registering.
func (m *Manager) DeregisterBySource(source, backendAddress string) error {
svcs, err := m.List()
doms, err := m.List()
if err != nil {
return err
}
for _, svc := range svcs {
if svc.Source == source && svc.EffectiveBackendAddress() == backendAddress {
if err := m.Deregister(svc.Domain); err != nil {
slog.Warn("failed to deregister service during cleanup", "domain", svc.Domain, "error", err)
for _, dom := range doms {
if dom.Source == source && dom.EffectiveBackendAddress() == backendAddress {
if err := m.Deregister(dom.DomainName); err != nil {
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
}
// 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 {
svc, err := m.Get(domain)
dom, err := m.Get(domain)
if err != nil {
return err
}
if public, ok := updates["public"].(bool); ok {
svc.Public = public
dom.Public = public
}
if sub, ok := updates["subdomains"].(bool); ok {
svc.Subdomains = sub
dom.Subdomains = sub
}
if backend, ok := updates["backend"].(map[string]any); ok {
if addr, ok := backend["address"].(string); ok {
svc.Backend.Address = addr
dom.Backend.Address = addr
}
if typ, ok := backend["type"].(string); ok {
svc.Backend.Type = BackendType(typ)
dom.Backend.Type = BackendType(typ)
}
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
// to express via map[string]any. Re-register the full service instead.
if tls, ok := updates["tls"].(string); ok {
dom.TLS = TLSMode(tls)
}
return m.Register(*svc)
return m.Register(*dom)
}

View File

@@ -1,4 +1,4 @@
package services
package domains
import (
"os"
@@ -9,17 +9,16 @@ import (
func TestRegisterAndGet(t *testing.T) {
mgr := NewManager(t.TempDir())
svc := Service{
Domain: "my-api.payne.io",
Source: "wild-works",
dom := Domain{
DomainName: "my-api.payne.io",
Source: "wild-works",
Backend: Backend{
Address: "192.168.8.60:9001",
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)
}
@@ -28,8 +27,8 @@ func TestRegisterAndGet(t *testing.T) {
t.Fatalf("Get failed: %v", err)
}
if got.Domain != "my-api.payne.io" {
t.Errorf("expected domain my-api.payne.io, got %s", got.Domain)
if got.DomainName != "my-api.payne.io" {
t.Errorf("expected domain my-api.payne.io, got %s", got.DomainName)
}
if got.Backend.Address != "192.168.8.60:9001" {
t.Errorf("expected backend 192.168.8.60:9001, got %s", got.Backend.Address)
@@ -45,18 +44,18 @@ func TestRegisterAndGet(t *testing.T) {
func TestRegisterTCPPassthrough(t *testing.T) {
mgr := NewManager(t.TempDir())
svc := Service{
Domain: "cloud.payne.io",
Source: "wild-cloud",
dom := Domain{
DomainName: "cloud.payne.io",
Source: "wild-cloud",
Backend: Backend{
Address: "192.168.8.240:443",
Type: BackendTCPPassthrough,
},
Subdomains: true,
Public: true,
Public: true,
}
if err := mgr.Register(svc); err != nil {
if err := mgr.Register(dom); err != nil {
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())
for _, domain := range []string{"a.example.com", "b.example.com", "c.example.com"} {
if err := mgr.Register(Service{
Domain: domain,
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
if err := mgr.Register(Domain{
DomainName: domain,
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
}); err != nil {
t.Fatalf("Register %s failed: %v", domain, err)
}
}
svcs, err := mgr.List()
doms, err := mgr.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(svcs) != 3 {
t.Errorf("expected 3 services, got %d", len(svcs))
if len(doms) != 3 {
t.Errorf("expected 3 domains, got %d", len(doms))
}
}
func TestDeregister(t *testing.T) {
mgr := NewManager(t.TempDir())
if err := mgr.Register(Service{
Domain: "temp.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
if err := mgr.Register(Domain{
DomainName: "temp.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
}); err != nil {
t.Fatalf("Register failed: %v", err)
}
@@ -123,45 +120,43 @@ func TestDeregister(t *testing.T) {
func TestDeregisterBySource(t *testing.T) {
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"} {
mgr.Register(Service{
Domain: domain,
Source: "wild-cloud",
Backend: Backend{Address: "192.168.8.240:443", Type: BackendTCPPassthrough},
Public: true,
mgr.Register(Domain{
DomainName: domain,
Source: "wild-cloud",
Backend: Backend{Address: "192.168.8.240:443", Type: BackendTCPPassthrough},
Public: true,
})
}
// Register 1 service from wild-works (different source)
mgr.Register(Service{
Domain: "my-api.payne.io",
Source: "wild-works",
Backend: Backend{Address: "127.0.0.1:9001", Type: BackendHTTP},
// Public defaults to false
// Register 1 domain from wild-works (different source)
mgr.Register(Domain{
DomainName: "my-api.payne.io",
Source: "wild-works",
Backend: Backend{Address: "127.0.0.1:9001", Type: BackendHTTP},
})
// 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 {
t.Fatalf("DeregisterBySource failed: %v", err)
}
svcs, _ := mgr.List()
if len(svcs) != 1 {
t.Errorf("expected 1 remaining service, got %d", len(svcs))
doms, _ := mgr.List()
if len(doms) != 1 {
t.Errorf("expected 1 remaining domain, got %d", len(doms))
}
if svcs[0].Domain != "my-api.payne.io" {
t.Errorf("expected wild-works service to remain, got %s", svcs[0].Domain)
if doms[0].DomainName != "my-api.payne.io" {
t.Errorf("expected wild-works domain to remain, got %s", doms[0].DomainName)
}
}
func TestUpdate(t *testing.T) {
mgr := NewManager(t.TempDir())
mgr.Register(Service{
Domain: "updatable.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
mgr.Register(Domain{
DomainName: "updatable.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
})
if err := mgr.Update("updatable.example.com", map[string]any{
@@ -184,17 +179,17 @@ func TestRegisterValidation(t *testing.T) {
mgr := NewManager(t.TempDir())
// 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")
}
// 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")
}
// 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")
}
}
@@ -202,10 +197,9 @@ func TestRegisterValidation(t *testing.T) {
func TestDefaultSource(t *testing.T) {
mgr := NewManager(t.TempDir())
mgr.Register(Service{
Domain: "test.com",
Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP},
// Public defaults to false
mgr.Register(Domain{
DomainName: "test.com",
Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP},
})
got, _ := mgr.Get("test.com")
@@ -217,31 +211,30 @@ func TestDefaultSource(t *testing.T) {
func TestRegisterIdempotent(t *testing.T) {
mgr := NewManager(t.TempDir())
svc := Service{
Domain: "idempotent.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
dom := Domain{
DomainName: "idempotent.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
}
// 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)
}
// Update the backend address on second registration
svc.Backend.Address = "127.0.0.1:9090"
if err := mgr.Register(svc); err != nil {
dom.Backend.Address = "127.0.0.1:9090"
if err := mgr.Register(dom); err != nil {
t.Fatalf("second Register failed: %v", err)
}
// Should still have only one service
svcs, err := mgr.List()
// Should still have only one domain
doms, err := mgr.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(svcs) != 1 {
t.Errorf("expected 1 service after idempotent register, got %d", len(svcs))
if len(doms) != 1 {
t.Errorf("expected 1 domain after idempotent register, got %d", len(doms))
}
// Should have the updated address
@@ -257,50 +250,48 @@ func TestRegisterIdempotent(t *testing.T) {
func TestListNoDuplicates(t *testing.T) {
mgr := NewManager(t.TempDir())
domains := []string{
domainNames := []string{
"alpha.example.com",
"beta.example.com",
"gamma.example.com",
}
// Register each domain
for _, domain := range domains {
if err := mgr.Register(Service{
Domain: domain,
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
for _, name := range domainNames {
if err := mgr.Register(Domain{
DomainName: name,
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
}); 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
if err := mgr.Register(Service{
Domain: "beta.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:9090", Type: BackendHTTP},
// Public defaults to false
if err := mgr.Register(Domain{
DomainName: "beta.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:9090", Type: BackendHTTP},
}); err != nil {
t.Fatalf("Re-register beta failed: %v", err)
}
svcs, err := mgr.List()
doms, err := mgr.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(svcs) != 3 {
t.Errorf("expected 3 unique services, got %d", len(svcs))
if len(doms) != 3 {
t.Errorf("expected 3 unique domains, got %d", len(doms))
}
// Verify no duplicate domains
seen := make(map[string]bool)
for _, svc := range svcs {
if seen[svc.Domain] {
t.Errorf("duplicate domain in List(): %s", svc.Domain)
for _, dom := range doms {
if seen[dom.DomainName] {
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) {
mgr := NewManager(t.TempDir())
// Register some services
mgr.Register(Service{
Domain: "a.example.com",
Source: "wild-cloud",
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
Public: true,
mgr.Register(Domain{
DomainName: "a.example.com",
Source: "wild-cloud",
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
Public: true,
})
mgr.Register(Service{
Domain: "b.example.com",
Source: "wild-works",
Backend: Backend{Address: "10.0.0.2:8080", Type: BackendHTTP},
// Public defaults to false
mgr.Register(Domain{
DomainName: "b.example.com",
Source: "wild-works",
Backend: Backend{Address: "10.0.0.2:8080", Type: BackendHTTP},
})
// 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)
}
// All services should still be present
svcs, _ := mgr.List()
if len(svcs) != 2 {
t.Errorf("expected 2 services after no-match deregister, got %d", len(svcs))
// All domains should still be present
doms, _ := mgr.List()
if len(doms) != 2 {
t.Errorf("expected 2 domains after no-match deregister, got %d", len(doms))
}
}
@@ -374,17 +363,17 @@ func TestDeregisterBySource_PartialMatch(t *testing.T) {
mgr := NewManager(t.TempDir())
// Same source, different backends
mgr.Register(Service{
Domain: "a.example.com",
Source: "wild-cloud",
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
Public: true,
mgr.Register(Domain{
DomainName: "a.example.com",
Source: "wild-cloud",
Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough},
Public: true,
})
mgr.Register(Service{
Domain: "b.example.com",
Source: "wild-cloud",
Backend: Backend{Address: "10.0.0.2:443", Type: BackendTCPPassthrough},
Public: true,
mgr.Register(Domain{
DomainName: "b.example.com",
Source: "wild-cloud",
Backend: Backend{Address: "10.0.0.2:443", Type: BackendTCPPassthrough},
Public: true,
})
// Deregister only the ones matching source AND backend
@@ -393,12 +382,12 @@ func TestDeregisterBySource_PartialMatch(t *testing.T) {
t.Fatalf("DeregisterBySource failed: %v", err)
}
svcs, _ := mgr.List()
if len(svcs) != 1 {
t.Errorf("expected 1 remaining service, got %d", len(svcs))
doms, _ := mgr.List()
if len(doms) != 1 {
t.Errorf("expected 1 remaining domain, got %d", len(doms))
}
if svcs[0].Domain != "b.example.com" {
t.Errorf("expected b.example.com to remain, got %s", svcs[0].Domain)
if doms[0].DomainName != "b.example.com" {
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()
mgr := NewManager(dir)
svc := Service{
Domain: "overwrite.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
dom := Domain{
DomainName: "overwrite.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
}
// Register first time
if err := mgr.Register(svc); err != nil {
if err := mgr.Register(dom); err != nil {
t.Fatalf("first Register failed: %v", err)
}
// Register again with different backend
svc.Backend.Address = "127.0.0.1:9090"
if err := mgr.Register(svc); err != nil {
dom.Backend.Address = "127.0.0.1:9090"
if err := mgr.Register(dom); err != nil {
t.Fatalf("second Register failed: %v", err)
}
// 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 {
t.Fatalf("ReadDir failed: %v", err)
}
@@ -454,21 +442,21 @@ func TestRegister_OverwritePreservesFile(t *testing.T) {
func TestList_EmptyDir(t *testing.T) {
mgr := NewManager(t.TempDir())
svcs, err := mgr.List()
doms, err := mgr.List()
if err != nil {
t.Fatalf("List on empty dir should not error: %v", err)
}
if len(svcs) != 0 {
t.Errorf("expected 0 services from empty dir, got %d", len(svcs))
if len(doms) != 0 {
t.Errorf("expected 0 domains from empty dir, got %d", len(doms))
}
}
func TestRegisterWithL7Fields(t *testing.T) {
mgr := NewManager(t.TempDir())
svc := Service{
Domain: "keila.cloud.payne.io",
Source: "wild-cloud",
dom := Domain{
DomainName: "keila.cloud.payne.io",
Source: "wild-cloud",
Routes: []Route{{
Paths: []string{"/.well-known/matrix", "/_matrix"},
Backend: Backend{
@@ -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)
}
@@ -520,9 +508,9 @@ func TestRegisterWithL7Fields(t *testing.T) {
func TestUpdateL7Fields(t *testing.T) {
mgr := NewManager(t.TempDir())
mgr.Register(Service{
Domain: "updatable.example.com",
Source: "test",
mgr.Register(Domain{
DomainName: "updatable.example.com",
Source: "test",
Routes: []Route{{
Paths: []string{"/api", "/webhook"},
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
@@ -555,28 +543,63 @@ func TestList_IgnoresNonYAML(t *testing.T) {
dir := t.TempDir()
mgr := NewManager(dir)
// Register one real service
mgr.Register(Service{
Domain: "real.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
// Public defaults to false
// Register one real domain
mgr.Register(Domain{
DomainName: "real.example.com",
Source: "test",
Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP},
})
// Create non-YAML files in the services directory
servicesDir := filepath.Join(dir, "services")
os.WriteFile(filepath.Join(servicesDir, "README.md"), []byte("# ignore me"), 0644)
os.WriteFile(filepath.Join(servicesDir, "notes.txt"), []byte("some notes"), 0644)
os.WriteFile(filepath.Join(servicesDir, ".gitkeep"), []byte(""), 0644)
// Create non-YAML files in the domains directory
domainsDir := filepath.Join(dir, "domains")
os.WriteFile(filepath.Join(domainsDir, "README.md"), []byte("# ignore me"), 0644)
os.WriteFile(filepath.Join(domainsDir, "notes.txt"), []byte("some notes"), 0644)
os.WriteFile(filepath.Join(domainsDir, ".gitkeep"), []byte(""), 0644)
svcs, err := mgr.List()
doms, err := mgr.List()
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(svcs) != 1 {
t.Errorf("expected 1 service (ignoring non-YAML files), got %d", len(svcs))
if len(doms) != 1 {
t.Errorf("expected 1 domain (ignoring non-YAML files), got %d", len(doms))
}
if svcs[0].Domain != "real.example.com" {
t.Errorf("expected real.example.com, got %s", svcs[0].Domain)
if doms[0].DomainName != "real.example.com" {
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"
"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
@@ -36,7 +36,7 @@ type HTTPRouteBackend struct {
Paths []string // path prefixes (empty = catch-all)
Backend string // host:port
HealthPath string // optional health check path
Headers *services.HeaderConfig // per-route headers
Headers *domains.HeaderConfig // per-route headers
IPAllow []string // per-route CIDR whitelist
}

View File

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

View File

@@ -17,7 +17,7 @@ import (
const (
// KV bucket names
BucketServices = "wild-services" // service registrations
BucketDomains = "wild-domains" // domain registrations
BucketPresence = "wild-presence" // node liveness (TTL keys)
// Stream names
@@ -126,13 +126,13 @@ func (s *Server) ensureBuckets() error {
// Services bucket — no TTL, entries persist until explicitly deleted
_, err := s.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: BucketServices,
Bucket: BucketDomains,
Description: "Service registrations from Wild Cloud, Wild Works, and manual entries",
})
if err != nil {
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
_, 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)
defer cancel()
// Verify services bucket exists
kv, err := s.js.KeyValue(ctx, BucketServices)
// Verify domains bucket exists
kv, err := s.js.KeyValue(ctx, BucketDomains)
if err != nil {
t.Fatalf("services bucket not found: %v", err)
t.Fatalf("domains bucket not found: %v", err)
}
// 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"`
}
// PublicService describes a service that should be exposed through the tunnel.
type PublicService struct {
Name string // service name (used as subdomain)
// PublicDomain describes a domain that should be exposed through the tunnel.
type PublicDomain struct {
Name string // domain name (used as subdomain)
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.
// 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 == "" {
return ""
}
@@ -131,7 +131,7 @@ func (m *Manager) GenerateConfig(cfg Config, services []PublicService) string {
}
// 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)
if content == "" {
// 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) {
m := NewManager(t.TempDir())
services := []PublicService{
services := []PublicDomain{
{Name: "my-api"},
{Name: "dashboard"},
}
@@ -71,7 +71,7 @@ func TestGenerateConfig_Basic(t *testing.T) {
func TestGenerateConfig_SubdomainOverride(t *testing.T) {
m := NewManager(t.TempDir())
services := []PublicService{
services := []PublicDomain{
{Name: "internal-name", Subdomain: "public-name"},
}
@@ -88,7 +88,7 @@ func TestGenerateConfig_Disabled(t *testing.T) {
cfg := testConfig()
cfg.Enabled = false
out := m.GenerateConfig(cfg, []PublicService{{Name: "my-api"}})
out := m.GenerateConfig(cfg, []PublicDomain{{Name: "my-api"}})
if 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.TunnelID = ""
out := m.GenerateConfig(cfg, []PublicService{{Name: "my-api"}})
out := m.GenerateConfig(cfg, []PublicDomain{{Name: "my-api"}})
if 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()
m := NewManager(tmpDir)
services := []PublicService{{Name: "my-api"}}
services := []PublicDomain{{Name: "my-api"}}
if err := m.WriteConfig(testConfig(), services); err != nil {
t.Fatalf("WriteConfig failed: %v", err)
}
@@ -139,14 +139,14 @@ func TestWriteConfig_RemovesWhenDisabled(t *testing.T) {
m := NewManager(tmpDir)
// 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)
}
// Now disable and write again — should remove the file
cfg := testConfig()
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)
}

View File

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

View File

@@ -7,7 +7,7 @@ import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge';
import {
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';
import { useCloudflare } from '../hooks/useCloudflare';
import { useDdns } from '../hooks/useDdns';
@@ -22,7 +22,7 @@ export function DashboardComponent() {
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
const { verification, isLoading: cfLoading } = useCloudflare();
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
const { config: globalConfig, updateConfig } = useConfig();
const { config: globalConfig } = useConfig();
const { config: vpnConfig } = useVpn();
const queryClient = useQueryClient();
@@ -36,8 +36,6 @@ export function DashboardComponent() {
const [editingToken, setEditingToken] = useState(false);
const [tokenValue, setTokenValue] = useState('');
const [addingDdnsRecord, setAddingDdnsRecord] = useState(false);
const [newDdnsRecord, setNewDdnsRecord] = useState('');
const updateSecretsMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => secretsApi.update(values),
@@ -260,76 +258,33 @@ export function DashboardComponent() {
</div>
</div>
{/* Record list with remove buttons */}
{globalConfig?.cloud?.ddns?.records?.length ? (
{/* Record list (read-only — derived from public domains) */}
{ddnsStatus.records?.length ? (
<div className="space-y-1">
{globalConfig.cloud.ddns.records.map((r: string) => {
const rs: DdnsRecordStatus | undefined = ddnsStatus.records?.find((s: DdnsRecordStatus) => s.name === r);
return (
<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" />
) : 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" />
)}
<span className="font-mono bg-muted px-2 py-0.5 rounded">{r}</span>
{rs?.ok && rs.ip && (
<span className="text-muted-foreground font-mono">{rs.ip}</span>
)}
{rs && !rs.ok && rs.error && (
<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>
);
})}
{ddnsStatus.records.map((rs: DdnsRecordStatus) => (
<div key={rs.name} className="flex items-center gap-2 text-xs">
{rs.ok ? (
<CheckCircle className="h-3.5 w-3.5 text-green-500 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">{rs.name}</span>
{rs.ok && rs.ip && (
<span className="text-muted-foreground font-mono">{rs.ip}</span>
)}
{!rs.ok && rs.error && (
<span className="text-red-500 truncate" title={rs.error}>{rs.error}</span>
)}
</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>
) : 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">
<Input
value={newDdnsRecord}
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>
<p className="text-xs text-muted-foreground">
No public domains registered. Set a domain to Public on the Domains page to enable DDNS.
</p>
)}
<Button
@@ -345,7 +300,7 @@ export function DashboardComponent() {
</div>
) : (
<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>
)}
</Card>
@@ -378,10 +333,11 @@ export function DashboardComponent() {
<div className="space-y-3 ml-7">
{/* Service badges */}
<div className="flex flex-wrap gap-2">
<ServiceBadge name="dnsmasq" />
<ServiceBadge name="haproxy" />
<ServiceBadge name="wireguard" />
<ServiceBadge name="crowdsec" />
<ServiceBadge name="dnsmasq" active={centralStatus?.daemons?.dnsmasq?.active} />
<ServiceBadge name="haproxy" active={centralStatus?.daemons?.haproxy?.active} />
<ServiceBadge name="nftables" active={centralStatus?.daemons?.nftables?.active} />
<ServiceBadge name="wireguard" active={centralStatus?.daemons?.wireguard?.active} />
<ServiceBadge name="crowdsec" active={centralStatus?.daemons?.crowdsec?.active} />
</div>
{/* Version and uptime */}
@@ -452,13 +408,12 @@ export function DashboardComponent() {
);
}
/** Inline badge showing a service name with a colored dot. Status is informational only. */
function ServiceBadge({ name }: { name: string }) {
// These are presentational — the daemon is running if we can reach the API,
// and individual service health comes from their own endpoints.
/** Inline badge showing a service name with a colored dot reflecting actual daemon status. */
function ServiceBadge({ name, active }: { name: string; active?: boolean }) {
const dotColor = active === undefined ? 'bg-gray-400' : active ? 'bg-green-500' : 'bg-red-500';
return (
<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}
</span>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
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({
queryKey: ['services'],
queryFn: () => servicesApi.list(),
queryKey: ['domains'],
queryFn: () => domainsApi.list(),
refetchInterval: 30000,
staleTime: 10000,
});
return {
services: query.data?.services ?? [],
domains: query.data?.domains ?? [],
isLoading: query.isLoading,
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 { NotFoundPage } from './pages/NotFoundPage';
import { DashboardPage } from './pages/DashboardPage';
import { ServicesPage } from './pages/ServicesPage';
import { DomainsPage } from './pages/DomainsPage';
import { FirewallPage } from './pages/FirewallPage';
import { VpnPage } from './pages/VpnPage';
import { CrowdSecPage } from './pages/CrowdSecPage';
@@ -19,7 +19,7 @@ export const routes: RouteObject[] = [
element: <CentralLayout />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'services', element: <ServicesPage /> },
{ path: 'domains', element: <DomainsPage /> },
{ path: 'firewall', element: <FirewallPage /> },
{ path: 'vpn', element: <VpnPage /> },
{ path: 'crowdsec', element: <CrowdSecPage /> },

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { apiClient } from './client';
// Services
// Domains
export interface HeaderConfig {
request?: Record<string, string>;
@@ -18,7 +18,7 @@ export interface Route {
ipAllow?: string[];
}
export interface RegisteredService {
export interface RegisteredDomain {
domain: string;
source: string;
subdomains: boolean;
@@ -32,15 +32,15 @@ export interface RegisteredService {
routes?: Route[];
}
export const servicesApi = {
async list(): Promise<{ services: RegisteredService[] }> {
return apiClient.get('/api/v1/services');
export const domainsApi = {
async list(): Promise<{ domains: RegisteredDomain[] }> {
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 }> {
return apiClient.post('/api/v1/services', svc);
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/domains', dom);
},
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)
// Endpoint: /api/v1/config
// Endpoint: /api/v1/state
// File: {dataDir}/state.yaml
// ========================================
@@ -17,7 +17,7 @@ export interface HAProxyCustomRoute {
backend: string;
}
export interface GlobalConfig {
export interface CentralState {
operator?: {
email?: string;
};
@@ -48,15 +48,14 @@ export interface GlobalConfig {
ddns?: {
enabled?: boolean;
provider?: string;
records?: string[];
intervalMinutes?: number;
};
};
}
export interface GlobalConfigResponse {
export interface CentralStateResponse {
configured: boolean;
config?: GlobalConfig;
state?: CentralState;
message?: string;
}