mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 14:03:15 +02:00
Implement basic ban-API call back to Go-application for handling
This commit is contained in:
7
go.mod
7
go.mod
@@ -2,7 +2,10 @@ module github.com/swissmakers/fail2ban-ui
|
|||||||
|
|
||||||
go 1.22.9
|
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 (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
@@ -27,7 +30,7 @@ require (
|
|||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.23.0 // indirect
|
golang.org/x/crypto v0.23.0 // indirect
|
||||||
golang.org/x/net v0.25.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
|
golang.org/x/text v0.15.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
6
go.sum
6
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/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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
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=
|
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/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func initializeFail2banAction() error {
|
|||||||
fmt.Println("Error setting up jail.d configuration:", err)
|
fmt.Println("Error setting up jail.d configuration:", err)
|
||||||
}
|
}
|
||||||
// Write the fail2ban action file
|
// 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
|
// 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.
|
// writeFail2banAction creates or updates the action file with the AlertCountries.
|
||||||
func writeFail2banAction(alertCountries []string) error {
|
func writeFail2banAction() error {
|
||||||
|
|
||||||
// Join the alertCountries into a comma-separated string
|
|
||||||
countriesFormatted := strings.Join(alertCountries, ",")
|
|
||||||
|
|
||||||
// Define the Fail2Ban action file content
|
// Define the Fail2Ban action file content
|
||||||
actionConfig := fmt.Sprintf(`[INCLUDES]
|
actionConfig := `[INCLUDES]
|
||||||
|
|
||||||
before = sendmail-common.conf
|
before = sendmail-common.conf
|
||||||
mail-whois-common.conf
|
mail-whois-common.conf
|
||||||
@@ -266,9 +262,16 @@ before = sendmail-common.conf
|
|||||||
norestored = 1
|
norestored = 1
|
||||||
|
|
||||||
# Option: actionban
|
# Option: actionban
|
||||||
# This executes the Python script with <ip> and the list of allowed countries.
|
# This executes a cURL request to notify our API when an IP is banned.
|
||||||
# If the country matches the allowed list, it sends the email.
|
|
||||||
actionban = /etc/fail2ban/action.d/geoip_notify.py <ip> "%s" 'name=<name>;ip=<ip>;fq-hostname=<fq-hostname>;sendername=<sendername>;sender=<sender>;dest=<dest>;failures=<failures>;_whois_command=%%(_whois_command)s;_grep_logs=%%(_grep_logs)s;grepmax=<grepmax>;mailcmd=<mailcmd>'
|
actionban = /usr/bin/curl -X POST http://127.0.0.1:8080/api/ban \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"ip\": \"<ip>\", \
|
||||||
|
\"jail\": \"<name>\", \
|
||||||
|
\"hostname\": \"<fq-hostname>\", \
|
||||||
|
\"failures\": \"<failures>\", \
|
||||||
|
\"whois\": \"%%(_whois_command)s\", \
|
||||||
|
\"logs\": \"%%(_grep_logs)s\"}"
|
||||||
|
|
||||||
[Init]
|
[Init]
|
||||||
|
|
||||||
@@ -280,8 +283,7 @@ logpath = /dev/null
|
|||||||
|
|
||||||
# Number of log lines to include in the email
|
# Number of log lines to include in the email
|
||||||
# grepmax = 1000
|
# grepmax = 1000
|
||||||
# grepopts = -m <grepmax>
|
# grepopts = -m <grepmax>`
|
||||||
`, countriesFormatted)
|
|
||||||
|
|
||||||
// Write the action file
|
// Write the action file
|
||||||
err := os.WriteFile(actionFile, []byte(actionConfig), 0644)
|
err := os.WriteFile(actionFile, []byte(actionConfig), 0644)
|
||||||
@@ -335,7 +337,7 @@ func saveSettings() error {
|
|||||||
log.Println("Settings saved successfully!") // Debug
|
log.Println("Settings saved successfully!") // Debug
|
||||||
}
|
}
|
||||||
// Update the Fail2ban action file
|
// Update the Fail2ban action file
|
||||||
return writeFail2banAction(currentSettings.AlertCountries)
|
return writeFail2banAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSettings returns a copy of the current settings
|
// GetSettings returns a copy of the current settings
|
||||||
|
|||||||
@@ -17,13 +17,18 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/oschwald/maxminddb-golang"
|
||||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||||
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
|
"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) {
|
func sortByTimeDesc(events []fail2ban.BanEvent) {
|
||||||
for i := 0; i < len(events); i++ {
|
for i := 0; i < len(events); i++ {
|
||||||
for j := i + 1; j < len(events); j++ {
|
for j := i + 1; j < len(events); j++ {
|
||||||
|
|||||||
@@ -46,5 +46,8 @@ func RegisterRoutes(r *gin.Engine) {
|
|||||||
|
|
||||||
// Reload endpoint
|
// Reload endpoint
|
||||||
api.POST("/fail2ban/reload", ReloadFail2banHandler)
|
api.POST("/fail2ban/reload", ReloadFail2banHandler)
|
||||||
|
|
||||||
|
// Handle Fail2Ban notifications
|
||||||
|
api.POST("/ban", BanNotificationHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user