diff --git a/internal/api/v1/handlers_authelia.go b/internal/api/v1/handlers_authelia.go index d7ec597..d94360d 100644 --- a/internal/api/v1/handlers_authelia.go +++ b/internal/api/v1/handlers_authelia.go @@ -31,6 +31,19 @@ func (api *API) AutheliaStatus(w http.ResponseWriter, r *http.Request) { defaultPolicy = state.Cloud.Authelia.DefaultPolicy } + smtp := struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Sender string `json:"sender"` + }{} + if state != nil { + smtp.Host = state.Cloud.Authelia.SMTP.Host + smtp.Port = state.Cloud.Authelia.SMTP.Port + smtp.Username = state.Cloud.Authelia.SMTP.Username + smtp.Sender = state.Cloud.Authelia.SMTP.Sender + } + respondJSON(w, http.StatusOK, map[string]any{ "active": status.Active, "version": status.Version, @@ -40,6 +53,7 @@ func (api *API) AutheliaStatus(w http.ResponseWriter, r *http.Request) { "userCount": api.authelia.UserCount(), "clientCount": api.authelia.OIDCClientCount(), "installed": api.authelia.IsInstalled(), + "smtp": smtp, }) } @@ -59,6 +73,11 @@ func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) { Enabled *bool `json:"enabled"` Domain *string `json:"domain"` DefaultPolicy *string `json:"defaultPolicy"` + SMTPHost *string `json:"smtpHost"` + SMTPPort *int `json:"smtpPort"` + SMTPUsername *string `json:"smtpUsername"` + SMTPSender *string `json:"smtpSender"` + SMTPPassword *string `json:"smtpPassword"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid JSON") @@ -79,6 +98,21 @@ func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) { if req.Enabled != nil { state.Cloud.Authelia.Enabled = *req.Enabled } + if req.SMTPHost != nil { + state.Cloud.Authelia.SMTP.Host = *req.SMTPHost + } + if req.SMTPPort != nil { + state.Cloud.Authelia.SMTP.Port = *req.SMTPPort + } + if req.SMTPUsername != nil { + state.Cloud.Authelia.SMTP.Username = *req.SMTPUsername + } + if req.SMTPSender != nil { + state.Cloud.Authelia.SMTP.Sender = *req.SMTPSender + } + if req.SMTPPassword != nil && *req.SMTPPassword != "" { + _ = api.secrets.SetSecret("authelia.smtpPassword", *req.SMTPPassword) + } // Enabling Authelia if state.Cloud.Authelia.Enabled { @@ -132,27 +166,36 @@ func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) { api.ensureAuthDomain(state.Cloud.Authelia.Domain) // Start service — requires at least one user + startErr := error(nil) 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 - } + startErr = api.authelia.RestartService() + } + + // Save state and reconcile regardless of whether the service started + if err := config.SaveState(state, api.statePath()); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to save state") + return + } + go api.reconcileNetworking() + api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated") + + if startErr != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("Configuration saved but Authelia failed to start: %v", startErr)) + 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 + } + go api.reconcileNetworking() + api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated") } - 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") } @@ -382,6 +425,8 @@ func (api *API) generateAutheliaConfig(state *config.State) error { sessionDomain := authelia.SessionDomainFromAuthDomain(state.Cloud.Authelia.Domain) + smtpPassword, _ := api.secrets.GetSecret("authelia.smtpPassword") + opts := authelia.ConfigOpts{ Domain: state.Cloud.Authelia.Domain, JWTSecret: jwtSecret, @@ -391,6 +436,11 @@ func (api *API) generateAutheliaConfig(state *config.State) error { DefaultPolicy: defaultPolicy, SessionDomain: sessionDomain, OIDCClients: clients, + SMTPHost: state.Cloud.Authelia.SMTP.Host, + SMTPPort: state.Cloud.Authelia.SMTP.Port, + SMTPUsername: state.Cloud.Authelia.SMTP.Username, + SMTPSender: state.Cloud.Authelia.SMTP.Sender, + SMTPPassword: smtpPassword, } return api.authelia.GenerateConfig(opts) diff --git a/internal/authelia/config.go b/internal/authelia/config.go index 0342255..f1d4bd9 100644 --- a/internal/authelia/config.go +++ b/internal/authelia/config.go @@ -25,6 +25,11 @@ type ConfigOpts struct { DataDir string // authelia data directory path ListenAddr string // listen address (default: 127.0.0.1:9091) OIDCClients []OIDCClient // registered OIDC clients + SMTPHost string // SMTP server host (empty = use filesystem notifier) + SMTPPort int // SMTP server port (default: 587) + SMTPUsername string // SMTP login username (defaults to sender if empty) + SMTPSender string // From address + SMTPPassword string // SMTP password (from secrets) } // GenerateConfig builds Authelia's configuration.yml and writes it atomically. @@ -143,11 +148,7 @@ func (m *Manager) buildConfig(opts ConfigOpts) map[string]any { }, }, - "notifier": map[string]any{ - "filesystem": map[string]any{ - "filename": m.notificationPath(), - }, - }, + "notifier": buildNotifier(opts, m.notificationPath()), "identity_validation": map[string]any{ "reset_password": map[string]any{ @@ -202,6 +203,40 @@ func (m *Manager) buildConfig(opts ConfigOpts) map[string]any { return cfg } +// buildNotifier returns the notifier config — SMTP if configured, filesystem otherwise. +func buildNotifier(opts ConfigOpts, filesystemPath string) map[string]any { + if opts.SMTPHost != "" && opts.SMTPSender != "" { + username := opts.SMTPUsername + if username == "" { + username = opts.SMTPSender + } + port := opts.SMTPPort + if port == 0 { + port = 587 + } + // Use the modern address format: submission:// for STARTTLS on 587, smtps:// for implicit TLS on 465 + scheme := "submission" + if port == 465 { + scheme = "smtps" + } + smtp := map[string]any{ + "address": fmt.Sprintf("%s://%s:%d", scheme, opts.SMTPHost, port), + "sender": opts.SMTPSender, + "username": username, + "password": opts.SMTPPassword, + "tls": map[string]any{ + "server_name": opts.SMTPHost, + }, + } + return map[string]any{"smtp": smtp} + } + return map[string]any{ + "filesystem": map[string]any{ + "filename": filesystemPath, + }, + } +} + // GenerateSecret creates a cryptographically random hex string of the given byte length. func GenerateSecret(byteLen int) (string, error) { b := make([]byte, byteLen) diff --git a/internal/authelia/manager.go b/internal/authelia/manager.go index 41a1639..04d1a6e 100644 --- a/internal/authelia/manager.go +++ b/internal/authelia/manager.go @@ -95,17 +95,66 @@ func (m *Manager) GetStatus() (*Status, error) { return status, nil } -// RestartService restarts the Authelia systemd service +// 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") diff --git a/internal/config/config.go b/internal/config/config.go index e1155da..a42a98f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -118,6 +118,12 @@ type State 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 + SMTP struct { + Host string `yaml:"host,omitempty" json:"host,omitempty"` // e.g. smtp.gmail.com + Port int `yaml:"port,omitempty" json:"port,omitempty"` // e.g. 587 + Username string `yaml:"username,omitempty" json:"username,omitempty"` // SMTP login username + Sender string `yaml:"sender,omitempty" json:"sender,omitempty"` // From address (e.g. auth@payne.io) + } `yaml:"smtp,omitempty" json:"smtp,omitempty"` } `yaml:"authelia,omitempty" json:"authelia,omitempty"` DNSFilter struct { Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` diff --git a/web/src/components/AutheliaComponent.tsx b/web/src/components/AutheliaComponent.tsx index cd68b24..27acd4f 100644 --- a/web/src/components/AutheliaComponent.tsx +++ b/web/src/components/AutheliaComponent.tsx @@ -11,6 +11,7 @@ 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 type { AutheliaStatus } from '../services/api/authelia'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; export function AutheliaComponent() { @@ -312,21 +313,37 @@ function ConfigCard({ status, onUpdate, showSuccess, showError }: { showError: (msg: string) => void; }) { const [editing, setEditing] = useState(false); - const { register, handleSubmit, watch, setValue } = useForm({ + const [initialized, setInitialized] = useState(false); + const { register, handleSubmit, watch, setValue, reset } = useForm({ defaultValues: { - domain: status?.domain ?? '', - defaultPolicy: status?.defaultPolicy || 'one_factor', + domain: '', + defaultPolicy: 'one_factor', + smtpHost: '', + smtpPort: 587, + smtpUsername: '', + smtpSender: '', + smtpPassword: '', }, - values: status ? { + }); + + // Sync form with status data once on load, not continuously + if (status && !initialized) { + reset({ domain: status.domain ?? '', defaultPolicy: status.defaultPolicy || 'one_factor', - } : undefined, - }); + smtpHost: status.smtp?.host ?? '', + smtpPort: status.smtp?.port || 587, + smtpUsername: status.smtp?.username ?? '', + smtpSender: status.smtp?.sender ?? '', + smtpPassword: '', + }); + setInitialized(true); + } const enabled = status?.enabled ?? false; const isUpdating = onUpdate.isPending; - const doUpdate = (data: { enabled?: boolean; domain?: string; defaultPolicy?: string }) => { + const doUpdate = (data: { enabled?: boolean; domain?: string; defaultPolicy?: string; smtpHost?: string; smtpPort?: number; smtpUsername?: string; smtpSender?: string; smtpPassword?: string }) => { onUpdate.mutate(data, { onSuccess: () => { setEditing(false); @@ -363,7 +380,11 @@ function ConfigCard({ status, onUpdate, showSuccess, showError }: {
Authelia is not installed. Install it first: apt install authelia