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")
|
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.reloadDDNSIfEnabled()
|
||||||
|
go api.EnsureCentralService()
|
||||||
|
|
||||||
respondMessage(w, http.StatusOK, "Config updated successfully")
|
respondMessage(w, http.StatusOK, "Config updated successfully")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
"github.com/wild-cloud/wild-central/internal/services"
|
"github.com/wild-cloud/wild-central/internal/services"
|
||||||
)
|
)
|
||||||
@@ -58,6 +60,37 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
certs = append(certs, entry)
|
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{
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
"configured": centralDomain != "",
|
"configured": centralDomain != "",
|
||||||
"domain": centralDomain,
|
"domain": centralDomain,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -81,13 +83,32 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Central's own domain (as reconcileNetworking does from config)
|
// Central registers itself as a service; add it to the test data
|
||||||
centralDomain := "central.payne.io"
|
if err := api.services.Register(services.Service{
|
||||||
httpRoutes = append([]haproxy.HTTPRoute{{
|
Domain: "central.payne.io",
|
||||||
Name: "wild-central",
|
Source: "central",
|
||||||
Domain: centralDomain,
|
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
|
||||||
Backend: "127.0.0.1:5055",
|
Reach: services.ReachInternal,
|
||||||
}}, httpRoutes...)
|
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{
|
cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
|
||||||
HTTPRoutes: httpRoutes,
|
HTTPRoutes: httpRoutes,
|
||||||
@@ -97,7 +118,7 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
|||||||
// --- Assertions on HAProxy config ---
|
// --- Assertions on HAProxy config ---
|
||||||
|
|
||||||
// L7 services have exact-match ACLs in the SNI frontend
|
// L7 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)
|
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") {
|
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
|
// 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")
|
l4WildcardPos := strings.Index(cfg, "req_ssl_sni -m end .cloud.payne.io")
|
||||||
if l7Pos > l4WildcardPos {
|
if l7Pos > l4WildcardPos {
|
||||||
t.Errorf("L7 exact matches (pos %d) must appear before L4 wildcards (pos %d)", 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
|
// TestReconciliation_CentralDomainInHAProxy verifies that Central, registered
|
||||||
// from global config is injected as the first L7 HTTP route.
|
// as a service, produces correct HAProxy L7 routes.
|
||||||
func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
||||||
api, _ := setupTestAPI(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{
|
if err := api.services.Register(services.Service{
|
||||||
Domain: "my-app.payne.io",
|
Domain: "my-app.payne.io",
|
||||||
Source: "wild-works",
|
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{
|
cfg := api.haproxy.GenerateWithOpts(nil, nil, haproxy.GenerateOpts{
|
||||||
HTTPRoutes: httpRoutes,
|
HTTPRoutes: httpRoutes,
|
||||||
})
|
})
|
||||||
@@ -294,22 +318,9 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
|||||||
t.Errorf("expected Central domain Host ACL:\n%s", cfg)
|
t.Errorf("expected Central domain Host ACL:\n%s", cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Central backend must use the configured port (15055), not default 5055
|
// Central backend must use the configured port (15055)
|
||||||
if !strings.Contains(cfg, "server s0 127.0.0.1:15055") {
|
if !strings.Contains(cfg, fmt.Sprintf("server s0 127.0.0.1:%d", centralPort)) {
|
||||||
t.Errorf("Central backend must use port 15055, not 5055:\n%s", cfg)
|
t.Errorf("Central backend must use port %d:\n%s", centralPort, 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,3 +372,96 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
|
|||||||
t.Errorf("tcp-passthrough DNS must NOT point to Central IP:\n%s", dnsmasqCfg)
|
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"
|
"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
|
// Reconcile runs networking reconciliation immediately. Called on startup
|
||||||
// and automatically whenever a service is registered/updated/deregistered.
|
// and automatically whenever a service is registered/updated/deregistered.
|
||||||
func (api *API) Reconcile() {
|
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.
|
// Only include L7 HTTP routes for services that have a cert.
|
||||||
certsDir := "/etc/haproxy/certs/"
|
certsDir := "/etc/haproxy/certs/"
|
||||||
var activeHTTPRoutes []haproxy.HTTPRoute
|
var activeHTTPRoutes []haproxy.HTTPRoute
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// routing, TLS certificates, and optional public exposure via DDNS.
|
// routing, TLS certificates, and optional public exposure via DDNS.
|
||||||
//
|
//
|
||||||
// Each registration is keyed by its domain — one registration per domain.
|
// Each registration is keyed by its domain — one registration per domain.
|
||||||
// Central'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
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
6
main.go
6
main.go
@@ -150,10 +150,10 @@ func main() {
|
|||||||
fmt.Sscanf(v, "%d", &port)
|
fmt.Sscanf(v, "%d", &port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell the API what port it's running on and run initial reconciliation.
|
// Tell the API what port it's running on, register Central as a service
|
||||||
// Must happen AFTER port is known so Central's config-driven HAProxy
|
// (if a domain is configured), and reconcile all networking.
|
||||||
// route points to the correct port.
|
|
||||||
api.SetPort(port)
|
api.SetPort(port)
|
||||||
|
api.EnsureCentralService()
|
||||||
api.Reconcile()
|
api.Reconcile()
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", host, port)
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
|||||||
Reference in New Issue
Block a user