From 6fc47c635ad3e9ed178e2549006d55558c8b3a5c Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Sat, 25 Jan 2025 21:18:29 +0100 Subject: [PATCH 01/13] Add planned routes for next release features --- pkg/web/routes.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 2d85923..5ff0eb0 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -14,11 +14,21 @@ func RegisterRoutes(r *gin.Engine) { api.GET("/summary", SummaryHandler) api.POST("/jails/:jail/unban/:ip", UnbanIPHandler) - // New config endpoints - api.GET("/jails/:jail/config", GetJailConfigHandler) - api.POST("/jails/:jail/config", SetJailConfigHandler) + // config endpoints + api.GET("/jails/:jail/config", GetJailFilterConfigHandler) + api.POST("/jails/:jail/config", SetJailFilterConfigHandler) - // Reload endpoint - api.POST("/fail2ban/reload", ReloadFail2banHandler) + // settings + api.GET("/settings", GetSettingsHandler) + api.POST("/settings", UpdateSettingsHandler) + + // filter debugger + api.GET("/filters", ListFiltersHandler) + api.POST("/filters/test", TestFilterHandler) + // TODO create or generate new filters + // api.POST("/filters/generate", GenerateFilterHandler) + + // Reload endpoint + api.POST("/fail2ban/reload", ReloadFail2banHandler) } } From 9535710a7a093a1b2165ab50121b29b1f09276a1 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Sat, 25 Jan 2025 21:23:19 +0100 Subject: [PATCH 02/13] Write initial settings-function --- internal/config/settings.go | 150 ++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 internal/config/settings.go diff --git a/internal/config/settings.go b/internal/config/settings.go new file mode 100644 index 0000000..1f63e5f --- /dev/null +++ b/internal/config/settings.go @@ -0,0 +1,150 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "sync" +) + +// UISettings holds both the UI settings (like language) and +// relevant Fail2ban jail/local config options. +type UISettings struct { + // UI-specific + Language string `json:"language"` + // Whether a reload is needed (e.g. user changed filter or jail settings). + ReloadNeeded bool `json:"reloadNeeded"` + + // These mirror some Fail2ban [DEFAULT] section parameters 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"` +} + +// path to the JSON file (relative to where the app is started) +const settingsFile = "fail2ban-ui-settings.json" + +// in-memory copy of settings +var ( + currentSettings UISettings + settingsLock sync.RWMutex +) + +func init() { + // Attempt to load existing file; if it doesn't exist, create with defaults. + if err := loadSettings(); err != nil { + fmt.Println("Error loading settings:", err) + fmt.Println("Creating a new settings file with defaults...") + + // set defaults + setDefaults() + + // save defaults to file + if err := saveSettings(); err != nil { + fmt.Println("Failed to save default settings:", err) + } + } +} + +// setDefaults populates default values in currentSettings +func setDefaults() { + settingsLock.Lock() + defer settingsLock.Unlock() + + currentSettings = UISettings{ + Language: "en", + ReloadNeeded: false, + + BantimeIncrement: true, + IgnoreIP: "127.0.0.1/8 ::1 172.16.10.1/24", + Bantime: "48h", + Findtime: "30m", + Maxretry: 3, + Destemail: "admin@swissmakers.ch", + Sender: "noreply@swissmakers.ch", + } +} + +// loadSettings reads the file (if exists) into currentSettings +func loadSettings() error { + data, err := os.ReadFile(settingsFile) + if os.IsNotExist(err) { + return err // triggers setDefaults + save + } + if err != nil { + return err + } + + var s UISettings + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + settingsLock.Lock() + defer settingsLock.Unlock() + currentSettings = s + return nil +} + +// saveSettings writes currentSettings to JSON +func saveSettings() error { + settingsLock.RLock() + defer settingsLock.RUnlock() + + b, err := json.MarshalIndent(currentSettings, "", " ") + if err != nil { + return err + } + return os.WriteFile(settingsFile, b, 0644) +} + +// GetSettings returns a copy of the current settings +func GetSettings() UISettings { + settingsLock.RLock() + defer settingsLock.RUnlock() + return currentSettings +} + +// UpdateSettings modifies the in-memory settings, sets ReloadNeeded if required, then saves to disk. +// Optionally, we can detect changes that require a reload vs. changes that don't. +func UpdateSettings(new UISettings) (UISettings, error) { + settingsLock.Lock() + defer settingsLock.Unlock() + + // If user changed certain fields that require a Fail2ban reload, set ReloadNeeded = true. + // For example, if any of these fields changed: + reloadNeededBefore := currentSettings.ReloadNeeded + + if currentSettings.BantimeIncrement != new.BantimeIncrement || + currentSettings.IgnoreIP != new.IgnoreIP || + currentSettings.Bantime != new.Bantime || + currentSettings.Findtime != new.Findtime || + currentSettings.Maxretry != new.Maxretry || + currentSettings.Destemail != new.Destemail || + currentSettings.Sender != new.Sender { + new.ReloadNeeded = true + } else { + // preserve previous ReloadNeeded if it was already true + new.ReloadNeeded = new.ReloadNeeded || reloadNeededBefore + } + + currentSettings = new + + // persist to file + if err := saveSettings(); err != nil { + return currentSettings, err + } + return currentSettings, nil +} + +// MarkReloadDone sets ReloadNeeded = false after the user reloaded fail2ban +func MarkReloadDone() error { + settingsLock.Lock() + defer settingsLock.Unlock() + currentSettings.ReloadNeeded = false + return saveSettings() +} From ebb15d6e731edf136d4b2c9a50b0fccd1ff66109 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Sat, 25 Jan 2025 21:43:50 +0100 Subject: [PATCH 03/13] Create new views settings and debug --- pkg/web/handlers.go | 141 ++++++++++++++----- pkg/web/templates/index.html | 258 ++++++++++++++++++++++++++++++++++- 2 files changed, 355 insertions(+), 44 deletions(-) diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 1f76a1e..b02ebab 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1,17 +1,20 @@ package web import ( + "io/ioutil" "net/http" + "strings" "time" "github.com/gin-gonic/gin" + "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"` + Jails []fail2ban.JailInfo `json:"jails"` + LastBans []fail2ban.BanEvent `json:"lastBans"` } // SummaryHandler returns a JSON summary of all jails, including @@ -78,53 +81,117 @@ func sortByTimeDesc(events []fail2ban.BanEvent) { } } -// IndexHandler serves the main HTML page +// IndexHandler serves the HTML page func IndexHandler(c *gin.Context) { c.HTML(http.StatusOK, "index.html", gin.H{ "timestamp": time.Now().Format(time.RFC1123), }) } -// GetJailConfigHandler returns the raw config for a given jail -func GetJailConfigHandler(c *gin.Context) { - 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, - }) +// GetJailFilterConfigHandler returns the raw filter config for a given jail +func GetJailFilterConfigHandler(c *gin.Context) { + 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, + }) } -// SetJailConfigHandler overwrites the jail config with new content -func SetJailConfigHandler(c *gin.Context) { - jail := c.Param("jail") +// SetJailFilterConfigHandler overwrites the current filter config with new content +func SetJailFilterConfigHandler(c *gin.Context) { + jail := c.Param("jail") - 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 - } + 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 + } - if err := fail2ban.SetJailConfig(jail, req.Config); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } + if err := fail2ban.SetJailConfig(jail, req.Config); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - c.JSON(http.StatusOK, gin.H{"message": "jail config updated"}) + c.JSON(http.StatusOK, gin.H{"message": "jail config updated"}) +} + +// GetSettingsHandler returns the current fail2ban-ui settings +func GetSettingsHandler(c *gin.Context) { + s := config.GetSettings() + c.JSON(http.StatusOK, s) +} + +// UpdateSettingsHandler updates the fail2ban-ui settings +func UpdateSettingsHandler(c *gin.Context) { + // var req config.Settings + // if err := c.ShouldBindJSON(&req); err != nil { + // c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) + // return + // } + + // needsRestart, err := config.UpdateSettings(req) + // if err != nil { + // c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + // return + // } + + c.JSON(http.StatusOK, gin.H{ + "message": "Settings updated", + // "needsRestart": needsRestart, + }) +} + +// ListFiltersHandler returns a JSON array of filter names +// found as *.conf in /etc/fail2ban/filter.d +func ListFiltersHandler(c *gin.Context) { + dir := "/etc/fail2ban/filter.d" // adjust if needed + + files, err := ioutil.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) { + 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{}}) } // ReloadFail2banHandler reloads the Fail2ban service func ReloadFail2banHandler(c *gin.Context) { - err := fail2ban.ReloadFail2ban() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "Fail2ban reloaded successfully"}) -} \ No newline at end of file + err := fail2ban.ReloadFail2ban() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Fail2ban reloaded successfully"}) +} diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 94195f7..12aeb12 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -33,12 +33,31 @@ +
+ + Fail2ban UI + + + + +
+
@@ -48,11 +67,62 @@
-
+ +

Dashboard

+ + + + + +

@@ -345,6 +415,180 @@ function reloadFail2ban() { showLoading(false); }); } + +function loadSettings() { + showLoading(true); + fetch('/api/settings') + .then(res => res.json()) + .then(data => { + // populate the form + document.getElementById('languageSelect').value = data.language || 'en'; + document.getElementById('alertEmail').value = data.alertEmail || ''; + if (data.alertCountries && data.alertCountries.length > 0) { + if (data.alertCountries[0] === 'all') { + document.getElementById('alertCountries').value = 'all'; + } else { + document.getElementById('alertCountries').value = data.alertCountries.join(','); + } + } + }) + .catch(err => { + alert('Error loading settings: ' + err); + }) + .finally(() => showLoading(false)); +} + +function saveSettings(e) { + e.preventDefault(); // prevent form submission + + showLoading(true); + const lang = document.getElementById('languageSelect').value; + const mail = document.getElementById('alertEmail').value; + const countries = document.getElementById('alertCountries').value; + + let countryList = []; + if (!countries || countries.trim() === '') { + countryList.push('all'); + } else { + countryList = countries.split(',').map(s => s.trim()); + } + + const body = { + language: lang, + alertEmail: mail, + alertCountries: countryList + }; + + fetch('/api/settings', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body) + }) + .then(res => res.json()) + .then(data => { + if (data.error) { + alert('Error saving settings: ' + data.error); + } else { + alert(data.message || 'Settings saved'); + if (data.needsRestart) { + // show the same "reload" banner used for filter changes + document.getElementById('reloadBanner').style.display = 'block'; + } + } + }) + .catch(err => { + alert('Error: ' + err); + }) + .finally(() => showLoading(false)); +} + +function showSection(sectionId) { + // hide all sections + document.getElementById('dashboardSection').style.display = 'none'; + document.getElementById('filterSection').style.display = 'none'; + document.getElementById('settingsSection').style.display = 'none'; + + // show the requested section + document.getElementById(sectionId).style.display = 'block'; + + // If it's filterSection, load filters + if (sectionId === 'filterSection') { + showFilterSection(); + } + // If it's settingsSection, load settings + if (sectionId === 'settingsSection') { + loadSettings(); + } +} + +// Load the list of filters from /api/filters +function loadFilters() { + showLoading(true); + fetch('/api/filters') + .then(res => res.json()) + .then(data => { + if (data.error) { + alert('Error loading filters: ' + data.error); + return; + } + const select = document.getElementById('filterSelect'); + select.innerHTML = ''; // clear existing + if (!data.filters || data.filters.length === 0) { + // optional fallback + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'No Filters Found'; + select.appendChild(opt); + } else { + data.filters.forEach(f => { + const opt = document.createElement('option'); + opt.value = f; + opt.textContent = f; + select.appendChild(opt); + }); + } + }) + .catch(err => { + alert('Error loading filters: ' + err); + }) + .finally(() => showLoading(false)); +} + +// Called when clicking "Test Filter" button +function testSelectedFilter() { + const filterName = document.getElementById('filterSelect').value; + const lines = document.getElementById('logLinesTextarea').value.split('\n'); + + if (!filterName) { + alert('Please select a filter.'); + return; + } + + showLoading(true); + fetch('/api/filters/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filterName: filterName, + logLines: lines + }) + }) + .then(res => res.json()) + .then(data => { + if (data.error) { + alert('Error: ' + data.error); + return; + } + // data.matches, for example + renderTestResults(data.matches); + }) + .catch(err => { + alert('Error: ' + err); + }) + .finally(() => showLoading(false)); +} + +function renderTestResults(matches) { + let html = '

Test Results
'; + if (!matches || matches.length === 0) { + html += '

No matches found.

'; + } else { + html += '
    '; + matches.forEach(m => { + html += '
  • ' + m + '
  • '; + }); + html += '
'; + } + document.getElementById('testResults').innerHTML = html; +} + +// When showing the filter section +function showFilterSection() { + loadFilters(); // fetch the filter list + document.getElementById('testResults').innerHTML = ''; + document.getElementById('logLinesTextarea').value = ''; +} + From 0470f2f26f646a5ec71978ba3a6274d7647b7a15 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Sat, 25 Jan 2025 21:44:32 +0100 Subject: [PATCH 04/13] Update .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6f72f89..f8b9f34 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ go.work.sum # env file .env + +# Project specific +fail2ban-ui-settings.json \ No newline at end of file From 6e11189025a5fc26e89efeca3b206bbe7dc3acb1 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Sun, 26 Jan 2025 19:58:36 +0100 Subject: [PATCH 05/13] Implement config-change check, before doing reload --- internal/config/settings.go | 89 ++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 30 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index 1f63e5f..5f91374 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -7,13 +7,12 @@ import ( "sync" ) -// UISettings holds both the UI settings (like language) and +// AppSettings holds both the UI settings (like language) and // relevant Fail2ban jail/local config options. -type UISettings struct { - // UI-specific - Language string `json:"language"` - // Whether a reload is needed (e.g. user changed filter or jail settings). - ReloadNeeded bool `json:"reloadNeeded"` +type AppSettings struct { + Language string `json:"language"` + ReloadNeeded bool `json:"reloadNeeded"` + AlertCountries []string `json:"alertCountries"` // These mirror some Fail2ban [DEFAULT] section parameters from jail.local BantimeIncrement bool `json:"bantimeIncrement"` @@ -30,7 +29,7 @@ const settingsFile = "fail2ban-ui-settings.json" // in-memory copy of settings var ( - currentSettings UISettings + currentSettings AppSettings settingsLock sync.RWMutex ) @@ -55,9 +54,10 @@ func setDefaults() { settingsLock.Lock() defer settingsLock.Unlock() - currentSettings = UISettings{ + currentSettings = AppSettings{ Language: "en", ReloadNeeded: false, + AlertCountries: []string{"all"}, BantimeIncrement: true, IgnoreIP: "127.0.0.1/8 ::1 172.16.10.1/24", @@ -79,7 +79,7 @@ func loadSettings() error { return err } - var s UISettings + var s AppSettings if err := json.Unmarshal(data, &s); err != nil { return err } @@ -103,33 +103,54 @@ func saveSettings() error { } // GetSettings returns a copy of the current settings -func GetSettings() UISettings { +func GetSettings() AppSettings { settingsLock.RLock() defer settingsLock.RUnlock() return currentSettings } -// UpdateSettings modifies the in-memory settings, sets ReloadNeeded if required, then saves to disk. -// Optionally, we can detect changes that require a reload vs. changes that don't. -func UpdateSettings(new UISettings) (UISettings, error) { +// MarkReloadNeeded sets reloadNeeded = true and saves JSON +func MarkReloadNeeded() error { settingsLock.Lock() defer settingsLock.Unlock() - // If user changed certain fields that require a Fail2ban reload, set ReloadNeeded = true. - // For example, if any of these fields changed: - reloadNeededBefore := currentSettings.ReloadNeeded + currentSettings.ReloadNeeded = true + return saveSettings() +} - if currentSettings.BantimeIncrement != new.BantimeIncrement || - currentSettings.IgnoreIP != new.IgnoreIP || - currentSettings.Bantime != new.Bantime || - currentSettings.Findtime != new.Findtime || - currentSettings.Maxretry != new.Maxretry || - currentSettings.Destemail != new.Destemail || - currentSettings.Sender != new.Sender { +// MarkReloadDone sets reloadNeeded = false and saves JSON +func MarkReloadDone() error { + settingsLock.Lock() + defer settingsLock.Unlock() + + currentSettings.ReloadNeeded = false + return saveSettings() +} + +// UpdateSettings merges new settings with old and sets reloadNeeded if needed +func UpdateSettings(new AppSettings) (AppSettings, error) { + settingsLock.Lock() + defer settingsLock.Unlock() + + old := currentSettings + + // If certain fields change, we mark reload needed + if old.BantimeIncrement != new.BantimeIncrement || + old.IgnoreIP != new.IgnoreIP || + old.Bantime != new.Bantime || + old.Findtime != new.Findtime || + old.Maxretry != new.Maxretry || + old.Destemail != new.Destemail || + old.Sender != new.Sender { new.ReloadNeeded = true } else { // preserve previous ReloadNeeded if it was already true - new.ReloadNeeded = new.ReloadNeeded || reloadNeededBefore + new.ReloadNeeded = new.ReloadNeeded || old.ReloadNeeded + } + + // Countries change? Currently also requires a reload + if !equalStringSlices(old.AlertCountries, new.AlertCountries) { + new.ReloadNeeded = true } currentSettings = new @@ -141,10 +162,18 @@ func UpdateSettings(new UISettings) (UISettings, error) { return currentSettings, nil } -// MarkReloadDone sets ReloadNeeded = false after the user reloaded fail2ban -func MarkReloadDone() error { - settingsLock.Lock() - defer settingsLock.Unlock() - currentSettings.ReloadNeeded = false - return saveSettings() +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + m := make(map[string]bool) + for _, x := range a { + m[x] = false + } + for _, x := range b { + if _, ok := m[x]; !ok { + return false + } + } + return true } From c02423ac08a06516ef721b20340c8231a6c9a46c Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Sun, 26 Jan 2025 20:05:07 +0100 Subject: [PATCH 06/13] Adding first needed config update functions for later implementation --- pkg/web/handlers.go | 86 ++++++++++++++++++++++++++++++++++++--------- pkg/web/routes.go | 18 +++++----- 2 files changed, 78 insertions(+), 26 deletions(-) diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index b02ebab..1c8f737 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1,8 +1,10 @@ package web import ( + "fmt" "io/ioutil" "net/http" + "os" "strings" "time" @@ -106,6 +108,7 @@ func GetJailFilterConfigHandler(c *gin.Context) { func SetJailFilterConfigHandler(c *gin.Context) { jail := c.Param("jail") + // Parse JSON body (containing the new filter content) var req struct { Config string `json:"config"` } @@ -114,44 +117,57 @@ func SetJailFilterConfigHandler(c *gin.Context) { return } + // Write the filter config file to /etc/fail2ban/filter.d/.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 current fail2ban-ui settings +// GetSettingsHandler returns the entire AppSettings struct as JSON func GetSettingsHandler(c *gin.Context) { s := config.GetSettings() c.JSON(http.StatusOK, s) } -// UpdateSettingsHandler updates the fail2ban-ui settings +// UpdateSettingsHandler updates the AppSettings from a JSON body func UpdateSettingsHandler(c *gin.Context) { - // var req config.Settings - // if err := c.ShouldBindJSON(&req); err != nil { - // c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) - // return - // } + var req config.AppSettings + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) + return + } - // needsRestart, err := config.UpdateSettings(req) - // if err != nil { - // c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - // return - // } + newSettings, err := config.UpdateSettings(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusOK, gin.H{ - "message": "Settings updated", - // "needsRestart": needsRestart, + "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) { - dir := "/etc/fail2ban/filter.d" // adjust if needed + dir := "/etc/fail2ban/filter.d" files, err := ioutil.ReadDir(dir) if err != nil { @@ -186,12 +202,48 @@ func TestFilterHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"matches": []string{}}) } +// ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON +func ApplyFail2banSettings(jailLocalPath string) error { + 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) { - err := fail2ban.ReloadFail2ban() - if err != nil { + // 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"}) } diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 5ff0eb0..9878065 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -9,19 +9,19 @@ func RegisterRoutes(r *gin.Engine) { // Render the dashboard r.GET("/", IndexHandler) - api := r.Group("/api") - { - api.GET("/summary", SummaryHandler) - api.POST("/jails/:jail/unban/:ip", UnbanIPHandler) + api := r.Group("/api") + { + api.GET("/summary", SummaryHandler) + api.POST("/jails/:jail/unban/:ip", UnbanIPHandler) - // config endpoints - api.GET("/jails/:jail/config", GetJailFilterConfigHandler) - api.POST("/jails/:jail/config", SetJailFilterConfigHandler) + // config endpoints + api.GET("/jails/:jail/config", GetJailFilterConfigHandler) + api.POST("/jails/:jail/config", SetJailFilterConfigHandler) // settings api.GET("/settings", GetSettingsHandler) api.POST("/settings", UpdateSettingsHandler) - + // filter debugger api.GET("/filters", ListFiltersHandler) api.POST("/filters/test", TestFilterHandler) @@ -30,5 +30,5 @@ func RegisterRoutes(r *gin.Engine) { // Reload endpoint api.POST("/fail2ban/reload", ReloadFail2banHandler) - } + } } From 75a1ef9a249fa7d7448691fdd22585a85e5f828b Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Sun, 26 Jan 2025 20:11:06 +0100 Subject: [PATCH 07/13] restructure the main html-file for better understanding --- pkg/web/templates/index.html | 304 +++++++++++++++++++++-------------- 1 file changed, 186 insertions(+), 118 deletions(-) diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 12aeb12..e572ee5 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -1,7 +1,9 @@ + + Fail2ban UI Dashboard + - + + +
- + +
@@ -67,61 +73,67 @@
+ + + +

Dashboard

+ + + - - - +
@@ -134,6 +146,9 @@

+ + +
@@ -165,6 +180,7 @@
+ From c2e953a0242b3fdd0983805befa0ef0c76a025b0 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Mon, 27 Jan 2025 11:09:06 +0100 Subject: [PATCH 08/13] Implement basic settings save and load structures and enhance UI --- internal/config/settings.go | 25 +++++++- pkg/web/handlers.go | 40 ++++++++++--- pkg/web/templates/index.html | 110 ++++++++++++++++++++++++++++++----- 3 files changed, 149 insertions(+), 26 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index 5f91374..ca3fddc 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "fmt" + "log" "os" "sync" ) @@ -11,6 +12,7 @@ import ( // 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"` @@ -56,6 +58,7 @@ func setDefaults() { currentSettings = AppSettings{ Language: "en", + Debug: false, ReloadNeeded: false, AlertCountries: []string{"all"}, @@ -71,6 +74,8 @@ func setDefaults() { // loadSettings reads the file (if exists) into currentSettings func loadSettings() error { + fmt.Println("----------------------------") + fmt.Println("loadSettings called (settings.go)") // entry point data, err := os.ReadFile(settingsFile) if os.IsNotExist(err) { return err // triggers setDefaults + save @@ -92,14 +97,23 @@ func loadSettings() error { // saveSettings writes currentSettings to JSON func saveSettings() error { - settingsLock.RLock() - defer settingsLock.RUnlock() + fmt.Println("----------------------------") + fmt.Println("saveSettings called (settings.go)") // entry point b, err := json.MarshalIndent(currentSettings, "", " ") if err != nil { + fmt.Println("Error marshalling settings:", err) // Debug return err } - return os.WriteFile(settingsFile, b, 0644) + fmt.Println("Settings marshaled, writing to file...") // Log marshaling success + //return os.WriteFile(settingsFile, b, 0644) + err = os.WriteFile(settingsFile, b, 0644) + if err != nil { + log.Println("Error writing to file:", err) // Debug + } else { + log.Println("Settings saved successfully!") // Debug + } + return nil } // GetSettings returns a copy of the current settings @@ -132,6 +146,8 @@ func UpdateSettings(new AppSettings) (AppSettings, error) { settingsLock.Lock() defer settingsLock.Unlock() + fmt.Println("Locked settings for update") // Log lock acquisition + old := currentSettings // If certain fields change, we mark reload needed @@ -154,11 +170,14 @@ func UpdateSettings(new AppSettings) (AppSettings, error) { } currentSettings = new + fmt.Println("New settings applied:", currentSettings) // Log settings applied // persist to file if err := saveSettings(); err != nil { + fmt.Println("Error saving settings:", err) // Log save error return currentSettings, err } + fmt.Println("Settings saved to file successfully") // Log save success return currentSettings, nil } diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 1c8f737..cf6dcca 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -2,7 +2,6 @@ package web import ( "fmt" - "io/ioutil" "net/http" "os" "strings" @@ -58,6 +57,8 @@ func SummaryHandler(c *gin.Context) { // 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") @@ -68,6 +69,7 @@ func UnbanIPHandler(c *gin.Context) { }) return } + fmt.Println(ip + " from jail " + jail + " unbanned successfully (handlers.go)") c.JSON(http.StatusOK, gin.H{ "message": "IP unbanned successfully", }) @@ -92,6 +94,8 @@ func IndexHandler(c *gin.Context) { // 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 { @@ -106,6 +110,8 @@ func GetJailFilterConfigHandler(c *gin.Context) { // 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) @@ -140,23 +146,34 @@ func SetJailFilterConfigHandler(c *gin.Context) { // 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 { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) + 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", @@ -167,9 +184,11 @@ func UpdateSettingsHandler(c *gin.Context) { // 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 := ioutil.ReadDir(dir) + files, err := os.ReadDir(dir) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to read filter directory: " + err.Error(), @@ -189,6 +208,8 @@ func ListFiltersHandler(c *gin.Context) { } 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"` @@ -204,6 +225,8 @@ func TestFilterHandler(c *gin.Context) { // 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: @@ -228,6 +251,9 @@ func ApplyFail2banSettings(jailLocalPath string) error { // 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()}) @@ -241,9 +267,9 @@ func ReloadFail2banHandler(c *gin.Context) { } // We set reload done in config - //if err := config.MarkReloadDone(); err != nil { - // c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - // return - //} + 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"}) } diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index e572ee5..f0562f0 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -103,8 +103,12 @@ + + + +
+ Fail2Ban Configuration + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + @@ -464,10 +518,13 @@ function loadSettings() { fetch('/api/settings') .then(res => res.json()) .then(data => { - // populate language, email, etc... + // Get current general settings document.getElementById('languageSelect').value = data.language || 'en'; - document.getElementById('alertEmail').value = data.sender || ''; + document.getElementById('debugMode').checked = data.debug || false; + // Get current alert settings + document.getElementById('sourceEmail').value = data.sender || ''; + document.getElementById('destEmail').value = data.destemail || ''; // alertCountries multi const select = document.getElementById('alertCountries'); // clear selection @@ -486,6 +543,13 @@ function loadSettings() { } } } + + // Get current Fail2Ban Configuration + document.getElementById('bantimeIncrement').checked = data.bantimeIncrement || false; + document.getElementById('banTime').value = data.bantime || ''; + document.getElementById('findTime').value = data.findtime || ''; + document.getElementById('maxRetry').value = data.maxretry || ''; + document.getElementById('ignoreIP').value = data.ignoreip || ''; }) .catch(err => { alert('Error loading settings: ' + err); @@ -499,7 +563,9 @@ function saveSettings(e) { showLoading(true); const lang = document.getElementById('languageSelect').value; - const mail = document.getElementById('alertEmail').value; + const debugMode = document.getElementById("debugMode").checked; + const srcmail = document.getElementById('sourceEmail').value; + const destmail = document.getElementById('destEmail').value; const select = document.getElementById('alertCountries'); let chosenCountries = []; @@ -513,10 +579,23 @@ function saveSettings(e) { chosenCountries = ["all"]; } + 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 body = { language: lang, - sender: mail, - alertCountries: chosenCountries + debug: debugMode, + sender: srcmail, + destemail: destmail, + alertCountries: chosenCountries, + bantimeIncrement: bantimeinc, + bantime: bant, + findtime: findt, + maxretry: maxre, + ignoreip: ignip }; fetch('/api/settings', { @@ -527,7 +606,7 @@ function saveSettings(e) { .then(res => res.json()) .then(data => { if (data.error) { - alert('Error saving settings: ' + 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); @@ -642,7 +721,6 @@ function reloadFail2ban() { if (data.error) { alert("Error: " + data.error); } else { - alert(data.message || "Fail2ban reloaded"); // Hide reload banner document.getElementById('reloadBanner').style.display = 'none'; // Refresh data From 65612dad8abd77772b4c25ac17b815c291d8e07c Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Mon, 27 Jan 2025 11:35:21 +0100 Subject: [PATCH 09/13] Add basic IP-search --- pkg/web/templates/index.html | 45 ++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index f0562f0..3753765 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -329,13 +329,21 @@ function fetchSummary() { function renderDashboard(data) { var html = ""; + // Add a search bar + html += ` +
+ + +
+ `; + // Jails table if (!data.jails || data.jails.length === 0) { html += '

No jails found.

'; } else { html += '' + '

Overview

' - + '' + + '
' + ' ' + ' ' + ' ' @@ -349,7 +357,7 @@ function renderDashboard(data) { data.jails.forEach(function(jail) { var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs); html += '' - + '' + + '' + '
Jail Name
' + ' ' + jail.jailName @@ -417,6 +425,39 @@ function renderBannedIPs(jailName, ips) { return content; } +// Filter IPs on dashboard table +function filterIPs() { + const query = document.getElementById("ipSearch").value.toLowerCase(); // Get the search query + const rows = document.querySelectorAll("#jailsTable .jail-row"); // Get all jail rows + + rows.forEach((row) => { + const ipSpans = row.querySelectorAll("ul li span"); // Find all IP span elements in this row + let matchFound = false; // Reset match flag for the row + + ipSpans.forEach((span) => { + const originalText = span.textContent; // The full original text + const ipText = originalText.toLowerCase(); + + if (query && ipText.includes(query)) { + matchFound = true; // Match found in this row + + // Highlight the matching part + const highlightedText = originalText.replace( + new RegExp(query, "gi"), // Case-insensitive match + (match) => `${match}` // Wrap match in + ); + span.innerHTML = highlightedText; // Update span's HTML with highlighting + } else { + // Remove highlighting if no match or search is cleared + span.innerHTML = originalText; + } + }); + + // Show the row if a match is found or the query is empty + row.style.display = matchFound || !query ? "" : "none"; + }); +} + //******************************************************************* //* Functions to manage IP-bans : * //******************************************************************* From 879207a5f413f5b141cd3944ae9a1b8bb13848d2 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Mon, 27 Jan 2025 13:21:45 +0100 Subject: [PATCH 10/13] Add bootstrap tooltips and refactor some code formatings --- cmd/server/main.go | 2 +- internal/fail2ban/client.go | 50 +++++++++++++++++------------------ internal/fail2ban/logparse.go | 29 -------------------- pkg/web/templates/index.html | 11 +++++++- 4 files changed, 36 insertions(+), 56 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index a1bce65..7d470e4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,7 +16,7 @@ func main() { // Register our routes (IndexHandler, /api/summary, /api/jails/:jail/unban/:ip) web.RegisterRoutes(r) - log.Println("Starting Fail2ban UI on :8080. Run with 'sudo' if fail2ban-client requires it.") + log.Println("Starting Fail2ban-UI server on :8080.") if err := r.Run(":8080"); err != nil { log.Fatalf("Server crashed: %v", err) } diff --git a/internal/fail2ban/client.go b/internal/fail2ban/client.go index ef6cd18..81fea76 100644 --- a/internal/fail2ban/client.go +++ b/internal/fail2ban/client.go @@ -2,18 +2,18 @@ package fail2ban import ( "fmt" - "io/ioutil" - "os/exec" - "path/filepath" + "os" + "os/exec" + "path/filepath" "strings" "time" ) type JailInfo struct { - JailName string `json:"jailName"` - TotalBanned int `json:"totalBanned"` - NewInLastHour int `json:"newInLastHour"` - BannedIPs []string `json:"bannedIPs"` + JailName string `json:"jailName"` + TotalBanned int `json:"totalBanned"` + NewInLastHour int `json:"newInLastHour"` + BannedIPs []string `json:"bannedIPs"` } // GetJails returns all configured jails using "fail2ban-client status". @@ -128,29 +128,29 @@ func BuildJailInfos(logPath string) ([]JailInfo, error) { // Example: we assume each jail config is at /etc/fail2ban/filter.d/.conf // Adapt this to your environment. func GetJailConfig(jail string) (string, error) { - configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf") - content, err := ioutil.ReadFile(configPath) - if err != nil { - return "", fmt.Errorf("failed to read config for jail %s: %v", jail, err) - } - return string(content), nil + configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf") + content, err := os.ReadFile(configPath) + if err != nil { + return "", fmt.Errorf("failed to read config for jail %s: %v", jail, err) + } + return string(content), nil } // SetJailConfig overwrites the config file for a given jail with new content. func SetJailConfig(jail, newContent string) error { - configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf") - if err := ioutil.WriteFile(configPath, []byte(newContent), 0644); err != nil { - return fmt.Errorf("failed to write config for jail %s: %v", jail, err) - } - return nil + configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf") + if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write config for jail %s: %v", jail, err) + } + return nil } // ReloadFail2ban runs "fail2ban-client reload" func ReloadFail2ban() error { - cmd := exec.Command("fail2ban-client", "reload") - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("fail2ban reload error: %v\nOutput: %s", err, out) - } - return nil -} \ No newline at end of file + cmd := exec.Command("fail2ban-client", "reload") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("fail2ban reload error: %v\nOutput: %s", err, out) + } + return nil +} diff --git a/internal/fail2ban/logparse.go b/internal/fail2ban/logparse.go index caeed1d..66bc211 100644 --- a/internal/fail2ban/logparse.go +++ b/internal/fail2ban/logparse.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "regexp" - //"strings" "time" ) @@ -69,31 +68,3 @@ func ParseBanLog(logPath string) (map[string][]BanEvent, error) { } return eventsByJail, nil } - -// GetLastFiveBans crawls the parse results to find the last 5 ban events overall. -func GetLastFiveBans(eventsByJail map[string][]BanEvent) []BanEvent { - var allEvents []BanEvent - for _, events := range eventsByJail { - allEvents = append(allEvents, events...) - } - - // Sort by time descending - // (We want the latest 5 ban events) - sortByTimeDesc(allEvents) - - if len(allEvents) > 5 { - return allEvents[:5] - } - return allEvents -} - -// A simple in-file sorting utility -func sortByTimeDesc(events []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] - } - } - } -} diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 3753765..08df4e5 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -256,9 +256,18 @@ window.addEventListener('DOMContentLoaded', function() { checkReloadNeeded(); fetchSummary().then(function() { showLoading(false); + initializeTooltips(); // Initialize tooltips after fetching and rendering }); }); +// Function to initialize Bootstrap tooltips +function initializeTooltips() { + const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + tooltipTriggerList.forEach(function (tooltipTriggerEl) { + new bootstrap.Tooltip(tooltipTriggerEl); + }); +} + // Toggle the loading overlay (with !important) function showLoading(show) { var overlay = document.getElementById('loading-overlay'); @@ -342,7 +351,7 @@ function renderDashboard(data) { html += '

No jails found.

'; } else { html += '' - + '

Overview

' + + '

Overview

' + '' + ' ' + ' ' From 7910a22de4074d5e0fe3b134661e43b3e22c92ac Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Tue, 28 Jan 2025 14:49:01 +0100 Subject: [PATCH 11/13] add custom fail2ban action for our filtered geo-mail alerts --- internal/config/settings.go | 279 +++++++++++++++++++++++++++++++++--- 1 file changed, 258 insertions(+), 21 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index ca3fddc..088280e 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1,10 +1,14 @@ package config import ( + "bufio" "encoding/json" "fmt" "log" "os" + "regexp" + "strconv" + "strings" "sync" ) @@ -26,8 +30,13 @@ type AppSettings struct { Sender string `json:"sender"` } -// path to the JSON file (relative to where the app is started) -const settingsFile = "fail2ban-ui-settings.json" +// init paths to key-files +const ( + settingsFile = "fail2ban-ui-settings.json" // this is relative to where the app was started + jailFile = "/etc/fail2ban/jail.local" // Path to jail.local (to override conf-values from jail.conf) + jailDFile = "/etc/fail2ban/jail.d/ui-custom-action.conf" + actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf" +) // in-memory copy of settings var ( @@ -38,10 +47,10 @@ var ( func init() { // Attempt to load existing file; if it doesn't exist, create with defaults. if err := loadSettings(); err != nil { - fmt.Println("Error loading settings:", err) - fmt.Println("Creating a new settings file with defaults...") - - // set defaults + fmt.Println("App settings not found, initializing new from jail.local (if exist):", err) + if err := initializeFromJailFile(); err != nil { + fmt.Println("Error reading jail.local:", err) + } setDefaults() // save defaults to file @@ -49,6 +58,9 @@ func init() { fmt.Println("Failed to save default settings:", err) } } + if err := initializeFail2banAction(); err != nil { + fmt.Println("Error initializing Fail2ban action:", err) + } } // setDefaults populates default values in currentSettings @@ -56,23 +68,247 @@ func setDefaults() { settingsLock.Lock() defer settingsLock.Unlock() - currentSettings = AppSettings{ - Language: "en", - Debug: false, - ReloadNeeded: false, - AlertCountries: []string{"all"}, - - BantimeIncrement: true, - IgnoreIP: "127.0.0.1/8 ::1 172.16.10.1/24", - Bantime: "48h", - Findtime: "30m", - Maxretry: 3, - Destemail: "admin@swissmakers.ch", - Sender: "noreply@swissmakers.ch", + if currentSettings.Language == "" { + currentSettings.Language = "en" + } + if currentSettings.AlertCountries == nil { + currentSettings.AlertCountries = []string{"all"} + } + if currentSettings.Bantime == "" { + currentSettings.Bantime = "48h" + } + if currentSettings.Findtime == "" { + currentSettings.Findtime = "30m" + } + if currentSettings.Maxretry == 0 { + currentSettings.Maxretry = 3 + } + if currentSettings.Destemail == "" { + currentSettings.Destemail = "alerts@swissmakers.ch" + } + if currentSettings.Sender == "" { + currentSettings.Sender = "noreply@swissmakers.ch" + } + if currentSettings.IgnoreIP == "" { + currentSettings.IgnoreIP = "127.0.0.1/8 ::1" } } -// loadSettings reads the file (if exists) into currentSettings +// initializeFromJailFile reads Fail2ban jail.local and merges its settings into currentSettings. +func initializeFromJailFile() error { + file, err := os.Open(jailFile) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + re := regexp.MustCompile(`^\s*(?P[a-zA-Z0-9_]+)\s*=\s*(?P.+)$`) + + settings := map[string]string{} + for scanner.Scan() { + line := scanner.Text() + if matches := re.FindStringSubmatch(line); matches != nil { + key := strings.ToLower(matches[1]) + value := matches[2] + settings[key] = value + } + } + + settingsLock.Lock() + defer settingsLock.Unlock() + + if val, ok := settings["bantime"]; ok { + currentSettings.Bantime = val + } + if val, ok := settings["findtime"]; ok { + currentSettings.Findtime = val + } + if val, ok := settings["maxretry"]; ok { + if maxRetry, err := strconv.Atoi(val); err == nil { + currentSettings.Maxretry = maxRetry + } + } + if val, ok := settings["ignoreip"]; ok { + currentSettings.IgnoreIP = val + } + if val, ok := settings["destemail"]; ok { + currentSettings.Destemail = val + } + if val, ok := settings["sender"]; ok { + currentSettings.Sender = val + } + + return nil +} + +// initializeFail2banAction writes a custom action configuration for Fail2ban to use AlertCountries. +func initializeFail2banAction() error { + // Ensure the jail.local is configured correctly + if err := setupGeoCustomAction(); err != nil { + fmt.Println("Error setup GeoCustomAction in jail.local:", err) + } + // Ensure the jail.d config file is set up + if err := ensureJailDConfig(); err != nil { + fmt.Println("Error setting up jail.d configuration:", err) + } + // Write the fail2ban action file + return writeFail2banAction(currentSettings.AlertCountries) +} + +// setupGeoCustomAction checks and replaces the default action in jail.local with our from fail2ban-UI +func setupGeoCustomAction() error { + file, err := os.Open(jailFile) + if err != nil { + return err // File not found or inaccessible + } + defer file.Close() + + var lines []string + actionPattern := regexp.MustCompile(`^\s*action\s*=\s*%(.*?)\s*$`) + alreadyModified := false + actionFound := false + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + + // Check if we already modified the file (prevent duplicate modifications) + if strings.Contains(line, "# Custom Fail2Ban action applied") { + alreadyModified = true + } + + // Look for an existing action definition + if actionPattern.MatchString(line) && !alreadyModified { + actionFound = true + + // Comment out the existing action line + lines = append(lines, "# "+line) + + // Add our replacement action with a comment marker + lines = append(lines, "# Custom Fail2Ban action applied by fail2ban-ui") + lines = append(lines, "action = %(action_mwlg)s") + continue + } + + // Store the original line + lines = append(lines, line) + } + + // If no action was found, no need to modify the file + if !actionFound || alreadyModified { + return nil + } + + // Write back the modified lines + output := strings.Join(lines, "\n") + return os.WriteFile(jailFile, []byte(output), 0644) +} + +// ensureJailDConfig checks if the jail.d file exists and creates it if necessary +func ensureJailDConfig() error { + // Check if the file already exists + if _, err := os.Stat(jailDFile); err == nil { + // File already exists, do nothing + fmt.Println("Custom jail.d configuration already exists.") + return nil + } + + // Define the content for the custom jail.d configuration + jailDConfig := `[DEFAULT] +# Custom Fail2Ban action using geo-filter for email alerts + +action_mwlg = %(action_)s + ui-custom-action[sender="%(sender)s", dest="%(destemail)s", logpath="%(logpath)s", chain="%(chain)s"] +` + // Write the new configuration file + err := os.WriteFile(jailDFile, []byte(jailDConfig), 0644) + if err != nil { + return fmt.Errorf("failed to write jail.d config: %v", err) + } + + fmt.Println("Created custom jail.d configuration at:", jailDFile) + return nil +} + +// writeFail2banAction creates or updates the action file with the AlertCountries. +func writeFail2banAction(alertCountries []string) error { + // If "all" is included in AlertCountries, allow all countries + if len(alertCountries) == 1 && strings.ToLower(alertCountries[0]) == "all" { + alertCountries = []string{"CH DE IT FR UK US"} // Match everything + } + + // Convert country list into properly formatted Python set syntax + //countries := strings.Join(alertCountries, "','") + //countriesFormatted := fmt.Sprintf("'%s'", countries) + + // Convert country list into properly formatted Bash syntax + countries := strings.Join(alertCountries, "' '") + countriesFormatted := fmt.Sprintf("' %s '", countries) + + //actionConfig := `[Definition] + //actionstart = + //actionban = python3 -c ' + //import sys + //from geoip import geolite2 + //country = geolite2.lookup(sys.argv[1]).country + //if country in {{ALERT_COUNTRIES}}: + // sys.exit(0) # Send alert + //sys.exit(1) # Do not send alert' + + // Define the Fail2Ban action file content + actionConfig := fmt.Sprintf(`[INCLUDES] + +before = sendmail-common.conf + mail-whois-common.conf + helpers-common.conf + +[Definition] + +# bypass ban/unban for restored tickets +norestored = 1 + +# Option: actionban +# Notes.: command executed when banning an IP. Take care that the +# command is executed with Fail2Ban user rights. + +actionban = bash -c ' + COUNTRY="" + if [[ " %s " =~ " $COUNTRY " ]]; then + ( printf %%%%b "Subject: [Fail2Ban] : banned from \n" + printf "Date: `+"`LC_ALL=C date +\"%%%%a, %%%%d %%%%h %%%%Y %%%%T %%%%z\"`"+`\n" + printf "From: <>\n" + printf "To: \n\n" + printf "Hi,\n" + printf "The IP has just been banned by Fail2Ban after attempts against .\n\n" + printf "Here is more information about :\n" + printf "%%%%(_whois_command)s\n" + printf "\nLines containing failures of (max )\n" + printf "%%%%(_grep_logs)s\n" + printf "\n\nRegards,\nFail2Ban\n" + ) | + fi' + +[Init] + +# Default name of the chain +# +name = default + +# Path to the log files which contain relevant lines for the abuser IP +# +logpath = /dev/null + +# Number of log lines to include in the email +# +#grepmax = 1000 +#grepopts = -m +`, countriesFormatted) + + return os.WriteFile(actionFile, []byte(actionConfig), 0644) +} + +// loadSettings reads fail2ban-ui-settings.json into currentSettings. func loadSettings() error { fmt.Println("----------------------------") fmt.Println("loadSettings called (settings.go)") // entry point @@ -113,7 +349,8 @@ func saveSettings() error { } else { log.Println("Settings saved successfully!") // Debug } - return nil + // Update the Fail2ban action file + return writeFail2banAction(currentSettings.AlertCountries) } // GetSettings returns a copy of the current settings From 80a0772050ca80e9b40b80d048d1998e6e07df27 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Tue, 28 Jan 2025 18:07:20 +0100 Subject: [PATCH 12/13] Fix generation of geoIP filtering action --- internal/config/settings.go | 81 +++++++++++++++---------------------- 1 file changed, 32 insertions(+), 49 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index 088280e..e4350ff 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -233,28 +233,9 @@ action_mwlg = %(action_)s // writeFail2banAction creates or updates the action file with the AlertCountries. func writeFail2banAction(alertCountries []string) error { - // If "all" is included in AlertCountries, allow all countries - if len(alertCountries) == 1 && strings.ToLower(alertCountries[0]) == "all" { - alertCountries = []string{"CH DE IT FR UK US"} // Match everything - } - // Convert country list into properly formatted Python set syntax - //countries := strings.Join(alertCountries, "','") - //countriesFormatted := fmt.Sprintf("'%s'", countries) - - // Convert country list into properly formatted Bash syntax - countries := strings.Join(alertCountries, "' '") - countriesFormatted := fmt.Sprintf("' %s '", countries) - - //actionConfig := `[Definition] - //actionstart = - //actionban = python3 -c ' - //import sys - //from geoip import geolite2 - //country = geolite2.lookup(sys.argv[1]).country - //if country in {{ALERT_COUNTRIES}}: - // sys.exit(0) # Send alert - //sys.exit(1) # Do not send alert' + // Join the alertCountries into a comma-separated string + countriesFormatted := strings.Join(alertCountries, ",") // Define the Fail2Ban action file content actionConfig := fmt.Sprintf(`[INCLUDES] @@ -265,47 +246,49 @@ before = sendmail-common.conf [Definition] -# bypass ban/unban for restored tickets +# Bypass ban/unban for restored tickets norestored = 1 -# Option: actionban -# Notes.: command executed when banning an IP. Take care that the -# command is executed with Fail2Ban user rights. - -actionban = bash -c ' - COUNTRY="" - if [[ " %s " =~ " $COUNTRY " ]]; then - ( printf %%%%b "Subject: [Fail2Ban] : banned from \n" - printf "Date: `+"`LC_ALL=C date +\"%%%%a, %%%%d %%%%h %%%%Y %%%%T %%%%z\"`"+`\n" - printf "From: <>\n" - printf "To: \n\n" - printf "Hi,\n" - printf "The IP has just been banned by Fail2Ban after attempts against .\n\n" - printf "Here is more information about :\n" - printf "%%%%(_whois_command)s\n" - printf "\nLines containing failures of (max )\n" - printf "%%%%(_grep_logs)s\n" - printf "\n\nRegards,\nFail2Ban\n" - ) | - fi' +# 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/scripts/check_geoip.py "%s" && ( + printf %%%%b "Subject: [Fail2Ban] : banned from + Date: `+"`LC_ALL=C date +\"%%%%a, %%%%d %%%%h %%%%Y %%%%T %%%%z\"`"+` + From: <> + To: \n + Hi,\n + The IP has just been banned by Fail2Ban after attempts against .\n\n + Here is more information about :\n" + %%(_whois_command)s; + printf %%%%b "\nLines containing failures of (max )\n"; + %%(_grep_logs)s; + printf %%%%b "\n + Regards,\n + Fail2Ban" ) | [Init] # Default name of the chain -# name = default -# Path to the log files which contain relevant lines for the abuser IP -# +# Path to log files containing relevant lines for the abuser IP logpath = /dev/null # Number of log lines to include in the email -# -#grepmax = 1000 -#grepopts = -m +# grepmax = 1000 +# grepopts = -m `, countriesFormatted) - return os.WriteFile(actionFile, []byte(actionConfig), 0644) + // Write the action file + //actionFilePath := "/etc/fail2ban/action.d/ui-custom-action.conf" + err := os.WriteFile(actionFile, []byte(actionConfig), 0644) + if err != nil { + return fmt.Errorf("failed to write action file: %w", err) + } + + fmt.Printf("Action file successfully written to %s\n", actionFile) + return nil } // loadSettings reads fail2ban-ui-settings.json into currentSettings. From 689f3fadd893afacad1dd217ea5cf802c556f2cd Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Wed, 29 Jan 2025 19:49:51 +0100 Subject: [PATCH 13/13] Update file-headers and add a testing geoip_notify script --- README.md | 3 +- internal/config/settings.go | 32 +++++---- internal/fail2ban/client.go | 18 ++++- internal/fail2ban/logparse.go | 16 +++++ internal/geoip_notify.py | 132 ++++++++++++++++++++++++++++++++++ pkg/web/handlers.go | 16 +++++ pkg/web/routes.go | 16 +++++ pkg/web/templates/index.html | 17 +++++ 8 files changed, 233 insertions(+), 17 deletions(-) create mode 100644 internal/geoip_notify.py diff --git a/README.md b/README.md index 5890f6a..328cd55 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fail2ban UI -A **Go**-powered, **single-page** web interface for [Fail2ban](https://www.fail2ban.org/). +A Swissmade, management interface for [Fail2ban](https://www.fail2ban.org/). It provides a modern dashboard to currently: - View all Fail2ban jails and banned IPs @@ -8,6 +8,7 @@ It provides a modern dashboard to currently: - Edit and save jail/filter configs - Reload Fail2ban when needed - See recent ban events +- More to come... Built by [Swissmakers GmbH](https://swissmakers.ch). diff --git a/internal/config/settings.go b/internal/config/settings.go index e4350ff..30bf18c 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1,3 +1,19 @@ +// 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 config import ( @@ -252,20 +268,7 @@ 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/scripts/check_geoip.py "%s" && ( - printf %%%%b "Subject: [Fail2Ban] : banned from - Date: `+"`LC_ALL=C date +\"%%%%a, %%%%d %%%%h %%%%Y %%%%T %%%%z\"`"+` - From: <> - To: \n - Hi,\n - The IP has just been banned by Fail2Ban after attempts against .\n\n - Here is more information about :\n" - %%(_whois_command)s; - printf %%%%b "\nLines containing failures of (max )\n"; - %%(_grep_logs)s; - printf %%%%b "\n - Regards,\n - Fail2Ban" ) | +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=' [Init] @@ -281,7 +284,6 @@ logpath = /dev/null `, countriesFormatted) // Write the action file - //actionFilePath := "/etc/fail2ban/action.d/ui-custom-action.conf" err := os.WriteFile(actionFile, []byte(actionConfig), 0644) if err != nil { return fmt.Errorf("failed to write action file: %w", err) diff --git a/internal/fail2ban/client.go b/internal/fail2ban/client.go index 81fea76..654aa8b 100644 --- a/internal/fail2ban/client.go +++ b/internal/fail2ban/client.go @@ -1,3 +1,19 @@ +// 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 fail2ban import ( @@ -21,7 +37,7 @@ func GetJails() ([]string, error) { cmd := exec.Command("fail2ban-client", "status") out, err := cmd.CombinedOutput() if err != nil { - return nil, fmt.Errorf("could not run 'fail2ban-client status': %v", err) + return nil, fmt.Errorf("could not get jail information. is fail2ban running? error: %v", err) } var jails []string diff --git a/internal/fail2ban/logparse.go b/internal/fail2ban/logparse.go index 66bc211..e77913b 100644 --- a/internal/fail2ban/logparse.go +++ b/internal/fail2ban/logparse.go @@ -1,3 +1,19 @@ +// 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 fail2ban import ( diff --git a/internal/geoip_notify.py b/internal/geoip_notify.py new file mode 100644 index 0000000..d9cf4d5 --- /dev/null +++ b/internal/geoip_notify.py @@ -0,0 +1,132 @@ +#!/usr/bin/python3.9 +""" +Fail2ban UI - A Swiss made, management interface for Fail2ban. + +Copyright (C) 2025 Swissmakers GmbH + +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. +""" + +# This file is for testing purposes only. +# (Must be copied to "/etc/fail2ban/action.d/geoip_notify.py") +#python3.9 -c "import maxminddb; print('maxminddb is installed successfully')" + +import sys +import subprocess + +# Manually set Python path where maxminddb is installed +sys.path.append("/usr/local/lib64/python3.9/site-packages/") + +try: + import maxminddb +except ImportError: + print("Error: maxminddb module not found, even after modifying PYTHONPATH.") + sys.exit(1) + + +# Path to MaxMind GeoIP2 database +GEOIP_DB_PATH = "/usr/share/GeoIP/GeoLite2-Country.mmdb" + +def get_country(ip): + """ + Perform a GeoIP lookup to get the country code from an IP address. + Returns the country code (e.g., "CH") or None if lookup fails. + """ + try: + with maxminddb.open_database(GEOIP_DB_PATH) as reader: + geo_data = reader.get(ip) + if geo_data and "country" in geo_data and "iso_code" in geo_data["country"]: + return geo_data["country"]["iso_code"] + except Exception as e: + print(f"GeoIP lookup failed: {e}", file=sys.stderr) + return None + +def parse_placeholders(placeholder_str): + """ + Parses Fail2Ban placeholders passed as a string in "key=value" format. + Returns a dictionary. + """ + placeholders = {} + for item in placeholder_str.split(";"): + key_value = item.split("=", 1) + if len(key_value) == 2: + key, value = key_value + placeholders[key.strip()] = value.strip() + return placeholders + +def send_email(placeholders): + """ + Generates and sends the email alert using sendmail. + """ + email_content = f"""Subject: [Fail2Ban] {placeholders['name']}: banned {placeholders['ip']} from {placeholders['fq-hostname']} +Date: $(LC_ALL=C date +"%a, %d %h %Y %T %z") +From: {placeholders['sendername']} <{placeholders['sender']}> +To: {placeholders['dest']} + +Hi, + +The IP {placeholders['ip']} has just been banned by Fail2Ban after {placeholders['failures']} attempts against {placeholders['name']}. + +Here is more information about {placeholders['ip']}: +{subprocess.getoutput(placeholders['_whois_command'])} + +Lines containing failures of {placeholders['ip']} (max {placeholders['grepmax']}): +{subprocess.getoutput(placeholders['_grep_logs'])} + +Regards, +Fail2Ban""" + + try: + subprocess.run( + ["/usr/sbin/sendmail", "-f", placeholders["sender"], placeholders["dest"]], + input=email_content, + text=True, + check=True + ) + print("Email sent successfully.") + except subprocess.CalledProcessError as e: + print(f"Failed to send email: {e}", file=sys.stderr) + +def main(ip, allowed_countries, placeholder_str): + """ + Main function to check the IP's country and send an email if it matches the allowed list. + """ + allowed_countries = allowed_countries.split(",") + placeholders = parse_placeholders(placeholder_str) + + # Perform GeoIP lookup + country = get_country(ip) + if not country: + print(f"Could not determine country for IP {ip}", file=sys.stderr) + sys.exit(1) + + print(f"IP {ip} belongs to country: {country}") + + # If the country is in the allowed list or "ALL" is selected, send the email + if "ALL" in allowed_countries or country in allowed_countries: + print(f"IP {ip} is in the alert countries list. Sending email...") + send_email(placeholders) + else: + print(f"IP {ip} is NOT in the alert countries list. No email sent.") + sys.exit(0) # Exit normally without error + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: geoip_notify.py ", file=sys.stderr) + sys.exit(1) + + ip = sys.argv[1] + allowed_countries = sys.argv[2] + placeholders = sys.argv[3] + + main(ip, allowed_countries, placeholders) diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index cf6dcca..c4eb0fb 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1,3 +1,19 @@ +// 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 ( diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 9878065..674d693 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -1,3 +1,19 @@ +// 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 ( diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 08df4e5..63ea496 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -1,3 +1,20 @@ +