When HTTP services are registered (where Central terminates TLS), reconciliation now checks for and provisions TLS certificates: - Derives gateway domain from central domain (e.g., payne.io) - Checks for existing wildcard cert (*.payne.io) - If no wildcard exists and Cloudflare token + operator email are configured, provisions one via certbot DNS-01 - For services outside the gateway domain, provisions individual certs - Services with TLS passthrough (k8s) are skipped (backend handles TLS) - Graceful: skips silently if no Cloudflare token or email configured Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
272 lines
8.3 KiB
Go
272 lines
8.3 KiB
Go
package v1
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/wild-cloud/wild-central/internal/certbot"
|
|
"github.com/wild-cloud/wild-central/internal/config"
|
|
"github.com/wild-cloud/wild-central/internal/haproxy"
|
|
"github.com/wild-cloud/wild-central/internal/services"
|
|
"github.com/wild-cloud/wild-central/internal/sse"
|
|
)
|
|
|
|
// reconcileNetworking reads all registered services and regenerates
|
|
// dnsmasq DNS entries and HAProxy routes to match. Called automatically
|
|
// whenever a service is registered, updated, or deregistered.
|
|
func (api *API) reconcileNetworking() {
|
|
svcs, err := api.services.List()
|
|
if err != nil {
|
|
slog.Error("reconcile: failed to list services", "error", err)
|
|
return
|
|
}
|
|
|
|
globalConfigPath := filepath.Join(api.dataDir, "config.yaml")
|
|
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
|
|
if err != nil {
|
|
slog.Warn("reconcile: failed to load global config, using empty", "error", err)
|
|
globalCfg = &config.GlobalConfig{}
|
|
}
|
|
|
|
// Build HAProxy routes from registered services
|
|
var instanceRoutes []haproxy.InstanceRoute
|
|
var httpRoutes []haproxy.HTTPRoute
|
|
|
|
for _, svc := range svcs {
|
|
if svc.Reach == services.ReachOff {
|
|
continue
|
|
}
|
|
|
|
switch svc.Backend.Type {
|
|
case services.BackendTCPPassthrough:
|
|
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
|
|
Name: svc.Name,
|
|
Domain: svc.Domain,
|
|
BackendIP: extractHost(svc.Backend.Address),
|
|
ExtraDomains: svc.ExtraDomains,
|
|
})
|
|
case services.BackendHTTP, services.BackendStatic:
|
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
|
Name: svc.Name,
|
|
Domain: svc.Domain,
|
|
Backend: svc.Backend.Address,
|
|
HealthPath: svc.Backend.Health,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Determine central domain for HAProxy
|
|
centralDomain := ""
|
|
if globalCfg.Cloud.Central.Domain != "" {
|
|
centralDomain = globalCfg.Cloud.Central.Domain
|
|
}
|
|
|
|
// Generate and write HAProxy config
|
|
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
|
|
CentralDomain: centralDomain,
|
|
HTTPRoutes: httpRoutes,
|
|
})
|
|
|
|
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
|
slog.Error("reconcile: failed to write HAProxy config", "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")
|
|
}
|
|
}
|
|
|
|
// Generate dnsmasq DNS entries from registered services
|
|
// Build instance configs for backward compatibility with dnsmasq generator
|
|
var instanceConfigs []config.InstanceConfig
|
|
for _, svc := range svcs {
|
|
if svc.Reach == services.ReachOff || svc.Domain == "" {
|
|
continue
|
|
}
|
|
ic := config.InstanceConfig{}
|
|
ic.Cloud.Domain = svc.Domain
|
|
ic.Cluster.LoadBalancerIp = extractHost(svc.Backend.Address)
|
|
instanceConfigs = append(instanceConfigs, ic)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// Ensure TLS certs exist for HTTP services (where Central terminates TLS).
|
|
// For services under the gateway domain, a wildcard cert covers them all.
|
|
// For others, provision individual certs.
|
|
if len(httpRoutes) > 0 {
|
|
api.ensureTLSCerts(globalCfg, svcs)
|
|
}
|
|
|
|
slog.Info("reconcile: networking updated",
|
|
"services", len(svcs),
|
|
"l4Routes", len(instanceRoutes),
|
|
"l7Routes", len(httpRoutes),
|
|
)
|
|
}
|
|
|
|
// ensureTLSCerts checks that TLS certificates exist for HTTP services
|
|
// that need Central to terminate TLS. Uses wildcard certs where possible.
|
|
func (api *API) ensureTLSCerts(globalCfg *config.GlobalConfig, svcs []services.Service) {
|
|
// Determine the gateway domain from central domain (e.g., central.payne.io → payne.io)
|
|
centralDomain := globalCfg.Cloud.Central.Domain
|
|
gatewayDomain := ""
|
|
if parts := strings.SplitN(centralDomain, ".", 2); len(parts) == 2 {
|
|
gatewayDomain = parts[1] // e.g., "payne.io"
|
|
}
|
|
|
|
// Check if we have a Cloudflare token for cert provisioning
|
|
cfToken := api.getCloudflareToken()
|
|
if cfToken == "" {
|
|
slog.Debug("reconcile/tls: no Cloudflare token, skipping cert provisioning")
|
|
return
|
|
}
|
|
|
|
email := globalCfg.Operator.Email
|
|
if email == "" {
|
|
slog.Debug("reconcile/tls: no operator email, skipping cert provisioning")
|
|
return
|
|
}
|
|
|
|
// Check if wildcard cert exists for the gateway domain
|
|
wildcardCertPath := "/etc/haproxy/certs/wildcard.pem"
|
|
if gatewayDomain != "" {
|
|
wildcardDomain := "*." + gatewayDomain
|
|
// Check if cert exists at the standard wildcard path or per-domain path
|
|
certPath := certbot.HAProxyCertPath(gatewayDomain)
|
|
if _, err := os.Stat(certPath); err == nil {
|
|
// Per-domain cert exists, use it as wildcard
|
|
slog.Debug("reconcile/tls: wildcard cert exists", "domain", gatewayDomain, "path", certPath)
|
|
} else if _, err := os.Stat(wildcardCertPath); err == nil {
|
|
slog.Debug("reconcile/tls: wildcard cert exists at default path")
|
|
} else {
|
|
// No wildcard cert — provision one
|
|
slog.Info("reconcile/tls: provisioning wildcard cert", "domain", wildcardDomain)
|
|
if err := api.certbot.EnsureCredentials(cfToken); err != nil {
|
|
slog.Error("reconcile/tls: failed to write certbot credentials", "error", err)
|
|
return
|
|
}
|
|
if err := api.certbot.Provision(wildcardDomain, email); err != nil {
|
|
slog.Warn("reconcile/tls: failed to provision wildcard cert", "domain", wildcardDomain, "error", err)
|
|
// Fall through — try per-domain certs
|
|
}
|
|
}
|
|
}
|
|
|
|
// For services NOT under the gateway domain, provision individual certs
|
|
for _, svc := range svcs {
|
|
if svc.TLS != services.TLSTerminate || svc.Domain == "" {
|
|
continue
|
|
}
|
|
|
|
// Check if this domain is covered by the gateway wildcard
|
|
if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) {
|
|
continue // Covered by wildcard
|
|
}
|
|
|
|
// Check if per-domain cert exists
|
|
certPath := certbot.HAProxyCertPath(svc.Domain)
|
|
if _, err := os.Stat(certPath); err == nil {
|
|
continue // Cert already exists
|
|
}
|
|
|
|
// Provision individual cert
|
|
slog.Info("reconcile/tls: provisioning cert", "domain", svc.Domain)
|
|
if err := api.certbot.EnsureCredentials(cfToken); err != nil {
|
|
slog.Error("reconcile/tls: failed to write certbot credentials", "error", err)
|
|
continue
|
|
}
|
|
if err := api.certbot.Provision(svc.Domain, email); err != nil {
|
|
slog.Warn("reconcile/tls: failed to provision cert", "domain", svc.Domain, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractHost gets the host part from a host:port string
|
|
func extractHost(addr string) string {
|
|
for i := len(addr) - 1; i >= 0; i-- {
|
|
if addr[i] == ':' {
|
|
return addr[:i]
|
|
}
|
|
}
|
|
return addr
|
|
}
|
|
|
|
// broadcastDnsmasqEvent broadcasts SSE events for dnsmasq status changes
|
|
func (api *API) broadcastDnsmasqEvent(eventType string, message string) {
|
|
if api.sseManager == nil {
|
|
return
|
|
}
|
|
|
|
// Get current dnsmasq status
|
|
status, err := api.dnsmasq.GetStatus()
|
|
if err != nil {
|
|
status = nil
|
|
}
|
|
|
|
event := &sse.Event{
|
|
ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()),
|
|
Type: eventType,
|
|
InstanceName: "global", // dnsmasq is global/central-level, not instance-specific
|
|
Timestamp: time.Now(),
|
|
Data: map[string]any{
|
|
"message": message,
|
|
"status": status,
|
|
},
|
|
}
|
|
|
|
api.sseManager.Broadcast(event)
|
|
}
|
|
|
|
// broadcastHaproxyEvent broadcasts SSE events for HAProxy status changes
|
|
func (api *API) broadcastHaproxyEvent(eventType string, message string) {
|
|
if api.sseManager == nil {
|
|
return
|
|
}
|
|
|
|
event := &sse.Event{
|
|
ID: fmt.Sprintf("haproxy-%d", time.Now().UnixNano()),
|
|
Type: eventType,
|
|
InstanceName: "global",
|
|
Timestamp: time.Now(),
|
|
Data: map[string]any{
|
|
"message": message,
|
|
},
|
|
}
|
|
|
|
api.sseManager.Broadcast(event)
|
|
}
|
|
|
|
// broadcastCentralStatusEvent broadcasts SSE events for central status changes
|
|
func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
|
|
if api.sseManager == nil {
|
|
return
|
|
}
|
|
|
|
uptime := time.Since(startTime)
|
|
|
|
event := &sse.Event{
|
|
ID: fmt.Sprintf("central-%d", time.Now().UnixNano()),
|
|
Type: "central:status",
|
|
InstanceName: "global",
|
|
Timestamp: time.Now(),
|
|
Data: map[string]any{
|
|
"status": "running",
|
|
"version": api.version,
|
|
"uptime": uptime.String(),
|
|
"uptimeSeconds": int(uptime.Seconds()),
|
|
},
|
|
}
|
|
|
|
api.sseManager.Broadcast(event)
|
|
}
|