Add Authelia as centralized authentication and OIDC provider

Authelia runs as a managed native service on Wild Central, providing
two integration patterns for network services:

- Forward-auth via HAProxy for apps without native SSO (Lua auth-request
  script intercepts requests, redirects unauthenticated users to login portal)
- OIDC provider for apps with native support (Gitea, Grafana, etc.)

Backend: new internal/authelia/ package with service manager, config
generation, file-based user management (argon2id), and OIDC client
management. API endpoints for status, config, users CRUD, and OIDC
clients CRUD.

HAProxy: config generator extended with lua-load, auth-request
directives, and Authelia backend. Auth directives scoped to session
cookie domain — only subdomains of the auth portal's parent are
eligible for forward-auth.

Frontend: Authentication page with enable/disable, user management,
OIDC client management (add/edit/delete), and protected domains
toggles. Advanced subsystem page for raw config view. Dashboard
service card and sidebar entries.
This commit is contained in:
2026-07-12 11:47:37 +00:00
parent 24bb976652
commit d796a79f79
27 changed files with 3097 additions and 11 deletions

View File

@@ -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)

View File

@@ -0,0 +1,442 @@
package v1
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/authelia"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/domains"
)
// AutheliaStatus returns the current Authelia service status
func (api *API) AutheliaStatus(w http.ResponseWriter, r *http.Request) {
status, err := api.authelia.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get authelia status: %v", err))
return
}
state, _ := config.LoadState(api.statePath())
enabled := false
domain := ""
defaultPolicy := ""
if state != nil {
enabled = state.Cloud.Authelia.Enabled
domain = state.Cloud.Authelia.Domain
defaultPolicy = state.Cloud.Authelia.DefaultPolicy
}
respondJSON(w, http.StatusOK, map[string]any{
"active": status.Active,
"version": status.Version,
"enabled": enabled,
"domain": domain,
"defaultPolicy": defaultPolicy,
"userCount": api.authelia.UserCount(),
"clientCount": api.authelia.OIDCClientCount(),
"installed": api.authelia.IsInstalled(),
})
}
// AutheliaGetConfig returns the raw Authelia configuration for the advanced view
func (api *API) AutheliaGetConfig(w http.ResponseWriter, r *http.Request) {
content, err := api.authelia.ReadConfig()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read authelia config: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"content": content})
}
// AutheliaUpdateConfig enables/disables Authelia and updates its configuration
func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled *bool `json:"enabled"`
Domain *string `json:"domain"`
DefaultPolicy *string `json:"defaultPolicy"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
state, err := config.LoadState(api.statePath())
if err != nil {
state = &config.State{}
}
if req.Domain != nil {
state.Cloud.Authelia.Domain = *req.Domain
}
if req.DefaultPolicy != nil {
state.Cloud.Authelia.DefaultPolicy = *req.DefaultPolicy
}
if req.Enabled != nil {
state.Cloud.Authelia.Enabled = *req.Enabled
}
// Enabling Authelia
if state.Cloud.Authelia.Enabled {
if !api.authelia.IsInstalled() {
respondError(w, http.StatusBadRequest, "Authelia is not installed. Install it first (apt install authelia).")
return
}
if state.Cloud.Authelia.Domain == "" {
respondError(w, http.StatusBadRequest, "Auth portal domain is required when enabling Authelia")
return
}
if !api.authelia.HasLuaScript() {
respondError(w, http.StatusBadRequest,
"HAProxy auth-request Lua script not found at /etc/haproxy/lua/haproxy-auth-request.lua. "+
"Install it: sudo mkdir -p /etc/haproxy/lua && sudo curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua "+
"https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua")
return
}
// Ensure data directory exists
if err := api.authelia.EnsureDataDir(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data directory: %v", err))
return
}
// Auto-generate secrets if missing
if err := api.ensureAutheliaSecrets(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate secrets: %v", err))
return
}
// Generate JWKS key pair for OIDC
if err := api.authelia.EnsureJWKS(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate JWKS: %v", err))
return
}
// Ensure user database exists
if err := api.authelia.EnsureUsersDB(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to initialize user database: %v", err))
return
}
// Generate config
if err := api.generateAutheliaConfig(state); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate config: %v", err))
return
}
// Register auth portal domain
api.ensureAuthDomain(state.Cloud.Authelia.Domain)
// Start service — requires at least one user
if api.authelia.UserCount() > 0 {
if err := api.authelia.RestartService(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to start Authelia: %v", err))
return
}
}
} else {
// Disabling — stop service and deregister domain
_ = api.authelia.StopService()
api.deregisterAuthDomain()
}
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
// Trigger reconciliation to update HAProxy
go api.reconcileNetworking()
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
respondMessage(w, http.StatusOK, "Authelia configuration updated")
}
// AutheliaRestart restarts the Authelia service
func (api *API) AutheliaRestart(w http.ResponseWriter, r *http.Request) {
if err := api.authelia.RestartService(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restart authelia: %v", err))
return
}
api.broadcastAutheliaEvent("authelia:restart", "Authelia service restarted")
respondMessage(w, http.StatusOK, "Authelia restarted")
}
// --- User management ---
// AutheliaListUsers returns all Authelia users (passwords omitted)
func (api *API) AutheliaListUsers(w http.ResponseWriter, r *http.Request) {
users, err := api.authelia.ListUsers()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list users: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"users": users})
}
// AutheliaCreateUser creates a new Authelia user
func (api *API) AutheliaCreateUser(w http.ResponseWriter, r *http.Request) {
var user authelia.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.CreateUser(user); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create user: %v", err))
return
}
slog.Info("authelia user created", "username", user.Username)
// If Authelia is enabled but not running (was waiting for first user), start it
state, _ := config.LoadState(api.statePath())
if state != nil && state.Cloud.Authelia.Enabled {
status, _ := api.authelia.GetStatus()
if status != nil && !status.Active {
if err := api.authelia.RestartService(); err != nil {
slog.Warn("failed to start authelia after first user", "error", err)
}
}
}
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q created", user.Username))
respondMessage(w, http.StatusCreated, fmt.Sprintf("User %q created", user.Username))
}
// AutheliaUpdateUser updates an existing Authelia user
func (api *API) AutheliaUpdateUser(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
var updates authelia.UserUpdate
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.UpdateUser(username, updates); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update user: %v", err))
return
}
slog.Info("authelia user updated", "username", username)
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q updated", username))
}
// AutheliaDeleteUser deletes an Authelia user
func (api *API) AutheliaDeleteUser(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"]
if err := api.authelia.DeleteUser(username); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete user: %v", err))
return
}
slog.Info("authelia user deleted", "username", username)
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q deleted", username))
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q deleted", username))
}
// --- OIDC client management ---
// AutheliaListOIDCClients returns all registered OIDC clients (secrets redacted)
func (api *API) AutheliaListOIDCClients(w http.ResponseWriter, r *http.Request) {
clients, err := api.authelia.ListOIDCClients()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list OIDC clients: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"clients": clients})
}
// AutheliaCreateOIDCClient creates a new OIDC client and returns the secret once
func (api *API) AutheliaCreateOIDCClient(w http.ResponseWriter, r *http.Request) {
var client authelia.OIDCClient
if err := json.NewDecoder(r.Body).Decode(&client); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
secret, err := api.authelia.AddOIDCClient(client)
if err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create OIDC client: %v", err))
return
}
// Regenerate full config (adds OIDC section) and restart
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client create", "error", err)
}
slog.Info("authelia OIDC client created", "clientId", client.ID)
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q created", client.ID))
respondJSON(w, http.StatusCreated, map[string]any{
"clientId": client.ID,
"clientSecret": secret,
"message": "OIDC client created. Save the client secret — it will not be shown again.",
})
}
// AutheliaUpdateOIDCClient updates an OIDC client
func (api *API) AutheliaUpdateOIDCClient(w http.ResponseWriter, r *http.Request) {
clientID := mux.Vars(r)["clientId"]
var updates authelia.OIDCClientUpdate
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if err := api.authelia.UpdateOIDCClient(clientID, updates); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update OIDC client: %v", err))
return
}
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client update", "error", err)
}
slog.Info("authelia OIDC client updated", "clientId", clientID)
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q updated", clientID))
}
// AutheliaDeleteOIDCClient deletes an OIDC client
func (api *API) AutheliaDeleteOIDCClient(w http.ResponseWriter, r *http.Request) {
clientID := mux.Vars(r)["clientId"]
if err := api.authelia.DeleteOIDCClient(clientID); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete OIDC client: %v", err))
return
}
if err := api.regenAndRestartAuthelia(); err != nil {
slog.Warn("failed to restart authelia after OIDC client delete", "error", err)
}
slog.Info("authelia OIDC client deleted", "clientId", clientID)
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q deleted", clientID))
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q deleted", clientID))
}
// --- Internal helpers ---
// regenAndRestartAuthelia regenerates the full Authelia config and restarts the service.
// Used after OIDC client changes to ensure the config includes the OIDC section.
func (api *API) regenAndRestartAuthelia() error {
state, err := config.LoadState(api.statePath())
if err != nil || !state.Cloud.Authelia.Enabled {
return nil
}
if err := api.generateAutheliaConfig(state); err != nil {
return fmt.Errorf("regenerating config: %w", err)
}
if api.authelia.UserCount() > 0 {
return api.authelia.RestartService()
}
return nil
}
// ensureAutheliaSecrets generates Authelia secrets if they don't already exist
func (api *API) ensureAutheliaSecrets() error {
keys := []string{
"authelia.jwtSecret",
"authelia.sessionSecret",
"authelia.storageEncryptionKey",
"authelia.oidcHmacSecret",
}
for _, key := range keys {
existing, _ := api.secrets.GetSecret(key)
if existing != "" {
continue
}
secret, err := authelia.GenerateSecret(32)
if err != nil {
return fmt.Errorf("generating %s: %w", key, err)
}
if err := api.secrets.SetSecret(key, secret); err != nil {
return fmt.Errorf("saving %s: %w", key, err)
}
}
return nil
}
// generateAutheliaConfig reads secrets and state, then generates the Authelia config
func (api *API) generateAutheliaConfig(state *config.State) error {
jwtSecret, _ := api.secrets.GetSecret("authelia.jwtSecret")
sessionSecret, _ := api.secrets.GetSecret("authelia.sessionSecret")
storageEncKey, _ := api.secrets.GetSecret("authelia.storageEncryptionKey")
oidcHmac, _ := api.secrets.GetSecret("authelia.oidcHmacSecret")
// Read OIDC clients from the clients file (with secrets for config generation)
clients, _ := api.authelia.ReadOIDCClients()
defaultPolicy := state.Cloud.Authelia.DefaultPolicy
if defaultPolicy == "" {
defaultPolicy = "one_factor"
}
sessionDomain := authelia.SessionDomainFromAuthDomain(state.Cloud.Authelia.Domain)
opts := authelia.ConfigOpts{
Domain: state.Cloud.Authelia.Domain,
JWTSecret: jwtSecret,
SessionSecret: sessionSecret,
StorageEncKey: storageEncKey,
OIDCHMACSecret: oidcHmac,
DefaultPolicy: defaultPolicy,
SessionDomain: sessionDomain,
OIDCClients: clients,
}
return api.authelia.GenerateConfig(opts)
}
// ensureAuthDomain registers the Authelia login portal as a domain
func (api *API) ensureAuthDomain(authDomain string) {
if authDomain == "" {
return
}
// Clean up stale authelia registrations
doms, _ := api.domains.List()
for _, dom := range doms {
if dom.Source == "authelia" && dom.DomainName != authDomain {
_ = api.domains.Deregister(dom.DomainName)
}
}
_ = api.domains.Register(domains.Domain{
DomainName: authDomain,
Source: "authelia",
Routes: []domains.Route{
{
Backend: domains.Backend{
Address: "127.0.0.1:9091",
Type: domains.BackendHTTP,
},
Headers: &domains.HeaderConfig{
Request: map[string]string{
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": authDomain,
},
},
},
},
TLS: domains.TLSTerminate,
})
}
// deregisterAuthDomain removes all authelia-sourced domain registrations
func (api *API) deregisterAuthDomain() {
doms, _ := api.domains.List()
for _, dom := range doms {
if dom.Source == "authelia" {
_ = api.domains.Deregister(dom.DomainName)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/wild-cloud/wild-central/internal/authelia"
"github.com/wild-cloud/wild-central/internal/certbot"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsmasq"
@@ -15,6 +16,7 @@ import (
"github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/sse"
"github.com/wild-cloud/wild-central/internal/storage"
)
// EnsureCentralDomain registers (or updates) Central's own domain as a domain
@@ -96,13 +98,18 @@ func (api *API) reconcileNetworking() {
case domains.BackendHTTP:
var routeBackends []haproxy.HTTPRouteBackend
for _, r := range dom.EffectiveRoutes() {
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{
rb := haproxy.HTTPRouteBackend{
Paths: r.Paths,
Backend: r.Backend.Address,
HealthPath: r.Backend.Health,
Headers: r.Headers,
IPAllow: r.IPAllow,
})
}
if dom.Auth != nil && dom.Auth.Enabled {
rb.AuthEnabled = true
rb.AuthPolicy = dom.Auth.Policy
}
routeBackends = append(routeBackends, rb)
}
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
@@ -143,6 +150,22 @@ func (api *API) reconcileNetworking() {
HTTPRoutes: activeHTTPRoutes,
CertsDir: certsDir,
}
// Propagate Authelia forward-auth settings if enabled
if globalCfg.Cloud.Authelia.Enabled && globalCfg.Cloud.Authelia.Domain != "" {
genOpts.AuthEnabled = true
genOpts.AuthBackend = "127.0.0.1:9091"
genOpts.AuthDomain = globalCfg.Cloud.Authelia.Domain
genOpts.AuthSessionDomain = authelia.SessionDomainFromAuthDomain(globalCfg.Cloud.Authelia.Domain)
// Regenerate Authelia config so session cookie domains stay in sync
// with the set of auth-protected domains
if err := api.generateAutheliaConfig(globalCfg); err != nil {
slog.Warn("reconcile: failed to regenerate authelia config", "error", err)
} else if api.authelia.UserCount() > 0 {
_ = api.authelia.RestartService()
}
}
haproxyCfg := api.haproxy.GenerateWithOpts(l4Routes, nil, genOpts)
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
@@ -224,6 +247,16 @@ func (api *API) reconcileNetworking() {
})
}
// Set addn-hosts path for DNS filtering
if globalCfg.Cloud.DNSFilter.Enabled {
hostsPath := api.dnsFilter.HostsFilePath()
if storage.FileExists(hostsPath) {
api.dnsmasq.SetFilterConfPath(hostsPath)
}
} else {
api.dnsmasq.SetFilterConfPath("")
}
if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
slog.Error("reconcile: failed to update dnsmasq", "error", err)
} else {
@@ -359,6 +392,25 @@ func (api *API) broadcastHaproxyEvent(eventType string, message string) {
api.sseManager.Broadcast(event)
}
// broadcastAutheliaEvent broadcasts SSE events for Authelia status changes
func (api *API) broadcastAutheliaEvent(eventType string, message string) {
if api.sseManager == nil {
return
}
event := &sse.Event{
ID: fmt.Sprintf("authelia-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
},
}
api.sseManager.Broadcast(event)
}
// broadcastCentralStatusEvent broadcasts SSE events for central status changes
func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
if api.sseManager == nil {
@@ -382,3 +434,22 @@ func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
api.sseManager.Broadcast(event)
}
// broadcastDNSFilterEvent broadcasts SSE events for DNS filter changes
func (api *API) broadcastDNSFilterEvent(eventType string, message string) {
if api.sseManager == nil {
return
}
event := &sse.Event{
ID: fmt.Sprintf("dns-filter-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
},
}
api.sseManager.Broadcast(event)
}

86
internal/authelia/TODO.md Normal file
View File

@@ -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).

222
internal/authelia/config.go Normal file
View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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()
}

219
internal/authelia/oidc.go Normal file
View File

@@ -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
}

242
internal/authelia/users.go Normal file
View File

@@ -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$<base64-salt>$<base64-hash>
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
}

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -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))

View File

@@ -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")
}
}