Authelia runs as a managed native service on Wild Central, providing two integration patterns for network services: - Forward-auth via HAProxy for apps without native SSO (Lua auth-request script intercepts requests, redirects unauthenticated users to login portal) - OIDC provider for apps with native support (Gitea, Grafana, etc.) Backend: new internal/authelia/ package with service manager, config generation, file-based user management (argon2id), and OIDC client management. API endpoints for status, config, users CRUD, and OIDC clients CRUD. HAProxy: config generator extended with lua-load, auth-request directives, and Authelia backend. Auth directives scoped to session cookie domain — only subdomains of the auth portal's parent are eligible for forward-auth. Frontend: Authentication page with enable/disable, user management, OIDC client management (add/edit/delete), and protected domains toggles. Advanced subsystem page for raw config view. Dashboard service card and sidebar entries.
456 lines
14 KiB
Go
456 lines
14 KiB
Go
package v1
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/wild-cloud/wild-central/internal/authelia"
|
|
"github.com/wild-cloud/wild-central/internal/certbot"
|
|
"github.com/wild-cloud/wild-central/internal/config"
|
|
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
|
"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/sse"
|
|
"github.com/wild-cloud/wild-central/internal/storage"
|
|
)
|
|
|
|
// 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 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.domains.Get(centralDomain); existing != nil &&
|
|
existing.Source == "central" &&
|
|
existing.Backend.Address == backend {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Clean up stale Central registrations (e.g. domain changed).
|
|
doms, _ := api.domains.List()
|
|
for _, dom := range doms {
|
|
if dom.Source == "central" && dom.DomainName != centralDomain {
|
|
_ = api.domains.Deregister(dom.DomainName)
|
|
}
|
|
}
|
|
|
|
if centralDomain == "" {
|
|
return
|
|
}
|
|
|
|
_ = api.domains.Register(domains.Domain{
|
|
DomainName: centralDomain,
|
|
Source: "central",
|
|
Backend: domains.Backend{
|
|
Address: backend,
|
|
Type: domains.BackendHTTP,
|
|
},
|
|
TLS: domains.TLSTerminate,
|
|
})
|
|
}
|
|
|
|
// Reconcile runs networking reconciliation immediately. Called on startup
|
|
// and automatically whenever a domain is registered/updated/deregistered.
|
|
func (api *API) Reconcile() {
|
|
api.reconcileNetworking()
|
|
}
|
|
|
|
// reconcileNetworking reads all registered domains and regenerates
|
|
// dnsmasq DNS entries and HAProxy routes to match.
|
|
func (api *API) reconcileNetworking() {
|
|
doms, err := api.domains.List()
|
|
if err != nil {
|
|
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.State{}
|
|
}
|
|
|
|
// Build HAProxy routes from registered domains
|
|
var l4Routes []haproxy.L4Route
|
|
var httpRoutes []haproxy.HTTPRoute
|
|
|
|
for _, dom := range doms {
|
|
switch dom.EffectiveBackendType() {
|
|
case domains.BackendTCPPassthrough:
|
|
l4Routes = append(l4Routes, haproxy.L4Route{
|
|
Name: dom.DomainName,
|
|
Domain: dom.DomainName,
|
|
BackendIP: extractHost(dom.EffectiveBackendAddress()),
|
|
Subdomains: dom.Subdomains,
|
|
})
|
|
case domains.BackendDNSOnly:
|
|
// dns-only: no gateway routing, just DNS resolution
|
|
case domains.BackendHTTP:
|
|
var routeBackends []haproxy.HTTPRouteBackend
|
|
for _, r := range dom.EffectiveRoutes() {
|
|
rb := haproxy.HTTPRouteBackend{
|
|
Paths: r.Paths,
|
|
Backend: r.Backend.Address,
|
|
HealthPath: r.Backend.Health,
|
|
Headers: r.Headers,
|
|
IPAllow: r.IPAllow,
|
|
}
|
|
if dom.Auth != nil && dom.Auth.Enabled {
|
|
rb.AuthEnabled = true
|
|
rb.AuthPolicy = dom.Auth.Policy
|
|
}
|
|
routeBackends = append(routeBackends, rb)
|
|
}
|
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
|
Name: dom.DomainName,
|
|
Domain: dom.DomainName,
|
|
Subdomains: dom.Subdomains,
|
|
Routes: routeBackends,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Clean up any 0-byte cert files that would poison HAProxy validation.
|
|
// A 0-byte .pem in the certs directory causes the global `bind ssl crt`
|
|
// to fail, taking down ALL L7 routes — not just the broken domain.
|
|
certsDir := "/etc/haproxy/certs/"
|
|
if entries, err := os.ReadDir(certsDir); err == nil {
|
|
for _, e := range entries {
|
|
if strings.HasSuffix(e.Name(), ".pem") {
|
|
if info, err := e.Info(); err == nil && info.Size() == 0 {
|
|
slog.Warn("reconcile: removing 0-byte cert file", "file", e.Name())
|
|
_ = os.Remove(filepath.Join(certsDir, e.Name()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only include L7 HTTP routes for domains that have a valid cert.
|
|
var activeHTTPRoutes []haproxy.HTTPRoute
|
|
for _, route := range httpRoutes {
|
|
if hasCertForDomain(certsDir, route.Domain) {
|
|
activeHTTPRoutes = append(activeHTTPRoutes, route)
|
|
} else {
|
|
slog.Warn("reconcile: skipping L7 route (no cert)", "domain", route.Domain)
|
|
}
|
|
}
|
|
|
|
// Generate and write HAProxy config
|
|
genOpts := haproxy.GenerateOpts{
|
|
HTTPRoutes: activeHTTPRoutes,
|
|
CertsDir: certsDir,
|
|
}
|
|
|
|
// Propagate Authelia forward-auth settings if enabled
|
|
if globalCfg.Cloud.Authelia.Enabled && globalCfg.Cloud.Authelia.Domain != "" {
|
|
genOpts.AuthEnabled = true
|
|
genOpts.AuthBackend = "127.0.0.1:9091"
|
|
genOpts.AuthDomain = globalCfg.Cloud.Authelia.Domain
|
|
genOpts.AuthSessionDomain = authelia.SessionDomainFromAuthDomain(globalCfg.Cloud.Authelia.Domain)
|
|
|
|
// Regenerate Authelia config so session cookie domains stay in sync
|
|
// with the set of auth-protected domains
|
|
if err := api.generateAutheliaConfig(globalCfg); err != nil {
|
|
slog.Warn("reconcile: failed to regenerate authelia config", "error", err)
|
|
} else if api.authelia.UserCount() > 0 {
|
|
_ = api.authelia.RestartService()
|
|
}
|
|
}
|
|
haproxyCfg := api.haproxy.GenerateWithOpts(l4Routes, nil, genOpts)
|
|
|
|
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
|
// 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 domains and retrying",
|
|
"broken", brokenDomains, "error", err)
|
|
|
|
exclude := map[string]bool{}
|
|
for _, d := range brokenDomains {
|
|
exclude[d] = true
|
|
}
|
|
|
|
var filteredInstances []haproxy.L4Route
|
|
for _, r := range l4Routes {
|
|
if !exclude[r.Domain] {
|
|
filteredInstances = append(filteredInstances, r)
|
|
}
|
|
}
|
|
var filteredHTTP []haproxy.HTTPRoute
|
|
for _, r := range activeHTTPRoutes {
|
|
if !exclude[r.Domain] {
|
|
filteredHTTP = append(filteredHTTP, r)
|
|
}
|
|
}
|
|
|
|
genOpts.HTTPRoutes = filteredHTTP
|
|
haproxyCfg = api.haproxy.GenerateWithOpts(filteredInstances, nil, genOpts)
|
|
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
|
slog.Error("reconcile: retry also failed", "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 (excluded broken domains)")
|
|
}
|
|
}
|
|
} else {
|
|
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 domains")
|
|
}
|
|
}
|
|
|
|
// Build dnsmasq DNS entries from registered domains.
|
|
// DNS target IP depends on backend type:
|
|
// tcp-passthrough/dns-only → backend IP (LAN traffic goes direct)
|
|
// http → Central IP (HAProxy terminates TLS)
|
|
// Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected
|
|
// (auto-detect can pick wlan0 if that's the default route).
|
|
centralIP := globalCfg.Cloud.Dnsmasq.IP
|
|
if centralIP == "" {
|
|
centralIP, _ = network.GetWildCentralIP()
|
|
}
|
|
if centralIP == "" {
|
|
centralIP = "127.0.0.1"
|
|
}
|
|
|
|
var dnsEntries []dnsmasq.DNSEntry
|
|
for _, dom := range doms {
|
|
if dom.DomainName == "" {
|
|
continue
|
|
}
|
|
|
|
dnsIP := centralIP
|
|
bt := dom.EffectiveBackendType()
|
|
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
|
|
dnsIP = extractHost(dom.EffectiveBackendAddress())
|
|
}
|
|
|
|
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
|
Domain: dom.DomainName,
|
|
IP: dnsIP,
|
|
})
|
|
}
|
|
|
|
// Set addn-hosts path for DNS filtering
|
|
if globalCfg.Cloud.DNSFilter.Enabled {
|
|
hostsPath := api.dnsFilter.HostsFilePath()
|
|
if storage.FileExists(hostsPath) {
|
|
api.dnsmasq.SetFilterConfPath(hostsPath)
|
|
}
|
|
} else {
|
|
api.dnsmasq.SetFilterConfPath("")
|
|
}
|
|
|
|
if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
|
|
slog.Error("reconcile: failed to update dnsmasq", "error", err)
|
|
} else {
|
|
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains")
|
|
}
|
|
|
|
// 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, 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",
|
|
"domains", len(doms),
|
|
"l4Routes", len(l4Routes),
|
|
"l7Routes", len(httpRoutes),
|
|
)
|
|
}
|
|
|
|
// 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.State, doms []domains.Domain) {
|
|
gatewayDomain := ""
|
|
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
|
|
gatewayDomain = parts[1]
|
|
}
|
|
|
|
for _, dom := range doms {
|
|
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
|
|
continue
|
|
}
|
|
|
|
// Check if covered by wildcard
|
|
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", dom.DomainName, "needed", "*."+gatewayDomain)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Check individual cert
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// hasCertForDomain checks if a valid (non-empty) cert exists for a domain —
|
|
// either an individual cert (<domain>.pem) or a wildcard cert that covers it.
|
|
func hasCertForDomain(_, domain string) bool {
|
|
// Check individual cert
|
|
if isValidCertFile(certbot.HAProxyCertPath(domain)) {
|
|
return true
|
|
}
|
|
// Check wildcard certs — a *.example.com cert covers foo.example.com
|
|
parts := strings.SplitN(domain, ".", 2)
|
|
if len(parts) == 2 {
|
|
wildcardBase := parts[1]
|
|
if isValidCertFile(certbot.HAProxyCertPath(wildcardBase)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isValidCertFile checks that a cert file exists and is non-empty.
|
|
func isValidCertFile(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.Size() > 0
|
|
}
|
|
|
|
// 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",
|
|
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)
|
|
}
|
|
|
|
// broadcastAutheliaEvent broadcasts SSE events for Authelia status changes
|
|
func (api *API) broadcastAutheliaEvent(eventType string, message string) {
|
|
if api.sseManager == nil {
|
|
return
|
|
}
|
|
|
|
event := &sse.Event{
|
|
ID: fmt.Sprintf("authelia-%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)
|
|
}
|
|
|
|
// broadcastDNSFilterEvent broadcasts SSE events for DNS filter changes
|
|
func (api *API) broadcastDNSFilterEvent(eventType string, message string) {
|
|
if api.sseManager == nil {
|
|
return
|
|
}
|
|
|
|
event := &sse.Event{
|
|
ID: fmt.Sprintf("dns-filter-%d", time.Now().UnixNano()),
|
|
Type: eventType,
|
|
InstanceName: "global",
|
|
Timestamp: time.Now(),
|
|
Data: map[string]any{
|
|
"message": message,
|
|
},
|
|
}
|
|
|
|
api.sseManager.Broadcast(event)
|
|
}
|