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:
2026-07-09 22:26:56 +00:00
parent 2e6fce54e3
commit c18217944f
6 changed files with 222 additions and 54 deletions

View File

@@ -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")
}

View File

@@ -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,

View File

@@ -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")
}
}
}

View File

@@ -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

View File

@@ -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 (

View File

@@ -150,10 +150,10 @@ func main() {
fmt.Sscanf(v, "%d", &port)
}
// Tell the API what port it's running on and run initial reconciliation.
// Must happen AFTER port is known so Central's config-driven HAProxy
// route points to the correct port.
// Tell the API what port it's running on, register Central as a service
// (if a domain is configured), and reconcile all networking.
api.SetPort(port)
api.EnsureCentralService()
api.Reconcile()
addr := fmt.Sprintf("%s:%d", host, port)