From 77e6e8cf9dae13b75d3670626b300fec7975d283 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Thu, 30 Jan 2025 11:00:14 +0100 Subject: [PATCH] Implement mail functioning / handling and sending from golang via API --- internal/config/settings.go | 60 ++++++--- pkg/web/handlers.go | 238 +++++++++++++++++++++++++++++------ pkg/web/routes.go | 1 + pkg/web/templates/index.html | 151 ++++++++++++++-------- 4 files changed, 342 insertions(+), 108 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index fb3ab0f..7a05604 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -28,22 +28,32 @@ import ( "sync" ) -// AppSettings holds both the UI settings (like language) and -// relevant Fail2ban jail/local config options. -type AppSettings struct { - Language string `json:"language"` - Debug bool `json:"debug"` - ReloadNeeded bool `json:"reloadNeeded"` - AlertCountries []string `json:"alertCountries"` +// SMTPSettings holds the SMTP server configuration for sending alert emails +type SMTPSettings struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + From string `json:"from"` + UseTLS bool `json:"useTLS"` +} - // These mirror some Fail2ban [DEFAULT] section parameters from jail.local +// AppSettings holds the main UI settings and Fail2ban configuration +type AppSettings struct { + Language string `json:"language"` + Debug bool `json:"debug"` + ReloadNeeded bool `json:"reloadNeeded"` + AlertCountries []string `json:"alertCountries"` + SMTP SMTPSettings `json:"smtp"` + + // Fail2Ban [DEFAULT] section values from jail.local BantimeIncrement bool `json:"bantimeIncrement"` IgnoreIP string `json:"ignoreip"` Bantime string `json:"bantime"` Findtime string `json:"findtime"` Maxretry int `json:"maxretry"` Destemail string `json:"destemail"` - Sender string `json:"sender"` + //Sender string `json:"sender"` } // init paths to key-files @@ -88,7 +98,7 @@ func setDefaults() { currentSettings.Language = "en" } if currentSettings.AlertCountries == nil { - currentSettings.AlertCountries = []string{"all"} + currentSettings.AlertCountries = []string{"ALL"} } if currentSettings.Bantime == "" { currentSettings.Bantime = "48h" @@ -100,10 +110,25 @@ func setDefaults() { currentSettings.Maxretry = 3 } if currentSettings.Destemail == "" { - currentSettings.Destemail = "alerts@swissmakers.ch" + currentSettings.Destemail = "alerts@example.com" } - if currentSettings.Sender == "" { - currentSettings.Sender = "noreply@swissmakers.ch" + if currentSettings.SMTP.Host == "" { + currentSettings.SMTP.Host = "smtp.office365.com" + } + if currentSettings.SMTP.Port == 0 { + currentSettings.SMTP.Port = 587 + } + if currentSettings.SMTP.Username == "" { + currentSettings.SMTP.Username = "noreply@swissmakers.ch" + } + if currentSettings.SMTP.Password == "" { + currentSettings.SMTP.Password = "password" + } + if currentSettings.SMTP.From == "" { + currentSettings.SMTP.From = "noreply@swissmakers.ch" + } + if !currentSettings.SMTP.UseTLS { + currentSettings.SMTP.UseTLS = true } if currentSettings.IgnoreIP == "" { currentSettings.IgnoreIP = "127.0.0.1/8 ::1" @@ -151,9 +176,9 @@ func initializeFromJailFile() error { if val, ok := settings["destemail"]; ok { currentSettings.Destemail = val } - if val, ok := settings["sender"]; ok { + /*if val, ok := settings["sender"]; ok { currentSettings.Sender = val - } + }*/ return nil } @@ -379,9 +404,10 @@ func UpdateSettings(new AppSettings) (AppSettings, error) { old.IgnoreIP != new.IgnoreIP || old.Bantime != new.Bantime || old.Findtime != new.Findtime || - old.Maxretry != new.Maxretry || + //old.Maxretry != new.Maxretry || old.Destemail != new.Destemail || - old.Sender != new.Sender { + //old.Sender != new.Sender { + old.Maxretry != new.Maxretry { new.ReloadNeeded = true } else { // preserve previous ReloadNeeded if it was already true diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 2351b11..61ee32d 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -17,13 +17,14 @@ package web import ( - "bytes" + "crypto/tls" + "errors" "fmt" "log" "net" "net/http" + "net/smtp" "os" - "os/exec" "strings" "time" @@ -195,40 +196,6 @@ func shouldAlertForCountry(country string, alertCountries []string) bool { return false } -// sendBanAlert sends an email notification for a banned IP. -func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, settings config.AppSettings) error { - // Construct email content - emailBody := fmt.Sprintf(`Subject: [Fail2Ban] %s: banned %s from %s -Date: `+"`LC_ALL=C date +\"%%a, %%d %%h %%Y %%T %%z\"`"+` -From: %s <%s> -To: %s - -Hi, - -The IP %s has just been banned by Fail2Ban after %s attempts against %s. - -📍 Country: %s -🔍 Whois Info: -%s - -📄 Log Entries: -%s - -Best Regards, -Fail2Ban -`, jail, ip, hostname, settings.Sender, settings.Sender, settings.Destemail, ip, failures, jail, country, whois, logs) - - // Use msmtp or sendmail to send email - cmd := exec.Command("/usr/sbin/sendmail", "-f", settings.Sender, settings.Destemail) - cmd.Stdin = bytes.NewBufferString(emailBody) - - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to send email: %w", err) - } - - return nil -} - func sortByTimeDesc(events []fail2ban.BanEvent) { for i := 0; i < len(events); i++ { for j := i + 1; j < len(events); j++ { @@ -395,7 +362,7 @@ func ApplyFail2banSettings(jailLocalPath string) error { fmt.Sprintf("findtime = %s", s.Findtime), fmt.Sprintf("maxretry = %d", s.Maxretry), fmt.Sprintf("destemail = %s", s.Destemail), - fmt.Sprintf("sender = %s", s.Sender), + //fmt.Sprintf("sender = %s", s.Sender), "", } content := strings.Join(newLines, "\n") @@ -427,3 +394,200 @@ func ReloadFail2banHandler(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "Fail2ban reloaded successfully"}) } + +// ******************************************************************* +// * Unified Email Sending Function : * +// ******************************************************************* +func sendEmail(to, subject, body string, settings config.AppSettings) error { + // Validate SMTP settings + if settings.SMTP.Host == "" || settings.SMTP.Username == "" || settings.SMTP.Password == "" || settings.SMTP.From == "" { + return errors.New("SMTP settings are incomplete. Please configure all required fields") + } + + // Format message with **correct HTML headers** + message := fmt.Sprintf("From: %s\nTo: %s\nSubject: %s\n"+ + "MIME-Version: 1.0\nContent-Type: text/html; charset=\"UTF-8\"\n\n%s", + settings.SMTP.From, to, subject, body) + msg := []byte(message) + + // SMTP Connection Config + smtpHost := settings.SMTP.Host + smtpPort := settings.SMTP.Port + auth := LoginAuth(settings.SMTP.Username, settings.SMTP.Password) + smtpAddr := fmt.Sprintf("%s:%d", smtpHost, smtpPort) + + // **Choose Connection Type** + if smtpPort == 465 { + // SMTPS (Implicit TLS) - Not supported at the moment. + tlsConfig := &tls.Config{ServerName: smtpHost} + conn, err := tls.Dial("tcp", smtpAddr, tlsConfig) + if err != nil { + return fmt.Errorf("failed to connect via TLS: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, smtpHost) + if err != nil { + return fmt.Errorf("failed to create SMTP client: %w", err) + } + defer client.Quit() + + if err := client.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + + return sendSMTPMessage(client, settings.SMTP.From, to, msg) + + } else if smtpPort == 587 { + // STARTTLS (Explicit TLS) + conn, err := net.Dial("tcp", smtpAddr) + if err != nil { + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, smtpHost) + if err != nil { + return fmt.Errorf("failed to create SMTP client: %w", err) + } + defer client.Quit() + + // Start TLS Upgrade + tlsConfig := &tls.Config{ServerName: smtpHost} + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("failed to start TLS: %w", err) + } + + if err := client.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + + return sendSMTPMessage(client, settings.SMTP.From, to, msg) + } + + return errors.New("unsupported SMTP port. Use 587 (STARTTLS) or 465 (SMTPS)") +} + +// Helper Function to Send SMTP Message +func sendSMTPMessage(client *smtp.Client, from, to string, msg []byte) error { + // Set sender & recipient + if err := client.Mail(from); err != nil { + return fmt.Errorf("failed to set sender: %w", err) + } + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("failed to set recipient: %w", err) + } + + // Send email body + wc, err := client.Data() + if err != nil { + return fmt.Errorf("failed to start data command: %w", err) + } + defer wc.Close() + + if _, err = wc.Write(msg); err != nil { + return fmt.Errorf("failed to write email content: %w", err) + } + + // Close connection + client.Quit() + return nil +} + +// ******************************************************************* +// * sendBanAlert Function : * +// ******************************************************************* +func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, settings config.AppSettings) error { + subject := fmt.Sprintf("[Fail2Ban] %s: banned %s from %s", jail, ip, hostname) + + // Ensure HTML email format + body := fmt.Sprintf(` + + + + Fail2Ban Alert + + + +
+

