Architecture: - Extract reconcileNetworking into internal/reconcile package with 7 consumer-side interfaces - Add locked modifyState helper to fix state.yaml read-modify-write race condition - Extract CrowdSec and VPN handler groups with interfaces documenting dependency surface - Replace raw map[string]any YAML manipulation with typed AddDHCPStaticLease/RemoveDHCPStaticLease - Extract Cloudflare API functions into cfClient struct, eliminating repeated auth boilerplate Complexity reduction: - haproxy.GenerateWithOpts: 50 → 6 (extracted 8 focused helpers) - reconcile.Reconcile: 42 → 11 (extracted buildRoutes, buildDNSEntries, writeHAProxyConfig) - AutheliaUpdateConfig: 25 → 10 (extracted enableAuthelia/disableAuthelia) Test coverage improvements: - reconcile: 4.5% → 56.8% (stub-based tests for route building, DNS entries, orchestration) - dnsfilter: 10.7% → 45.6% (Manager.Compile, AddList, ToggleList, custom entries) - config: 67.3% → 89.8% (DHCP static lease mutation tests)
475 lines
15 KiB
Go
475 lines
15 KiB
Go
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: %v", err)
|
|
}
|
|
if err := api.ensureAutheliaSecrets(); err != nil {
|
|
return fmt.Errorf("Failed to generate secrets: %v", err)
|
|
}
|
|
if err := api.authelia.EnsureJWKS(); err != nil {
|
|
return fmt.Errorf("Failed to generate JWKS: %v", err)
|
|
}
|
|
if err := api.authelia.EnsureUsersDB(); err != nil {
|
|
return fmt.Errorf("Failed to initialize user database: %v", err)
|
|
}
|
|
if err := api.generateAutheliaConfig(state); err != nil {
|
|
return fmt.Errorf("Failed to generate config: %v", err)
|
|
}
|
|
|
|
api.ensureAuthDomain(state.Cloud.Authelia.Domain)
|
|
|
|
if api.authelia.UserCount() > 0 {
|
|
if err := api.authelia.RestartService(); err != nil {
|
|
return fmt.Errorf("Configuration saved but Authelia failed to start: %v", 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)
|
|
}
|
|
}
|
|
}
|