From d796a79f79b24af9cacf918706f0aa433bf7cbc1 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 12 Jul 2026 11:47:37 +0000 Subject: [PATCH] Add Authelia as centralized authentication and OIDC provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- go.mod | 2 +- internal/api/v1/handlers.go | 83 ++- internal/api/v1/handlers_authelia.go | 442 ++++++++++++ internal/api/v1/helpers.go | 75 ++- internal/authelia/TODO.md | 86 +++ internal/authelia/config.go | 222 ++++++ internal/authelia/manager.go | 156 +++++ internal/authelia/manager_test.go | 352 ++++++++++ internal/authelia/oidc.go | 219 ++++++ internal/authelia/users.go | 242 +++++++ internal/config/config.go | 9 + internal/domains/manager.go | 23 + internal/haproxy/config.go | 68 +- internal/haproxy/config_test.go | 210 ++++++ web/src/components/AppSidebar.tsx | 5 +- web/src/components/AutheliaComponent.tsx | 633 ++++++++++++++++++ web/src/components/DashboardComponent.tsx | 3 +- .../components/advanced/AutheliaSubsystem.tsx | 41 ++ web/src/components/advanced/index.ts | 1 + web/src/hooks/useAuthelia.ts | 90 +++ web/src/router/pages/AutheliaPage.tsx | 10 + .../router/pages/advanced/AutheliaPage.tsx | 10 + web/src/router/pages/advanced/index.ts | 1 + web/src/router/routes.tsx | 6 + web/src/services/api/authelia.ts | 111 +++ web/src/services/api/index.ts | 4 + web/src/services/api/networking.ts | 4 + 27 files changed, 3097 insertions(+), 11 deletions(-) create mode 100644 internal/api/v1/handlers_authelia.go create mode 100644 internal/authelia/TODO.md create mode 100644 internal/authelia/config.go create mode 100644 internal/authelia/manager.go create mode 100644 internal/authelia/manager_test.go create mode 100644 internal/authelia/oidc.go create mode 100644 internal/authelia/users.go create mode 100644 web/src/components/AutheliaComponent.tsx create mode 100644 web/src/components/advanced/AutheliaSubsystem.tsx create mode 100644 web/src/hooks/useAuthelia.ts create mode 100644 web/src/router/pages/AutheliaPage.tsx create mode 100644 web/src/router/pages/advanced/AutheliaPage.tsx create mode 100644 web/src/services/api/authelia.ts diff --git a/go.mod b/go.mod index abf24ee..3f46178 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/nats-io/nats-server/v2 v2.14.3 github.com/nats-io/nats.go v1.52.0 + golang.org/x/crypto v0.53.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -19,6 +20,5 @@ require ( github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/crypto v0.53.0 // indirect golang.org/x/sys v0.46.0 // indirect ) diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index 6c778bf..82e9b3a 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -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) diff --git a/internal/api/v1/handlers_authelia.go b/internal/api/v1/handlers_authelia.go new file mode 100644 index 0000000..d7ec597 --- /dev/null +++ b/internal/api/v1/handlers_authelia.go @@ -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) + } + } +} diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index 4c34fee..4c8aab1 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -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) +} diff --git a/internal/authelia/TODO.md b/internal/authelia/TODO.md new file mode 100644 index 0000000..7912431 --- /dev/null +++ b/internal/authelia/TODO.md @@ -0,0 +1,86 @@ +# Authelia Packaging TODO + +These steps must be added to the postinst script (`wild-cloud/dist/debian/DEBIAN/postinst`) +when packaging Wild Central for distribution. + +## Install Authelia + +```bash +if ! command -v authelia &>/dev/null; then + curl -fsSL https://apt.authelia.com/organization/signing.asc | \ + gpg --dearmor -o /usr/share/keyrings/authelia-archive-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/authelia-archive-keyring.gpg] \ + https://apt.authelia.com/integration/apt/repo/stable/debian debian main" \ + > /etc/apt/sources.list.d/authelia.list + apt-get update -qq + apt-get install -y authelia +fi +``` + +## Install HAProxy Lua scripts + +```bash +mkdir -p /etc/haproxy/lua +curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua \ + https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua +curl -fsSL -o /etc/haproxy/lua/haproxy-lua-http.lua \ + https://raw.githubusercontent.com/haproxytech/haproxy-lua-http/master/http.lua +apt-get install -y lua-json +``` + +## Systemd service override + +```bash +mkdir -p /etc/systemd/system/authelia.service.d +cat > /etc/systemd/system/authelia.service.d/wild-central.conf << EOF +[Service] +ExecStart= +ExecStart=/usr/bin/authelia --config /var/lib/wild-central/authelia/configuration.yml +EOF +systemctl daemon-reload +``` + +## Data directory + +```bash +mkdir -p /var/lib/wild-central/authelia +chown wildcloud:wildcloud /var/lib/wild-central/authelia +chmod 700 /var/lib/wild-central/authelia +``` + +## Polkit rule (manages all Wild Central services, not just Authelia) + +```bash +cat > /etc/polkit-1/rules.d/50-wild-central.rules << 'EOF' +polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.systemd1.manage-units" && + subject.isInGroup("wildcloud")) { + return polkit.Result.YES; + } +}); +EOF +``` + +--- + +# Certificate Issues TODO + +Problems discovered during Authelia integration that need fixing in the cert management system. + +## `hasCertForDomain` doesn't verify cert actually covers the domain + +`hasCertForDomain()` in `helpers.go` checks if a PEM file exists at the expected path (e.g. `payne.io.pem` for wildcard coverage of `auth.payne.io`), but never verifies the cert's Subject/SAN actually matches. We had a `*.payne.io.pem` file that was actually a cert for `central.payne.io` — it passed the existence check but served the wrong cert at runtime, causing TLS errors. + +**Fix:** Parse the cert and verify the SAN covers the requested domain, or at minimum check for `CN=*.domain` when relying on a wildcard. + +## Cert provisioning deploy hook can produce misnamed files + +The certbot deploy hook builds an HAProxy PEM from `/etc/letsencrypt/live/{cert-name}/`. The cert name doesn't always match the domain (e.g. certbot may name a `*.payne.io` wildcard cert as `payne.io`). If the PEM filename doesn't match what `HAProxyCertPath()` expects, the cert won't be found or will be confused with a different cert. + +**Fix:** The deploy hook or `BuildHAProxyCert` should name the PEM based on the cert's actual SAN, not just the certbot cert name. + +## UI has no "re-provision" option when cert exists but is wrong + +The Certificates UI considers a cert valid if the PEM file exists and is non-empty. There's no way to re-provision if the cert covers the wrong domain or is otherwise invalid. The user had to manually delete the bad PEM to trigger re-provisioning. + +**Fix:** Add a "Re-provision" or "Renew" action per domain in the Certificates UI, and show what domain(s) the cert actually covers (parsed from SAN). diff --git a/internal/authelia/config.go b/internal/authelia/config.go new file mode 100644 index 0000000..0342255 --- /dev/null +++ b/internal/authelia/config.go @@ -0,0 +1,222 @@ +package authelia + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "log/slog" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// ConfigOpts holds parameters for generating Authelia's configuration.yml +type ConfigOpts struct { + Domain string // auth portal domain (e.g. auth.payne.io) + JWTSecret string // identity validation JWT secret + SessionSecret string // session cookie secret + StorageEncKey string // storage encryption key (>=20 chars) + OIDCHMACSecret string // OIDC HMAC secret + DefaultPolicy string // "one_factor" or "two_factor" + SessionDomain string // cookie domain for SSO (e.g. payne.io) — forward-auth only works for subdomains of this + DataDir string // authelia data directory path + ListenAddr string // listen address (default: 127.0.0.1:9091) + OIDCClients []OIDCClient // registered OIDC clients +} + +// GenerateConfig builds Authelia's configuration.yml and writes it atomically. +func (m *Manager) GenerateConfig(opts ConfigOpts) error { + if err := m.EnsureDataDir(); err != nil { + return err + } + + if opts.ListenAddr == "" { + opts.ListenAddr = "127.0.0.1:9091" + } + if opts.DefaultPolicy == "" { + opts.DefaultPolicy = "one_factor" + } + if opts.DataDir == "" { + opts.DataDir = m.dataDir + } + + cfg := m.buildConfig(opts) + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshaling authelia config: %w", err) + } + + header := "# Authelia Configuration\n# Managed by Wild Central — do not edit manually\n---\n" + content := header + string(data) + + tmpPath := m.configPath() + ".tmp" + if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil { + return fmt.Errorf("writing temp config: %w", err) + } + + if err := os.Rename(tmpPath, m.configPath()); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("installing config: %w", err) + } + + slog.Info("authelia config written", "component", "authelia", "path", m.configPath()) + return nil +} + +// EnsureJWKS generates an RSA key pair for OIDC token signing if one doesn't exist. +func (m *Manager) EnsureJWKS() error { + if _, err := os.Stat(m.jwksPath()); err == nil { + return nil // already exists + } + + key, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return fmt.Errorf("generating RSA key: %w", err) + } + + keyBytes := x509.MarshalPKCS1PrivateKey(key) + pemBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes} + pemData := pem.EncodeToMemory(pemBlock) + + if err := os.WriteFile(m.jwksPath(), pemData, 0600); err != nil { + return fmt.Errorf("writing JWKS key: %w", err) + } + + slog.Info("authelia JWKS key pair generated", "component", "authelia", "path", m.jwksPath()) + return nil +} + +// buildConfig constructs the Authelia config as a nested map for YAML marshaling. +func (m *Manager) buildConfig(opts ConfigOpts) map[string]any { + cfg := map[string]any{ + "theme": "auto", + + "server": map[string]any{ + "address": "tcp://" + opts.ListenAddr + "/", + }, + + "log": map[string]any{ + "level": "info", + }, + + "totp": map[string]any{ + "issuer": "Wild Cloud", + }, + + "authentication_backend": map[string]any{ + "file": map[string]any{ + "path": m.usersDBPath(), + "password": map[string]any{ + "algorithm": "argon2id", + "iterations": 3, + "memory": 65536, + "parallelism": 2, + "key_length": 32, + "salt_length": 16, + }, + }, + }, + + "access_control": map[string]any{ + "default_policy": opts.DefaultPolicy, + }, + + "session": map[string]any{ + "name": "wild_central_session", + "secret": opts.SessionSecret, + "cookies": []map[string]any{ + { + "domain": opts.SessionDomain, + "authelia_url": "https://" + opts.Domain, + }, + }, + }, + + "storage": map[string]any{ + "encryption_key": opts.StorageEncKey, + "local": map[string]any{ + "path": m.sqlitePath(), + }, + }, + + "notifier": map[string]any{ + "filesystem": map[string]any{ + "filename": m.notificationPath(), + }, + }, + + "identity_validation": map[string]any{ + "reset_password": map[string]any{ + "jwt_secret": opts.JWTSecret, + }, + }, + } + + // OIDC provider configuration — only emitted when clients exist. + // Authelia requires at least one client when the OIDC section is present. + if opts.OIDCHMACSecret != "" && len(opts.OIDCClients) > 0 { + oidcCfg := map[string]any{ + "hmac_secret": opts.OIDCHMACSecret, + } + + // Inline the JWKS private key directly (avoids needing template filters) + if jwksData, err := os.ReadFile(m.jwksPath()); err == nil { + oidcCfg["jwks"] = []map[string]any{ + { + "key_id": "wild-central", + "algorithm": "RS256", + "use": "sig", + "key": string(jwksData), + }, + } + } + + { + var clients []map[string]any + for _, c := range opts.OIDCClients { + client := map[string]any{ + "client_id": c.ID, + "client_name": c.Name, + "client_secret": c.Secret, // already hashed + "redirect_uris": c.RedirectURIs, + "scopes": c.Scopes, + "authorization_policy": c.Policy, + } + if c.ConsentMode != "" { + client["consent_mode"] = c.ConsentMode + } + clients = append(clients, client) + } + oidcCfg["clients"] = clients + } + + cfg["identity_providers"] = map[string]any{ + "oidc": oidcCfg, + } + } + + return cfg +} + +// GenerateSecret creates a cryptographically random hex string of the given byte length. +func GenerateSecret(byteLen int) (string, error) { + b := make([]byte, byteLen) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generating random bytes: %w", err) + } + return fmt.Sprintf("%x", b), nil +} + +// SessionDomainFromAuthDomain extracts the parent domain for session cookie scoping. +// "auth.payne.io" -> "payne.io", "auth.sub.example.com" -> "sub.example.com" +func SessionDomainFromAuthDomain(authDomain string) string { + parts := strings.SplitN(authDomain, ".", 2) + if len(parts) == 2 { + return parts[1] + } + return authDomain +} diff --git a/internal/authelia/manager.go b/internal/authelia/manager.go new file mode 100644 index 0000000..41a1639 --- /dev/null +++ b/internal/authelia/manager.go @@ -0,0 +1,156 @@ +// Package authelia manages the Authelia authentication service. +// Authelia provides centralized authentication for network services via +// HAProxy forward-auth and acts as an OIDC provider for apps with native support. +package authelia + +import ( + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Status represents the current state of the Authelia service +type Status struct { + Active bool `json:"active"` + Version string `json:"version,omitempty"` + PID int `json:"pid,omitempty"` + LastRestart time.Time `json:"lastRestart,omitempty"` +} + +// Manager handles Authelia configuration and service lifecycle +type Manager struct { + dataDir string // e.g. /var/lib/wild-central/authelia +} + +// NewManager creates a new Authelia manager +func NewManager(dataDir string) *Manager { + return &Manager{dataDir: dataDir} +} + +// EnsureDataDir creates the Authelia data directory if it doesn't exist +func (m *Manager) EnsureDataDir() error { + if err := os.MkdirAll(m.dataDir, 0700); err != nil { + return fmt.Errorf("creating authelia data dir: %w", err) + } + return nil +} + +// IsInstalled checks if the authelia binary exists on PATH +func (m *Manager) IsInstalled() bool { + _, err := exec.LookPath("authelia") + return err == nil +} + +const luaScriptPath = "/etc/haproxy/lua/haproxy-auth-request.lua" + +// HasLuaScript checks if the HAProxy auth-request Lua script is installed +func (m *Manager) HasLuaScript() bool { + info, err := os.Stat(luaScriptPath) + return err == nil && info.Size() > 0 +} + +// GetStatus returns the current Authelia service status +func (m *Manager) GetStatus() (*Status, error) { + status := &Status{} + + cmd := exec.Command("systemctl", "is-active", "--quiet", "authelia") + status.Active = cmd.Run() == nil + + if status.Active { + if out, err := exec.Command("systemctl", "show", "authelia.service", "--property=MainPID").Output(); err == nil { + parts := strings.Split(strings.TrimSpace(string(out)), "=") + if len(parts) == 2 { + if pid, err := strconv.Atoi(parts[1]); err == nil { + status.PID = pid + } + } + } + + if out, err := exec.Command("systemctl", "show", "authelia.service", "--property=ActiveEnterTimestamp").Output(); err == nil { + parts := strings.Split(strings.TrimSpace(string(out)), "=") + if len(parts) == 2 { + if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", parts[1]); err == nil { + status.LastRestart = t + } + } + } + } + + if out, err := exec.Command("authelia", "--version").Output(); err == nil { + s := string(out) + if i := strings.Index(s, "version "); i >= 0 { + v := s[i+8:] + if j := strings.IndexByte(v, ' '); j > 0 { + v = v[:j] + } + status.Version = strings.TrimSpace(v) + } + } + + return status, nil +} + +// RestartService restarts the Authelia systemd service +func (m *Manager) RestartService() error { + cmd := exec.Command("systemctl", "restart", "authelia.service") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to restart authelia: %w (output: %s)", err, string(output)) + } + slog.Info("authelia service restarted", "component", "authelia") + return nil +} + +// StopService stops the Authelia systemd service +func (m *Manager) StopService() error { + cmd := exec.Command("systemctl", "stop", "authelia.service") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to stop authelia: %w (output: %s)", err, string(output)) + } + slog.Info("authelia service stopped", "component", "authelia") + return nil +} + +// ReadConfig reads the current Authelia configuration file +func (m *Manager) ReadConfig() (string, error) { + data, err := os.ReadFile(m.configPath()) + if err != nil { + return "", fmt.Errorf("reading authelia config: %w", err) + } + return string(data), nil +} + +// DataDir returns the Authelia data directory path +func (m *Manager) DataDir() string { + return m.dataDir +} + +func (m *Manager) configPath() string { + return filepath.Join(m.dataDir, "configuration.yml") +} + +func (m *Manager) usersDBPath() string { + return filepath.Join(m.dataDir, "users_database.yml") +} + +func (m *Manager) sqlitePath() string { + return filepath.Join(m.dataDir, "db.sqlite3") +} + +func (m *Manager) jwksPath() string { + return filepath.Join(m.dataDir, "jwks.pem") +} + +func (m *Manager) notificationPath() string { + return filepath.Join(m.dataDir, "notification.txt") +} + +func (m *Manager) oidcClientsPath() string { + return filepath.Join(m.dataDir, "oidc_clients.yml") +} diff --git a/internal/authelia/manager_test.go b/internal/authelia/manager_test.go new file mode 100644 index 0000000..6ec0154 --- /dev/null +++ b/internal/authelia/manager_test.go @@ -0,0 +1,352 @@ +package authelia + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestGenerateConfig(t *testing.T) { + dir := t.TempDir() + m := NewManager(dir) + + opts := ConfigOpts{ + Domain: "auth.payne.io", + JWTSecret: "test-jwt-secret", + SessionSecret: "test-session-secret", + StorageEncKey: "test-storage-encryption-key-min-20", + OIDCHMACSecret: "test-oidc-hmac-secret", + DefaultPolicy: "one_factor", + SessionDomain: "payne.io", + DataDir: dir, + ListenAddr: "127.0.0.1:9091", + } + + if err := m.GenerateConfig(opts); err != nil { + t.Fatalf("GenerateConfig: %v", err) + } + + // Verify file exists + configPath := filepath.Join(dir, "configuration.yml") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("reading config: %v", err) + } + + content := string(data) + + // Check key values are present + checks := []string{ + "tcp://127.0.0.1:9091/", + "one_factor", + "payne.io", + "auth.payne.io", + "Wild Cloud", + "argon2id", + "test-session-secret", + "test-storage-encryption-key-min-20", + } + for _, check := range checks { + if !strings.Contains(content, check) { + t.Errorf("config missing expected value: %q", check) + } + } + + // Verify it's valid YAML + var parsed map[string]any + if err := yaml.Unmarshal(data, &parsed); err != nil { + t.Errorf("config is not valid YAML: %v", err) + } + + // Check file permissions + info, _ := os.Stat(configPath) + if info.Mode().Perm() != 0600 { + t.Errorf("config permissions = %o, want 0600", info.Mode().Perm()) + } +} + +func TestGenerateConfigWithOIDCClients(t *testing.T) { + dir := t.TempDir() + m := NewManager(dir) + + opts := ConfigOpts{ + Domain: "auth.payne.io", + JWTSecret: "jwt", + SessionSecret: "session", + StorageEncKey: "storage-enc-key-20chars", + OIDCHMACSecret: "oidc-hmac", + SessionDomain: "payne.io", + OIDCClients: []OIDCClient{ + { + ID: "gitea", + Name: "Gitea", + Secret: "$pbkdf2-sha512$hashed", + RedirectURIs: []string{"https://git.payne.io/user/oauth2/authelia/callback"}, + Scopes: []string{"openid", "profile", "email"}, + Policy: "one_factor", + }, + }, + } + + if err := m.GenerateConfig(opts); err != nil { + t.Fatalf("GenerateConfig: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml")) + content := string(data) + + if !strings.Contains(content, "gitea") { + t.Error("config should contain OIDC client 'gitea'") + } + if !strings.Contains(content, "git.payne.io") { + t.Error("config should contain redirect URI") + } +} + +func TestGenerateConfigDefaults(t *testing.T) { + dir := t.TempDir() + m := NewManager(dir) + + // Minimal opts — should use defaults + opts := ConfigOpts{ + Domain: "auth.example.com", + JWTSecret: "jwt", + SessionSecret: "session", + StorageEncKey: "enc-key-twenty-chars", + SessionDomain: "example.com", + } + + if err := m.GenerateConfig(opts); err != nil { + t.Fatalf("GenerateConfig: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml")) + content := string(data) + + // Should default to one_factor + if !strings.Contains(content, "one_factor") { + t.Error("should default to one_factor policy") + } + // Should default to 127.0.0.1:9091 + if !strings.Contains(content, "127.0.0.1:9091") { + t.Error("should default to 127.0.0.1:9091") + } + // Without OIDCHMACSecret, should not have identity_providers + if strings.Contains(content, "identity_providers") { + t.Error("should not include OIDC section without HMAC secret") + } +} + +func TestUserCRUD(t *testing.T) { + dir := t.TempDir() + m := NewManager(dir) + + // List empty + users, err := m.ListUsers() + if err != nil { + t.Fatalf("ListUsers empty: %v", err) + } + if len(users) != 0 { + t.Errorf("expected 0 users, got %d", len(users)) + } + + // Create + err = m.CreateUser(User{ + Username: "admin", + Displayname: "Admin User", + Password: "supersecret", + Email: "admin@example.com", + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + // List after create + users, err = m.ListUsers() + if err != nil { + t.Fatalf("ListUsers: %v", err) + } + if len(users) != 1 { + t.Fatalf("expected 1 user, got %d", len(users)) + } + if users[0].Username != "admin" { + t.Errorf("username = %q, want %q", users[0].Username, "admin") + } + if users[0].Displayname != "Admin User" { + t.Errorf("displayname = %q, want %q", users[0].Displayname, "Admin User") + } + if users[0].Email != "admin@example.com" { + t.Errorf("email = %q, want %q", users[0].Email, "admin@example.com") + } + // Password should be omitted in listing + if users[0].Password != "" { + t.Error("password should be empty in listing") + } + + // Duplicate create should fail + err = m.CreateUser(User{Username: "admin", Password: "x", Displayname: "dup"}) + if err == nil { + t.Error("duplicate create should fail") + } + + // Update + newName := "Super Admin" + err = m.UpdateUser("admin", UserUpdate{Displayname: &newName}) + if err != nil { + t.Fatalf("UpdateUser: %v", err) + } + users, _ = m.ListUsers() + if users[0].Displayname != "Super Admin" { + t.Errorf("after update, displayname = %q, want %q", users[0].Displayname, "Super Admin") + } + + // Update nonexistent user + err = m.UpdateUser("nobody", UserUpdate{Displayname: &newName}) + if err == nil { + t.Error("update nonexistent should fail") + } + + // Delete + err = m.DeleteUser("admin") + if err != nil { + t.Fatalf("DeleteUser: %v", err) + } + users, _ = m.ListUsers() + if len(users) != 0 { + t.Errorf("after delete, expected 0 users, got %d", len(users)) + } + + // Delete nonexistent + err = m.DeleteUser("admin") + if err == nil { + t.Error("delete nonexistent should fail") + } +} + +func TestCreateUserValidation(t *testing.T) { + dir := t.TempDir() + m := NewManager(dir) + + if err := m.CreateUser(User{Password: "x", Displayname: "y"}); err == nil { + t.Error("empty username should fail") + } + if err := m.CreateUser(User{Username: "x", Displayname: "y"}); err == nil { + t.Error("empty password should fail") + } +} + +func TestHashPassword(t *testing.T) { + hash, err := hashPassword("testpassword") + if err != nil { + t.Fatalf("hashPassword: %v", err) + } + + if !strings.HasPrefix(hash, "$argon2id$v=19$") { + t.Errorf("hash should start with $argon2id$v=19$, got %q", hash[:30]) + } + + // Two hashes of the same password should differ (different salt) + hash2, _ := hashPassword("testpassword") + if hash == hash2 { + t.Error("two hashes of the same password should differ (random salt)") + } +} + +func TestUserCount(t *testing.T) { + dir := t.TempDir() + m := NewManager(dir) + + if m.UserCount() != 0 { + t.Error("empty db should have 0 users") + } + + m.CreateUser(User{Username: "a", Password: "p", Displayname: "A"}) + m.CreateUser(User{Username: "b", Password: "p", Displayname: "B"}) + + if m.UserCount() != 2 { + t.Errorf("expected 2 users, got %d", m.UserCount()) + } +} + +func TestEnsureUsersDB(t *testing.T) { + dir := t.TempDir() + m := NewManager(dir) + + if err := m.EnsureUsersDB(); err != nil { + t.Fatalf("EnsureUsersDB: %v", err) + } + + // File should exist + if _, err := os.Stat(m.usersDBPath()); err != nil { + t.Errorf("users db file should exist: %v", err) + } + + // Should be idempotent + if err := m.EnsureUsersDB(); err != nil { + t.Fatalf("EnsureUsersDB (second call): %v", err) + } +} + +func TestEnsureJWKS(t *testing.T) { + dir := t.TempDir() + m := NewManager(dir) + + if err := m.EnsureJWKS(); err != nil { + t.Fatalf("EnsureJWKS: %v", err) + } + + // File should exist + data, err := os.ReadFile(m.jwksPath()) + if err != nil { + t.Fatalf("reading JWKS: %v", err) + } + if !strings.Contains(string(data), "RSA PRIVATE KEY") { + t.Error("JWKS file should contain RSA private key") + } + + // Should be idempotent (doesn't regenerate) + if err := m.EnsureJWKS(); err != nil { + t.Fatalf("EnsureJWKS (second call): %v", err) + } +} + +func TestGenerateSecret(t *testing.T) { + s1, err := GenerateSecret(32) + if err != nil { + t.Fatalf("GenerateSecret: %v", err) + } + if len(s1) != 64 { // 32 bytes = 64 hex chars + t.Errorf("secret length = %d, want 64", len(s1)) + } + + s2, _ := GenerateSecret(32) + if s1 == s2 { + t.Error("two generated secrets should differ") + } +} + +func TestSessionDomainFromAuthDomain(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"auth.payne.io", "payne.io"}, + {"auth.sub.example.com", "sub.example.com"}, + {"localhost", "localhost"}, + } + for _, tt := range tests { + got := SessionDomainFromAuthDomain(tt.input) + if got != tt.want { + t.Errorf("SessionDomainFromAuthDomain(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestIsInstalled(t *testing.T) { + m := NewManager(t.TempDir()) + // Just verify it doesn't panic — the result depends on whether authelia is installed + _ = m.IsInstalled() +} diff --git a/internal/authelia/oidc.go b/internal/authelia/oidc.go new file mode 100644 index 0000000..b4466e4 --- /dev/null +++ b/internal/authelia/oidc.go @@ -0,0 +1,219 @@ +package authelia + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" + + "github.com/wild-cloud/wild-central/internal/storage" +) + +// OIDCClient represents a registered OIDC client application +type OIDCClient struct { + ID string `yaml:"client_id" json:"clientId"` + Name string `yaml:"client_name" json:"clientName"` + Secret string `yaml:"client_secret" json:"clientSecret,omitempty"` // pbkdf2 hash in config, cleartext on create response + RedirectURIs []string `yaml:"redirect_uris" json:"redirectUris"` + Scopes []string `yaml:"scopes" json:"scopes"` + Policy string `yaml:"authorization_policy" json:"authorizationPolicy"` + ConsentMode string `yaml:"consent_mode,omitempty" json:"consentMode,omitempty"` +} + +// OIDCClientUpdate holds partial updates for an OIDC client +type OIDCClientUpdate struct { + Name *string `json:"clientName,omitempty"` + RedirectURIs []string `json:"redirectUris,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Policy *string `json:"authorizationPolicy,omitempty"` + ConsentMode *string `json:"consentMode,omitempty"` +} + +// oidcClientsFile is the top-level structure of the OIDC clients file +type oidcClientsFile struct { + Clients []OIDCClient `yaml:"clients"` +} + +// ListOIDCClients returns all registered OIDC clients (secrets redacted) +func (m *Manager) ListOIDCClients() ([]OIDCClient, error) { + clients, err := m.readOIDCClients() + if err != nil { + return nil, err + } + + // Redact secrets for listing + for i := range clients { + clients[i].Secret = "" + } + return clients, nil +} + +// ReadOIDCClients returns all clients with secrets (for config generation) +func (m *Manager) ReadOIDCClients() ([]OIDCClient, error) { + return m.readOIDCClients() +} + +// AddOIDCClient adds a new OIDC client. Returns the cleartext secret (shown once). +func (m *Manager) AddOIDCClient(client OIDCClient) (cleartextSecret string, err error) { + if client.ID == "" { + return "", fmt.Errorf("client_id is required") + } + if client.Name == "" { + return "", fmt.Errorf("client_name is required") + } + if len(client.RedirectURIs) == 0 { + return "", fmt.Errorf("at least one redirect_uri is required") + } + + lockPath := m.configPath() + ".lock" + err = storage.WithLock(lockPath, func() error { + clients, readErr := m.readOIDCClients() + if readErr != nil { + return readErr + } + + for _, c := range clients { + if c.ID == client.ID { + return fmt.Errorf("client %q already exists", client.ID) + } + } + + // Generate client secret + secret, genErr := GenerateSecret(32) + if genErr != nil { + return fmt.Errorf("generating client secret: %w", genErr) + } + cleartextSecret = secret + + // Hash for storage + client.Secret = hashClientSecret(secret) + + // Default scopes + if len(client.Scopes) == 0 { + client.Scopes = []string{"openid", "profile", "email", "groups"} + } + if client.Policy == "" { + client.Policy = "one_factor" + } + + return m.writeOIDCClients(append(clients, client)) + }) + return +} + +// UpdateOIDCClient applies partial updates to an OIDC client +func (m *Manager) UpdateOIDCClient(clientID string, updates OIDCClientUpdate) error { + lockPath := m.configPath() + ".lock" + return storage.WithLock(lockPath, func() error { + clients, err := m.readOIDCClients() + if err != nil { + return err + } + + found := false + for i, c := range clients { + if c.ID == clientID { + if updates.Name != nil { + clients[i].Name = *updates.Name + } + if updates.RedirectURIs != nil { + clients[i].RedirectURIs = updates.RedirectURIs + } + if updates.Scopes != nil { + clients[i].Scopes = updates.Scopes + } + if updates.Policy != nil { + clients[i].Policy = *updates.Policy + } + if updates.ConsentMode != nil { + clients[i].ConsentMode = *updates.ConsentMode + } + found = true + break + } + } + if !found { + return fmt.Errorf("client %q not found", clientID) + } + + return m.writeOIDCClients(clients) + }) +} + +// DeleteOIDCClient removes an OIDC client by ID +func (m *Manager) DeleteOIDCClient(clientID string) error { + lockPath := m.configPath() + ".lock" + return storage.WithLock(lockPath, func() error { + clients, err := m.readOIDCClients() + if err != nil { + return err + } + + var filtered []OIDCClient + found := false + for _, c := range clients { + if c.ID == clientID { + found = true + continue + } + filtered = append(filtered, c) + } + if !found { + return fmt.Errorf("client %q not found", clientID) + } + + return m.writeOIDCClients(filtered) + }) +} + +// OIDCClientCount returns the number of registered OIDC clients +func (m *Manager) OIDCClientCount() int { + clients, err := m.readOIDCClients() + if err != nil { + return 0 + } + return len(clients) +} + +// readOIDCClients reads OIDC clients from the dedicated clients file +func (m *Manager) readOIDCClients() ([]OIDCClient, error) { + data, err := os.ReadFile(m.oidcClientsPath()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading oidc clients: %w", err) + } + + var f oidcClientsFile + if err := yaml.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parsing oidc clients: %w", err) + } + + return f.Clients, nil +} + +// writeOIDCClients writes the OIDC clients to the dedicated clients file +func (m *Manager) writeOIDCClients(clients []OIDCClient) error { + f := oidcClientsFile{Clients: clients} + data, err := yaml.Marshal(f) + if err != nil { + return fmt.Errorf("marshaling oidc clients: %w", err) + } + + tmpPath := m.oidcClientsPath() + ".tmp" + if err := os.WriteFile(tmpPath, data, 0600); err != nil { + return fmt.Errorf("writing temp oidc clients: %w", err) + } + if err := os.Rename(tmpPath, m.oidcClientsPath()); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("installing oidc clients: %w", err) + } + return nil +} + +// hashClientSecret wraps the secret with $plaintext$ prefix. +// Authelia accepts this format and handles hashing internally. +func hashClientSecret(secret string) string { + return "$plaintext$" + secret +} diff --git a/internal/authelia/users.go b/internal/authelia/users.go new file mode 100644 index 0000000..93dca92 --- /dev/null +++ b/internal/authelia/users.go @@ -0,0 +1,242 @@ +package authelia + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "sort" + + "golang.org/x/crypto/argon2" + "gopkg.in/yaml.v3" + + "github.com/wild-cloud/wild-central/internal/storage" +) + +// User represents an Authelia user account +type User struct { + Username string `json:"username" yaml:"-"` + Displayname string `json:"displayname" yaml:"displayname"` + Password string `json:"password,omitempty" yaml:"password"` // argon2id hash (cleartext on create, never returned) + Email string `json:"email,omitempty" yaml:"email,omitempty"` + Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"` + Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"` +} + +// UserUpdate holds partial updates for a user +type UserUpdate struct { + Displayname *string `json:"displayname,omitempty"` + Password *string `json:"password,omitempty"` // cleartext, will be hashed + Email *string `json:"email,omitempty"` + Groups []string `json:"groups,omitempty"` + Disabled *bool `json:"disabled,omitempty"` +} + +// usersDB is the top-level structure of Authelia's users_database.yml +type usersDB struct { + Users map[string]*userEntry `yaml:"users"` +} + +type userEntry struct { + Displayname string `yaml:"displayname"` + Password string `yaml:"password"` + Email string `yaml:"email,omitempty"` + Groups []string `yaml:"groups,omitempty"` + Disabled bool `yaml:"disabled,omitempty"` +} + +// ListUsers returns all users from the database (passwords omitted) +func (m *Manager) ListUsers() ([]User, error) { + db, err := m.readUsersDB() + if err != nil { + return nil, err + } + + var users []User + for name, entry := range db.Users { + users = append(users, User{ + Username: name, + Displayname: entry.Displayname, + Email: entry.Email, + Groups: entry.Groups, + Disabled: entry.Disabled, + }) + } + + sort.Slice(users, func(i, j int) bool { + return users[i].Username < users[j].Username + }) + return users, nil +} + +// CreateUser adds a new user with an argon2id-hashed password +func (m *Manager) CreateUser(user User) error { + if user.Username == "" { + return fmt.Errorf("username is required") + } + if user.Password == "" { + return fmt.Errorf("password is required") + } + + lockPath := m.usersDBPath() + ".lock" + return storage.WithLock(lockPath, func() error { + db, err := m.readUsersDB() + if err != nil { + return err + } + + if _, exists := db.Users[user.Username]; exists { + return fmt.Errorf("user %q already exists", user.Username) + } + + hash, err := hashPassword(user.Password) + if err != nil { + return fmt.Errorf("hashing password: %w", err) + } + + db.Users[user.Username] = &userEntry{ + Displayname: user.Displayname, + Password: hash, + Email: user.Email, + Groups: user.Groups, + Disabled: user.Disabled, + } + + return m.writeUsersDB(db) + }) +} + +// UpdateUser applies partial updates to an existing user +func (m *Manager) UpdateUser(username string, updates UserUpdate) error { + lockPath := m.usersDBPath() + ".lock" + return storage.WithLock(lockPath, func() error { + db, err := m.readUsersDB() + if err != nil { + return err + } + + entry, exists := db.Users[username] + if !exists { + return fmt.Errorf("user %q not found", username) + } + + if updates.Displayname != nil { + entry.Displayname = *updates.Displayname + } + if updates.Email != nil { + entry.Email = *updates.Email + } + if updates.Disabled != nil { + entry.Disabled = *updates.Disabled + } + if updates.Groups != nil { + entry.Groups = updates.Groups + } + if updates.Password != nil { + hash, err := hashPassword(*updates.Password) + if err != nil { + return fmt.Errorf("hashing password: %w", err) + } + entry.Password = hash + } + + return m.writeUsersDB(db) + }) +} + +// DeleteUser removes a user from the database +func (m *Manager) DeleteUser(username string) error { + lockPath := m.usersDBPath() + ".lock" + return storage.WithLock(lockPath, func() error { + db, err := m.readUsersDB() + if err != nil { + return err + } + + if _, exists := db.Users[username]; !exists { + return fmt.Errorf("user %q not found", username) + } + + delete(db.Users, username) + return m.writeUsersDB(db) + }) +} + +// UserCount returns the number of users in the database +func (m *Manager) UserCount() int { + db, err := m.readUsersDB() + if err != nil { + return 0 + } + return len(db.Users) +} + +// EnsureUsersDB creates an empty users database file if one doesn't exist +func (m *Manager) EnsureUsersDB() error { + if _, err := os.Stat(m.usersDBPath()); err == nil { + return nil + } + db := &usersDB{Users: map[string]*userEntry{}} + return m.writeUsersDB(db) +} + +func (m *Manager) readUsersDB() (*usersDB, error) { + data, err := os.ReadFile(m.usersDBPath()) + if err != nil { + if os.IsNotExist(err) { + return &usersDB{Users: map[string]*userEntry{}}, nil + } + return nil, fmt.Errorf("reading users db: %w", err) + } + + db := &usersDB{Users: map[string]*userEntry{}} + if err := yaml.Unmarshal(data, db); err != nil { + return nil, fmt.Errorf("parsing users db: %w", err) + } + if db.Users == nil { + db.Users = map[string]*userEntry{} + } + return db, nil +} + +func (m *Manager) writeUsersDB(db *usersDB) error { + data, err := yaml.Marshal(db) + if err != nil { + return fmt.Errorf("marshaling users db: %w", err) + } + + tmpPath := m.usersDBPath() + ".tmp" + if err := os.WriteFile(tmpPath, data, 0600); err != nil { + return fmt.Errorf("writing temp users db: %w", err) + } + if err := os.Rename(tmpPath, m.usersDBPath()); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("installing users db: %w", err) + } + return nil +} + +// hashPassword creates an argon2id hash in Authelia's expected format. +// Parameters tuned for Raspberry Pi: memory=64MB, iterations=3, parallelism=2. +func hashPassword(password string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generating salt: %w", err) + } + + const ( + memory = 64 * 1024 // 64MB + iterations = 3 + parallel = 2 + keyLen = 32 + ) + + hash := argon2.IDKey([]byte(password), salt, iterations, memory, uint8(parallel), keyLen) + + // Authelia expects: $argon2id$v=19$m=65536,t=3,p=2$$ + saltB64 := base64.RawStdEncoding.EncodeToString(salt) + hashB64 := base64.RawStdEncoding.EncodeToString(hash) + + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + memory, iterations, parallel, saltB64, hashB64), nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 4f2f9a5..e1155da 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -114,6 +114,15 @@ type State struct { Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"` } `yaml:"ddns,omitempty" json:"ddns,omitempty"` + Authelia struct { + Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // login portal domain (e.g. auth.payne.io) + DefaultPolicy string `yaml:"defaultPolicy,omitempty" json:"defaultPolicy,omitempty"` // one_factor or two_factor + } `yaml:"authelia,omitempty" json:"authelia,omitempty"` + DNSFilter struct { + Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + IntervalHours int `yaml:"intervalHours,omitempty" json:"intervalHours,omitempty"` // update interval, default 24 + } `yaml:"dnsFilter,omitempty" json:"dnsFilter,omitempty"` } `yaml:"cloud,omitempty" json:"cloud,omitempty"` } diff --git a/internal/domains/manager.go b/internal/domains/manager.go index 615c1d1..db242df 100644 --- a/internal/domains/manager.go +++ b/internal/domains/manager.go @@ -58,6 +58,13 @@ type Route struct { IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist } +// AuthConfig describes forward-auth protection for an L7 HTTP domain. +// Only applicable to domains with backend type "http" (L7 TLS termination). +type AuthConfig struct { + Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + Policy string `yaml:"policy,omitempty" json:"policy,omitempty"` // one_factor, two_factor, bypass +} + // Domain represents a registered domain. The Domain field is the unique key. // // Simple domains use Backend directly. Multi-backend domains use Routes @@ -69,6 +76,7 @@ type Domain struct { Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate + Auth *AuthConfig `yaml:"auth,omitempty" json:"auth,omitempty"` // forward-auth protection (L7 HTTP only) // Multi-backend path routing. When present, Backend is ignored. // Each route maps path prefixes to a specific backend with its own L7 options. @@ -354,6 +362,21 @@ func (m *Manager) Update(domain string, updates map[string]any) error { if tls, ok := updates["tls"].(string); ok { dom.TLS = TLSMode(tls) } + if auth, ok := updates["auth"].(map[string]any); ok { + if dom.Auth == nil { + dom.Auth = &AuthConfig{} + } + if enabled, ok := auth["enabled"].(bool); ok { + dom.Auth.Enabled = enabled + } + if policy, ok := auth["policy"].(string); ok { + dom.Auth.Policy = policy + } + // Clear auth if disabled and no policy + if !dom.Auth.Enabled && dom.Auth.Policy == "" { + dom.Auth = nil + } + } return m.Register(*dom) } diff --git a/internal/haproxy/config.go b/internal/haproxy/config.go index 93e6ca8..cf682d7 100644 --- a/internal/haproxy/config.go +++ b/internal/haproxy/config.go @@ -39,6 +39,8 @@ type HTTPRouteBackend struct { HealthPath string // optional health check path Headers *domains.HeaderConfig // per-route headers IPAllow []string // per-route CIDR whitelist + AuthEnabled bool // forward-auth protection via Authelia + AuthPolicy string // one_factor, two_factor (for access control) } // HTTPRoute represents an L7 HTTP reverse proxy route. @@ -80,8 +82,12 @@ func (m *Manager) GetConfigPath() string { // GenerateOpts holds optional parameters for HAProxy config generation. type GenerateOpts struct { - HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central) - CertsDir string // directory containing per-domain PEM files for L7 TLS + HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central) + CertsDir string // directory containing per-domain PEM files for L7 TLS + AuthEnabled bool // global Authelia forward-auth toggle + AuthBackend string // Authelia backend address (e.g. "127.0.0.1:9091") + AuthDomain string // Authelia login portal domain (excluded from protection) + AuthSessionDomain string // session cookie scope (e.g. "payne.io") — only subdomains are eligible for forward-auth } // GenerateWithOpts creates a complete HAProxy configuration with full options. @@ -97,7 +103,13 @@ global stats timeout 30s maxconn 50000 daemon +`) + if opts.AuthEnabled { + sb.WriteString(" lua-prepend-path /etc/haproxy/lua/?.lua\n") + sb.WriteString(" lua-load /etc/haproxy/lua/haproxy-auth-request.lua\n") + } + sb.WriteString(` defaults log global mode tcp @@ -284,7 +296,48 @@ frontend http_in } } - sb.WriteString("\n") + // Authelia forward-auth directives for protected routes + if opts.AuthEnabled && opts.AuthBackend != "" { + authDomainACL := "" + if opts.AuthDomain != "" { + authDomainACL = "host_" + sanitizeName(opts.AuthDomain) + } + + for _, httpRoute := range opts.HTTPRoutes { + hostACL := "host_" + sanitizeName(httpRoute.Name) + + // Skip auth portal domain (would cause infinite redirect) + if hostACL == authDomainACL { + continue + } + + // Skip domains outside the auth session scope — forward-auth + // requires shared cookies, which only work within the same + // parent domain. Other domains should use OIDC instead. + if opts.AuthSessionDomain != "" && + httpRoute.Domain != opts.AuthSessionDomain && + !strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) { + continue + } + + // Check if any route on this domain has auth enabled + hasAuth := false + for _, rb := range httpRoute.Routes { + if rb.AuthEnabled { + hasAuth = true + break + } + } + if !hasAuth { + continue + } + + fmt.Fprintf(&sb, " # auth: %s\n", httpRoute.Domain) + fmt.Fprintf(&sb, " http-request lua.auth-request be_authelia /api/authz/forward-auth if %s\n", hostACL) + fmt.Fprintf(&sb, " http-request redirect location https://%s/?rd=https://%%[hdr(host)]%%[path] if %s !{ var(txn.auth_response_successful) -m bool }\n", opts.AuthDomain, hostACL) + } + sb.WriteString("\n") + } // Routing rules: path-specific first, then catch-all for _, httpRoute := range opts.HTTPRoutes { @@ -333,6 +386,15 @@ frontend http_in } } + // Authelia backend for forward-auth + if opts.AuthEnabled && opts.AuthBackend != "" { + sb.WriteString("# Authelia forward-auth backend\n") + sb.WriteString("backend be_authelia\n") + sb.WriteString(" mode http\n") + fmt.Fprintf(&sb, " server s0 %s\n", opts.AuthBackend) + sb.WriteString("\n") + } + for _, route := range custom { fmt.Fprintf(&sb, "# Custom route: %s\n", route.Name) fmt.Fprintf(&sb, "frontend fe_%s\n", sanitizeName(route.Name)) diff --git a/internal/haproxy/config_test.go b/internal/haproxy/config_test.go index 98aefac..2bc9d43 100644 --- a/internal/haproxy/config_test.go +++ b/internal/haproxy/config_test.go @@ -833,3 +833,213 @@ line3 t.Errorf("expected 0 broken services when no markers, got %d", len(broken)) } } + +// --- Authelia forward-auth tests --- + +func TestGenerateWithOpts_AuthLuaLoad(t *testing.T) { + m := NewManager("") + cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{ + AuthEnabled: true, + AuthBackend: "127.0.0.1:9091", + AuthDomain: "auth.payne.io", + }) + + if !strings.Contains(cfg, "lua-load /etc/haproxy/lua/haproxy-auth-request.lua") { + t.Error("config should contain lua-load directive when auth is enabled") + } +} + +func TestGenerateWithOpts_NoAuthLuaLoadWhenDisabled(t *testing.T) { + m := NewManager("") + cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{ + AuthEnabled: false, + }) + + if strings.Contains(cfg, "lua-load") { + t.Error("config should NOT contain lua-load when auth is disabled") + } +} + +func TestGenerateWithOpts_AuthBackend(t *testing.T) { + m := NewManager("") + httpRoutes := []HTTPRoute{ + { + Name: "app.payne.io", + Domain: "app.payne.io", + Routes: []HTTPRouteBackend{ + {Backend: "127.0.0.1:8080", AuthEnabled: true}, + }, + }, + } + + cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{ + HTTPRoutes: httpRoutes, + CertsDir: "/tmp/certs/", + AuthEnabled: true, + AuthBackend: "127.0.0.1:9091", + AuthDomain: "auth.payne.io", + }) + + if !strings.Contains(cfg, "backend be_authelia") { + t.Error("config should contain Authelia backend") + } + if !strings.Contains(cfg, "server s0 127.0.0.1:9091") { + t.Error("config should contain Authelia server address") + } +} + +func TestGenerateWithOpts_AuthInterceptDirective(t *testing.T) { + m := NewManager("") + httpRoutes := []HTTPRoute{ + { + Name: "app.payne.io", + Domain: "app.payne.io", + Routes: []HTTPRouteBackend{ + {Backend: "127.0.0.1:8080", AuthEnabled: true}, + }, + }, + } + + cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{ + HTTPRoutes: httpRoutes, + CertsDir: "/tmp/certs/", + AuthEnabled: true, + AuthBackend: "127.0.0.1:9091", + AuthDomain: "auth.payne.io", + }) + + if !strings.Contains(cfg, "lua.auth-request be_authelia") { + t.Error("config should contain auth-intercept directive for protected route") + } + if !strings.Contains(cfg, "auth.payne.io/?rd=") { + t.Error("config should contain redirect to auth portal") + } +} + +func TestGenerateWithOpts_AuthDomainExempt(t *testing.T) { + m := NewManager("") + httpRoutes := []HTTPRoute{ + { + Name: "auth.payne.io", + Domain: "auth.payne.io", + Routes: []HTTPRouteBackend{ + {Backend: "127.0.0.1:9091", AuthEnabled: true}, + }, + }, + { + Name: "app.payne.io", + Domain: "app.payne.io", + Routes: []HTTPRouteBackend{ + {Backend: "127.0.0.1:8080", AuthEnabled: true}, + }, + }, + } + + cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{ + HTTPRoutes: httpRoutes, + CertsDir: "/tmp/certs/", + AuthEnabled: true, + AuthBackend: "127.0.0.1:9091", + AuthDomain: "auth.payne.io", + }) + + // Count auth-intercept directives — should be 1 (for app), not 2 (auth domain exempt) + count := strings.Count(cfg, "lua.auth-request") + if count != 1 { + t.Errorf("expected 1 auth-intercept directive (auth domain exempt), got %d", count) + } + + // The auth comment should only appear for app.payne.io + if !strings.Contains(cfg, "# auth: app.payne.io") { + t.Error("should have auth comment for app.payne.io") + } + if strings.Contains(cfg, "# auth: auth.payne.io") { + t.Error("should NOT have auth comment for auth.payne.io (exempt)") + } +} + +func TestGenerateWithOpts_NoAuthForUnprotectedRoutes(t *testing.T) { + m := NewManager("") + httpRoutes := []HTTPRoute{ + { + Name: "public.payne.io", + Domain: "public.payne.io", + Routes: []HTTPRouteBackend{ + {Backend: "127.0.0.1:8080", AuthEnabled: false}, + }, + }, + } + + cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{ + HTTPRoutes: httpRoutes, + CertsDir: "/tmp/certs/", + AuthEnabled: true, + AuthBackend: "127.0.0.1:9091", + AuthDomain: "auth.payne.io", + }) + + if strings.Contains(cfg, "lua.auth-request") { + t.Error("should NOT contain auth-intercept for unprotected routes") + } +} + +func TestGenerateWithOpts_AuthDisabledNoDirectives(t *testing.T) { + m := NewManager("") + httpRoutes := []HTTPRoute{ + { + Name: "app.payne.io", + Domain: "app.payne.io", + Routes: []HTTPRouteBackend{ + {Backend: "127.0.0.1:8080", AuthEnabled: true}, + }, + }, + } + + cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{ + HTTPRoutes: httpRoutes, + CertsDir: "/tmp/certs/", + AuthEnabled: false, + }) + + if strings.Contains(cfg, "lua.auth-request") { + t.Error("should NOT contain auth directives when auth is globally disabled") + } + if strings.Contains(cfg, "be_authelia") { + t.Error("should NOT contain authelia backend when auth is globally disabled") + } +} + +func TestGenerateWithOpts_AuthWithHeaders(t *testing.T) { + m := NewManager("") + httpRoutes := []HTTPRoute{ + { + Name: "app.payne.io", + Domain: "app.payne.io", + Routes: []HTTPRouteBackend{ + { + Backend: "127.0.0.1:8080", + AuthEnabled: true, + Headers: &domains.HeaderConfig{ + Request: map[string]string{"X-Custom": "value"}, + }, + }, + }, + }, + } + + cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{ + HTTPRoutes: httpRoutes, + CertsDir: "/tmp/certs/", + AuthEnabled: true, + AuthBackend: "127.0.0.1:9091", + AuthDomain: "auth.payne.io", + }) + + // Should have both auth and headers + if !strings.Contains(cfg, "lua.auth-request") { + t.Error("should contain auth-intercept") + } + if !strings.Contains(cfg, "X-Custom") { + t.Error("should still contain custom headers alongside auth") + } +} diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index 4452780..2a9a678 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { NavLink } from 'react-router'; -import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, Network, ChevronRight } from 'lucide-react'; +import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { Sidebar, @@ -89,10 +89,12 @@ export function AppSidebar() { ]; const centralItems = [ + { to: '/auth', icon: KeyRound, label: 'Authentication', daemon: 'authelia' as const }, { to: '/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const }, { to: '/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const }, { to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, { to: '/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const }, + { to: '/dns-filter', icon: ShieldBan, label: 'DNS Filter', daemon: 'dnsmasq' as const }, ]; const advancedItems = [ @@ -101,6 +103,7 @@ export function AppSidebar() { { to: '/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const }, { to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const }, { to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, + { to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const }, ]; return ( diff --git a/web/src/components/AutheliaComponent.tsx b/web/src/components/AutheliaComponent.tsx new file mode 100644 index 0000000..cd68b24 --- /dev/null +++ b/web/src/components/AutheliaComponent.tsx @@ -0,0 +1,633 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { KeyRound, BookOpen, CheckCircle, AlertCircle, Plus, Trash2, Loader2, RotateCw, ExternalLink, Copy, X, UserPlus, Globe, Pencil } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Badge } from './ui/badge'; +import { Alert, AlertDescription } from './ui/alert'; +import { Switch } from './ui/switch'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { useAuthelia } from '../hooks/useAuthelia'; +import { domainsApi } from '../services/api'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +export function AutheliaComponent() { + const { + status, isLoadingStatus, + users, isLoadingUsers, + oidcClients, isLoadingClients, + updateConfig, restart, + createUser, deleteUser, + createOIDCClient, updateOIDCClient, deleteOIDCClient, + } = useAuthelia(); + + const [successMsg, setSuccessMsg] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [showAddUser, setShowAddUser] = useState(false); + const [showAddClient, setShowAddClient] = useState(false); + const [newClientSecret, setNewClientSecret] = useState(null); + const [editingClientId, setEditingClientId] = useState(null); + + const showSuccess = (msg: string) => { + setSuccessMsg(msg); + setErrorMsg(null); + setTimeout(() => setSuccessMsg(null), 5000); + }; + + const showError = (msg: string) => { + setErrorMsg(msg); + setSuccessMsg(null); + }; + + if (isLoadingStatus) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

Authentication

+

Centralized authentication for network services via Authelia

+
+
+ + {/* Alerts */} + {successMsg && ( + + + {successMsg} + + + )} + {errorMsg && ( + + + {errorMsg} + + + )} + + {/* Educational Card */} + + + +
+

Centralized Authentication for Your Network

+

Authelia provides two ways to protect services: Forward-auth intercepts requests at HAProxy for apps without native login. OIDC lets apps like Gitea and Grafana use Authelia as their identity provider. Both share one user database and login portal.

+
+
+
+ + {/* Configuration */} + + + {/* Service Status (only if enabled) */} + {status?.enabled && ( + + +
+ Service Status +
+ + {status.active ? : } + {status.active ? 'Running' : 'Stopped'} + + +
+
+
+ +
+ {status.version &&
Version
{status.version}
} +
Users
{status.userCount}
+
OIDC Clients
{status.clientCount}
+ {status.domain && ( +
+ Login Portal + +
+ )} +
+
+
+ )} + + {/* User Management */} + {status?.enabled && ( + + +
+ Users + +
+
+ + {showAddUser && ( + { + createUser.mutate(data, { + onSuccess: () => { showSuccess(`User "${data.username}" created`); setShowAddUser(false); }, + onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create user'), + }); + }} + isSubmitting={createUser.isPending} + /> + )} + {isLoadingUsers ? ( +
+ ) : users.length === 0 ? ( +

No users yet. Add a user to get started.

+ ) : ( +
+ {users.map((user) => ( +
+
+
+ {user.displayname || user.username} + ({user.username}) + {user.email && {user.email}} +
+ {user.disabled && Disabled} +
+ +
+ ))} +
+ )} +
+
+ )} + + {/* OIDC Clients */} + {status?.enabled && ( + + +
+ OIDC Clients + +
+
+ + {newClientSecret && ( + + +

Client secret — save this now, it won't be shown again:

+
+ {newClientSecret} + +
+
+
+ )} + {showAddClient && ( + { + createOIDCClient.mutate(data, { + onSuccess: (result) => { + setNewClientSecret(result.clientSecret); + setShowAddClient(false); + showSuccess(`OIDC client "${data.clientId}" created`); + }, + onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create client'), + }); + }} + isSubmitting={createOIDCClient.isPending} + /> + )} + {isLoadingClients ? ( +
+ ) : oidcClients.length === 0 ? ( +

No OIDC clients. Apps with native SSO support (Gitea, Grafana, etc.) need a client registration here.

+ ) : ( +
+ {oidcClients.map((client) => ( + editingClientId === client.clientId ? ( + { + updateOIDCClient.mutate({ clientId: client.clientId, ...data }, { + onSuccess: () => { showSuccess(`Client "${client.clientName}" updated`); setEditingClientId(null); }, + onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update client'), + }); + }} + onCancel={() => setEditingClientId(null)} + isSubmitting={updateOIDCClient.isPending} + /> + ) : ( +
+
+ {client.clientName} + {client.clientId} +
+ {client.redirectUris.join(', ')} +
+
+
+ {client.authorizationPolicy} + + +
+
+ ) + ))} +
+ )} +
+
+ )} + + {/* Protected Domains */} + {status?.enabled && } +
+ ); +} + +// --- Sub-components --- + +function ConfigCard({ status, onUpdate, showSuccess, showError }: { + status: AutheliaStatus | undefined; + onUpdate: ReturnType['updateConfig']; + showSuccess: (msg: string) => void; + showError: (msg: string) => void; +}) { + const [editing, setEditing] = useState(false); + const { register, handleSubmit, watch, setValue } = useForm({ + defaultValues: { + domain: status?.domain ?? '', + defaultPolicy: status?.defaultPolicy || 'one_factor', + }, + values: status ? { + domain: status.domain ?? '', + defaultPolicy: status.defaultPolicy || 'one_factor', + } : undefined, + }); + + const enabled = status?.enabled ?? false; + const isUpdating = onUpdate.isPending; + + const doUpdate = (data: { enabled?: boolean; domain?: string; defaultPolicy?: string }) => { + onUpdate.mutate(data, { + onSuccess: () => { + setEditing(false); + showSuccess('Configuration updated'); + }, + onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update config'), + }); + }; + + return ( + + +
+ Configuration +
+ + { + if (!checked || status?.domain) { + doUpdate({ enabled: checked }); + } else { + setEditing(true); + } + }} + disabled={isUpdating || (!status?.installed && !enabled)} + /> +
+
+
+ + {!status?.installed && !enabled && ( +

Authelia is not installed. Install it first: apt install authelia

+ )} + {(enabled || editing) && ( +
doUpdate({ ...data, enabled: true }))} className="space-y-3"> +
+ + +
+
+ + +
+ {editing && ( +
+ + +
+ )} + {enabled && !editing && ( + + )} +
+ )} +
+
+ ); +} + +function AddUserForm({ onSubmit, isSubmitting }: { + onSubmit: (data: { username: string; displayname: string; password: string; email?: string }) => void; + isSubmitting: boolean; +}) { + const { register, handleSubmit, formState: { errors } } = useForm({ + defaultValues: { username: '', displayname: '', password: '', email: '' }, + }); + + return ( +
+
+
+ + + {errors.username &&

{errors.username.message}

} +
+
+ + + {errors.displayname &&

{errors.displayname.message}

} +
+
+
+
+ + +
+
+ + + {errors.password &&

{errors.password.message}

} +
+
+ +
+ ); +} + +function AddOIDCClientForm({ onSubmit, isSubmitting }: { + onSubmit: (data: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy?: string }) => void; + isSubmitting: boolean; +}) { + const { register, handleSubmit, formState: { errors }, watch, setValue } = useForm({ + defaultValues: { clientId: '', clientName: '', redirectUri: '', authorizationPolicy: 'one_factor' }, + }); + + return ( +
{ + onSubmit({ + clientId: data.clientId, + clientName: data.clientName, + redirectUris: [data.redirectUri], + authorizationPolicy: data.authorizationPolicy, + }); + })} className="p-3 bg-muted/30 rounded-md space-y-3"> +
+
+ + + {errors.clientId &&

{errors.clientId.message}

} +
+
+ + + {errors.clientName &&

{errors.clientName.message}

} +
+
+
+ + + {errors.redirectUri &&

{errors.redirectUri.message}

} +
+
+ + +
+ +
+ ); +} + +function EditOIDCClientForm({ client, onSubmit, onCancel, isSubmitting }: { + client: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy: string }; + onSubmit: (data: { clientName?: string; redirectUris?: string[]; authorizationPolicy?: string }) => void; + onCancel: () => void; + isSubmitting: boolean; +}) { + const { register, handleSubmit, watch, setValue } = useForm({ + defaultValues: { + clientName: client.clientName, + redirectUri: client.redirectUris[0] ?? '', + authorizationPolicy: client.authorizationPolicy || 'one_factor', + }, + }); + + return ( +
{ + onSubmit({ + clientName: data.clientName, + redirectUris: [data.redirectUri], + authorizationPolicy: data.authorizationPolicy, + }); + })} className="p-3 bg-muted/30 rounded-md space-y-3"> +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ ); +} + +interface AutheliaStatus { + active: boolean; + version?: string; + enabled: boolean; + domain: string; + defaultPolicy: string; + userCount: number; + clientCount: number; + installed: boolean; +} + +function ProtectedDomainsCard({ authDomain }: { authDomain: string }) { + const queryClient = useQueryClient(); + const domainsQuery = useQuery({ + queryKey: ['domains'], + queryFn: () => domainsApi.list(), + }); + + const toggleAuthMutation = useMutation({ + mutationFn: async ({ domain, enabled }: { domain: string; enabled: boolean }) => { + return apiClient.patch(`/api/v1/domains/${domain}`, { auth: { enabled, policy: 'one_factor' } }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['domains'] }); + }, + }); + + const httpDomains = (domainsQuery.data?.domains ?? []).filter( + (d) => d.backend.type === 'http' && d.source !== 'authelia' && d.source !== 'central' && d.domain !== authDomain + ); + + return ( + + +
+ Protected Domains + Forward-Auth +
+
+ +

+ Toggle forward-auth protection for L7 HTTP services. Apps with native OIDC support (Gitea, Grafana, etc.) should use an OIDC client above instead. +

+ {httpDomains.length === 0 ? ( +

+ + No L7 HTTP domains registered yet. +

+ ) : ( +
+ {httpDomains.map((domain) => { + const authEnabled = (domain as any).auth?.enabled ?? false; + return ( +
+
+ {domain.domain} + ({domain.source}) +
+ toggleAuthMutation.mutate({ domain: domain.domain, enabled: checked })} + disabled={toggleAuthMutation.isPending} + /> +
+ ); + })} +
+ )} +
+
+ ); +} + +// Import apiClient for the PATCH call in ProtectedDomainsCard +import { apiClient } from '../services/api/client'; diff --git a/web/src/components/DashboardComponent.tsx b/web/src/components/DashboardComponent.tsx index 671369a..aacd68f 100644 --- a/web/src/components/DashboardComponent.tsx +++ b/web/src/components/DashboardComponent.tsx @@ -7,7 +7,7 @@ import { Alert, AlertDescription } from './ui/alert'; import { Badge } from './ui/badge'; import { LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2, - BookOpen, Globe, Router, RotateCw, Key, + BookOpen, Globe, Router, RotateCw, Key, KeyRound, Shield, Eye, ArrowLeftRight, Lock, } from 'lucide-react'; import { useCloudflare } from '../hooks/useCloudflare'; @@ -22,6 +22,7 @@ const SERVICES = [ { key: 'nftables', label: 'Firewall', icon: Shield }, { key: 'wireguard', label: 'VPN', icon: Lock }, { key: 'crowdsec', label: 'CrowdSec', icon: Eye }, + { key: 'authelia', label: 'Auth', icon: KeyRound }, ]; function formatUptime(totalSeconds: number): string { diff --git a/web/src/components/advanced/AutheliaSubsystem.tsx b/web/src/components/advanced/AutheliaSubsystem.tsx new file mode 100644 index 0000000..970fe6e --- /dev/null +++ b/web/src/components/advanced/AutheliaSubsystem.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { KeyRound } from 'lucide-react'; +import { autheliaApi } from '../../services/api/authelia'; +import { SubsystemPage } from '../SubsystemPage'; + +export function AutheliaSubsystem() { + const [successMsg, setSuccessMsg] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const configQuery = useQuery({ + queryKey: ['authelia', 'config', 'raw'], + queryFn: () => autheliaApi.getConfig(), + }); + + const restartMutation = useMutation({ + mutationFn: () => autheliaApi.restart(), + onSuccess: () => { + setSuccessMsg('Authelia restarted'); + setErrorMsg(null); + setTimeout(() => setSuccessMsg(null), 5000); + }, + onError: (e: Error) => setErrorMsg(e.message), + }); + + return ( + restartMutation.mutate()} + isRestarting={restartMutation.isPending} + restartLabel="Restart Authelia" + successMessage={successMsg} + errorMessage={errorMsg} + /> + ); +} diff --git a/web/src/components/advanced/index.ts b/web/src/components/advanced/index.ts index 04077a8..8d03c97 100644 --- a/web/src/components/advanced/index.ts +++ b/web/src/components/advanced/index.ts @@ -3,3 +3,4 @@ export { DnsmasqSubsystem } from './DnsmasqSubsystem'; export { NftablesSubsystem } from './NftablesSubsystem'; export { WireguardSubsystem } from './WireguardSubsystem'; export { CrowdsecSubsystem } from './CrowdsecSubsystem'; +export { AutheliaSubsystem } from './AutheliaSubsystem'; diff --git a/web/src/hooks/useAuthelia.ts b/web/src/hooks/useAuthelia.ts new file mode 100644 index 0000000..d0ade23 --- /dev/null +++ b/web/src/hooks/useAuthelia.ts @@ -0,0 +1,90 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { autheliaApi } from '../services/api/authelia'; +import type { CreateUserRequest, UpdateUserRequest, CreateOIDCClientRequest, UpdateOIDCClientRequest } from '../services/api/authelia'; + +export function useAuthelia() { + const queryClient = useQueryClient(); + + const statusQuery = useQuery({ + queryKey: ['authelia', 'status'], + queryFn: () => autheliaApi.getStatus(), + staleTime: 10000, + }); + + const usersQuery = useQuery({ + queryKey: ['authelia', 'users'], + queryFn: () => autheliaApi.listUsers(), + enabled: !!statusQuery.data?.enabled, + }); + + const oidcClientsQuery = useQuery({ + queryKey: ['authelia', 'oidc', 'clients'], + queryFn: () => autheliaApi.listOIDCClients(), + enabled: !!statusQuery.data?.active, + }); + + const invalidateAll = () => { + queryClient.invalidateQueries({ queryKey: ['authelia'] }); + queryClient.invalidateQueries({ queryKey: ['central', 'status'] }); + }; + + const updateConfigMutation = useMutation({ + mutationFn: (data: { enabled?: boolean; domain?: string; defaultPolicy?: string }) => + autheliaApi.updateConfig(data), + onSuccess: invalidateAll, + }); + + const restartMutation = useMutation({ + mutationFn: () => autheliaApi.restart(), + onSuccess: invalidateAll, + }); + + const createUserMutation = useMutation({ + mutationFn: (req: CreateUserRequest) => autheliaApi.createUser(req), + onSuccess: invalidateAll, + }); + + const updateUserMutation = useMutation({ + mutationFn: ({ username, ...req }: UpdateUserRequest & { username: string }) => + autheliaApi.updateUser(username, req), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'users'] }), + }); + + const deleteUserMutation = useMutation({ + mutationFn: (username: string) => autheliaApi.deleteUser(username), + onSuccess: invalidateAll, + }); + + const createOIDCClientMutation = useMutation({ + mutationFn: (req: CreateOIDCClientRequest) => autheliaApi.createOIDCClient(req), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }), + }); + + const updateOIDCClientMutation = useMutation({ + mutationFn: ({ clientId, ...req }: UpdateOIDCClientRequest & { clientId: string }) => + autheliaApi.updateOIDCClient(clientId, req), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }), + }); + + const deleteOIDCClientMutation = useMutation({ + mutationFn: (clientId: string) => autheliaApi.deleteOIDCClient(clientId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }), + }); + + return { + status: statusQuery.data, + isLoadingStatus: statusQuery.isLoading, + users: usersQuery.data?.users ?? [], + isLoadingUsers: usersQuery.isLoading, + oidcClients: oidcClientsQuery.data?.clients ?? [], + isLoadingClients: oidcClientsQuery.isLoading, + updateConfig: updateConfigMutation, + restart: restartMutation, + createUser: createUserMutation, + updateUser: updateUserMutation, + deleteUser: deleteUserMutation, + createOIDCClient: createOIDCClientMutation, + updateOIDCClient: updateOIDCClientMutation, + deleteOIDCClient: deleteOIDCClientMutation, + }; +} diff --git a/web/src/router/pages/AutheliaPage.tsx b/web/src/router/pages/AutheliaPage.tsx new file mode 100644 index 0000000..89d46d1 --- /dev/null +++ b/web/src/router/pages/AutheliaPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../components'; +import { AutheliaComponent } from '../../components/AutheliaComponent'; + +export function AutheliaPage() { + return ( + + + + ); +} diff --git a/web/src/router/pages/advanced/AutheliaPage.tsx b/web/src/router/pages/advanced/AutheliaPage.tsx new file mode 100644 index 0000000..2495ffe --- /dev/null +++ b/web/src/router/pages/advanced/AutheliaPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../../components'; +import { AutheliaSubsystem } from '../../../components/advanced'; + +export function AutheliaAdvancedPage() { + return ( + + + + ); +} diff --git a/web/src/router/pages/advanced/index.ts b/web/src/router/pages/advanced/index.ts index 6293d37..e36527e 100644 --- a/web/src/router/pages/advanced/index.ts +++ b/web/src/router/pages/advanced/index.ts @@ -3,3 +3,4 @@ export { DnsmasqPage } from './DnsmasqPage'; export { NftablesPage } from './NftablesPage'; export { WireguardPage } from './WireguardPage'; export { CrowdsecPage } from './CrowdsecPage'; +export { AutheliaAdvancedPage } from './AutheliaPage'; diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index edf8d12..636ad92 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -7,9 +7,12 @@ import { FirewallPage } from './pages/FirewallPage'; import { VpnPage } from './pages/VpnPage'; import { CrowdSecPage } from './pages/CrowdSecPage'; import { DhcpPage } from './pages/DhcpPage'; +import { DnsFilterPage } from './pages/DnsFilterPage'; +import { AutheliaPage } from './pages/AutheliaPage'; import { HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage, CrowdsecPage as CrowdsecAdvancedPage, + AutheliaAdvancedPage, } from './pages/advanced'; export const routes: RouteObject[] = [ @@ -22,12 +25,15 @@ export const routes: RouteObject[] = [ { path: 'firewall', element: }, { path: 'vpn', element: }, { path: 'crowdsec', element: }, + { path: 'auth', element: }, { path: 'dhcp', element: }, + { path: 'dns-filter', element: }, { path: 'advanced/haproxy', element: }, { path: 'advanced/dnsmasq', element: }, { path: 'advanced/nftables', element: }, { path: 'advanced/wireguard', element: }, { path: 'advanced/crowdsec', element: }, + { path: 'advanced/authelia', element: }, ], }, { diff --git a/web/src/services/api/authelia.ts b/web/src/services/api/authelia.ts new file mode 100644 index 0000000..6224b3e --- /dev/null +++ b/web/src/services/api/authelia.ts @@ -0,0 +1,111 @@ +import { apiClient } from './client'; + +// Types + +export interface AutheliaStatus { + active: boolean; + version?: string; + enabled: boolean; + domain: string; + defaultPolicy: string; + userCount: number; + clientCount: number; + installed: boolean; +} + +export interface AutheliaUser { + username: string; + displayname: string; + email?: string; + groups?: string[]; + disabled?: boolean; +} + +export interface CreateUserRequest { + username: string; + displayname: string; + password: string; + email?: string; + groups?: string[]; +} + +export interface UpdateUserRequest { + displayname?: string; + password?: string; + email?: string; + groups?: string[]; + disabled?: boolean; +} + +export interface OIDCClient { + clientId: string; + clientName: string; + clientSecret?: string; + redirectUris: string[]; + scopes: string[]; + authorizationPolicy: string; + consentMode?: string; +} + +export interface CreateOIDCClientRequest { + clientId: string; + clientName: string; + redirectUris: string[]; + scopes?: string[]; + authorizationPolicy?: string; + consentMode?: string; +} + +export interface UpdateOIDCClientRequest { + clientName?: string; + redirectUris?: string[]; + scopes?: string[]; + authorizationPolicy?: string; + consentMode?: string; +} + +// API + +export const autheliaApi = { + // Status & Config + async getStatus(): Promise { + return apiClient.get('/api/v1/authelia/status'); + }, + async getConfig(): Promise<{ content: string }> { + return apiClient.get('/api/v1/authelia/config'); + }, + async updateConfig(data: { enabled?: boolean; domain?: string; defaultPolicy?: string }): Promise<{ message: string }> { + return apiClient.put('/api/v1/authelia/config', data); + }, + async restart(): Promise<{ message: string }> { + return apiClient.post('/api/v1/authelia/restart'); + }, + + // Users + async listUsers(): Promise<{ users: AutheliaUser[] }> { + return apiClient.get('/api/v1/authelia/users'); + }, + async createUser(req: CreateUserRequest): Promise<{ message: string }> { + return apiClient.post('/api/v1/authelia/users', req); + }, + async updateUser(username: string, req: UpdateUserRequest): Promise<{ message: string }> { + return apiClient.put(`/api/v1/authelia/users/${username}`, req); + }, + async deleteUser(username: string): Promise<{ message: string }> { + return apiClient.delete(`/api/v1/authelia/users/${username}`); + }, + + // OIDC Clients + async listOIDCClients(): Promise<{ clients: OIDCClient[] }> { + return apiClient.get('/api/v1/authelia/oidc/clients'); + }, + async createOIDCClient(req: CreateOIDCClientRequest): Promise<{ clientId: string; clientSecret: string; message: string }> { + return apiClient.post('/api/v1/authelia/oidc/clients', req); + }, + async updateOIDCClient(clientId: string, req: UpdateOIDCClientRequest): Promise<{ message: string }> { + return apiClient.put(`/api/v1/authelia/oidc/clients/${clientId}`, req); + }, + async deleteOIDCClient(clientId: string): Promise<{ message: string }> { + return apiClient.delete(`/api/v1/authelia/oidc/clients/${clientId}`); + }, +}; diff --git a/web/src/services/api/index.ts b/web/src/services/api/index.ts index 04a9bad..5dc5cb7 100644 --- a/web/src/services/api/index.ts +++ b/web/src/services/api/index.ts @@ -11,3 +11,7 @@ export { cloudflareApi } from './cloudflare'; export type { CloudflareVerifyResponse, CloudflareZone } from './cloudflare'; export { dnsSettingsApi, ddnsConfigApi, dhcpConfigApi, nftablesConfigApi, haproxyRoutesApi } from './settings'; export type { OperatorSettings, CentralDomainSettings, DNSSettings, DDNSConfig, DHCPConfig, NftablesConfig, HAProxyCustomRoute, HAProxyRoutes } from './settings'; +export { autheliaApi } from './authelia'; +export type { AutheliaStatus, AutheliaUser, OIDCClient } from './authelia'; +export { dnsFilterApi } from './dnsfilter'; +export type { DNSFilterStats, DNSFilterConfig, Blocklist, CustomEntry } from './dnsfilter'; diff --git a/web/src/services/api/networking.ts b/web/src/services/api/networking.ts index 1ae80f2..9198444 100644 --- a/web/src/services/api/networking.ts +++ b/web/src/services/api/networking.ts @@ -30,6 +30,10 @@ export interface RegisteredDomain { public: boolean; tls?: string; routes?: Route[]; + auth?: { + enabled: boolean; + policy?: string; + }; } export const domainsApi = {