🚨 Fail2Ban Alert

+

A new IP has been banned due to excessive failed login attempts.

+
+

📌 Banned IP: %s

+

🛡️ Jail Name: %s

+

🏠 Hostname: %s

+

🚫 Failed Attempts: %s

+

🌍 Country: %s

+
+

🔍 Whois Information:

+
%s
+

📄 Log Entries:

+
%s
+ +
+ + `, ip, jail, hostname, failures, country, whois, logs) + + // Send the email + return sendEmail(settings.Destemail, subject, body, settings) +} + +// ******************************************************************* +// * TestEmailHandler to send test-mail : * +// ******************************************************************* +func TestEmailHandler(c *gin.Context) { + settings := config.GetSettings() + + err := sendEmail( + settings.Destemail, + "Test Email from Fail2Ban UI", + "This is a test email sent from the Fail2Ban UI to verify SMTP settings.", + settings, + ) + + if err != nil { + log.Printf("❌ Test email failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test email: " + err.Error()}) + return + } + + log.Println("✅ Test email sent successfully!") + c.JSON(http.StatusOK, gin.H{"message": "Test email sent successfully!"}) +} + +// ******************************************************************* +// * Office365 LOGIN Authentication : * +// ******************************************************************* +type loginAuth struct { + username, password string +} + +func LoginAuth(username, password string) smtp.Auth { + return &loginAuth{username, password} +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte(a.username), nil +} + +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch string(fromServer) { + case "Username:": + return []byte(a.username), nil + case "Password:": + return []byte(a.password), nil + default: + return nil, errors.New("unexpected server challenge") + } + } + return nil, nil +} diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 752e9d2..def310f 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -37,6 +37,7 @@ func RegisterRoutes(r *gin.Engine) { // settings api.GET("/settings", GetSettingsHandler) api.POST("/settings", UpdateSettingsHandler) + api.POST("/settings/test-email", TestEmailHandler) // filter debugger api.GET("/filters", ListFiltersHandler) diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 63ea496..8b9db2d 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -143,21 +143,13 @@
Alert Settings - -
- - + +
-
- - -
- -
- -

Choose which country IP blocks should trigger an email. You can select multiple with CTRL.

+ +

Choose which country IP blocks should trigger an email (hold CTRL for multiple).

+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
Fail2Ban Configuration @@ -198,9 +221,9 @@
- +
-
+ @@ -589,12 +612,11 @@ function loadSettings() { document.getElementById('languageSelect').value = data.language || 'en'; document.getElementById('debugMode').checked = data.debug || false; - // Get current alert settings - document.getElementById('sourceEmail').value = data.sender || ''; + // Get Alert settings document.getElementById('destEmail').value = data.destemail || ''; - // alertCountries multi + + // Get Alert countries selection const select = document.getElementById('alertCountries'); - // clear selection for (let i = 0; i < select.options.length; i++) { select.options[i].selected = false; } @@ -611,7 +633,17 @@ function loadSettings() { } } - // Get current Fail2Ban Configuration + // Get SMTP settings + if (data.smtp) { + document.getElementById('smtpHost').value = data.smtp.host || ''; + document.getElementById('smtpPort').value = data.smtp.port || 587; + document.getElementById('smtpUsername').value = data.smtp.username || ''; + document.getElementById('smtpPassword').value = data.smtp.password || ''; + document.getElementById('smtpFrom').value = data.smtp.from || ''; + document.getElementById('smtpUseTLS').checked = data.smtp.useTLS || false; + } + + // Get current Fail2Ban settings document.getElementById('bantimeIncrement').checked = data.bantimeIncrement || false; document.getElementById('banTime').value = data.bantime || ''; document.getElementById('findTime').value = data.findtime || ''; @@ -625,57 +657,49 @@ function loadSettings() { } // Save settings when hit the save button -function saveSettings(e) { - e.preventDefault(); // prevent form submission +function saveSettings(event) { + event.preventDefault(); // prevent form submission showLoading(true); - const lang = document.getElementById('languageSelect').value; - const debugMode = document.getElementById("debugMode").checked; - const srcmail = document.getElementById('sourceEmail').value; - const destmail = document.getElementById('destEmail').value; + + // Gather form values + const smtpSettings = { + host: document.getElementById('smtpHost').value.trim(), + port: parseInt(document.getElementById('smtpPort').value, 10) || 587, + username: document.getElementById('smtpUsername').value.trim(), + password: document.getElementById('smtpPassword').value.trim(), + from: document.getElementById('smtpFrom').value.trim(), + useTLS: document.getElementById('smtpUseTLS').checked, + }; - const select = document.getElementById('alertCountries'); - let chosenCountries = []; - for (let i = 0; i < select.options.length; i++) { - if (select.options[i].selected) { - chosenCountries.push(select.options[i].value); - } - } - // If user selected "ALL", we override everything - if (chosenCountries.includes("ALL")) { - chosenCountries = ["all"]; - } + const selectedCountries = Array.from(document.getElementById('alertCountries').selectedOptions).map(opt => opt.value); - const bantimeinc = document.getElementById('bantimeIncrement').checked; - const bant = document.getElementById('banTime').value; - const findt = document.getElementById('findTime').value; - const maxre = parseInt(document.getElementById('maxRetry').value, 10) || 1; // Default to 1 (if parsing fails) - const ignip = document.getElementById('ignoreIP').value; + const settingsData = { + language: document.getElementById('languageSelect').value, + debug: document.getElementById('debugMode').checked, + destemail: document.getElementById('destEmail').value.trim(), + alertCountries: selectedCountries.length > 0 ? selectedCountries : ["ALL"], + + bantimeIncrement: document.getElementById('bantimeIncrement').checked, + bantime: document.getElementById('banTime').value.trim(), + findtime: document.getElementById('findTime').value.trim(), + + maxretry: parseInt(document.getElementById('maxRetry').value, 10) || 3, + ignoreip: document.getElementById('ignoreIP').value.trim(), - const body = { - language: lang, - debug: debugMode, - sender: srcmail, - destemail: destmail, - alertCountries: chosenCountries, - bantimeIncrement: bantimeinc, - bantime: bant, - findtime: findt, - maxretry: maxre, - ignoreip: ignip + smtp: smtpSettings // (Includes SMTP settings) }; fetch('/api/settings', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(body) + body: JSON.stringify(settingsData), }) .then(res => res.json()) .then(data => { if (data.error) { alert('Error saving settings: ' + data.error + data.details); } else { - //alert(data.message || 'Settings saved'); console.log("Settings saved successfully. Reload needed? " + data.reloadNeeded); if (data.reloadNeeded) { document.getElementById('reloadBanner').style.display = 'block'; @@ -719,6 +743,25 @@ function loadFilters() { .finally(() => showLoading(false)); } +function sendTestEmail() { + showLoading(true); + + fetch('/api/settings/test-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + .then(res => res.json()) + .then(data => { + if (data.error) { + alert('Error sending test email: ' + data.error); + } else { + alert('Test email sent successfully!'); + } + }) + .catch(error => alert('Error: ' + error)) + .finally(() => showLoading(false)); +} + // Called when clicking "Test Filter" button function testSelectedFilter() { const filterName = document.getElementById('filterSelect').value;