Files
fail2ban-ui/pkg/web/handlers.go

430 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.gnu.org/licenses/gpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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"
)
// SummaryResponse is what we return from /api/summary
type SummaryResponse struct {
Jails []fail2ban.JailInfo `json:"jails"`
LastBans []fail2ban.BanEvent `json:"lastBans"`
}
// SummaryHandler returns a JSON summary of all jails, including
// number of banned IPs, how many are new in the last hour, etc.
// and the last 5 overall ban events from the log.
func SummaryHandler(c *gin.Context) {
const logPath = "/var/log/fail2ban.log"
jailInfos, err := fail2ban.BuildJailInfos(logPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Parse the log to find last 5 ban events
eventsByJail, err := fail2ban.ParseBanLog(logPath)
lastBans := make([]fail2ban.BanEvent, 0)
if err == nil {
// If we can parse logs successfully, let's gather all events
var all []fail2ban.BanEvent
for _, evs := range eventsByJail {
all = append(all, evs...)
}
// Sort by descending time
sortByTimeDesc(all)
if len(all) > 5 {
lastBans = all[:5]
} else {
lastBans = all
}
}
resp := SummaryResponse{
Jails: jailInfos,
LastBans: lastBans,
}
c.JSON(http.StatusOK, resp)
}
// UnbanIPHandler unbans a given IP in a specific jail.
func UnbanIPHandler(c *gin.Context) {
fmt.Println("----------------------------")
fmt.Println("UnbanIPHandler called (handlers.go)") // entry point
jail := c.Param("jail")
ip := c.Param("ip")
err := fail2ban.UnbanIP(jail, ip)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
fmt.Println(ip + " from jail " + jail + " unbanned successfully (handlers.go)")
c.JSON(http.StatusOK, gin.H{
"message": "IP unbanned successfully",
})
}
// 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 IPs 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++ {
if events[j].Time.After(events[i].Time) {
events[i], events[j] = events[j], events[i]
}
}
}
}
// IndexHandler serves the HTML page
func IndexHandler(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"timestamp": time.Now().Format(time.RFC1123),
})
}
// GetJailFilterConfigHandler returns the raw filter config for a given jail
func GetJailFilterConfigHandler(c *gin.Context) {
fmt.Println("----------------------------")
fmt.Println("GetJailFilterConfigHandler called (handlers.go)") // entry point
jail := c.Param("jail")
cfg, err := fail2ban.GetJailConfig(jail)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"jail": jail,
"config": cfg,
})
}
// SetJailFilterConfigHandler overwrites the current filter config with new content
func SetJailFilterConfigHandler(c *gin.Context) {
fmt.Println("----------------------------")
fmt.Println("SetJailFilterConfigHandler called (handlers.go)") // entry point
jail := c.Param("jail")
// Parse JSON body (containing the new filter content)
var req struct {
Config string `json:"config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
return
}
// Write the filter config file to /etc/fail2ban/filter.d/<jail>.conf
if err := fail2ban.SetJailConfig(jail, req.Config); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Mark reload needed in our UI settings
// if err := config.MarkReloadNeeded(); err != nil {
// c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
// return
// }
c.JSON(http.StatusOK, gin.H{"message": "jail config updated"})
// Return a simple JSON response without forcing a blocking alert
// c.JSON(http.StatusOK, gin.H{
// "message": "Filter updated, reload needed",
// "reloadNeeded": true,
// })
}
// GetSettingsHandler returns the entire AppSettings struct as JSON
func GetSettingsHandler(c *gin.Context) {
fmt.Println("----------------------------")
fmt.Println("GetSettingsHandler called (handlers.go)") // entry point
s := config.GetSettings()
c.JSON(http.StatusOK, s)
}
// UpdateSettingsHandler updates the AppSettings from a JSON body
func UpdateSettingsHandler(c *gin.Context) {
fmt.Println("----------------------------")
fmt.Println("UpdateSettingsHandler called (handlers.go)") // entry point
var req config.AppSettings
if err := c.ShouldBindJSON(&req); err != nil {
fmt.Println("JSON binding error:", err) // Debug
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid JSON",
"details": err.Error(),
})
return
}
fmt.Println("JSON binding successful, updating settings (handlers.go)")
newSettings, err := config.UpdateSettings(req)
if err != nil {
fmt.Println("Error updating settings:", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
fmt.Println("Settings updated successfully (handlers.go)")
c.JSON(http.StatusOK, gin.H{
"message": "Settings updated",
"reloadNeeded": newSettings.ReloadNeeded,
})
}
// ListFiltersHandler returns a JSON array of filter names
// found as *.conf in /etc/fail2ban/filter.d
func ListFiltersHandler(c *gin.Context) {
fmt.Println("----------------------------")
fmt.Println("ListFiltersHandler called (handlers.go)") // entry point
dir := "/etc/fail2ban/filter.d"
files, err := os.ReadDir(dir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to read filter directory: " + err.Error(),
})
return
}
var filters []string
for _, f := range files {
if !f.IsDir() && strings.HasSuffix(f.Name(), ".conf") {
name := strings.TrimSuffix(f.Name(), ".conf")
filters = append(filters, name)
}
}
c.JSON(http.StatusOK, gin.H{"filters": filters})
}
func TestFilterHandler(c *gin.Context) {
fmt.Println("----------------------------")
fmt.Println("TestFilterHandler called (handlers.go)") // entry point
var req struct {
FilterName string `json:"filterName"`
LogLines []string `json:"logLines"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
// For now, just pretend nothing matches
c.JSON(http.StatusOK, gin.H{"matches": []string{}})
}
// ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON
func ApplyFail2banSettings(jailLocalPath string) error {
fmt.Println("----------------------------")
fmt.Println("ApplyFail2banSettings called (handlers.go)") // entry point
s := config.GetSettings()
// open /etc/fail2ban/jail.local, parse or do a simplistic approach:
// TODO: -> maybe we store [DEFAULT] block in memory, replace lines
// or do a line-based approach. Example is simplistic:
newLines := []string{
"[DEFAULT]",
fmt.Sprintf("bantime.increment = %t", s.BantimeIncrement),
fmt.Sprintf("ignoreip = %s", s.IgnoreIP),
fmt.Sprintf("bantime = %s", s.Bantime),
fmt.Sprintf("findtime = %s", s.Findtime),
fmt.Sprintf("maxretry = %d", s.Maxretry),
fmt.Sprintf("destemail = %s", s.Destemail),
fmt.Sprintf("sender = %s", s.Sender),
"",
}
content := strings.Join(newLines, "\n")
return os.WriteFile(jailLocalPath, []byte(content), 0644)
}
// ReloadFail2banHandler reloads the Fail2ban service
func ReloadFail2banHandler(c *gin.Context) {
fmt.Println("----------------------------")
fmt.Println("ApplyFail2banSettings called (handlers.go)") // entry point
// First we write our new settings to /etc/fail2ban/jail.local
// if err := fail2ban.ApplyFail2banSettings("/etc/fail2ban/jail.local"); err != nil {
// c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
// return
// }
// Then reload
if err := fail2ban.ReloadFail2ban(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// We set reload done in config
if err := config.MarkReloadDone(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Fail2ban reloaded successfully"})
}