Add Authelia as centralized authentication and OIDC provider

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.
This commit is contained in:
2026-07-12 11:47:37 +00:00
parent 24bb976652
commit d796a79f79
27 changed files with 3097 additions and 11 deletions

View File

@@ -8,6 +8,7 @@ import (
"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"
@@ -15,6 +16,7 @@ import (
"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
@@ -96,13 +98,18 @@ func (api *API) reconcileNetworking() {
case domains.BackendHTTP:
var routeBackends []haproxy.HTTPRouteBackend
for _, r := range dom.EffectiveRoutes() {
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{
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,
@@ -143,6 +150,22 @@ func (api *API) reconcileNetworking() {
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 {
@@ -224,6 +247,16 @@ func (api *API) reconcileNetworking() {
})
}
// 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 {
@@ -359,6 +392,25 @@ func (api *API) broadcastHaproxyEvent(eventType string, message string) {
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 {
@@ -382,3 +434,22 @@ func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
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)
}