feat: Add EnsureCentralService + improved tests
Linter-contributed improvements: - EnsureCentralService() method: registers Central's own domain as a service from config, with cleanup on domain change - Tests for EnsureCentralService: registration, idempotency, domain change cleanup, no-domain-is-noop - Improved reconciliation tests with Central as a registered service - Certbot handler cleanup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -320,8 +320,9 @@ func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
slog.Info("global config updated")
|
||||
|
||||
// Reload DDNS if config changed
|
||||
// Reload DDNS and re-register Central's service if config changed
|
||||
go api.reloadDDNSIfEnabled()
|
||||
go api.EnsureCentralService()
|
||||
|
||||
respondMessage(w, http.StatusOK, "Config updated successfully")
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package v1
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"github.com/wild-cloud/wild-central/internal/services"
|
||||
)
|
||||
@@ -58,6 +60,37 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
|
||||
certs = append(certs, entry)
|
||||
}
|
||||
|
||||
// Include standalone certs from disk (e.g. wildcard certs) not tied to a service.
|
||||
certsDir := "/etc/haproxy/certs/"
|
||||
if entries, err := os.ReadDir(certsDir); err == nil {
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".pem") {
|
||||
continue
|
||||
}
|
||||
domain := strings.TrimSuffix(e.Name(), ".pem")
|
||||
if seen[domain] {
|
||||
continue
|
||||
}
|
||||
seen[domain] = true
|
||||
status := api.certbot.GetStatus(domain)
|
||||
if !status.Exists {
|
||||
// PEM file exists but openssl couldn't parse it — skip
|
||||
continue
|
||||
}
|
||||
// Use the certbot live directory to confirm this is a certbot-managed cert
|
||||
source := "standalone"
|
||||
certLive := certbot.CertPath(domain)
|
||||
if _, err := os.Stat(certLive); err == nil {
|
||||
source = "certbot"
|
||||
}
|
||||
certs = append(certs, map[string]any{
|
||||
"domain": domain,
|
||||
"source": source,
|
||||
"cert": status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"configured": centralDomain != "",
|
||||
"domain": centralDomain,
|
||||
|
||||
@@ -2,6 +2,8 @@ package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -81,13 +83,32 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add Central's own domain (as reconcileNetworking does from config)
|
||||
centralDomain := "central.payne.io"
|
||||
httpRoutes = append([]haproxy.HTTPRoute{{
|
||||
Name: "wild-central",
|
||||
Domain: centralDomain,
|
||||
Backend: "127.0.0.1:5055",
|
||||
}}, httpRoutes...)
|
||||
// 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},
|
||||
Reach: services.ReachInternal,
|
||||
TLS: services.TLSTerminate,
|
||||
}); err != nil {
|
||||
t.Fatalf("Register central failed: %v", err)
|
||||
}
|
||||
// Re-list to pick up Central
|
||||
svcs, err = api.services.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List failed: %v", err)
|
||||
}
|
||||
httpRoutes = nil
|
||||
for _, svc := range svcs {
|
||||
if svc.Backend.Type == services.BackendHTTP {
|
||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||
Name: svc.Domain,
|
||||
Domain: svc.Domain,
|
||||
Backend: svc.Backend.Address,
|
||||
HealthPath: svc.Backend.Health,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
@@ -97,7 +118,7 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
||||
// --- Assertions on HAProxy config ---
|
||||
|
||||
// L7 services have exact-match ACLs in the SNI frontend
|
||||
if !strings.Contains(cfg, "acl is_l7_wild_central req_ssl_sni -m str central.payne.io") {
|
||||
if !strings.Contains(cfg, "acl is_l7_central_payne_io req_ssl_sni -m str central.payne.io") {
|
||||
t.Errorf("expected L7 exact-match ACL for central.payne.io:\n%s", cfg)
|
||||
}
|
||||
if !strings.Contains(cfg, "acl is_l7_wild_cloud_payne_io req_ssl_sni -m str wild-cloud.payne.io") {
|
||||
@@ -123,7 +144,7 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
||||
}
|
||||
|
||||
// L7 ACLs appear BEFORE L4 wildcard ACLs
|
||||
l7Pos := strings.Index(cfg, "is_l7_wild_central")
|
||||
l7Pos := strings.Index(cfg, "is_l7_central_payne_io")
|
||||
l4WildcardPos := strings.Index(cfg, "req_ssl_sni -m end .cloud.payne.io")
|
||||
if l7Pos > l4WildcardPos {
|
||||
t.Errorf("L7 exact matches (pos %d) must appear before L4 wildcards (pos %d)", l7Pos, l4WildcardPos)
|
||||
@@ -244,12 +265,25 @@ func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconciliation_CentralDomainInHAProxy verifies that the Central domain
|
||||
// from global config is injected as the first L7 HTTP route.
|
||||
// TestReconciliation_CentralDomainInHAProxy verifies that Central, registered
|
||||
// as a service, produces correct HAProxy L7 routes.
|
||||
func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
||||
api, _ := setupTestAPI(t)
|
||||
|
||||
// Register one HTTP service
|
||||
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},
|
||||
Reach: services.ReachInternal,
|
||||
TLS: services.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",
|
||||
@@ -272,16 +306,6 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Central domain goes first (as reconcileNetworking does).
|
||||
// Uses port 15055 — must match the running port, NOT default 5055.
|
||||
centralDomain := "central.payne.io"
|
||||
centralPort := 15055
|
||||
httpRoutes = append([]haproxy.HTTPRoute{{
|
||||
Name: "wild-central",
|
||||
Domain: centralDomain,
|
||||
Backend: fmt.Sprintf("127.0.0.1:%d", centralPort),
|
||||
}}, httpRoutes...)
|
||||
|
||||
cfg := api.haproxy.GenerateWithOpts(nil, nil, haproxy.GenerateOpts{
|
||||
HTTPRoutes: httpRoutes,
|
||||
})
|
||||
@@ -294,22 +318,9 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
||||
t.Errorf("expected Central domain Host ACL:\n%s", cfg)
|
||||
}
|
||||
|
||||
// Central backend must use the configured port (15055), not default 5055
|
||||
if !strings.Contains(cfg, "server s0 127.0.0.1:15055") {
|
||||
t.Errorf("Central backend must use port 15055, not 5055:\n%s", cfg)
|
||||
}
|
||||
if strings.Contains(cfg, "server s0 127.0.0.1:5055") {
|
||||
t.Errorf("Central backend must NOT use default port 5055:\n%s", cfg)
|
||||
}
|
||||
|
||||
// Central route should be the first L7 route
|
||||
centralPos := strings.Index(cfg, "is_l7_wild_central")
|
||||
appPos := strings.Index(cfg, "is_l7_my_app_payne_io")
|
||||
if centralPos < 0 || appPos < 0 {
|
||||
t.Fatalf("expected both L7 ACLs in config:\n%s", cfg)
|
||||
}
|
||||
if centralPos > appPos {
|
||||
t.Errorf("Central domain (pos %d) should appear before app domain (pos %d)", centralPos, appPos)
|
||||
// Central backend must use the configured port (15055)
|
||||
if !strings.Contains(cfg, fmt.Sprintf("server s0 127.0.0.1:%d", centralPort)) {
|
||||
t.Errorf("Central backend must use port %d:\n%s", centralPort, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,3 +372,96 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
|
||||
t.Errorf("tcp-passthrough DNS must NOT point 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()
|
||||
configPath := filepath.Join(dataDir, "config.yaml")
|
||||
content := fmt.Sprintf("cloud:\n central:\n domain: %s\n", centralDomain)
|
||||
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCentralService_RegistersWhenDomainConfigured(t *testing.T) {
|
||||
api, dataDir := setupTestAPI(t)
|
||||
api.SetPort(15055)
|
||||
writeTestConfig(t, dataDir, "central.example.com")
|
||||
|
||||
api.EnsureCentralService()
|
||||
|
||||
svc, err := api.services.Get("central.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("expected Central service to be registered: %v", err)
|
||||
}
|
||||
if svc.Source != "central" {
|
||||
t.Errorf("source = %q, want %q", svc.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 svc.TLS != services.TLSTerminate {
|
||||
t.Errorf("tls = %q, want %q", svc.TLS, services.TLSTerminate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCentralService_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
|
||||
|
||||
svcs, _ := api.services.List()
|
||||
count := 0
|
||||
for _, s := range svcs {
|
||||
if s.Source == "central" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 central service, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCentralService_CleansUpOnDomainChange(t *testing.T) {
|
||||
api, dataDir := setupTestAPI(t)
|
||||
api.SetPort(15055)
|
||||
|
||||
// Register with old domain
|
||||
writeTestConfig(t, dataDir, "old.example.com")
|
||||
api.EnsureCentralService()
|
||||
|
||||
// Change domain
|
||||
writeTestConfig(t, dataDir, "new.example.com")
|
||||
api.EnsureCentralService()
|
||||
|
||||
// Old domain should be gone
|
||||
if _, err := api.services.Get("old.example.com"); err == nil {
|
||||
t.Error("old Central service should have been deregistered")
|
||||
}
|
||||
// New domain should exist
|
||||
svc, err := api.services.Get("new.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("new Central service should be registered: %v", err)
|
||||
}
|
||||
if svc.Source != "central" {
|
||||
t.Errorf("source = %q, want %q", svc.Source, "central")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCentralService_NoDomainIsNoop(t *testing.T) {
|
||||
api, _ := setupTestAPI(t)
|
||||
api.SetPort(15055)
|
||||
// Default config has no Central domain
|
||||
|
||||
api.EnsureCentralService()
|
||||
|
||||
svcs, _ := api.services.List()
|
||||
for _, s := range svcs {
|
||||
if s.Source == "central" {
|
||||
t.Error("no Central service should be registered when domain is empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,47 @@ import (
|
||||
"github.com/wild-cloud/wild-central/internal/sse"
|
||||
)
|
||||
|
||||
// EnsureCentralService registers (or updates) Central's own domain as a service
|
||||
// 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() {
|
||||
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 &&
|
||||
existing.Source == "central" &&
|
||||
existing.Backend.Address == backend {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if centralDomain == "" {
|
||||
return
|
||||
}
|
||||
|
||||
_ = api.services.Register(services.Service{
|
||||
Domain: centralDomain,
|
||||
Source: "central",
|
||||
Backend: services.Backend{
|
||||
Address: backend,
|
||||
Type: services.BackendHTTP,
|
||||
},
|
||||
Reach: services.ReachInternal,
|
||||
TLS: services.TLSTerminate,
|
||||
})
|
||||
}
|
||||
|
||||
// Reconcile runs networking reconciliation immediately. Called on startup
|
||||
// and automatically whenever a service is registered/updated/deregistered.
|
||||
func (api *API) Reconcile() {
|
||||
@@ -61,17 +102,6 @@ func (api *API) reconcileNetworking() {
|
||||
}
|
||||
}
|
||||
|
||||
// Inject Central's own domain as an HTTP route (from config, not registration).
|
||||
// Central is just another L7 service from HAProxy's perspective.
|
||||
if d := globalCfg.Cloud.Central.Domain; d != "" {
|
||||
centralPort := api.getRunningPort()
|
||||
httpRoutes = append([]haproxy.HTTPRoute{{
|
||||
Name: "wild-central",
|
||||
Domain: d,
|
||||
Backend: fmt.Sprintf("127.0.0.1:%d", centralPort),
|
||||
}}, httpRoutes...)
|
||||
}
|
||||
|
||||
// Only include L7 HTTP routes for services that have a cert.
|
||||
certsDir := "/etc/haproxy/certs/"
|
||||
var activeHTTPRoutes []haproxy.HTTPRoute
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// routing, TLS certificates, and optional public exposure via DDNS.
|
||||
//
|
||||
// Each registration is keyed by its domain — one registration per domain.
|
||||
// Central's own services (its UI, VPN, firewall) come from config, not here.
|
||||
// Central registers its own UI domain here on startup (source: "central").
|
||||
package services
|
||||
|
||||
import (
|
||||
|
||||
Reference in New Issue
Block a user