From 3ffd2457ed20858a59e053c4b62d76faa63e9a53 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Wed, 29 Jan 2025 23:48:06 +0100 Subject: [PATCH 1/6] Implement basic ban-API call back to Go-application for handling --- go.mod | 7 +- go.sum | 6 +- internal/config/settings.go | 28 ++++---- pkg/web/handlers.go | 138 ++++++++++++++++++++++++++++++++++++ pkg/web/routes.go | 3 + 5 files changed, 165 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 8b611af..bcb0fc7 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/swissmakers/fail2ban-ui go 1.22.9 -require github.com/gin-gonic/gin v1.10.0 +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/oschwald/maxminddb-golang v1.13.1 +) require ( github.com/bytedance/sonic v1.11.6 // indirect @@ -27,7 +30,7 @@ require ( golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect + golang.org/x/sys v0.21.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 7f08abb..451fafe 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -72,8 +74,8 @@ golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= diff --git a/internal/config/settings.go b/internal/config/settings.go index 30bf18c..fb3ab0f 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -169,7 +169,7 @@ func initializeFail2banAction() error { fmt.Println("Error setting up jail.d configuration:", err) } // Write the fail2ban action file - return writeFail2banAction(currentSettings.AlertCountries) + return writeFail2banAction() } // setupGeoCustomAction checks and replaces the default action in jail.local with our from fail2ban-UI @@ -248,13 +248,9 @@ action_mwlg = %(action_)s } // writeFail2banAction creates or updates the action file with the AlertCountries. -func writeFail2banAction(alertCountries []string) error { - - // Join the alertCountries into a comma-separated string - countriesFormatted := strings.Join(alertCountries, ",") - +func writeFail2banAction() error { // Define the Fail2Ban action file content - actionConfig := fmt.Sprintf(`[INCLUDES] + actionConfig := `[INCLUDES] before = sendmail-common.conf mail-whois-common.conf @@ -266,9 +262,16 @@ before = sendmail-common.conf norestored = 1 # Option: actionban -# This executes the Python script with and the list of allowed countries. -# If the country matches the allowed list, it sends the email. -actionban = /etc/fail2ban/action.d/geoip_notify.py "%s" 'name=;ip=;fq-hostname=;sendername=;sender=;dest=;failures=;_whois_command=%%(_whois_command)s;_grep_logs=%%(_grep_logs)s;grepmax=;mailcmd=' +# This executes a cURL request to notify our API when an IP is banned. + +actionban = /usr/bin/curl -X POST http://127.0.0.1:8080/api/ban \ + -H "Content-Type: application/json" \ + -d "{\"ip\": \"\", \ + \"jail\": \"\", \ + \"hostname\": \"\", \ + \"failures\": \"\", \ + \"whois\": \"%%(_whois_command)s\", \ + \"logs\": \"%%(_grep_logs)s\"}" [Init] @@ -280,8 +283,7 @@ logpath = /dev/null # Number of log lines to include in the email # grepmax = 1000 -# grepopts = -m -`, countriesFormatted) +# grepopts = -m ` // Write the action file err := os.WriteFile(actionFile, []byte(actionConfig), 0644) @@ -335,7 +337,7 @@ func saveSettings() error { log.Println("Settings saved successfully!") // Debug } // Update the Fail2ban action file - return writeFail2banAction(currentSettings.AlertCountries) + return writeFail2banAction() } // GetSettings returns a copy of the current settings diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index c4eb0fb..2351b11 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -17,13 +17,18 @@ package web import ( + "bytes" "fmt" + "log" + "net" "net/http" "os" + "os/exec" "strings" "time" "github.com/gin-gonic/gin" + "github.com/oschwald/maxminddb-golang" "github.com/swissmakers/fail2ban-ui/internal/config" "github.com/swissmakers/fail2ban-ui/internal/fail2ban" ) @@ -91,6 +96,139 @@ func UnbanIPHandler(c *gin.Context) { }) } +// BanNotificationHandler processes incoming ban notifications from Fail2Ban. +func BanNotificationHandler(c *gin.Context) { + var request struct { + IP string `json:"ip" binding:"required"` + Jail string `json:"jail" binding:"required"` + Hostname string `json:"hostname"` + Failures string `json:"failures"` + Whois string `json:"whois"` + Logs string `json:"logs"` + } + + // Parse JSON request body + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + // Handle the Fail2Ban notification + if err := HandleBanNotification(request.IP, request.Jail, request.Hostname, request.Failures, request.Whois, request.Logs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process ban notification: " + err.Error()}) + return + } + + // Respond with success + c.JSON(http.StatusOK, gin.H{"message": "Ban notification processed successfully"}) +} + +// HandleBanNotification processes Fail2Ban notifications, checks geo-location, and sends alerts. +func HandleBanNotification(ip, jail, hostname, failures, whois, logs string) error { + // Load settings to get alert countries + settings := config.GetSettings() + + // Lookup the country for the given IP + country, err := lookupCountry(ip) + if err != nil { + log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err) + return err + } + + // Check if country is in alert list + if !shouldAlertForCountry(country, settings.AlertCountries) { + log.Printf("❌ IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, country, settings.AlertCountries) + return nil + } + + // Send email notification + if err := sendBanAlert(ip, jail, hostname, failures, whois, logs, country, settings); err != nil { + log.Printf("❌ Failed to send alert email: %v", err) + return err + } + + log.Printf("✅ Email alert sent for banned IP %s (%s)", ip, country) + return nil +} + +// lookupCountry finds the country ISO code for a given IP using MaxMind GeoLite2 database. +func lookupCountry(ip string) (string, error) { + // Convert the IP string to net.IP + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return "", fmt.Errorf("invalid IP address: %s", ip) + } + + // Open the GeoIP database + db, err := maxminddb.Open("/usr/share/GeoIP/GeoLite2-Country.mmdb") + if err != nil { + return "", fmt.Errorf("failed to open GeoIP database: %w", err) + } + defer db.Close() + + // Define the structure to store the lookup result + var record struct { + Country struct { + ISOCode string `maxminddb:"iso_code"` + } `maxminddb:"country"` + } + + // Perform the lookup using net.IP type + if err := db.Lookup(parsedIP, &record); err != nil { + return "", fmt.Errorf("GeoIP lookup error: %w", err) + } + + // Return the country code + return record.Country.ISOCode, nil +} + +// shouldAlertForCountry checks if an IP’s country is in the allowed alert list. +func shouldAlertForCountry(country string, alertCountries []string) bool { + if len(alertCountries) == 0 || strings.Contains(strings.Join(alertCountries, ","), "ALL") { + return true // If "ALL" is selected, alert for all bans + } + for _, c := range alertCountries { + if strings.EqualFold(country, c) { + return true + } + } + 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++ { diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 674d693..752e9d2 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -46,5 +46,8 @@ func RegisterRoutes(r *gin.Engine) { // Reload endpoint api.POST("/fail2ban/reload", ReloadFail2banHandler) + + // Handle Fail2Ban notifications + api.POST("/ban", BanNotificationHandler) } } From ed95571b391f6b6a4c161f940ead6fc76b2a5097 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Wed, 29 Jan 2025 23:49:04 +0100 Subject: [PATCH 2/6] Add SELinux Module, to allow fail2ban communication via localhost on port 8080 --- internal/fail2ban-curl-allow.pp | Bin 0 -> 983 bytes internal/fail2ban-curl-allow.te | 11 +++++++++++ 2 files changed, 11 insertions(+) create mode 100644 internal/fail2ban-curl-allow.pp create mode 100644 internal/fail2ban-curl-allow.te diff --git a/internal/fail2ban-curl-allow.pp b/internal/fail2ban-curl-allow.pp new file mode 100644 index 0000000000000000000000000000000000000000..936404a5a97dfd4d04211abf3bf36358d3264213 GIT binary patch literal 983 zcmb_aO-lnY5MA4gh)_km_YYLmUoe;6>d7Av(imwqO_psIJ@^;I-|I=|)qQN*A_(=t z%gns_$fli-ueZ;ms;a;<<-5iswfEEUY#H0v@m+dogCD4LsYKba61Y%4+GV(%+cFe`IBZ$BdQTX zM{Vg-E;YOhJnt;;&~U4Fk#lXFbqjCm)MS(K1QzpS1YI5Y!QGn&}LH~U0I*Z=?k literal 0 HcmV?d00001 diff --git a/internal/fail2ban-curl-allow.te b/internal/fail2ban-curl-allow.te new file mode 100644 index 0000000..14c604d --- /dev/null +++ b/internal/fail2ban-curl-allow.te @@ -0,0 +1,11 @@ + +module fail2ban-curl-allow 1.0; + +require { + type fail2ban_t; + type http_cache_port_t; + class tcp_socket name_connect; +} + +#============= fail2ban_t ============== +allow fail2ban_t http_cache_port_t:tcp_socket name_connect; From 77e6e8cf9dae13b75d3670626b300fec7975d283 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Thu, 30 Jan 2025 11:00:14 +0100 Subject: [PATCH 3/6] 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; From 9247ad2dd5afef9096a2a524bd33c8b2b385494a Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Thu, 30 Jan 2025 12:35:16 +0100 Subject: [PATCH 4/6] Improve JSON parsing and error-handling --- internal/config/settings.go | 13 +++-- pkg/web/handlers.go | 113 +++++++++++++++++++++++++----------- 2 files changed, 85 insertions(+), 41 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index 7a05604..1225a91 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -291,12 +291,13 @@ norestored = 1 actionban = /usr/bin/curl -X POST http://127.0.0.1:8080/api/ban \ -H "Content-Type: application/json" \ - -d "{\"ip\": \"\", \ - \"jail\": \"\", \ - \"hostname\": \"\", \ - \"failures\": \"\", \ - \"whois\": \"%%(_whois_command)s\", \ - \"logs\": \"%%(_grep_logs)s\"}" + -d "$(jq -n --arg ip '' \ + --arg jail '' \ + --arg hostname '' \ + --arg failures '' \ + --arg whois "$(whois || echo 'missing whois program')" \ + --arg logs "$(grep -wF | )" \ + '{ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, whois: $whois, logs: $logs}')" [Init] diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 61ee32d..e6111f7 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -17,9 +17,11 @@ package web import ( + "bytes" "crypto/tls" "errors" "fmt" + "io" "log" "net" "net/http" @@ -108,14 +110,27 @@ func BanNotificationHandler(c *gin.Context) { Logs string `json:"logs"` } + // **DEBUGGING: Log Raw JSON Body** + body, _ := io.ReadAll(c.Request.Body) + log.Printf("📩 Incoming Ban Notification: %s\n", string(body)) + + // Rebind body so Gin can parse it again (important!) + c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) + // Parse JSON request body if err := c.ShouldBindJSON(&request); err != nil { + log.Printf("❌ Invalid request: %v\n", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) return } + // **DEBUGGING: Log Parsed Request** + log.Printf("✅ Parsed Ban Request - IP: %s, Jail: %s, Hostname: %s, Failures: %s", + request.IP, request.Jail, request.Hostname, request.Failures) + // Handle the Fail2Ban notification if err := HandleBanNotification(request.IP, request.Jail, request.Hostname, request.Failures, request.Whois, request.Logs); err != nil { + log.Printf("❌ Failed to process ban notification: %v\n", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process ban notification: " + err.Error()}) return } @@ -498,43 +513,71 @@ func sendSMTPMessage(client *smtp.Client, from, to string, msg []byte) error { // * 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) + subject := fmt.Sprintf("[Fail2Ban] %s: Banned %s from %s", jail, ip, hostname) - // Ensure HTML email format + // Improved Responsive HTML Email 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) + + + + +Fail2Ban Alert + + + +
+ +
+ Swissmakers GmbH +

🚨 Security Alert from Fail2Ban

+
+ + +
+

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, time.Now().Year()) // Send the email return sendEmail(settings.Destemail, subject, body, settings) From b88023dd8d8b5fdf5e3b7c02e264e01f9d243dd5 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Thu, 30 Jan 2025 13:44:39 +0100 Subject: [PATCH 5/6] Minor fixes and fix grep log order --- internal/config/settings.go | 2 +- pkg/web/handlers.go | 23 ++++++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index 1225a91..b8f0f75 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -296,7 +296,7 @@ actionban = /usr/bin/curl -X POST http://127.0.0.1:8080/api/ban \ --arg hostname '' \ --arg failures '' \ --arg whois "$(whois || echo 'missing whois program')" \ - --arg logs "$(grep -wF | )" \ + --arg logs "$(tac | grep -wF )" \ '{ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, whois: $whois, logs: $logs}')" [Init] diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index e6111f7..ebd7844 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -525,14 +525,23 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set