Add SMTP support for Authelia notifications and fix config persistence
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).
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user