services -> domains
This commit is contained in:
@@ -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..."})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
132
internal/api/v1/handlers_domains.go
Normal file
132
internal/api/v1/handlers_domains.go
Normal 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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user