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 } smtp := struct { Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Sender string `json:"sender"` }{} if state != nil { smtp.Host = state.Cloud.Authelia.SMTP.Host smtp.Port = state.Cloud.Authelia.SMTP.Port smtp.Username = state.Cloud.Authelia.SMTP.Username smtp.Sender = state.Cloud.Authelia.SMTP.Sender } 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(), "smtp": smtp, }) } // 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"` SMTPHost *string `json:"smtpHost"` SMTPPort *int `json:"smtpPort"` SMTPUsername *string `json:"smtpUsername"` SMTPSender *string `json:"smtpSender"` SMTPPassword *string `json:"smtpPassword"` } 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 } if req.SMTPHost != nil { state.Cloud.Authelia.SMTP.Host = *req.SMTPHost } if req.SMTPPort != nil { state.Cloud.Authelia.SMTP.Port = *req.SMTPPort } if req.SMTPUsername != nil { state.Cloud.Authelia.SMTP.Username = *req.SMTPUsername } if req.SMTPSender != nil { state.Cloud.Authelia.SMTP.Sender = *req.SMTPSender } if req.SMTPPassword != nil && *req.SMTPPassword != "" { _ = api.secrets.SetSecret("authelia.smtpPassword", *req.SMTPPassword) } if state.Cloud.Authelia.Enabled { if err := api.enableAuthelia(state); err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } } else { api.disableAuthelia(state) } if err := config.SaveState(state, api.statePath()); err != nil { respondError(w, http.StatusInternalServerError, "Failed to save state") return } go api.reconciler.Reconcile() api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated") respondMessage(w, http.StatusOK, "Authelia configuration updated") } // enableAuthelia validates prerequisites, sets up services, generates config, // and starts Authelia. Returns a user-facing error message or nil. func (api *API) enableAuthelia(state *config.State) error { if !api.authelia.IsInstalled() { return fmt.Errorf("Authelia is not installed. Install it first (apt install authelia).") } if state.Cloud.Authelia.Domain == "" { return fmt.Errorf("Auth portal domain is required when enabling Authelia") } if !api.authelia.HasLuaScript() { return fmt.Errorf( "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") } if err := api.authelia.EnsureDataDir(); err != nil { return fmt.Errorf("failed to create data directory: %w", err) } if err := api.ensureAutheliaSecrets(); err != nil { return fmt.Errorf("failed to generate secrets: %w", err) } if err := api.authelia.EnsureJWKS(); err != nil { return fmt.Errorf("failed to generate JWKS: %w", err) } if err := api.authelia.EnsureUsersDB(); err != nil { return fmt.Errorf("failed to initialize user database: %w", err) } if err := api.generateAutheliaConfig(state); err != nil { return fmt.Errorf("failed to generate config: %w", err) } api.ensureAuthDomain(state.Cloud.Authelia.Domain) if api.authelia.UserCount() > 0 { if err := api.authelia.RestartService(); err != nil { return fmt.Errorf("authelia failed to start: %w", err) } } return nil } // disableAuthelia stops the service and removes auth domain registrations. func (api *API) disableAuthelia(_ *config.State) { _ = api.authelia.StopService() api.deregisterAuthDomain() } // 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) smtpPassword, _ := api.secrets.GetSecret("authelia.smtpPassword") opts := authelia.ConfigOpts{ Domain: state.Cloud.Authelia.Domain, JWTSecret: jwtSecret, SessionSecret: sessionSecret, StorageEncKey: storageEncKey, OIDCHMACSecret: oidcHmac, DefaultPolicy: defaultPolicy, SessionDomain: sessionDomain, OIDCClients: clients, SMTPHost: state.Cloud.Authelia.SMTP.Host, SMTPPort: state.Cloud.Authelia.SMTP.Port, SMTPUsername: state.Cloud.Authelia.SMTP.Username, SMTPSender: state.Cloud.Authelia.SMTP.Sender, SMTPPassword: smtpPassword, } 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) } } }