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:
@@ -17,10 +17,12 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"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/crowdsec"
|
||||
"github.com/wild-cloud/wild-central/internal/ddns"
|
||||
"github.com/wild-cloud/wild-central/internal/dnsfilter"
|
||||
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||
"github.com/wild-cloud/wild-central/internal/network"
|
||||
@@ -45,9 +47,12 @@ type API struct {
|
||||
crowdsec *crowdsec.Manager
|
||||
vpn *wireguard.Manager
|
||||
certbot *certbot.Manager
|
||||
domains *domains.Manager // Domain registration manager
|
||||
sseManager *sse.Manager // SSE manager for real-time events
|
||||
port int // Running API port (for config-driven routes)
|
||||
authelia *authelia.Manager // Authelia authentication service manager
|
||||
dnsFilter *dnsfilter.Manager // DNS filtering manager
|
||||
dnsFilterRunner *dnsfilter.Runner // DNS filter background update runner
|
||||
domains *domains.Manager // Domain registration manager
|
||||
sseManager *sse.Manager // SSE manager for real-time events
|
||||
port int // Running API port (for config-driven routes)
|
||||
}
|
||||
|
||||
// NewAPI creates a new Central API handler with all dependencies
|
||||
@@ -72,10 +77,18 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
|
||||
crowdsec: crowdsec.NewManager(),
|
||||
vpn: wireguard.NewManager(dataDir, vpnConfigPath),
|
||||
certbot: certbot.NewManager(""),
|
||||
authelia: authelia.NewManager(filepath.Join(dataDir, "authelia")),
|
||||
dnsFilter: dnsfilter.NewManager(filepath.Join(dataDir, "dns-filter")),
|
||||
domains: domains.NewManager(dataDir),
|
||||
sseManager: sseManager,
|
||||
}
|
||||
|
||||
// The compiled blocklist must be readable by dnsmasq which runs as its
|
||||
// own user. Use /var/lib/wild-central/dns-filter/ (world-readable) rather
|
||||
// than the data dir which may be under a user home with 0750 permissions.
|
||||
filterDir := envOrDefault("WILD_CENTRAL_FILTER_DIR", "/var/lib/wild-central/dns-filter")
|
||||
api.dnsFilter.SetHostsFilePath(filepath.Join(filterDir, "blocked.conf"))
|
||||
|
||||
// Wire up domain registration reconciliation: when domains change,
|
||||
// regenerate dnsmasq DNS entries and HAProxy routes.
|
||||
api.domains.SetReconcileFn(api.reconcileNetworking)
|
||||
@@ -110,6 +123,30 @@ func (api *API) StartDDNS(ctx gocontext.Context) {
|
||||
api.ddns.Start(ctx, api.ddnsParams)
|
||||
}
|
||||
|
||||
// StartDNSFilter launches the DNS filter background goroutine if enabled.
|
||||
func (api *API) StartDNSFilter(ctx gocontext.Context) {
|
||||
api.dnsFilterRunner = dnsfilter.NewRunner(api.dnsFilter, func() {
|
||||
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||
slog.Warn("dns-filter: failed to reload dnsmasq after update", "error", err)
|
||||
}
|
||||
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
|
||||
})
|
||||
|
||||
state, err := config.LoadState(api.statePath())
|
||||
if err != nil || !state.Cloud.DNSFilter.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
_ = api.dnsFilter.EnsureDataDir()
|
||||
|
||||
interval := state.Cloud.DNSFilter.IntervalHours
|
||||
if interval <= 0 {
|
||||
interval = 24
|
||||
}
|
||||
|
||||
api.dnsFilterRunner.Start(ctx, interval)
|
||||
}
|
||||
|
||||
// ddnsParams returns the current DDNS parameters derived from runtime state.
|
||||
// Called by the DDNS runner on each tick — always returns fresh values.
|
||||
func (api *API) ddnsParams() ddns.Params {
|
||||
@@ -257,6 +294,35 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET")
|
||||
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST")
|
||||
|
||||
// Authelia authentication management
|
||||
r.HandleFunc("/api/v1/authelia/status", api.AutheliaStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/authelia/config", api.AutheliaGetConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/authelia/config", api.AutheliaUpdateConfig).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/authelia/restart", api.AutheliaRestart).Methods("POST")
|
||||
r.HandleFunc("/api/v1/authelia/users", api.AutheliaListUsers).Methods("GET")
|
||||
r.HandleFunc("/api/v1/authelia/users", api.AutheliaCreateUser).Methods("POST")
|
||||
r.HandleFunc("/api/v1/authelia/users/{username}", api.AutheliaUpdateUser).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/authelia/users/{username}", api.AutheliaDeleteUser).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/authelia/oidc/clients", api.AutheliaListOIDCClients).Methods("GET")
|
||||
r.HandleFunc("/api/v1/authelia/oidc/clients", api.AutheliaCreateOIDCClient).Methods("POST")
|
||||
r.HandleFunc("/api/v1/authelia/oidc/clients/{clientId}", api.AutheliaUpdateOIDCClient).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/authelia/oidc/clients/{clientId}", api.AutheliaDeleteOIDCClient).Methods("DELETE")
|
||||
|
||||
// DNS Filtering
|
||||
r.HandleFunc("/api/v1/dns-filter/status", api.DNSFilterStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dns-filter/config", api.DNSFilterGetConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dns-filter/config", api.DNSFilterUpdateConfig).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists", api.DNSFilterGetLists).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists", api.DNSFilterAddList).Methods("POST")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists/upload", api.DNSFilterUploadList).Methods("POST")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists/suggested", api.DNSFilterGetSuggested).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists/{id}", api.DNSFilterRemoveList).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/dns-filter/lists/{id}/toggle", api.DNSFilterToggleList).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/dns-filter/custom", api.DNSFilterGetCustomEntries).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dns-filter/custom", api.DNSFilterSetCustomEntry).Methods("POST")
|
||||
r.HandleFunc("/api/v1/dns-filter/custom/{domain:.+}", api.DNSFilterRemoveCustomEntry).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/dns-filter/update", api.DNSFilterTriggerUpdate).Methods("POST")
|
||||
|
||||
// Domain registration — domain is the unique key
|
||||
r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET")
|
||||
r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST")
|
||||
@@ -386,6 +452,17 @@ func getDaemonStatus() map[string]map[string]any {
|
||||
return firstWord(out)
|
||||
},
|
||||
},
|
||||
"authelia": {
|
||||
unit: "authelia.service",
|
||||
verCmd: []string{"authelia", "--version"},
|
||||
verParse: func(out string) string {
|
||||
// "authelia version 4.x.x" or just "4.x.x"
|
||||
if after, found := strings.CutPrefix(out, "authelia version "); found {
|
||||
return firstWord(after)
|
||||
}
|
||||
return firstWord(out)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := make(map[string]map[string]any, len(daemons)+1)
|
||||
|
||||
442
internal/api/v1/handlers_authelia.go
Normal file
442
internal/api/v1/handlers_authelia.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/authelia"
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"github.com/wild-cloud/wild-central/internal/domains"
|
||||
)
|
||||
|
||||
// AutheliaStatus returns the current Authelia service status
|
||||
func (api *API) AutheliaStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status, err := api.authelia.GetStatus()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get authelia status: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
state, _ := config.LoadState(api.statePath())
|
||||
enabled := false
|
||||
domain := ""
|
||||
defaultPolicy := ""
|
||||
if state != nil {
|
||||
enabled = state.Cloud.Authelia.Enabled
|
||||
domain = state.Cloud.Authelia.Domain
|
||||
defaultPolicy = state.Cloud.Authelia.DefaultPolicy
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"active": status.Active,
|
||||
"version": status.Version,
|
||||
"enabled": enabled,
|
||||
"domain": domain,
|
||||
"defaultPolicy": defaultPolicy,
|
||||
"userCount": api.authelia.UserCount(),
|
||||
"clientCount": api.authelia.OIDCClientCount(),
|
||||
"installed": api.authelia.IsInstalled(),
|
||||
})
|
||||
}
|
||||
|
||||
// AutheliaGetConfig returns the raw Authelia configuration for the advanced view
|
||||
func (api *API) AutheliaGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
content, err := api.authelia.ReadConfig()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read authelia config: %v", err))
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"content": content})
|
||||
}
|
||||
|
||||
// AutheliaUpdateConfig enables/disables Authelia and updates its configuration
|
||||
func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Domain *string `json:"domain"`
|
||||
DefaultPolicy *string `json:"defaultPolicy"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
state, err := config.LoadState(api.statePath())
|
||||
if err != nil {
|
||||
state = &config.State{}
|
||||
}
|
||||
|
||||
if req.Domain != nil {
|
||||
state.Cloud.Authelia.Domain = *req.Domain
|
||||
}
|
||||
if req.DefaultPolicy != nil {
|
||||
state.Cloud.Authelia.DefaultPolicy = *req.DefaultPolicy
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
state.Cloud.Authelia.Enabled = *req.Enabled
|
||||
}
|
||||
|
||||
// Enabling Authelia
|
||||
if state.Cloud.Authelia.Enabled {
|
||||
if !api.authelia.IsInstalled() {
|
||||
respondError(w, http.StatusBadRequest, "Authelia is not installed. Install it first (apt install authelia).")
|
||||
return
|
||||
}
|
||||
if state.Cloud.Authelia.Domain == "" {
|
||||
respondError(w, http.StatusBadRequest, "Auth portal domain is required when enabling Authelia")
|
||||
return
|
||||
}
|
||||
if !api.authelia.HasLuaScript() {
|
||||
respondError(w, http.StatusBadRequest,
|
||||
"HAProxy auth-request Lua script not found at /etc/haproxy/lua/haproxy-auth-request.lua. "+
|
||||
"Install it: sudo mkdir -p /etc/haproxy/lua && sudo curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua "+
|
||||
"https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
if err := api.authelia.EnsureDataDir(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data directory: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-generate secrets if missing
|
||||
if err := api.ensureAutheliaSecrets(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate secrets: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWKS key pair for OIDC
|
||||
if err := api.authelia.EnsureJWKS(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate JWKS: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure user database exists
|
||||
if err := api.authelia.EnsureUsersDB(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to initialize user database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate config
|
||||
if err := api.generateAutheliaConfig(state); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate config: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Register auth portal domain
|
||||
api.ensureAuthDomain(state.Cloud.Authelia.Domain)
|
||||
|
||||
// Start service — requires at least one user
|
||||
if api.authelia.UserCount() > 0 {
|
||||
if err := api.authelia.RestartService(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to start Authelia: %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Disabling — stop service and deregister domain
|
||||
_ = api.authelia.StopService()
|
||||
api.deregisterAuthDomain()
|
||||
}
|
||||
|
||||
if err := config.SaveState(state, api.statePath()); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger reconciliation to update HAProxy
|
||||
go api.reconcileNetworking()
|
||||
|
||||
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
|
||||
respondMessage(w, http.StatusOK, "Authelia configuration updated")
|
||||
}
|
||||
|
||||
// AutheliaRestart restarts the Authelia service
|
||||
func (api *API) AutheliaRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if err := api.authelia.RestartService(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restart authelia: %v", err))
|
||||
return
|
||||
}
|
||||
api.broadcastAutheliaEvent("authelia:restart", "Authelia service restarted")
|
||||
respondMessage(w, http.StatusOK, "Authelia restarted")
|
||||
}
|
||||
|
||||
// --- User management ---
|
||||
|
||||
// AutheliaListUsers returns all Authelia users (passwords omitted)
|
||||
func (api *API) AutheliaListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := api.authelia.ListUsers()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list users: %v", err))
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
}
|
||||
|
||||
// AutheliaCreateUser creates a new Authelia user
|
||||
func (api *API) AutheliaCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var user authelia.User
|
||||
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.authelia.CreateUser(user); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create user: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("authelia user created", "username", user.Username)
|
||||
|
||||
// If Authelia is enabled but not running (was waiting for first user), start it
|
||||
state, _ := config.LoadState(api.statePath())
|
||||
if state != nil && state.Cloud.Authelia.Enabled {
|
||||
status, _ := api.authelia.GetStatus()
|
||||
if status != nil && !status.Active {
|
||||
if err := api.authelia.RestartService(); err != nil {
|
||||
slog.Warn("failed to start authelia after first user", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q created", user.Username))
|
||||
respondMessage(w, http.StatusCreated, fmt.Sprintf("User %q created", user.Username))
|
||||
}
|
||||
|
||||
// AutheliaUpdateUser updates an existing Authelia user
|
||||
func (api *API) AutheliaUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
username := mux.Vars(r)["username"]
|
||||
|
||||
var updates authelia.UserUpdate
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.authelia.UpdateUser(username, updates); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update user: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("authelia user updated", "username", username)
|
||||
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q updated", username))
|
||||
}
|
||||
|
||||
// AutheliaDeleteUser deletes an Authelia user
|
||||
func (api *API) AutheliaDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
username := mux.Vars(r)["username"]
|
||||
|
||||
if err := api.authelia.DeleteUser(username); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete user: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("authelia user deleted", "username", username)
|
||||
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q deleted", username))
|
||||
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q deleted", username))
|
||||
}
|
||||
|
||||
// --- OIDC client management ---
|
||||
|
||||
// AutheliaListOIDCClients returns all registered OIDC clients (secrets redacted)
|
||||
func (api *API) AutheliaListOIDCClients(w http.ResponseWriter, r *http.Request) {
|
||||
clients, err := api.authelia.ListOIDCClients()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list OIDC clients: %v", err))
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"clients": clients})
|
||||
}
|
||||
|
||||
// AutheliaCreateOIDCClient creates a new OIDC client and returns the secret once
|
||||
func (api *API) AutheliaCreateOIDCClient(w http.ResponseWriter, r *http.Request) {
|
||||
var client authelia.OIDCClient
|
||||
if err := json.NewDecoder(r.Body).Decode(&client); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := api.authelia.AddOIDCClient(client)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create OIDC client: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Regenerate full config (adds OIDC section) and restart
|
||||
if err := api.regenAndRestartAuthelia(); err != nil {
|
||||
slog.Warn("failed to restart authelia after OIDC client create", "error", err)
|
||||
}
|
||||
|
||||
slog.Info("authelia OIDC client created", "clientId", client.ID)
|
||||
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q created", client.ID))
|
||||
|
||||
respondJSON(w, http.StatusCreated, map[string]any{
|
||||
"clientId": client.ID,
|
||||
"clientSecret": secret,
|
||||
"message": "OIDC client created. Save the client secret — it will not be shown again.",
|
||||
})
|
||||
}
|
||||
|
||||
// AutheliaUpdateOIDCClient updates an OIDC client
|
||||
func (api *API) AutheliaUpdateOIDCClient(w http.ResponseWriter, r *http.Request) {
|
||||
clientID := mux.Vars(r)["clientId"]
|
||||
|
||||
var updates authelia.OIDCClientUpdate
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.authelia.UpdateOIDCClient(clientID, updates); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update OIDC client: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.regenAndRestartAuthelia(); err != nil {
|
||||
slog.Warn("failed to restart authelia after OIDC client update", "error", err)
|
||||
}
|
||||
|
||||
slog.Info("authelia OIDC client updated", "clientId", clientID)
|
||||
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q updated", clientID))
|
||||
}
|
||||
|
||||
// AutheliaDeleteOIDCClient deletes an OIDC client
|
||||
func (api *API) AutheliaDeleteOIDCClient(w http.ResponseWriter, r *http.Request) {
|
||||
clientID := mux.Vars(r)["clientId"]
|
||||
|
||||
if err := api.authelia.DeleteOIDCClient(clientID); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete OIDC client: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.regenAndRestartAuthelia(); err != nil {
|
||||
slog.Warn("failed to restart authelia after OIDC client delete", "error", err)
|
||||
}
|
||||
|
||||
slog.Info("authelia OIDC client deleted", "clientId", clientID)
|
||||
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q deleted", clientID))
|
||||
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q deleted", clientID))
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
// regenAndRestartAuthelia regenerates the full Authelia config and restarts the service.
|
||||
// Used after OIDC client changes to ensure the config includes the OIDC section.
|
||||
func (api *API) regenAndRestartAuthelia() error {
|
||||
state, err := config.LoadState(api.statePath())
|
||||
if err != nil || !state.Cloud.Authelia.Enabled {
|
||||
return nil
|
||||
}
|
||||
if err := api.generateAutheliaConfig(state); err != nil {
|
||||
return fmt.Errorf("regenerating config: %w", err)
|
||||
}
|
||||
if api.authelia.UserCount() > 0 {
|
||||
return api.authelia.RestartService()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAutheliaSecrets generates Authelia secrets if they don't already exist
|
||||
func (api *API) ensureAutheliaSecrets() error {
|
||||
keys := []string{
|
||||
"authelia.jwtSecret",
|
||||
"authelia.sessionSecret",
|
||||
"authelia.storageEncryptionKey",
|
||||
"authelia.oidcHmacSecret",
|
||||
}
|
||||
for _, key := range keys {
|
||||
existing, _ := api.secrets.GetSecret(key)
|
||||
if existing != "" {
|
||||
continue
|
||||
}
|
||||
secret, err := authelia.GenerateSecret(32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating %s: %w", key, err)
|
||||
}
|
||||
if err := api.secrets.SetSecret(key, secret); err != nil {
|
||||
return fmt.Errorf("saving %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateAutheliaConfig reads secrets and state, then generates the Authelia config
|
||||
func (api *API) generateAutheliaConfig(state *config.State) error {
|
||||
jwtSecret, _ := api.secrets.GetSecret("authelia.jwtSecret")
|
||||
sessionSecret, _ := api.secrets.GetSecret("authelia.sessionSecret")
|
||||
storageEncKey, _ := api.secrets.GetSecret("authelia.storageEncryptionKey")
|
||||
oidcHmac, _ := api.secrets.GetSecret("authelia.oidcHmacSecret")
|
||||
|
||||
// Read OIDC clients from the clients file (with secrets for config generation)
|
||||
clients, _ := api.authelia.ReadOIDCClients()
|
||||
|
||||
defaultPolicy := state.Cloud.Authelia.DefaultPolicy
|
||||
if defaultPolicy == "" {
|
||||
defaultPolicy = "one_factor"
|
||||
}
|
||||
|
||||
sessionDomain := authelia.SessionDomainFromAuthDomain(state.Cloud.Authelia.Domain)
|
||||
|
||||
opts := authelia.ConfigOpts{
|
||||
Domain: state.Cloud.Authelia.Domain,
|
||||
JWTSecret: jwtSecret,
|
||||
SessionSecret: sessionSecret,
|
||||
StorageEncKey: storageEncKey,
|
||||
OIDCHMACSecret: oidcHmac,
|
||||
DefaultPolicy: defaultPolicy,
|
||||
SessionDomain: sessionDomain,
|
||||
OIDCClients: clients,
|
||||
}
|
||||
|
||||
return api.authelia.GenerateConfig(opts)
|
||||
}
|
||||
|
||||
// ensureAuthDomain registers the Authelia login portal as a domain
|
||||
func (api *API) ensureAuthDomain(authDomain string) {
|
||||
if authDomain == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up stale authelia registrations
|
||||
doms, _ := api.domains.List()
|
||||
for _, dom := range doms {
|
||||
if dom.Source == "authelia" && dom.DomainName != authDomain {
|
||||
_ = api.domains.Deregister(dom.DomainName)
|
||||
}
|
||||
}
|
||||
|
||||
_ = api.domains.Register(domains.Domain{
|
||||
DomainName: authDomain,
|
||||
Source: "authelia",
|
||||
Routes: []domains.Route{
|
||||
{
|
||||
Backend: domains.Backend{
|
||||
Address: "127.0.0.1:9091",
|
||||
Type: domains.BackendHTTP,
|
||||
},
|
||||
Headers: &domains.HeaderConfig{
|
||||
Request: map[string]string{
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": authDomain,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
TLS: domains.TLSTerminate,
|
||||
})
|
||||
}
|
||||
|
||||
// deregisterAuthDomain removes all authelia-sourced domain registrations
|
||||
func (api *API) deregisterAuthDomain() {
|
||||
doms, _ := api.domains.List()
|
||||
for _, dom := range doms {
|
||||
if dom.Source == "authelia" {
|
||||
_ = api.domains.Deregister(dom.DomainName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user