services -> domains
This commit is contained in:
@@ -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