Merge pull request #1 from swissmakers/dev

Merge DEV into Main: First feature Enhancements and Interface Updates
This commit is contained in:
Swissmakers GmbH
2025-01-29 19:57:47 +01:00
committed by GitHub
10 changed files with 1331 additions and 128 deletions

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ go.work.sum
# env file
.env
# Project specific
fail2ban-ui-settings.json

View File

@@ -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).

View File

@@ -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)
}

420
internal/config/settings.go Normal file
View File

@@ -0,0 +1,420 @@
// 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 (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
"regexp"
"strconv"
"strings"
"sync"
)
// AppSettings holds both the UI settings (like language) and
// relevant Fail2ban jail/local config options.
type AppSettings struct {
Language string `json:"language"`
Debug bool `json:"debug"`
ReloadNeeded bool `json:"reloadNeeded"`
AlertCountries []string `json:"alertCountries"`
// 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"`
}
// 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 (
currentSettings AppSettings
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("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
if err := saveSettings(); err != nil {
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
func setDefaults() {
settingsLock.Lock()
defer settingsLock.Unlock()
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"
}
}
// 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<key>[a-zA-Z0-9_]+)\s*=\s*(?P<value>.+)$`)
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 {
// Join the alertCountries into a comma-separated string
countriesFormatted := strings.Join(alertCountries, ",")
// 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
# This executes the Python script with <ip> and the list of allowed countries.
# If the country matches the allowed list, it sends the email.
actionban = /etc/fail2ban/action.d/geoip_notify.py <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>'
[Init]
# Default name of the chain
name = default
# 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>
`, countriesFormatted)
// Write the action file
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.
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
}
if err != nil {
return err
}
var s AppSettings
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 {
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
}
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
}
// Update the Fail2ban action file
return writeFail2banAction(currentSettings.AlertCountries)
}
// GetSettings returns a copy of the current settings
func GetSettings() AppSettings {
settingsLock.RLock()
defer settingsLock.RUnlock()
return currentSettings
}
// MarkReloadNeeded sets reloadNeeded = true and saves JSON
func MarkReloadNeeded() error {
settingsLock.Lock()
defer settingsLock.Unlock()
currentSettings.ReloadNeeded = true
return saveSettings()
}
// 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()
fmt.Println("Locked settings for update") // Log lock acquisition
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 || old.ReloadNeeded
}
// Countries change? Currently also requires a reload
if !equalStringSlices(old.AlertCountries, new.AlertCountries) {
new.ReloadNeeded = true
}
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
}
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
}

View File

@@ -1,19 +1,35 @@
// 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 (
"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".
@@ -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
@@ -128,29 +144,29 @@ func BuildJailInfos(logPath string) ([]JailInfo, error) {
// Example: we assume each jail config is at /etc/fail2ban/filter.d/<jail>.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
}
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
}

View File

@@ -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 (
@@ -5,7 +21,6 @@ import (
"fmt"
"os"
"regexp"
//"strings"
"time"
)
@@ -69,31 +84,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]
}
}
}
}

132
internal/geoip_notify.py Normal file
View File

@@ -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 <ip> <allowed_countries> <placeholders>", file=sys.stderr)
sys.exit(1)
ip = sys.argv[1]
allowed_countries = sys.argv[2]
placeholders = sys.argv[3]
main(ip, allowed_countries, placeholders)

View File

@@ -1,17 +1,37 @@
// 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 (
"fmt"
"net/http"
"os"
"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
@@ -53,6 +73,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")
@@ -63,6 +85,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",
})
@@ -78,53 +101,191 @@ 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) {
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,
})
}
// 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) {
fmt.Println("----------------------------")
fmt.Println("SetJailFilterConfigHandler called (handlers.go)") // entry point
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
}
// 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
}
if err := fail2ban.SetJailConfig(jail, req.Config); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
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
}
c.JSON(http.StatusOK, gin.H{"message": "jail config updated"})
// 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) {
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"})
}
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"})
}

View File

@@ -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 (
@@ -9,16 +25,26 @@ 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)
// 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)
}
}

View File

@@ -1,7 +1,26 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Fail2ban UI Dashboard</title>
<!-- Bootstrap 5 (CDN) -->
<link
@@ -30,15 +49,38 @@
}
</style>
</head>
<body class="bg-light">
<!-- NavBar -->
<!-- ******************************************************************* -->
<!-- NAVIGATION : -->
<!-- ******************************************************************* -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="#">
<strong>Fail2ban UI</strong>
</a>
</div>
<div class="container-fluid">
<a class="navbar-brand" href="#">
<strong>Fail2ban UI</strong>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="#" onclick="showSection('dashboardSection')">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="showSection('filterSection')">Filter Debug</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="showSection('settingsSection')">Settings</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- ******************************************************************* -->
<!-- Reload Banner -->
<div id="reloadBanner" class="bg-warning text-dark p-3 text-center">
@@ -48,11 +90,122 @@
</button>
</div>
<div class="container my-4">
<!-- ******************************************************************* -->
<!-- APP Sections (Pages) : -->
<!-- ******************************************************************* -->
<!-- Dashboard Section -->
<div id="dashboardSection" class="container my-4">
<h1 class="mb-4">Dashboard</h1>
<div id="dashboard"></div>
</div>
<!-- Filter Debug Section -->
<div id="filterSection" style="display: none;" class="container my-4">
<h2>Filter Debug</h2>
<!-- Dropdown of available jail/filters -->
<div class="mb-3">
<label for="filterSelect" class="form-label">Select a Filter</label>
<select id="filterSelect" class="form-select"></select>
</div>
<!-- Textarea for log lines to test -->
<div class="mb-3">
<label class="form-label">Log Lines</label>
<textarea id="logLinesTextarea" class="form-control" rows="6" disabled></textarea>
</div>
<button class="btn btn-secondary" onclick="testSelectedFilter()">Test Filter</button>
<hr/>
<div id="testResults"></div>
</div>
<!-- Settings Section -->
<div id="settingsSection" style="display: none;" class="container my-4">
<h2>Settings</h2>
<form onsubmit="saveSettings(event)">
<!-- General Settings Group -->
<fieldset class="border p-3 rounded mb-4">
<legend class="w-auto px-2">General Settings</legend>
<!-- Language Selection -->
<div class="mb-3">
<label for="languageSelect" class="form-label">Language</label>
<select id="languageSelect" class="form-select" disabled>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</div>
<!-- Debug Log Output -->
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="debugMode">
<label for="debugMode" class="form-check-label">Enable Debug Log</label>
</div>
</fieldset>
<!-- Alert Settings Group -->
<fieldset class="border p-3 rounded mb-4">
<legend class="w-auto px-2">Alert Settings</legend>
<!-- Source Email -->
<div class="mb-3">
<label for="sourceEmail" class="form-label">Source Email - Emails are sent from this address.</label>
<input type="email" class="form-control" id="sourceEmail"/>
</div>
<!-- Destination Email -->
<div class="mb-3">
<label for="destEmail" class="form-label">Destination Email - Where to sent the alert messages?</label>
<input type="email" class="form-control" id="destEmail" placeholder="e.g., alerts@swissmakers.ch" />
</div>
<!-- Alert Countries -->
<div class="mb-3">
<label for="alertCountries" class="form-label">Select alert Countries</label>
<p class="text-muted">Choose which country IP blocks should trigger an email. You can select multiple with CTRL.</p>
<select id="alertCountries" class="form-select" multiple size="7">
<option value="ALL">ALL (Every Country)</option>
<option value="CH">Switzerland (CH)</option>
<option value="DE">Germany (DE)</option>
<option value="IT">Italy (IT)</option>
<option value="FR">France (FR)</option>
<option value="UK">England (UK)</option>
<option value="US">United States (US)</option>
<!-- Maybe i will add more later.. -->
</select>
</div>
</fieldset>
<!-- Fail2Ban Configuration Group -->
<fieldset class="border p-3 rounded mb-4">
<legend class="w-auto px-2">Fail2Ban Configuration</legend>
<!-- Bantime Increment -->
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="bantimeIncrement" />
<label for="bantimeIncrement" class="form-check-label">Enable Bantime Increment</label>
</div>
<!-- Bantime -->
<div class="mb-3">
<label for="banTime" class="form-label">Default Bantime</label>
<input type="text" class="form-control" id="banTime" placeholder="e.g., 48h" />
</div>
<!-- Findtime -->
<div class="mb-3">
<label for="findTime" class="form-label">Default Findtime</label>
<input type="text" class="form-control" id="findTime" placeholder="e.g., 30m" />
</div>
<!-- Max Retry -->
<div class="mb-3">
<label for="maxRetry" class="form-label">Default Max Retry</label>
<input type="number" class="form-control" id="maxRetry" placeholder="Enter maximum retries" />
</div>
<!-- Ignore IPs -->
<div class="mb-3">
<label for="ignoreIP" class="form-label">Ignore IPs</label>
<textarea class="form-control" id="ignoreIP" rows="2" placeholder="Enter IPs to ignore, separated by spaces"></textarea>
</div>
</fieldset>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
<!-- ******************************************************************* -->
<!-- Footer -->
<footer class="text-center mt-4 mb-4">
<p class="mb-0">
@@ -64,6 +217,9 @@
</p>
</footer>
<!-- ******************************************************************* -->
<!-- APP Components (HTML) : -->
<!-- ******************************************************************* -->
<!-- Loading Overlay -->
<div id="loading-overlay" class="d-flex">
<div class="spinner-border text-light" role="status">
@@ -95,6 +251,7 @@
</div>
</div>
</div>
<!-- ******************************************************************* -->
<!-- Bootstrap 5 JS (for modal, etc.) -->
<script
@@ -102,11 +259,31 @@
</script>
<script>
// For information: We avoid ES6 backticks in our JS, to prevent confusion with the Go template parser.
"use strict";
// We avoid ES6 backticks here to prevent confusion with the Go template parser.
//*******************************************************************
//* Init page and main-components : *
//*******************************************************************
// Init and run first function, when DOM is ready
var currentJailForConfig = null;
window.addEventListener('DOMContentLoaded', function() {
showLoading(true);
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) {
@@ -118,12 +295,43 @@ function showLoading(show) {
}
}
window.addEventListener('DOMContentLoaded', function() {
showLoading(true);
fetchSummary().then(function() {
showLoading(false);
});
});
// Check if there is still a reload of the fail2ban service needed
function checkReloadNeeded() {
fetch('/api/settings')
.then(res => res.json())
.then(data => {
if (data.reloadNeeded) {
document.getElementById('reloadBanner').style.display = 'block';
} else {
document.getElementById('reloadBanner').style.display = 'none';
}
})
.catch(err => console.error('Error checking reloadNeeded:', err));
}
// Load dynamically the other pages when navigating in nav
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();
}
}
//*******************************************************************
//* Fetch data and render dashboard : *
//*******************************************************************
// Fetch summary (jails, stats, last 5 bans)
function fetchSummary() {
@@ -147,18 +355,26 @@ function fetchSummary() {
function renderDashboard(data) {
var html = "";
// Add a search bar
html += `
<div class="mb-3">
<label for="ipSearch" class="form-label">Search Banned IPs</label>
<input type="text" id="ipSearch" class="form-control" placeholder="Enter IP address to search" onkeyup="filterIPs()">
</div>
`;
// Jails table
if (!data.jails || data.jails.length === 0) {
html += '<p>No jails found.</p>';
} else {
html += ''
+ '<h2>Overview</h2>'
+ '<table class="table table-striped">'
+ '<h2><span data-bs-toggle="tooltip" data-bs-placement="top" title="The Overview displays the currently enabled jails that you have added to your jail.local configuration.">Overview</span></h2>'
+ '<table class="table table-striped" id="jailsTable">'
+ ' <thead>'
+ ' <tr>'
+ ' <th>Jail Name</th>'
+ ' <th>Total Banned</th>'
+ ' <th>New in Last Hour</th>'
+ ' <th>New Last Hour</th>'
+ ' <th>Banned IPs (Unban)</th>'
+ ' </tr>'
+ ' </thead>'
@@ -167,7 +383,7 @@ function renderDashboard(data) {
data.jails.forEach(function(jail) {
var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs);
html += ''
+ '<tr>'
+ '<tr class="jail-row">'
+ ' <td>'
+ ' <a href="#" onclick="openJailConfigModal(\'' + jail.jailName + '\')">'
+ jail.jailName
@@ -235,6 +451,43 @@ 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) => `<mark>${match}</mark>` // Wrap match in <mark>
);
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 : *
//*******************************************************************
// Unban IP
function unbanIP(jail, ip) {
if (!confirm("Unban IP " + ip + " from jail " + jail + "?")) {
@@ -247,7 +500,7 @@ function unbanIP(jail, ip) {
if (data.error) {
alert("Error: " + data.error);
} else {
alert(data.message || "IP unbanned");
alert(data.message || "IP unbanned successfully");
}
return fetchSummary();
})
@@ -259,7 +512,11 @@ function unbanIP(jail, ip) {
});
}
// Open the jail config modal
//*******************************************************************
//* Filter-mod and config-mod actions : *
//*******************************************************************
// Open jail/filter modal and load filter-config
function openJailConfigModal(jailName) {
currentJailForConfig = jailName;
var textArea = document.getElementById('jailConfigTextarea');
@@ -288,7 +545,7 @@ function openJailConfigModal(jailName) {
});
}
// Save jail config
// Save filter config for the current opened jail
function saveJailConfig() {
if (!currentJailForConfig) return;
showLoading(true);
@@ -304,7 +561,8 @@ function saveJailConfig() {
if (data.error) {
alert("Error saving config: " + data.error);
} else {
alert(data.message || "Config saved");
//alert(data.message || "Config saved");
console.log("Filter saved successfully. Reload needed? " + data.reloadNeeded);
// Hide modal
var modalEl = document.getElementById('jailConfigModal');
var modalObj = bootstrap.Modal.getInstance(modalEl);
@@ -321,9 +579,208 @@ function saveJailConfig() {
});
}
// Load current settings when opening settings page
function loadSettings() {
showLoading(true);
fetch('/api/settings')
.then(res => res.json())
.then(data => {
// Get current general settings
document.getElementById('languageSelect').value = data.language || 'en';
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
for (let i = 0; i < select.options.length; i++) {
select.options[i].selected = false;
}
if (!data.alertCountries || data.alertCountries.length === 0) {
// default to "ALL"
select.options[0].selected = true;
} else {
// Mark them selected
for (let i = 0; i < select.options.length; i++) {
let val = select.options[i].value;
if (data.alertCountries.includes(val)) {
select.options[i].selected = true;
}
}
}
// 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);
})
.finally(() => showLoading(false));
}
// Save settings when hit the save button
function saveSettings(e) {
e.preventDefault(); // prevent form submission
showLoading(true);
const lang = document.getElementById('languageSelect').value;
const debugMode = document.getElementById("debugMode").checked;
const srcmail = document.getElementById('sourceEmail').value;
const destmail = document.getElementById('destEmail').value;
const select = document.getElementById('alertCountries');
let chosenCountries = [];
for (let i = 0; i < select.options.length; i++) {
if (select.options[i].selected) {
chosenCountries.push(select.options[i].value);
}
}
// If user selected "ALL", we override everything
if (chosenCountries.includes("ALL")) {
chosenCountries = ["all"];
}
const 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,
debug: debugMode,
sender: srcmail,
destemail: destmail,
alertCountries: chosenCountries,
bantimeIncrement: bantimeinc,
bantime: bant,
findtime: findt,
maxretry: maxre,
ignoreip: ignip
};
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 + data.details);
} else {
//alert(data.message || 'Settings saved');
console.log("Settings saved successfully. Reload needed? " + data.reloadNeeded);
if (data.reloadNeeded) {
document.getElementById('reloadBanner').style.display = 'block';
}
}
})
.catch(err => alert('Error: ' + err))
.finally(() => showLoading(false));
}
// 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 = '<h5>Test Results</h5>';
if (!matches || matches.length === 0) {
html += '<p>No matches found.</p>';
} else {
html += '<ul>';
matches.forEach(m => {
html += '<li>' + m + '</li>';
});
html += '</ul>';
}
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 = '';
}
//*******************************************************************
//* Reload fail2ban action : *
//*******************************************************************
// Reload Fail2ban
function reloadFail2ban() {
if (!confirm("Reload Fail2ban now?")) return;
if (!confirm("It can happen that some logs are not parsed during the reload of fail2ban. Reload Fail2ban now?")) return;
showLoading(true);
fetch('/api/fail2ban/reload', { method: 'POST' })
.then(function(res) { return res.json(); })
@@ -331,7 +788,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
@@ -345,6 +801,7 @@ function reloadFail2ban() {
showLoading(false);
});
}
</script>
</body>
</html>