mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +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
|
||||
|
||||
# Project specific
|
||||
fail2ban-ui-settings.json
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
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
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user