// 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 and verifies it stays running. 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)) } // Give it a moment to start (or crash) time.Sleep(2 * time.Second) // Check if it's actually running if exec.Command("systemctl", "is-active", "--quiet", "authelia.service").Run() != nil { // It crashed — grab the error from the journal reason := m.lastError() if reason != "" { return fmt.Errorf("authelia started but crashed: %s", reason) } return fmt.Errorf("authelia started but crashed (check journalctl -u authelia.service)") } slog.Info("authelia service restarted", "component", "authelia") return nil } // lastError extracts the most recent error-level message from the Authelia journal. // Authelia logs look like: time="..." level=error msg="..." error="..." provider=... func (m *Manager) lastError() string { cmd := exec.Command("journalctl", "-u", "authelia.service", "-n", "20", "--no-pager", "-o", "cat") output, err := cmd.CombinedOutput() if err != nil { return "" } // Find the last line containing level=error var lastErr string for _, line := range strings.Split(string(output), "\n") { if strings.Contains(line, "level=error") { lastErr = line } } if lastErr == "" { return "" } // Extract error="..." field (has the actual detail) if i := strings.Index(lastErr, "error=\""); i >= 0 { detail := lastErr[i+7:] if j := strings.Index(detail, "\""); j >= 0 { return detail[:j] } } // Fall back to msg="..." field if i := strings.Index(lastErr, "msg=\""); i >= 0 { detail := lastErr[i+5:] if j := strings.Index(detail, "\""); j >= 0 { return detail[:j] } } return lastErr } // 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") }