SMTP: Add host, port, username, sender, and password fields to the Authelia configuration. Uses modern address format (submission:// for STARTTLS on 587, smtps:// for implicit TLS on 465) with explicit TLS server name. Falls back to filesystem notifier when SMTP is not configured. Fixes: Config changes now persist before attempting service restart, so a restart failure (e.g. bad SMTP credentials) no longer prevents saving. The specific Authelia error is extracted from the journal and shown in the UI. Frontend: SMTP fields added to the Authentication config card. Form no longer continuously resets from server state while user is editing. OIDC client edit UI added (pencil icon).
206 lines
5.8 KiB
Go
206 lines
5.8 KiB
Go
// 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")
|
|
}
|