mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 05:53:15 +02:00
Merge pull request #1 from swissmakers/dev
Merge DEV into Main: First feature Enhancements and Interface Updates
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,3 +23,6 @@ go.work.sum
|
|||||||
|
|
||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
fail2ban-ui-settings.json
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Fail2ban UI
|
# 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:
|
It provides a modern dashboard to currently:
|
||||||
|
|
||||||
- View all Fail2ban jails and banned IPs
|
- View all Fail2ban jails and banned IPs
|
||||||
@@ -8,6 +8,7 @@ It provides a modern dashboard to currently:
|
|||||||
- Edit and save jail/filter configs
|
- Edit and save jail/filter configs
|
||||||
- Reload Fail2ban when needed
|
- Reload Fail2ban when needed
|
||||||
- See recent ban events
|
- See recent ban events
|
||||||
|
- More to come...
|
||||||
|
|
||||||
Built by [Swissmakers GmbH](https://swissmakers.ch).
|
Built by [Swissmakers GmbH](https://swissmakers.ch).
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func main() {
|
|||||||
// Register our routes (IndexHandler, /api/summary, /api/jails/:jail/unban/:ip)
|
// Register our routes (IndexHandler, /api/summary, /api/jails/:jail/unban/:ip)
|
||||||
web.RegisterRoutes(r)
|
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 {
|
if err := r.Run(":8080"); err != nil {
|
||||||
log.Fatalf("Server crashed: %v", err)
|
log.Fatalf("Server crashed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
420
internal/config/settings.go
Normal file
420
internal/config/settings.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
package fail2ban
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type JailInfo struct {
|
type JailInfo struct {
|
||||||
JailName string `json:"jailName"`
|
JailName string `json:"jailName"`
|
||||||
TotalBanned int `json:"totalBanned"`
|
TotalBanned int `json:"totalBanned"`
|
||||||
NewInLastHour int `json:"newInLastHour"`
|
NewInLastHour int `json:"newInLastHour"`
|
||||||
BannedIPs []string `json:"bannedIPs"`
|
BannedIPs []string `json:"bannedIPs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJails returns all configured jails using "fail2ban-client status".
|
// GetJails returns all configured jails using "fail2ban-client status".
|
||||||
@@ -21,7 +37,7 @@ func GetJails() ([]string, error) {
|
|||||||
cmd := exec.Command("fail2ban-client", "status")
|
cmd := exec.Command("fail2ban-client", "status")
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
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
|
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
|
// Example: we assume each jail config is at /etc/fail2ban/filter.d/<jail>.conf
|
||||||
// Adapt this to your environment.
|
// Adapt this to your environment.
|
||||||
func GetJailConfig(jail string) (string, error) {
|
func GetJailConfig(jail string) (string, error) {
|
||||||
configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf")
|
configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf")
|
||||||
content, err := ioutil.ReadFile(configPath)
|
content, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read config for jail %s: %v", jail, err)
|
return "", fmt.Errorf("failed to read config for jail %s: %v", jail, err)
|
||||||
}
|
}
|
||||||
return string(content), nil
|
return string(content), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetJailConfig overwrites the config file for a given jail with new content.
|
// SetJailConfig overwrites the config file for a given jail with new content.
|
||||||
func SetJailConfig(jail, newContent string) error {
|
func SetJailConfig(jail, newContent string) error {
|
||||||
configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf")
|
configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf")
|
||||||
if err := ioutil.WriteFile(configPath, []byte(newContent), 0644); err != nil {
|
if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil {
|
||||||
return fmt.Errorf("failed to write config for jail %s: %v", jail, err)
|
return fmt.Errorf("failed to write config for jail %s: %v", jail, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReloadFail2ban runs "fail2ban-client reload"
|
// ReloadFail2ban runs "fail2ban-client reload"
|
||||||
func ReloadFail2ban() error {
|
func ReloadFail2ban() error {
|
||||||
cmd := exec.Command("fail2ban-client", "reload")
|
cmd := exec.Command("fail2ban-client", "reload")
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fail2ban reload error: %v\nOutput: %s", err, out)
|
return fmt.Errorf("fail2ban reload error: %v\nOutput: %s", err, out)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package fail2ban
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -5,7 +21,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
//"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,31 +84,3 @@ func ParseBanLog(logPath string) (map[string][]BanEvent, error) {
|
|||||||
}
|
}
|
||||||
return eventsByJail, nil
|
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
132
internal/geoip_notify.py
Normal 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)
|
||||||
@@ -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
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||||
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
|
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SummaryResponse is what we return from /api/summary
|
// SummaryResponse is what we return from /api/summary
|
||||||
type SummaryResponse struct {
|
type SummaryResponse struct {
|
||||||
Jails []fail2ban.JailInfo `json:"jails"`
|
Jails []fail2ban.JailInfo `json:"jails"`
|
||||||
LastBans []fail2ban.BanEvent `json:"lastBans"`
|
LastBans []fail2ban.BanEvent `json:"lastBans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SummaryHandler returns a JSON summary of all jails, including
|
// 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.
|
// UnbanIPHandler unbans a given IP in a specific jail.
|
||||||
func UnbanIPHandler(c *gin.Context) {
|
func UnbanIPHandler(c *gin.Context) {
|
||||||
|
fmt.Println("----------------------------")
|
||||||
|
fmt.Println("UnbanIPHandler called (handlers.go)") // entry point
|
||||||
jail := c.Param("jail")
|
jail := c.Param("jail")
|
||||||
ip := c.Param("ip")
|
ip := c.Param("ip")
|
||||||
|
|
||||||
@@ -63,6 +85,7 @@ func UnbanIPHandler(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
fmt.Println(ip + " from jail " + jail + " unbanned successfully (handlers.go)")
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "IP unbanned successfully",
|
"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) {
|
func IndexHandler(c *gin.Context) {
|
||||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||||
"timestamp": time.Now().Format(time.RFC1123),
|
"timestamp": time.Now().Format(time.RFC1123),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJailConfigHandler returns the raw config for a given jail
|
// GetJailFilterConfigHandler returns the raw filter config for a given jail
|
||||||
func GetJailConfigHandler(c *gin.Context) {
|
func GetJailFilterConfigHandler(c *gin.Context) {
|
||||||
jail := c.Param("jail")
|
fmt.Println("----------------------------")
|
||||||
cfg, err := fail2ban.GetJailConfig(jail)
|
fmt.Println("GetJailFilterConfigHandler called (handlers.go)") // entry point
|
||||||
if err != nil {
|
jail := c.Param("jail")
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
cfg, err := fail2ban.GetJailConfig(jail)
|
||||||
return
|
if err != nil {
|
||||||
}
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
c.JSON(http.StatusOK, gin.H{
|
return
|
||||||
"jail": jail,
|
}
|
||||||
"config": cfg,
|
c.JSON(http.StatusOK, gin.H{
|
||||||
})
|
"jail": jail,
|
||||||
|
"config": cfg,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetJailConfigHandler overwrites the jail config with new content
|
// SetJailFilterConfigHandler overwrites the current filter config with new content
|
||||||
func SetJailConfigHandler(c *gin.Context) {
|
func SetJailFilterConfigHandler(c *gin.Context) {
|
||||||
jail := c.Param("jail")
|
fmt.Println("----------------------------")
|
||||||
|
fmt.Println("SetJailFilterConfigHandler called (handlers.go)") // entry point
|
||||||
|
jail := c.Param("jail")
|
||||||
|
|
||||||
var req struct {
|
// Parse JSON body (containing the new filter content)
|
||||||
Config string `json:"config"`
|
var req struct {
|
||||||
}
|
Config string `json:"config"`
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
}
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := fail2ban.SetJailConfig(jail, req.Config); err != nil {
|
// Write the filter config file to /etc/fail2ban/filter.d/<jail>.conf
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
if err := fail2ban.SetJailConfig(jail, req.Config); err != nil {
|
||||||
return
|
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
|
// ReloadFail2banHandler reloads the Fail2ban service
|
||||||
func ReloadFail2banHandler(c *gin.Context) {
|
func ReloadFail2banHandler(c *gin.Context) {
|
||||||
err := fail2ban.ReloadFail2ban()
|
fmt.Println("----------------------------")
|
||||||
if err != nil {
|
fmt.Println("ApplyFail2banSettings called (handlers.go)") // entry point
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
// First we write our new settings to /etc/fail2ban/jail.local
|
||||||
}
|
// if err := fail2ban.ApplyFail2banSettings("/etc/fail2ban/jail.local"); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Fail2ban reloaded successfully"})
|
// 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"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,16 +25,26 @@ func RegisterRoutes(r *gin.Engine) {
|
|||||||
// Render the dashboard
|
// Render the dashboard
|
||||||
r.GET("/", IndexHandler)
|
r.GET("/", IndexHandler)
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
api.GET("/summary", SummaryHandler)
|
api.GET("/summary", SummaryHandler)
|
||||||
api.POST("/jails/:jail/unban/:ip", UnbanIPHandler)
|
api.POST("/jails/:jail/unban/:ip", UnbanIPHandler)
|
||||||
|
|
||||||
// New config endpoints
|
// config endpoints
|
||||||
api.GET("/jails/:jail/config", GetJailConfigHandler)
|
api.GET("/jails/:jail/config", GetJailFilterConfigHandler)
|
||||||
api.POST("/jails/:jail/config", SetJailConfigHandler)
|
api.POST("/jails/:jail/config", SetJailFilterConfigHandler)
|
||||||
|
|
||||||
// Reload endpoint
|
// settings
|
||||||
api.POST("/fail2ban/reload", ReloadFail2banHandler)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
<title>Fail2ban UI Dashboard</title>
|
<title>Fail2ban UI Dashboard</title>
|
||||||
<!-- Bootstrap 5 (CDN) -->
|
<!-- Bootstrap 5 (CDN) -->
|
||||||
<link
|
<link
|
||||||
@@ -30,15 +49,38 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
<!-- NavBar -->
|
<!-- ******************************************************************* -->
|
||||||
|
<!-- NAVIGATION : -->
|
||||||
|
<!-- ******************************************************************* -->
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="#">
|
<a class="navbar-brand" href="#">
|
||||||
<strong>Fail2ban UI</strong>
|
<strong>Fail2ban UI</strong>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<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>
|
</nav>
|
||||||
|
<!-- ******************************************************************* -->
|
||||||
|
|
||||||
<!-- Reload Banner -->
|
<!-- Reload Banner -->
|
||||||
<div id="reloadBanner" class="bg-warning text-dark p-3 text-center">
|
<div id="reloadBanner" class="bg-warning text-dark p-3 text-center">
|
||||||
@@ -48,11 +90,122 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container my-4">
|
<!-- ******************************************************************* -->
|
||||||
|
<!-- APP Sections (Pages) : -->
|
||||||
|
<!-- ******************************************************************* -->
|
||||||
|
|
||||||
|
<!-- Dashboard Section -->
|
||||||
|
<div id="dashboardSection" class="container my-4">
|
||||||
<h1 class="mb-4">Dashboard</h1>
|
<h1 class="mb-4">Dashboard</h1>
|
||||||
<div id="dashboard"></div>
|
<div id="dashboard"></div>
|
||||||
</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 -->
|
||||||
<footer class="text-center mt-4 mb-4">
|
<footer class="text-center mt-4 mb-4">
|
||||||
<p class="mb-0">
|
<p class="mb-0">
|
||||||
@@ -64,6 +217,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<!-- ******************************************************************* -->
|
||||||
|
<!-- APP Components (HTML) : -->
|
||||||
|
<!-- ******************************************************************* -->
|
||||||
<!-- Loading Overlay -->
|
<!-- Loading Overlay -->
|
||||||
<div id="loading-overlay" class="d-flex">
|
<div id="loading-overlay" class="d-flex">
|
||||||
<div class="spinner-border text-light" role="status">
|
<div class="spinner-border text-light" role="status">
|
||||||
@@ -95,6 +251,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- ******************************************************************* -->
|
||||||
|
|
||||||
<!-- Bootstrap 5 JS (for modal, etc.) -->
|
<!-- Bootstrap 5 JS (for modal, etc.) -->
|
||||||
<script
|
<script
|
||||||
@@ -102,11 +259,31 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// For information: We avoid ES6 backticks in our JS, to prevent confusion with the Go template parser.
|
||||||
"use strict";
|
"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;
|
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)
|
// Toggle the loading overlay (with !important)
|
||||||
function showLoading(show) {
|
function showLoading(show) {
|
||||||
@@ -118,12 +295,43 @@ function showLoading(show) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', function() {
|
// Check if there is still a reload of the fail2ban service needed
|
||||||
showLoading(true);
|
function checkReloadNeeded() {
|
||||||
fetchSummary().then(function() {
|
fetch('/api/settings')
|
||||||
showLoading(false);
|
.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)
|
// Fetch summary (jails, stats, last 5 bans)
|
||||||
function fetchSummary() {
|
function fetchSummary() {
|
||||||
@@ -147,18 +355,26 @@ function fetchSummary() {
|
|||||||
function renderDashboard(data) {
|
function renderDashboard(data) {
|
||||||
var html = "";
|
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
|
// Jails table
|
||||||
if (!data.jails || data.jails.length === 0) {
|
if (!data.jails || data.jails.length === 0) {
|
||||||
html += '<p>No jails found.</p>';
|
html += '<p>No jails found.</p>';
|
||||||
} else {
|
} else {
|
||||||
html += ''
|
html += ''
|
||||||
+ '<h2>Overview</h2>'
|
+ '<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">'
|
+ '<table class="table table-striped" id="jailsTable">'
|
||||||
+ ' <thead>'
|
+ ' <thead>'
|
||||||
+ ' <tr>'
|
+ ' <tr>'
|
||||||
+ ' <th>Jail Name</th>'
|
+ ' <th>Jail Name</th>'
|
||||||
+ ' <th>Total Banned</th>'
|
+ ' <th>Total Banned</th>'
|
||||||
+ ' <th>New in Last Hour</th>'
|
+ ' <th>New Last Hour</th>'
|
||||||
+ ' <th>Banned IPs (Unban)</th>'
|
+ ' <th>Banned IPs (Unban)</th>'
|
||||||
+ ' </tr>'
|
+ ' </tr>'
|
||||||
+ ' </thead>'
|
+ ' </thead>'
|
||||||
@@ -167,7 +383,7 @@ function renderDashboard(data) {
|
|||||||
data.jails.forEach(function(jail) {
|
data.jails.forEach(function(jail) {
|
||||||
var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs);
|
var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs);
|
||||||
html += ''
|
html += ''
|
||||||
+ '<tr>'
|
+ '<tr class="jail-row">'
|
||||||
+ ' <td>'
|
+ ' <td>'
|
||||||
+ ' <a href="#" onclick="openJailConfigModal(\'' + jail.jailName + '\')">'
|
+ ' <a href="#" onclick="openJailConfigModal(\'' + jail.jailName + '\')">'
|
||||||
+ jail.jailName
|
+ jail.jailName
|
||||||
@@ -235,6 +451,43 @@ function renderBannedIPs(jailName, ips) {
|
|||||||
return content;
|
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
|
// Unban IP
|
||||||
function unbanIP(jail, ip) {
|
function unbanIP(jail, ip) {
|
||||||
if (!confirm("Unban IP " + ip + " from jail " + jail + "?")) {
|
if (!confirm("Unban IP " + ip + " from jail " + jail + "?")) {
|
||||||
@@ -247,7 +500,7 @@ function unbanIP(jail, ip) {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
alert("Error: " + data.error);
|
alert("Error: " + data.error);
|
||||||
} else {
|
} else {
|
||||||
alert(data.message || "IP unbanned");
|
alert(data.message || "IP unbanned successfully");
|
||||||
}
|
}
|
||||||
return fetchSummary();
|
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) {
|
function openJailConfigModal(jailName) {
|
||||||
currentJailForConfig = jailName;
|
currentJailForConfig = jailName;
|
||||||
var textArea = document.getElementById('jailConfigTextarea');
|
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() {
|
function saveJailConfig() {
|
||||||
if (!currentJailForConfig) return;
|
if (!currentJailForConfig) return;
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
@@ -304,7 +561,8 @@ function saveJailConfig() {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
alert("Error saving config: " + data.error);
|
alert("Error saving config: " + data.error);
|
||||||
} else {
|
} else {
|
||||||
alert(data.message || "Config saved");
|
//alert(data.message || "Config saved");
|
||||||
|
console.log("Filter saved successfully. Reload needed? " + data.reloadNeeded);
|
||||||
// Hide modal
|
// Hide modal
|
||||||
var modalEl = document.getElementById('jailConfigModal');
|
var modalEl = document.getElementById('jailConfigModal');
|
||||||
var modalObj = bootstrap.Modal.getInstance(modalEl);
|
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
|
// Reload Fail2ban
|
||||||
function reloadFail2ban() {
|
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);
|
showLoading(true);
|
||||||
fetch('/api/fail2ban/reload', { method: 'POST' })
|
fetch('/api/fail2ban/reload', { method: 'POST' })
|
||||||
.then(function(res) { return res.json(); })
|
.then(function(res) { return res.json(); })
|
||||||
@@ -331,7 +788,6 @@ function reloadFail2ban() {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
alert("Error: " + data.error);
|
alert("Error: " + data.error);
|
||||||
} else {
|
} else {
|
||||||
alert(data.message || "Fail2ban reloaded");
|
|
||||||
// Hide reload banner
|
// Hide reload banner
|
||||||
document.getElementById('reloadBanner').style.display = 'none';
|
document.getElementById('reloadBanner').style.display = 'none';
|
||||||
// Refresh data
|
// Refresh data
|
||||||
@@ -345,6 +801,7 @@ function reloadFail2ban() {
|
|||||||
showLoading(false);
|
showLoading(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user