mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 05:53:15 +02:00
Refactor the whole backend to support remote-fail2ban machines over ssh or over a agent-api(needs to be build)
This commit is contained in:
@@ -18,14 +18,23 @@ package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/swissmakers/fail2ban-ui/internal/storage"
|
||||
)
|
||||
|
||||
// SMTPSettings holds the SMTP server configuration for sending alert emails
|
||||
@@ -46,6 +55,9 @@ type AppSettings struct {
|
||||
RestartNeeded bool `json:"restartNeeded"`
|
||||
AlertCountries []string `json:"alertCountries"`
|
||||
SMTP SMTPSettings `json:"smtp"`
|
||||
CallbackURL string `json:"callbackUrl"`
|
||||
|
||||
Servers []Fail2banServer `json:"servers"`
|
||||
|
||||
// Fail2Ban [DEFAULT] section values from jail.local
|
||||
BantimeIncrement bool `json:"bantimeIncrement"`
|
||||
@@ -59,50 +71,358 @@ type AppSettings struct {
|
||||
|
||||
// init paths to key-files
|
||||
const (
|
||||
settingsFile = "fail2ban-ui-settings.json" // this file is created, relatively to where the app was started
|
||||
defaultJailFile = "/etc/fail2ban/jail.conf"
|
||||
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"
|
||||
settingsFile = "fail2ban-ui-settings.json" // this file is created, relatively to where the app was started
|
||||
defaultJailFile = "/etc/fail2ban/jail.conf"
|
||||
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"
|
||||
actionCallbackPlaceholder = "__CALLBACK_URL__"
|
||||
)
|
||||
|
||||
const fail2banActionTemplate = `[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 a cURL request to notify our API when an IP is banned.
|
||||
|
||||
actionban = /usr/bin/curl -X POST __CALLBACK_URL__/api/ban \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg ip '<ip>' \
|
||||
--arg jail '<name>' \
|
||||
--arg hostname '<fq-hostname>' \
|
||||
--arg failures '<failures>' \
|
||||
--arg whois "$(whois <ip> || echo 'missing whois program')" \
|
||||
--arg logs "$(tac <logpath> | grep <grepopts> -wF <ip>)" \
|
||||
'{ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, whois: $whois, logs: $logs}')"
|
||||
|
||||
[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 = 200
|
||||
grepopts = -m <grepmax>`
|
||||
|
||||
// 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 from jail.local (if exist)")
|
||||
if err := initializeFromJailFile(); err != nil {
|
||||
fmt.Println("Error reading jail.local:", err)
|
||||
}
|
||||
setDefaults()
|
||||
fmt.Println("Initialized successfully.")
|
||||
var (
|
||||
errSettingsNotFound = errors.New("settings not found")
|
||||
backgroundCtx = context.Background()
|
||||
)
|
||||
|
||||
// save defaults to file
|
||||
if err := saveSettings(); err != nil {
|
||||
fmt.Println("Failed to save default settings:", err)
|
||||
// Fail2banServer represents a Fail2ban instance the UI can manage.
|
||||
type Fail2banServer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // local, ssh, agent
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
SocketPath string `json:"socketPath,omitempty"`
|
||||
LogPath string `json:"logPath,omitempty"`
|
||||
SSHUser string `json:"sshUser,omitempty"`
|
||||
SSHKeyPath string `json:"sshKeyPath,omitempty"`
|
||||
AgentURL string `json:"agentUrl,omitempty"`
|
||||
AgentSecret string `json:"agentSecret,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
enabledSet bool
|
||||
}
|
||||
|
||||
func (s *Fail2banServer) UnmarshalJSON(data []byte) error {
|
||||
type Alias Fail2banServer
|
||||
aux := &struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(s),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
if aux.Enabled != nil {
|
||||
s.Enabled = *aux.Enabled
|
||||
s.enabledSet = true
|
||||
} else {
|
||||
s.enabledSet = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
if err := storage.Init(""); err != nil {
|
||||
panic(fmt.Sprintf("failed to initialise storage: %v", err))
|
||||
}
|
||||
|
||||
if err := loadSettingsFromStorage(); err != nil {
|
||||
if !errors.Is(err, errSettingsNotFound) {
|
||||
fmt.Println("Error loading settings from storage:", err)
|
||||
}
|
||||
|
||||
if err := migrateLegacySettings(); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Println("Error migrating legacy settings:", err)
|
||||
}
|
||||
fmt.Println("App settings not found, initializing from jail.local (if exist)")
|
||||
if err := initializeFromJailFile(); err != nil {
|
||||
fmt.Println("Error reading jail.local:", err)
|
||||
}
|
||||
setDefaults()
|
||||
fmt.Println("Initialized with defaults.")
|
||||
}
|
||||
|
||||
if err := persistAll(); err != nil {
|
||||
fmt.Println("Failed to persist settings:", err)
|
||||
}
|
||||
} else {
|
||||
if err := persistAll(); err != nil {
|
||||
fmt.Println("Failed to persist settings:", err)
|
||||
}
|
||||
}
|
||||
if err := initializeFail2banAction(); err != nil {
|
||||
fmt.Println("Error initializing Fail2ban action:", err)
|
||||
}
|
||||
|
||||
func loadSettingsFromStorage() error {
|
||||
appRec, found, err := storage.GetAppSettings(backgroundCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serverRecs, err := storage.ListServers(backgroundCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return errSettingsNotFound
|
||||
}
|
||||
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
applyAppSettingsRecordLocked(appRec)
|
||||
applyServerRecordsLocked(serverRecs)
|
||||
setDefaultsLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacySettings() error {
|
||||
data, err := os.ReadFile(settingsFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var legacy AppSettings
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settingsLock.Lock()
|
||||
currentSettings = legacy
|
||||
settingsLock.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func persistAll() error {
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
setDefaultsLocked()
|
||||
return persistAllLocked()
|
||||
}
|
||||
|
||||
func persistAllLocked() error {
|
||||
if err := persistAppSettingsLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
return persistServersLocked()
|
||||
}
|
||||
|
||||
func persistAppSettingsLocked() error {
|
||||
rec, err := toAppSettingsRecordLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.SaveAppSettings(backgroundCtx, rec)
|
||||
}
|
||||
|
||||
func persistServersLocked() error {
|
||||
records, err := toServerRecordsLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.ReplaceServers(backgroundCtx, records)
|
||||
}
|
||||
|
||||
func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) {
|
||||
currentSettings.Language = rec.Language
|
||||
currentSettings.Port = rec.Port
|
||||
currentSettings.Debug = rec.Debug
|
||||
currentSettings.CallbackURL = rec.CallbackURL
|
||||
currentSettings.RestartNeeded = rec.RestartNeeded
|
||||
currentSettings.BantimeIncrement = rec.BantimeIncrement
|
||||
currentSettings.IgnoreIP = rec.IgnoreIP
|
||||
currentSettings.Bantime = rec.Bantime
|
||||
currentSettings.Findtime = rec.Findtime
|
||||
currentSettings.Maxretry = rec.MaxRetry
|
||||
currentSettings.Destemail = rec.DestEmail
|
||||
currentSettings.SMTP = SMTPSettings{
|
||||
Host: rec.SMTPHost,
|
||||
Port: rec.SMTPPort,
|
||||
Username: rec.SMTPUsername,
|
||||
Password: rec.SMTPPassword,
|
||||
From: rec.SMTPFrom,
|
||||
UseTLS: rec.SMTPUseTLS,
|
||||
}
|
||||
|
||||
if rec.AlertCountriesJSON != "" {
|
||||
var countries []string
|
||||
if err := json.Unmarshal([]byte(rec.AlertCountriesJSON), &countries); err == nil {
|
||||
currentSettings.AlertCountries = countries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyServerRecordsLocked(records []storage.ServerRecord) {
|
||||
servers := make([]Fail2banServer, 0, len(records))
|
||||
for _, rec := range records {
|
||||
var tags []string
|
||||
if rec.TagsJSON != "" {
|
||||
_ = json.Unmarshal([]byte(rec.TagsJSON), &tags)
|
||||
}
|
||||
server := Fail2banServer{
|
||||
ID: rec.ID,
|
||||
Name: rec.Name,
|
||||
Type: rec.Type,
|
||||
Host: rec.Host,
|
||||
Port: rec.Port,
|
||||
SocketPath: rec.SocketPath,
|
||||
LogPath: rec.LogPath,
|
||||
SSHUser: rec.SSHUser,
|
||||
SSHKeyPath: rec.SSHKeyPath,
|
||||
AgentURL: rec.AgentURL,
|
||||
AgentSecret: rec.AgentSecret,
|
||||
Hostname: rec.Hostname,
|
||||
Tags: tags,
|
||||
IsDefault: rec.IsDefault,
|
||||
Enabled: rec.Enabled,
|
||||
CreatedAt: rec.CreatedAt,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
enabledSet: true,
|
||||
}
|
||||
servers = append(servers, server)
|
||||
}
|
||||
currentSettings.Servers = servers
|
||||
}
|
||||
|
||||
func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
|
||||
countries := currentSettings.AlertCountries
|
||||
if countries == nil {
|
||||
countries = []string{}
|
||||
}
|
||||
countryBytes, err := json.Marshal(countries)
|
||||
if err != nil {
|
||||
return storage.AppSettingsRecord{}, err
|
||||
}
|
||||
|
||||
return storage.AppSettingsRecord{
|
||||
Language: currentSettings.Language,
|
||||
Port: currentSettings.Port,
|
||||
Debug: currentSettings.Debug,
|
||||
CallbackURL: currentSettings.CallbackURL,
|
||||
RestartNeeded: currentSettings.RestartNeeded,
|
||||
AlertCountriesJSON: string(countryBytes),
|
||||
SMTPHost: currentSettings.SMTP.Host,
|
||||
SMTPPort: currentSettings.SMTP.Port,
|
||||
SMTPUsername: currentSettings.SMTP.Username,
|
||||
SMTPPassword: currentSettings.SMTP.Password,
|
||||
SMTPFrom: currentSettings.SMTP.From,
|
||||
SMTPUseTLS: currentSettings.SMTP.UseTLS,
|
||||
BantimeIncrement: currentSettings.BantimeIncrement,
|
||||
IgnoreIP: currentSettings.IgnoreIP,
|
||||
Bantime: currentSettings.Bantime,
|
||||
Findtime: currentSettings.Findtime,
|
||||
MaxRetry: currentSettings.Maxretry,
|
||||
DestEmail: currentSettings.Destemail,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toServerRecordsLocked() ([]storage.ServerRecord, error) {
|
||||
records := make([]storage.ServerRecord, 0, len(currentSettings.Servers))
|
||||
for _, srv := range currentSettings.Servers {
|
||||
tags := srv.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
tagBytes, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createdAt := srv.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now().UTC()
|
||||
}
|
||||
updatedAt := srv.UpdatedAt
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = createdAt
|
||||
}
|
||||
records = append(records, storage.ServerRecord{
|
||||
ID: srv.ID,
|
||||
Name: srv.Name,
|
||||
Type: srv.Type,
|
||||
Host: srv.Host,
|
||||
Port: srv.Port,
|
||||
SocketPath: srv.SocketPath,
|
||||
LogPath: srv.LogPath,
|
||||
SSHUser: srv.SSHUser,
|
||||
SSHKeyPath: srv.SSHKeyPath,
|
||||
AgentURL: srv.AgentURL,
|
||||
AgentSecret: srv.AgentSecret,
|
||||
Hostname: srv.Hostname,
|
||||
TagsJSON: string(tagBytes),
|
||||
IsDefault: srv.IsDefault,
|
||||
Enabled: srv.Enabled,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// setDefaults populates default values in currentSettings
|
||||
func setDefaults() {
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
setDefaultsLocked()
|
||||
}
|
||||
|
||||
func setDefaultsLocked() {
|
||||
if currentSettings.Language == "" {
|
||||
currentSettings.Language = "en"
|
||||
}
|
||||
if currentSettings.Port == 0 {
|
||||
currentSettings.Port = 8080
|
||||
}
|
||||
if currentSettings.CallbackURL == "" {
|
||||
currentSettings.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d", currentSettings.Port)
|
||||
}
|
||||
if currentSettings.AlertCountries == nil {
|
||||
currentSettings.AlertCountries = []string{"ALL"}
|
||||
}
|
||||
@@ -139,6 +459,8 @@ func setDefaults() {
|
||||
if currentSettings.IgnoreIP == "" {
|
||||
currentSettings.IgnoreIP = "127.0.0.1/8 ::1"
|
||||
}
|
||||
|
||||
normalizeServersLocked()
|
||||
}
|
||||
|
||||
// initializeFromJailFile reads Fail2ban jail.local and merges its settings into currentSettings.
|
||||
@@ -189,40 +511,126 @@ func initializeFromJailFile() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// initializeFail2banAction writes a custom action configuration for Fail2ban to use AlertCountries.
|
||||
func initializeFail2banAction() error {
|
||||
func normalizeServersLocked() {
|
||||
now := time.Now().UTC()
|
||||
if len(currentSettings.Servers) == 0 {
|
||||
hostname, _ := os.Hostname()
|
||||
currentSettings.Servers = []Fail2banServer{{
|
||||
ID: "local",
|
||||
Name: "Local Fail2ban",
|
||||
Type: "local",
|
||||
SocketPath: "/var/run/fail2ban/fail2ban.sock",
|
||||
LogPath: "/var/log/fail2ban.log",
|
||||
Hostname: hostname,
|
||||
IsDefault: false,
|
||||
Enabled: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
enabledSet: true,
|
||||
}}
|
||||
return
|
||||
}
|
||||
|
||||
hasDefault := false
|
||||
for idx := range currentSettings.Servers {
|
||||
server := ¤tSettings.Servers[idx]
|
||||
if server.ID == "" {
|
||||
server.ID = generateServerID()
|
||||
}
|
||||
if server.Name == "" {
|
||||
server.Name = "Fail2ban Server " + server.ID
|
||||
}
|
||||
if server.Type == "" {
|
||||
server.Type = "local"
|
||||
}
|
||||
if server.CreatedAt.IsZero() {
|
||||
server.CreatedAt = now
|
||||
}
|
||||
if server.UpdatedAt.IsZero() {
|
||||
server.UpdatedAt = now
|
||||
}
|
||||
if server.Type == "local" && server.SocketPath == "" {
|
||||
server.SocketPath = "/var/run/fail2ban/fail2ban.sock"
|
||||
}
|
||||
if server.Type == "local" && server.LogPath == "" {
|
||||
server.LogPath = "/var/log/fail2ban.log"
|
||||
}
|
||||
if !server.enabledSet {
|
||||
if server.Type == "local" {
|
||||
server.Enabled = false
|
||||
} else {
|
||||
server.Enabled = true
|
||||
}
|
||||
}
|
||||
server.enabledSet = true
|
||||
if server.IsDefault && !server.Enabled {
|
||||
server.IsDefault = false
|
||||
}
|
||||
if server.IsDefault && server.Enabled {
|
||||
hasDefault = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasDefault {
|
||||
for idx := range currentSettings.Servers {
|
||||
if currentSettings.Servers[idx].Enabled {
|
||||
currentSettings.Servers[idx].IsDefault = true
|
||||
hasDefault = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(currentSettings.Servers, func(i, j int) bool {
|
||||
return currentSettings.Servers[i].CreatedAt.Before(currentSettings.Servers[j].CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
func generateServerID() string {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("srv-%d", time.Now().UnixNano())
|
||||
}
|
||||
return "srv-" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// ensureFail2banActionFiles writes the local action files if Fail2ban is present.
|
||||
func ensureFail2banActionFiles(callbackURL string) error {
|
||||
DebugLog("----------------------------")
|
||||
DebugLog("Running initial initializeFail2banAction()") // entry point
|
||||
// Ensure the jail.local is configured correctly
|
||||
DebugLog("ensureFail2banActionFiles called (settings.go)")
|
||||
|
||||
if _, err := os.Stat(filepath.Dir(jailFile)); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := setupGeoCustomAction(); err != nil {
|
||||
fmt.Println("Error setup GeoCustomAction in jail.local:", err)
|
||||
return err
|
||||
}
|
||||
// Ensure the jail.d config file is set up
|
||||
if err := ensureJailDConfig(); err != nil {
|
||||
fmt.Println("Error setting up jail.d configuration:", err)
|
||||
return err
|
||||
}
|
||||
// Write the fail2ban action file
|
||||
return writeFail2banAction()
|
||||
return writeFail2banAction(callbackURL)
|
||||
}
|
||||
|
||||
// setupGeoCustomAction checks and replaces the default action in jail.local with our from fail2ban-UI
|
||||
func setupGeoCustomAction() error {
|
||||
DebugLog("Running initial setupGeoCustomAction()") // entry point
|
||||
if err := os.MkdirAll(filepath.Dir(jailFile), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to ensure jail.local directory: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.Open(jailFile)
|
||||
if err != nil {
|
||||
// Fallback: Copy default file if jail.local is not found
|
||||
if os.IsNotExist(err) {
|
||||
if err := copyFile(defaultJailFile, jailFile); err != nil {
|
||||
return fmt.Errorf("failed to copy default jail.conf to jail.local: %w", err)
|
||||
}
|
||||
fmt.Println("Successfully created jail.local from jail.conf.")
|
||||
file, err = os.Open(jailFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err // Other error
|
||||
if os.IsNotExist(err) {
|
||||
if _, statErr := os.Stat(defaultJailFile); os.IsNotExist(statErr) {
|
||||
return nil
|
||||
}
|
||||
if copyErr := copyFile(defaultJailFile, jailFile); copyErr != nil {
|
||||
return fmt.Errorf("failed to copy default jail.conf to jail.local: %w", copyErr)
|
||||
}
|
||||
file, err = os.Open(jailFile)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
@@ -295,6 +703,10 @@ func ensureJailDConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(jailDFile), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to ensure jail.d directory: %v", err)
|
||||
}
|
||||
|
||||
// Define the content for the custom jail.d configuration
|
||||
jailDConfig := `[DEFAULT]
|
||||
# Custom Fail2Ban action using geo-filter for email alerts
|
||||
@@ -313,47 +725,14 @@ action_mwlg = %(action_)s
|
||||
}
|
||||
|
||||
// writeFail2banAction creates or updates the action file with the AlertCountries.
|
||||
func writeFail2banAction() error {
|
||||
func writeFail2banAction(callbackURL string) error {
|
||||
DebugLog("Running initial writeFail2banAction()") // entry point
|
||||
DebugLog("----------------------------")
|
||||
// Define the Fail2Ban action file content
|
||||
actionConfig := `[INCLUDES]
|
||||
if err := os.MkdirAll(filepath.Dir(actionFile), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to ensure action.d directory: %w", err)
|
||||
}
|
||||
|
||||
before = sendmail-common.conf
|
||||
mail-whois-common.conf
|
||||
helpers-common.conf
|
||||
|
||||
[Definition]
|
||||
|
||||
# Bypass ban/unban for restored tickets
|
||||
norestored = 1
|
||||
|
||||
# Option: actionban
|
||||
# This executes a cURL request to notify our API when an IP is banned.
|
||||
|
||||
actionban = /usr/bin/curl -X POST http://127.0.0.1:8080/api/ban \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg ip '<ip>' \
|
||||
--arg jail '<name>' \
|
||||
--arg hostname '<fq-hostname>' \
|
||||
--arg failures '<failures>' \
|
||||
--arg whois "$(whois <ip> || echo 'missing whois program')" \
|
||||
--arg logs "$(tac <logpath> | grep <grepopts> -wF <ip>)" \
|
||||
'{ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, whois: $whois, logs: $logs}')"
|
||||
|
||||
[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 = 200
|
||||
grepopts = -m <grepmax>`
|
||||
|
||||
// Write the action file
|
||||
actionConfig := BuildFail2banActionConfig(callbackURL)
|
||||
err := os.WriteFile(actionFile, []byte(actionConfig), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write action file: %w", err)
|
||||
@@ -363,46 +742,253 @@ grepopts = -m <grepmax>`
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSettings reads fail2ban-ui-settings.json into currentSettings.
|
||||
func loadSettings() error {
|
||||
DebugLog("----------------------------")
|
||||
DebugLog("loadSettings called (settings.go)") // entry point
|
||||
data, err := os.ReadFile(settingsFile)
|
||||
if os.IsNotExist(err) {
|
||||
return err // triggers setDefaults + save
|
||||
func cloneServer(src Fail2banServer) Fail2banServer {
|
||||
dst := src
|
||||
if src.Tags != nil {
|
||||
dst.Tags = append([]string{}, src.Tags...)
|
||||
}
|
||||
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
|
||||
dst.enabledSet = src.enabledSet
|
||||
return dst
|
||||
}
|
||||
|
||||
// saveSettings writes currentSettings to JSON
|
||||
func saveSettings() error {
|
||||
DebugLog("----------------------------")
|
||||
DebugLog("saveSettings called (settings.go)") // entry point
|
||||
func BuildFail2banActionConfig(callbackURL string) string {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(callbackURL), "/")
|
||||
if trimmed == "" {
|
||||
trimmed = "http://127.0.0.1:8080"
|
||||
}
|
||||
return strings.ReplaceAll(fail2banActionTemplate, actionCallbackPlaceholder, trimmed)
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(currentSettings, "", " ")
|
||||
if err != nil {
|
||||
DebugLog("Error marshalling settings: %v", err) // Debug
|
||||
return err
|
||||
func getCallbackURLLocked() string {
|
||||
url := strings.TrimSpace(currentSettings.CallbackURL)
|
||||
if url == "" {
|
||||
port := currentSettings.Port
|
||||
if port == 0 {
|
||||
port = 8080
|
||||
}
|
||||
url = fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
}
|
||||
DebugLog("Settings marshaled, writing to file...") // Log marshaling success
|
||||
err = os.WriteFile(settingsFile, b, 0644)
|
||||
if err != nil {
|
||||
DebugLog("Error writing to file: %v", err) // Debug
|
||||
return strings.TrimRight(url, "/")
|
||||
}
|
||||
|
||||
// GetCallbackURL returns the callback URL used by Fail2ban agents.
|
||||
func GetCallbackURL() string {
|
||||
settingsLock.RLock()
|
||||
defer settingsLock.RUnlock()
|
||||
return getCallbackURLLocked()
|
||||
}
|
||||
|
||||
// EnsureLocalFail2banAction ensures the local Fail2ban action files exist when the local connector is enabled.
|
||||
func EnsureLocalFail2banAction(server Fail2banServer) error {
|
||||
if !server.Enabled {
|
||||
return nil
|
||||
}
|
||||
// Write again the Fail2ban-UI action file (in the future not used anymore)
|
||||
return writeFail2banAction()
|
||||
settingsLock.RLock()
|
||||
callbackURL := getCallbackURLLocked()
|
||||
settingsLock.RUnlock()
|
||||
return ensureFail2banActionFiles(callbackURL)
|
||||
}
|
||||
|
||||
func serverByIDLocked(id string) (Fail2banServer, bool) {
|
||||
for _, srv := range currentSettings.Servers {
|
||||
if srv.ID == id {
|
||||
return cloneServer(srv), true
|
||||
}
|
||||
}
|
||||
return Fail2banServer{}, false
|
||||
}
|
||||
|
||||
// ListServers returns a copy of the configured Fail2ban servers.
|
||||
func ListServers() []Fail2banServer {
|
||||
settingsLock.RLock()
|
||||
defer settingsLock.RUnlock()
|
||||
|
||||
out := make([]Fail2banServer, len(currentSettings.Servers))
|
||||
for idx, srv := range currentSettings.Servers {
|
||||
out[idx] = cloneServer(srv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetServerByID returns the server matching the supplied ID.
|
||||
func GetServerByID(id string) (Fail2banServer, bool) {
|
||||
settingsLock.RLock()
|
||||
defer settingsLock.RUnlock()
|
||||
srv, ok := serverByIDLocked(id)
|
||||
if !ok {
|
||||
return Fail2banServer{}, false
|
||||
}
|
||||
return cloneServer(srv), true
|
||||
}
|
||||
|
||||
// GetServerByHostname returns the first server matching the hostname.
|
||||
func GetServerByHostname(hostname string) (Fail2banServer, bool) {
|
||||
settingsLock.RLock()
|
||||
defer settingsLock.RUnlock()
|
||||
for _, srv := range currentSettings.Servers {
|
||||
if strings.EqualFold(srv.Hostname, hostname) {
|
||||
return cloneServer(srv), true
|
||||
}
|
||||
}
|
||||
return Fail2banServer{}, false
|
||||
}
|
||||
|
||||
// GetDefaultServer returns the default server.
|
||||
func GetDefaultServer() Fail2banServer {
|
||||
settingsLock.RLock()
|
||||
defer settingsLock.RUnlock()
|
||||
|
||||
for _, srv := range currentSettings.Servers {
|
||||
if srv.IsDefault && srv.Enabled {
|
||||
return cloneServer(srv)
|
||||
}
|
||||
}
|
||||
for _, srv := range currentSettings.Servers {
|
||||
if srv.Enabled {
|
||||
return cloneServer(srv)
|
||||
}
|
||||
}
|
||||
return Fail2banServer{}
|
||||
}
|
||||
|
||||
// UpsertServer adds or updates a Fail2ban server and persists the settings.
|
||||
func UpsertServer(input Fail2banServer) (Fail2banServer, error) {
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
input.Type = strings.ToLower(strings.TrimSpace(input.Type))
|
||||
if input.ID == "" {
|
||||
input.ID = generateServerID()
|
||||
input.CreatedAt = now
|
||||
}
|
||||
if input.CreatedAt.IsZero() {
|
||||
input.CreatedAt = now
|
||||
}
|
||||
input.UpdatedAt = now
|
||||
|
||||
if input.Type == "" {
|
||||
input.Type = "local"
|
||||
}
|
||||
if !input.enabledSet {
|
||||
if input.Type == "local" {
|
||||
input.Enabled = false
|
||||
} else {
|
||||
input.Enabled = true
|
||||
}
|
||||
input.enabledSet = true
|
||||
}
|
||||
if input.Type == "local" && input.SocketPath == "" {
|
||||
input.SocketPath = "/var/run/fail2ban/fail2ban.sock"
|
||||
}
|
||||
if input.Type == "local" && input.LogPath == "" {
|
||||
input.LogPath = "/var/log/fail2ban.log"
|
||||
}
|
||||
if input.Name == "" {
|
||||
input.Name = "Fail2ban Server " + input.ID
|
||||
}
|
||||
replaced := false
|
||||
for idx, srv := range currentSettings.Servers {
|
||||
if srv.ID == input.ID {
|
||||
if !input.enabledSet {
|
||||
input.Enabled = srv.Enabled
|
||||
input.enabledSet = true
|
||||
}
|
||||
if !input.Enabled {
|
||||
input.IsDefault = false
|
||||
}
|
||||
if input.IsDefault {
|
||||
clearDefaultLocked()
|
||||
}
|
||||
// preserve created timestamp if incoming zero
|
||||
if input.CreatedAt.IsZero() {
|
||||
input.CreatedAt = srv.CreatedAt
|
||||
}
|
||||
currentSettings.Servers[idx] = input
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
if input.IsDefault {
|
||||
clearDefaultLocked()
|
||||
}
|
||||
if len(currentSettings.Servers) == 0 && input.Enabled {
|
||||
input.IsDefault = true
|
||||
}
|
||||
currentSettings.Servers = append(currentSettings.Servers, input)
|
||||
}
|
||||
|
||||
normalizeServersLocked()
|
||||
if err := persistServersLocked(); err != nil {
|
||||
return Fail2banServer{}, err
|
||||
}
|
||||
srv, _ := serverByIDLocked(input.ID)
|
||||
return cloneServer(srv), nil
|
||||
}
|
||||
|
||||
func clearDefaultLocked() {
|
||||
for idx := range currentSettings.Servers {
|
||||
currentSettings.Servers[idx].IsDefault = false
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteServer removes a server by ID.
|
||||
func DeleteServer(id string) error {
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
if len(currentSettings.Servers) == 0 {
|
||||
return fmt.Errorf("no servers configured")
|
||||
}
|
||||
|
||||
index := -1
|
||||
for i, srv := range currentSettings.Servers {
|
||||
if srv.ID == id {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return fmt.Errorf("server %s not found", id)
|
||||
}
|
||||
|
||||
currentSettings.Servers = append(currentSettings.Servers[:index], currentSettings.Servers[index+1:]...)
|
||||
normalizeServersLocked()
|
||||
return persistServersLocked()
|
||||
}
|
||||
|
||||
// SetDefaultServer marks the specified server as default.
|
||||
func SetDefaultServer(id string) (Fail2banServer, error) {
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
found := false
|
||||
for idx := range currentSettings.Servers {
|
||||
srv := ¤tSettings.Servers[idx]
|
||||
if srv.ID == id {
|
||||
found = true
|
||||
srv.IsDefault = true
|
||||
if !srv.Enabled {
|
||||
srv.Enabled = true
|
||||
srv.enabledSet = true
|
||||
}
|
||||
srv.UpdatedAt = time.Now().UTC()
|
||||
} else {
|
||||
srv.IsDefault = false
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Fail2banServer{}, fmt.Errorf("server %s not found", id)
|
||||
}
|
||||
|
||||
normalizeServersLocked()
|
||||
if err := persistServersLocked(); err != nil {
|
||||
return Fail2banServer{}, err
|
||||
}
|
||||
srv, _ := serverByIDLocked(id)
|
||||
return cloneServer(srv), nil
|
||||
}
|
||||
|
||||
// GetSettings returns a copy of the current settings
|
||||
@@ -418,7 +1004,7 @@ func MarkRestartNeeded() error {
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
currentSettings.RestartNeeded = true
|
||||
return saveSettings()
|
||||
return persistAppSettingsLocked()
|
||||
}
|
||||
|
||||
// MarkRestartDone sets restartNeeded = false and saves JSON
|
||||
@@ -427,7 +1013,7 @@ func MarkRestartDone() error {
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
currentSettings.RestartNeeded = false
|
||||
return saveSettings()
|
||||
return persistAppSettingsLocked()
|
||||
}
|
||||
|
||||
// UpdateSettings merges new settings with old and sets restartNeeded if needed
|
||||
@@ -452,14 +1038,20 @@ func UpdateSettings(new AppSettings) (AppSettings, error) {
|
||||
new.RestartNeeded = new.RestartNeeded || old.RestartNeeded
|
||||
}
|
||||
|
||||
new.CallbackURL = strings.TrimSpace(new.CallbackURL)
|
||||
if len(new.Servers) == 0 && len(currentSettings.Servers) > 0 {
|
||||
new.Servers = make([]Fail2banServer, len(currentSettings.Servers))
|
||||
for i, srv := range currentSettings.Servers {
|
||||
new.Servers[i] = cloneServer(srv)
|
||||
}
|
||||
}
|
||||
currentSettings = new
|
||||
setDefaultsLocked()
|
||||
DebugLog("New settings applied: %v", currentSettings) // Log settings applied
|
||||
|
||||
// persist to file
|
||||
if err := saveSettings(); err != nil {
|
||||
fmt.Println("Error saving settings:", err) // Log save error
|
||||
if err := persistAllLocked(); err != nil {
|
||||
fmt.Println("Error saving settings:", err)
|
||||
return currentSettings, err
|
||||
}
|
||||
fmt.Println("Settings saved to file successfully") // Log save success
|
||||
return currentSettings, nil
|
||||
}
|
||||
|
||||
@@ -16,14 +16,7 @@
|
||||
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
import "context"
|
||||
|
||||
type JailInfo struct {
|
||||
JailName string `json:"jailName"`
|
||||
@@ -33,146 +26,65 @@ type JailInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Get active jails using "fail2ban-client status".
|
||||
// GetJails returns the jail names for the default server.
|
||||
func GetJails() ([]string, error) {
|
||||
cmd := exec.Command("fail2ban-client", "status")
|
||||
out, err := cmd.CombinedOutput()
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var jails []string
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "Jail list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
raw := strings.TrimSpace(parts[1])
|
||||
jails = strings.Split(raw, ",")
|
||||
for i := range jails {
|
||||
jails[i] = strings.TrimSpace(jails[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return jails, nil
|
||||
}
|
||||
|
||||
// GetBannedIPs returns a slice of currently banned IPs for a specific jail.
|
||||
func GetBannedIPs(jail string) ([]string, error) {
|
||||
cmd := exec.Command("fail2ban-client", "status", jail)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail2ban-client status %s failed: %v", jail, err)
|
||||
}
|
||||
|
||||
var bannedIPs []string
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "IP list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
ips := strings.Fields(strings.TrimSpace(parts[1]))
|
||||
bannedIPs = append(bannedIPs, ips...)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return bannedIPs, nil
|
||||
}
|
||||
|
||||
// UnbanIP unbans an IP from the given jail.
|
||||
func UnbanIP(jail, ip string) error {
|
||||
// We assume "fail2ban-client set <jail> unbanip <ip>" works.
|
||||
cmd := exec.Command("fail2ban-client", "set", jail, "unbanip", ip)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error unbanning IP %s from jail %s: %v\nOutput: %s", ip, jail, err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildJailInfos returns extended info for each jail:
|
||||
// - total banned count
|
||||
// - new banned in the last hour
|
||||
// - list of currently banned IPs
|
||||
func BuildJailInfos(logPath string) ([]JailInfo, error) {
|
||||
jails, err := GetJails()
|
||||
infos, err := conn.GetJailInfos(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the log once, so we can determine "newInLastHour" per jail
|
||||
// for performance reasons. We'll gather all ban timestamps by jail.
|
||||
banHistory, err := ParseBanLog(logPath)
|
||||
if err != nil {
|
||||
// If fail2ban.log can't be read, we can still show partial info.
|
||||
banHistory = make(map[string][]BanEvent)
|
||||
names := make([]string, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
names = append(names, info.JailName)
|
||||
}
|
||||
|
||||
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
var results []JailInfo
|
||||
for _, jail := range jails {
|
||||
bannedIPs, err := GetBannedIPs(jail)
|
||||
if err != nil {
|
||||
// Just skip or handle error per jail
|
||||
continue
|
||||
}
|
||||
|
||||
// Count how many bans occurred in the last hour for this jail
|
||||
newInLastHour := 0
|
||||
if events, ok := banHistory[jail]; ok {
|
||||
for _, e := range events {
|
||||
if e.Time.After(oneHourAgo) {
|
||||
newInLastHour++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jinfo := JailInfo{
|
||||
JailName: jail,
|
||||
TotalBanned: len(bannedIPs),
|
||||
NewInLastHour: newInLastHour,
|
||||
BannedIPs: bannedIPs,
|
||||
}
|
||||
results = append(results, jinfo)
|
||||
}
|
||||
return results, nil
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// ReloadFail2ban runs "fail2ban-client reload"
|
||||
// GetBannedIPs returns a slice of currently banned IPs for a specific jail.
|
||||
func GetBannedIPs(jail string) ([]string, error) {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn.GetBannedIPs(context.Background(), jail)
|
||||
}
|
||||
|
||||
// UnbanIP unbans an IP from the given jail.
|
||||
func UnbanIP(jail, ip string) error {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.UnbanIP(context.Background(), jail, ip)
|
||||
}
|
||||
|
||||
// BuildJailInfos returns extended info for each jail on the default server.
|
||||
func BuildJailInfos(_ string) ([]JailInfo, error) {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn.GetJailInfos(context.Background())
|
||||
}
|
||||
|
||||
// ReloadFail2ban triggers a reload on the default server.
|
||||
func ReloadFail2ban() error {
|
||||
cmd := exec.Command("fail2ban-client", "reload")
|
||||
out, err := cmd.CombinedOutput()
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fail2ban reload error: %v\noutput: %s", err, out)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return conn.Reload(context.Background())
|
||||
}
|
||||
|
||||
// RestartFail2ban restarts the Fail2ban service.
|
||||
// RestartFail2ban restarts the Fail2ban service using the default connector.
|
||||
func RestartFail2ban() error {
|
||||
|
||||
// Check if running inside a container.
|
||||
if _, container := os.LookupEnv("CONTAINER"); container {
|
||||
return fmt.Errorf("restart not supported inside container; please restart fail2ban on the host")
|
||||
}
|
||||
cmd := "systemctl restart fail2ban"
|
||||
out, err := execCommand(cmd)
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart fail2ban: %w - output: %s", err, out)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// execCommand is a helper function to execute shell commands.
|
||||
func execCommand(command string) (string, error) {
|
||||
parts := strings.Fields(command)
|
||||
if len(parts) == 0 {
|
||||
return "", errors.New("no command provided")
|
||||
}
|
||||
cmd := exec.Command(parts[0], parts[1:]...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
return conn.Restart(context.Background())
|
||||
}
|
||||
|
||||
251
internal/fail2ban/connector_agent.go
Normal file
251
internal/fail2ban/connector_agent.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
)
|
||||
|
||||
// AgentConnector connects to a remote fail2ban-agent via HTTP API.
|
||||
type AgentConnector struct {
|
||||
server config.Fail2banServer
|
||||
base *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAgentConnector constructs a new AgentConnector.
|
||||
func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
|
||||
if server.AgentURL == "" {
|
||||
return nil, fmt.Errorf("agentUrl is required for agent connector")
|
||||
}
|
||||
if server.AgentSecret == "" {
|
||||
return nil, fmt.Errorf("agentSecret is required for agent connector")
|
||||
}
|
||||
parsed, err := url.Parse(server.AgentURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid agentUrl: %w", err)
|
||||
}
|
||||
if parsed.Scheme == "" {
|
||||
parsed.Scheme = "https"
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
conn := &AgentConnector{
|
||||
server: server,
|
||||
base: parsed,
|
||||
client: client,
|
||||
}
|
||||
if err := conn.ensureAction(context.Background()); err != nil {
|
||||
fmt.Printf("warning: failed to ensure agent action for %s: %v\n", server.Name, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) ID() string {
|
||||
return ac.server.ID
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) Server() config.Fail2banServer {
|
||||
return ac.server
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) ensureAction(ctx context.Context) error {
|
||||
payload := map[string]any{
|
||||
"name": "ui-custom-action",
|
||||
"config": config.BuildFail2banActionConfig(config.GetCallbackURL()),
|
||||
"callbackUrl": config.GetCallbackURL(),
|
||||
"setDefault": true,
|
||||
}
|
||||
return ac.put(ctx, "/v1/actions/ui-custom", payload, nil)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
|
||||
var resp struct {
|
||||
Jails []JailInfo `json:"jails"`
|
||||
}
|
||||
if err := ac.get(ctx, "/v1/jails", &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Jails, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
|
||||
var resp struct {
|
||||
Jail string `json:"jail"`
|
||||
BannedIPs []string `json:"bannedIPs"`
|
||||
TotalBanned int `json:"totalBanned"`
|
||||
}
|
||||
if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s", url.PathEscape(jail)), &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.BannedIPs) > 0 {
|
||||
return resp.BannedIPs, nil
|
||||
}
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) UnbanIP(ctx context.Context, jail, ip string) error {
|
||||
payload := map[string]string{"ip": ip}
|
||||
return ac.post(ctx, fmt.Sprintf("/v1/jails/%s/unban", url.PathEscape(jail)), payload, nil)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) Reload(ctx context.Context) error {
|
||||
return ac.post(ctx, "/v1/actions/reload", nil, nil)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) Restart(ctx context.Context) error {
|
||||
return ac.post(ctx, "/v1/actions/restart", nil, nil)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
|
||||
var resp struct {
|
||||
Config string `json:"config"`
|
||||
}
|
||||
if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Config, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
|
||||
payload := map[string]string{"config": content}
|
||||
return ac.put(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), payload, nil)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
|
||||
query := url.Values{}
|
||||
if limit > 0 {
|
||||
query.Set("limit", strconv.Itoa(limit))
|
||||
}
|
||||
var resp struct {
|
||||
Events []struct {
|
||||
IP string `json:"ip"`
|
||||
Jail string `json:"jail"`
|
||||
Hostname string `json:"hostname"`
|
||||
Failures string `json:"failures"`
|
||||
Whois string `json:"whois"`
|
||||
Logs string `json:"logs"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
} `json:"events"`
|
||||
}
|
||||
endpoint := "/v1/events"
|
||||
if encoded := query.Encode(); encoded != "" {
|
||||
endpoint += "?" + encoded
|
||||
}
|
||||
if err := ac.get(ctx, endpoint, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]BanEvent, 0, len(resp.Events))
|
||||
for _, evt := range resp.Events {
|
||||
ts, err := time.Parse(time.RFC3339, evt.Timestamp)
|
||||
if err != nil {
|
||||
ts = time.Now()
|
||||
}
|
||||
result = append(result, BanEvent{
|
||||
Time: ts,
|
||||
Jail: evt.Jail,
|
||||
IP: evt.IP,
|
||||
LogLine: fmt.Sprintf("%s %s", evt.Hostname, evt.Failures),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) get(ctx context.Context, endpoint string, out any) error {
|
||||
req, err := ac.newRequest(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ac.do(req, out)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) post(ctx context.Context, endpoint string, payload any, out any) error {
|
||||
req, err := ac.newRequest(ctx, http.MethodPost, endpoint, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ac.do(req, out)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) put(ctx context.Context, endpoint string, payload any, out any) error {
|
||||
req, err := ac.newRequest(ctx, http.MethodPut, endpoint, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ac.do(req, out)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) newRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) {
|
||||
u := *ac.base
|
||||
u.Path = path.Join(ac.base.Path, strings.TrimPrefix(endpoint, "/"))
|
||||
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-F2B-Token", ac.server.AgentSecret)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) do(req *http.Request, out any) error {
|
||||
settingsSnapshot := config.GetSettings()
|
||||
if settingsSnapshot.Debug {
|
||||
config.DebugLog("Agent request [%s]: %s %s", ac.server.Name, req.Method, req.URL.String())
|
||||
}
|
||||
|
||||
resp, err := ac.client.Do(req)
|
||||
if err != nil {
|
||||
if settingsSnapshot.Debug {
|
||||
config.DebugLog("Agent request error [%s]: %v", ac.server.Name, err)
|
||||
}
|
||||
return fmt.Errorf("agent request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
|
||||
if settingsSnapshot.Debug {
|
||||
config.DebugLog("Agent response [%s]: %s | %s", ac.server.Name, resp.Status, trimmed)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("agent request failed: %s (%s)", resp.Status, trimmed)
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(trimmed) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
218
internal/fail2ban/connector_local.go
Normal file
218
internal/fail2ban/connector_local.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
)
|
||||
|
||||
// LocalConnector interacts with a local fail2ban instance via fail2ban-client CLI.
|
||||
type LocalConnector struct {
|
||||
server config.Fail2banServer
|
||||
}
|
||||
|
||||
// NewLocalConnector creates a new LocalConnector instance.
|
||||
func NewLocalConnector(server config.Fail2banServer) *LocalConnector {
|
||||
return &LocalConnector{server: server}
|
||||
}
|
||||
|
||||
// ID implements Connector.
|
||||
func (lc *LocalConnector) ID() string {
|
||||
return lc.server.ID
|
||||
}
|
||||
|
||||
// Server implements Connector.
|
||||
func (lc *LocalConnector) Server() config.Fail2banServer {
|
||||
return lc.server
|
||||
}
|
||||
|
||||
// GetJailInfos implements Connector.
|
||||
func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
|
||||
jails, err := lc.getJails(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logPath := lc.server.LogPath
|
||||
if logPath == "" {
|
||||
logPath = "/var/log/fail2ban.log"
|
||||
}
|
||||
|
||||
banHistory, err := ParseBanLog(logPath)
|
||||
if err != nil {
|
||||
banHistory = make(map[string][]BanEvent)
|
||||
}
|
||||
|
||||
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
||||
var results []JailInfo
|
||||
for _, jail := range jails {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
bannedIPs, err := lc.GetBannedIPs(ctx, jail)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
newInLastHour := 0
|
||||
if events, ok := banHistory[jail]; ok {
|
||||
for _, e := range events {
|
||||
if e.Time.After(oneHourAgo) {
|
||||
newInLastHour++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, JailInfo{
|
||||
JailName: jail,
|
||||
TotalBanned: len(bannedIPs),
|
||||
NewInLastHour: newInLastHour,
|
||||
BannedIPs: bannedIPs,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetBannedIPs implements Connector.
|
||||
func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
|
||||
args := []string{"status", jail}
|
||||
out, err := lc.runFail2banClient(ctx, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail2ban-client status %s failed: %w", jail, err)
|
||||
}
|
||||
var bannedIPs []string
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "IP list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
ips := strings.Fields(strings.TrimSpace(parts[1]))
|
||||
bannedIPs = append(bannedIPs, ips...)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return bannedIPs, nil
|
||||
}
|
||||
|
||||
// UnbanIP implements Connector.
|
||||
func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error {
|
||||
args := []string{"set", jail, "unbanip", ip}
|
||||
if _, err := lc.runFail2banClient(ctx, args...); err != nil {
|
||||
return fmt.Errorf("error unbanning IP %s from jail %s: %w", ip, jail, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload implements Connector.
|
||||
func (lc *LocalConnector) Reload(ctx context.Context) error {
|
||||
if _, err := lc.runFail2banClient(ctx, "reload"); err != nil {
|
||||
return fmt.Errorf("fail2ban reload error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart implements Connector.
|
||||
func (lc *LocalConnector) Restart(ctx context.Context) error {
|
||||
if _, container := os.LookupEnv("CONTAINER"); container {
|
||||
return fmt.Errorf("restart not supported inside container; please restart fail2ban on the host")
|
||||
}
|
||||
cmd := "systemctl restart fail2ban"
|
||||
out, err := executeShellCommand(ctx, cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart fail2ban: %w - output: %s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFilterConfig implements Connector.
|
||||
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
|
||||
return GetFilterConfigLocal(jail)
|
||||
}
|
||||
|
||||
// SetFilterConfig implements Connector.
|
||||
func (lc *LocalConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
|
||||
return SetFilterConfigLocal(jail, content)
|
||||
}
|
||||
|
||||
// FetchBanEvents implements Connector.
|
||||
func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
|
||||
logPath := lc.server.LogPath
|
||||
if logPath == "" {
|
||||
logPath = "/var/log/fail2ban.log"
|
||||
}
|
||||
eventsByJail, err := ParseBanLog(logPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var all []BanEvent
|
||||
for _, evs := range eventsByJail {
|
||||
all = append(all, evs...)
|
||||
}
|
||||
sort.SliceStable(all, func(i, j int) bool {
|
||||
return all[i].Time.After(all[j].Time)
|
||||
})
|
||||
if limit > 0 && len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
|
||||
out, err := lc.runFail2banClient(ctx, "status")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %w", err)
|
||||
}
|
||||
|
||||
var jails []string
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "Jail list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
raw := strings.TrimSpace(parts[1])
|
||||
jails = strings.Split(raw, ",")
|
||||
for i := range jails {
|
||||
jails[i] = strings.TrimSpace(jails[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return jails, nil
|
||||
}
|
||||
|
||||
func (lc *LocalConnector) runFail2banClient(ctx context.Context, args ...string) (string, error) {
|
||||
cmdArgs := lc.buildFail2banArgs(args...)
|
||||
cmd := exec.CommandContext(ctx, "fail2ban-client", cmdArgs...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func (lc *LocalConnector) buildFail2banArgs(args ...string) []string {
|
||||
if lc.server.SocketPath == "" {
|
||||
return args
|
||||
}
|
||||
base := []string{"-s", lc.server.SocketPath}
|
||||
return append(base, args...)
|
||||
}
|
||||
|
||||
func executeShellCommand(ctx context.Context, command string) (string, error) {
|
||||
parts := strings.Fields(command)
|
||||
if len(parts) == 0 {
|
||||
return "", errors.New("no command provided")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
256
internal/fail2ban/connector_ssh.go
Normal file
256
internal/fail2ban/connector_ssh.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
)
|
||||
|
||||
const sshEnsureActionScript = `sudo python3 - <<'PY'
|
||||
import base64
|
||||
import pathlib
|
||||
|
||||
action_dir = pathlib.Path("/etc/fail2ban/action.d")
|
||||
action_dir.mkdir(parents=True, exist_ok=True)
|
||||
action_cfg = base64.b64decode("__PAYLOAD__").decode("utf-8")
|
||||
(action_dir / "ui-custom-action.conf").write_text(action_cfg)
|
||||
|
||||
jail_file = pathlib.Path("/etc/fail2ban/jail.local")
|
||||
if not jail_file.exists():
|
||||
jail_file.write_text("[DEFAULT]\n")
|
||||
|
||||
lines = jail_file.read_text().splitlines()
|
||||
already = any("Custom Fail2Ban action applied by fail2ban-ui" in line for line in lines)
|
||||
if not already:
|
||||
new_lines = []
|
||||
inserted = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("action") and "ui-custom-action" not in stripped and not inserted:
|
||||
if not stripped.startswith("#"):
|
||||
new_lines.append("# " + line)
|
||||
else:
|
||||
new_lines.append(line)
|
||||
new_lines.append("# Custom Fail2Ban action applied by fail2ban-ui")
|
||||
new_lines.append("action = %(action_mwlg)s")
|
||||
inserted = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
if not inserted:
|
||||
insert_at = None
|
||||
for idx, value in enumerate(new_lines):
|
||||
if value.strip().startswith("[DEFAULT]"):
|
||||
insert_at = idx + 1
|
||||
break
|
||||
if insert_at is None:
|
||||
new_lines.append("[DEFAULT]")
|
||||
insert_at = len(new_lines)
|
||||
new_lines.insert(insert_at, "# Custom Fail2Ban action applied by fail2ban-ui")
|
||||
new_lines.insert(insert_at + 1, "action = %(action_mwlg)s")
|
||||
jail_file.write_text("\n".join(new_lines) + "\n")
|
||||
PY`
|
||||
|
||||
// SSHConnector connects to a remote Fail2ban instance over SSH.
|
||||
type SSHConnector struct {
|
||||
server config.Fail2banServer
|
||||
}
|
||||
|
||||
// NewSSHConnector creates a new SSH connector.
|
||||
func NewSSHConnector(server config.Fail2banServer) (Connector, error) {
|
||||
if server.Host == "" {
|
||||
return nil, fmt.Errorf("host is required for ssh connector")
|
||||
}
|
||||
if server.SSHUser == "" {
|
||||
return nil, fmt.Errorf("sshUser is required for ssh connector")
|
||||
}
|
||||
conn := &SSHConnector{server: server}
|
||||
if err := conn.ensureAction(context.Background()); err != nil {
|
||||
fmt.Printf("warning: failed to ensure remote fail2ban action for %s: %v\n", server.Name, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) ID() string {
|
||||
return sc.server.ID
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) Server() config.Fail2banServer {
|
||||
return sc.server
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
|
||||
jails, err := sc.getJails(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var infos []JailInfo
|
||||
for _, jail := range jails {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
ips, err := sc.GetBannedIPs(ctx, jail)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
infos = append(infos, JailInfo{
|
||||
JailName: jail,
|
||||
TotalBanned: len(ips),
|
||||
NewInLastHour: 0,
|
||||
BannedIPs: ips,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(infos, func(i, j int) bool {
|
||||
return infos[i].JailName < infos[j].JailName
|
||||
})
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
|
||||
out, err := sc.runFail2banCommand(ctx, "status", jail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bannedIPs []string
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "IP list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
ips := strings.Fields(strings.TrimSpace(parts[1]))
|
||||
bannedIPs = append(bannedIPs, ips...)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return bannedIPs, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) UnbanIP(ctx context.Context, jail, ip string) error {
|
||||
_, err := sc.runFail2banCommand(ctx, "set", jail, "unbanip", ip)
|
||||
return err
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) Reload(ctx context.Context) error {
|
||||
_, err := sc.runFail2banCommand(ctx, "reload")
|
||||
return err
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) Restart(ctx context.Context) error {
|
||||
_, err := sc.runRemoteCommand(ctx, []string{"sudo", "systemctl", "restart", "fail2ban"})
|
||||
return err
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
|
||||
path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
|
||||
out, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", path})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read remote filter config: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
|
||||
path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
|
||||
cmd := fmt.Sprintf("cat <<'EOF' | sudo tee %s >/dev/null\n%s\nEOF", path, content)
|
||||
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
|
||||
return err
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
|
||||
// Not available over SSH without copying logs; return empty slice.
|
||||
return []BanEvent{}, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) ensureAction(ctx context.Context) error {
|
||||
callbackURL := config.GetCallbackURL()
|
||||
actionConfig := config.BuildFail2banActionConfig(callbackURL)
|
||||
payload := base64.StdEncoding.EncodeToString([]byte(actionConfig))
|
||||
script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload)
|
||||
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script})
|
||||
return err
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) getJails(ctx context.Context) ([]string, error) {
|
||||
out, err := sc.runFail2banCommand(ctx, "status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var jails []string
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "Jail list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
raw := strings.TrimSpace(parts[1])
|
||||
jails = strings.Split(raw, ",")
|
||||
for i := range jails {
|
||||
jails[i] = strings.TrimSpace(jails[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return jails, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) runFail2banCommand(ctx context.Context, args ...string) (string, error) {
|
||||
fail2banArgs := sc.buildFail2banArgs(args...)
|
||||
cmdArgs := append([]string{"sudo", "fail2ban-client"}, fail2banArgs...)
|
||||
return sc.runRemoteCommand(ctx, cmdArgs)
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) buildFail2banArgs(args ...string) []string {
|
||||
if sc.server.SocketPath == "" {
|
||||
return args
|
||||
}
|
||||
base := []string{"-s", sc.server.SocketPath}
|
||||
return append(base, args...)
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) (string, error) {
|
||||
args := sc.buildSSHArgs(command)
|
||||
cmd := exec.CommandContext(ctx, "ssh", args...)
|
||||
settingSnapshot := config.GetSettings()
|
||||
if settingSnapshot.Debug {
|
||||
config.DebugLog("SSH command [%s]: ssh %s", sc.server.Name, strings.Join(args, " "))
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
output := strings.TrimSpace(string(out))
|
||||
if err != nil {
|
||||
if settingSnapshot.Debug {
|
||||
config.DebugLog("SSH command error [%s]: %v | output: %s", sc.server.Name, err, output)
|
||||
}
|
||||
return output, fmt.Errorf("ssh command failed: %w (output: %s)", err, output)
|
||||
}
|
||||
if settingSnapshot.Debug {
|
||||
config.DebugLog("SSH command output [%s]: %s", sc.server.Name, output)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) buildSSHArgs(command []string) []string {
|
||||
args := []string{"-o", "BatchMode=yes"}
|
||||
if sc.server.SSHKeyPath != "" {
|
||||
args = append(args, "-i", sc.server.SSHKeyPath)
|
||||
}
|
||||
if sc.server.Port > 0 {
|
||||
args = append(args, "-p", strconv.Itoa(sc.server.Port))
|
||||
}
|
||||
target := sc.server.Host
|
||||
if sc.server.SSHUser != "" {
|
||||
target = fmt.Sprintf("%s@%s", sc.server.SSHUser, target)
|
||||
}
|
||||
args = append(args, target)
|
||||
args = append(args, command...)
|
||||
return args
|
||||
}
|
||||
@@ -17,15 +17,32 @@
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// GetFilterConfig returns the config content for a given jail filter.
|
||||
// Example: we assume each jail config is at /etc/fail2ban/filter.d/<jailname>.conf
|
||||
// Adapt this to your environment.
|
||||
// GetFilterConfig returns the filter configuration using the default connector.
|
||||
func GetFilterConfig(jail string) (string, error) {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return conn.GetFilterConfig(context.Background(), jail)
|
||||
}
|
||||
|
||||
// SetFilterConfig writes the filter configuration using the default connector.
|
||||
func SetFilterConfig(jail, newContent string) error {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.SetFilterConfig(context.Background(), jail, newContent)
|
||||
}
|
||||
|
||||
// GetFilterConfigLocal reads a filter configuration from the local filesystem.
|
||||
func GetFilterConfigLocal(jail string) (string, error) {
|
||||
configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf")
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
@@ -34,8 +51,8 @@ func GetFilterConfig(jail string) (string, error) {
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// SetFilterConfig overwrites the config file for a given jail with new content.
|
||||
func SetFilterConfig(jail, newContent string) error {
|
||||
// SetFilterConfigLocal writes the filter configuration to the local filesystem.
|
||||
func SetFilterConfigLocal(jail, newContent string) error {
|
||||
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)
|
||||
|
||||
117
internal/fail2ban/manager.go
Normal file
117
internal/fail2ban/manager.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
)
|
||||
|
||||
// Connector describes a communication backend for a Fail2ban server.
|
||||
type Connector interface {
|
||||
ID() string
|
||||
Server() config.Fail2banServer
|
||||
|
||||
GetJailInfos(ctx context.Context) ([]JailInfo, error)
|
||||
GetBannedIPs(ctx context.Context, jail string) ([]string, error)
|
||||
UnbanIP(ctx context.Context, jail, ip string) error
|
||||
Reload(ctx context.Context) error
|
||||
Restart(ctx context.Context) error
|
||||
GetFilterConfig(ctx context.Context, jail string) (string, error)
|
||||
SetFilterConfig(ctx context.Context, jail, content string) error
|
||||
FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error)
|
||||
}
|
||||
|
||||
// Manager orchestrates all connectors for configured Fail2ban servers.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
connectors map[string]Connector
|
||||
}
|
||||
|
||||
var (
|
||||
managerOnce sync.Once
|
||||
managerInst *Manager
|
||||
)
|
||||
|
||||
// GetManager returns the singleton connector manager.
|
||||
func GetManager() *Manager {
|
||||
managerOnce.Do(func() {
|
||||
managerInst = &Manager{
|
||||
connectors: make(map[string]Connector),
|
||||
}
|
||||
})
|
||||
return managerInst
|
||||
}
|
||||
|
||||
// ReloadFromSettings rebuilds connectors using the provided settings.
|
||||
func (m *Manager) ReloadFromSettings(settings config.AppSettings) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
connectors := make(map[string]Connector)
|
||||
for _, srv := range settings.Servers {
|
||||
if !srv.Enabled {
|
||||
continue
|
||||
}
|
||||
conn, err := newConnectorForServer(srv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialise connector for %s (%s): %w", srv.Name, srv.ID, err)
|
||||
}
|
||||
connectors[srv.ID] = conn
|
||||
}
|
||||
|
||||
m.connectors = connectors
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connector returns the connector for the specified server ID.
|
||||
func (m *Manager) Connector(serverID string) (Connector, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if serverID == "" {
|
||||
return nil, fmt.Errorf("server id must be provided")
|
||||
}
|
||||
conn, ok := m.connectors[serverID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connector for server %s not found or not enabled", serverID)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// DefaultConnector returns the default connector as defined in settings.
|
||||
func (m *Manager) DefaultConnector() (Connector, error) {
|
||||
server := config.GetDefaultServer()
|
||||
if server.ID == "" {
|
||||
return nil, fmt.Errorf("no active fail2ban server configured")
|
||||
}
|
||||
return m.Connector(server.ID)
|
||||
}
|
||||
|
||||
// Connectors returns all connectors.
|
||||
func (m *Manager) Connectors() []Connector {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]Connector, 0, len(m.connectors))
|
||||
for _, conn := range m.connectors {
|
||||
result = append(result, conn)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func newConnectorForServer(server config.Fail2banServer) (Connector, error) {
|
||||
switch server.Type {
|
||||
case "local":
|
||||
if err := config.EnsureLocalFail2banAction(server); err != nil {
|
||||
fmt.Printf("warning: failed to ensure local fail2ban action: %v\n", err)
|
||||
}
|
||||
return NewLocalConnector(server), nil
|
||||
case "ssh":
|
||||
return NewSSHConnector(server)
|
||||
case "agent":
|
||||
return NewAgentConnector(server)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported server type %s", server.Type)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,21 @@
|
||||
"restart_banner.button": "Service neu starten",
|
||||
"dashboard.title": "Dashboard",
|
||||
"dashboard.overview": "Aktive Jails und Blocks Übersicht",
|
||||
"dashboard.overview_hint": "Verwende die Suche, um gesperrte IPs zu filtern, und klicke auf ein Jail, um dessen Konfiguration zu bearbeiten.",
|
||||
"dashboard.search_label": "Suche gesperrte IPs",
|
||||
"dashboard.search_placeholder": "Geben Sie eine IP-Adresse zum Suchen ein",
|
||||
"dashboard.external_ip": "Deine ext. IP:",
|
||||
"dashboard.manage_servers": "Server verwalten",
|
||||
"dashboard.no_servers_title": "Keine Fail2ban-Server konfiguriert",
|
||||
"dashboard.no_servers_body": "Füge einen Server hinzu, um Fail2ban-Instanzen zu überwachen und zu steuern.",
|
||||
"dashboard.loading_summary": "Zusammenfassung wird geladen…",
|
||||
"dashboard.no_enabled_servers_title": "Keine aktiven Verbindungen",
|
||||
"dashboard.no_enabled_servers_body": "Aktiviere den lokalen Connector oder registriere einen entfernten Fail2ban-Server, um Live-Daten zu sehen.",
|
||||
"dashboard.errors.summary_failed": "Zusammenfassung konnte nicht vom Server geladen werden.",
|
||||
"dashboard.cards.active_jails": "Aktive Jails",
|
||||
"dashboard.cards.total_banned": "Gesamt gesperrte IPs",
|
||||
"dashboard.cards.new_last_hour": "Neue in der letzten Stunde",
|
||||
"dashboard.cards.total_logged": "Gespeicherte Sperr-Ereignisse",
|
||||
"dashboard.table.jail_name": "Jail-Name",
|
||||
"dashboard.table.total_banned": "Insgesamt gesperrt",
|
||||
"dashboard.table.new_last_hour": "Neu in letzter Stunde",
|
||||
@@ -22,6 +35,20 @@
|
||||
"dashboard.no_recent_bans": "Keine aktuellen Sperrvorgänge gefunden.",
|
||||
"dashboard.no_banned_ips": "Keine gesperrten IPs",
|
||||
"dashboard.unban": "Entsperren",
|
||||
"logs.overview.title": "Interne Log-Übersicht",
|
||||
"logs.overview.subtitle": "Von Fail2ban-UI gespeicherte Ereignisse über alle Connectoren.",
|
||||
"logs.overview.refresh": "Daten aktualisieren",
|
||||
"logs.overview.total_events": "Gespeicherte Ereignisse gesamt",
|
||||
"logs.overview.per_server": "Ereignisse pro Server",
|
||||
"logs.overview.recent_events_title": "Letzte gespeicherte Ereignisse",
|
||||
"logs.overview.recent_empty": "Für den ausgewählten Server wurden keine gespeicherten Ereignisse gefunden.",
|
||||
"logs.overview.empty": "Es wurden noch keine Sperr-Ereignisse protokolliert.",
|
||||
"logs.table.server": "Server",
|
||||
"logs.table.count": "Anzahl",
|
||||
"logs.table.jail": "Jail",
|
||||
"logs.table.ip": "IP",
|
||||
"logs.table.time": "Zeit",
|
||||
"logs.table.country": "Land",
|
||||
"filter_debug.title": "Filter-Debug",
|
||||
"filter_debug.select_filter": "Wählen Sie einen Filter",
|
||||
"filter_debug.log_lines": "Logzeilen",
|
||||
@@ -34,6 +61,8 @@
|
||||
"settings.language": "Sprache",
|
||||
"settings.enable_debug": "Debug-Protokoll aktivieren",
|
||||
"settings.alert": "Alarm-Einstellungen",
|
||||
"settings.callback_url": "Fail2ban Callback-URL",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.destination_email": "Ziel-E-Mail (Alarmempfänger)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Alarm-Länder",
|
||||
@@ -64,8 +93,65 @@
|
||||
"modal.filter_config": "Filter-Konfiguration:",
|
||||
"modal.cancel": "Abbrechen",
|
||||
"modal.save": "Speichern",
|
||||
"modal.close": "Schließen",
|
||||
"loading": "Lade...",
|
||||
"dashboard.manage_jails": "Jails verwalten",
|
||||
"modal.manage_jails_title": "Jails verwalten"
|
||||
"modal.manage_jails_title": "Jails verwalten",
|
||||
"servers.selector.label": "Aktiver Server",
|
||||
"servers.selector.empty": "Keine Server konfiguriert",
|
||||
"servers.selector.none": "Kein Server konfiguriert. Bitte füge einen Fail2ban-Server hinzu.",
|
||||
"servers.modal.title": "Fail2ban-Server verwalten",
|
||||
"servers.modal.description": "Registriere entfernte Fail2ban-Instanzen und wähle, wie das UI sich verbindet.",
|
||||
"servers.modal.list_title": "Registrierte Server",
|
||||
"servers.modal.list_empty": "Keine Server konfiguriert. Füge rechts deinen ersten Fail2ban-Server hinzu.",
|
||||
"servers.modal.form_title": "Server hinzufügen oder bearbeiten",
|
||||
"servers.form.name": "Anzeigename",
|
||||
"servers.form.name_placeholder": "Mein Fail2ban-Server",
|
||||
"servers.form.type": "Verbindungstyp",
|
||||
"servers.type.local": "Lokal (gleicher Host)",
|
||||
"servers.type.ssh": "SSH",
|
||||
"servers.type.agent": "API-Agent",
|
||||
"servers.form.host": "Hostname / IP",
|
||||
"servers.form.host_placeholder": "fail2ban.beispiel.de",
|
||||
"servers.form.port": "Port",
|
||||
"servers.form.port_placeholder": "22",
|
||||
"servers.form.socket_path": "Fail2ban-Socket-Pfad",
|
||||
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
|
||||
"servers.form.log_path": "Fail2ban-Logpfad",
|
||||
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
|
||||
"servers.form.hostname": "Server-Hostname",
|
||||
"servers.form.hostname_placeholder": "optional",
|
||||
"servers.form.ssh_user": "SSH-Benutzer",
|
||||
"servers.form.ssh_user_placeholder": "root",
|
||||
"servers.form.ssh_key": "Pfad zum SSH-Schlüssel",
|
||||
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
|
||||
"servers.form.agent_url": "Agent-URL",
|
||||
"servers.form.agent_url_placeholder": "https://host:9443",
|
||||
"servers.form.agent_secret": "Agent-Secret",
|
||||
"servers.form.agent_secret_placeholder": "gemeinsames Geheimnis",
|
||||
"servers.form.tags": "Tags",
|
||||
"servers.form.tags_placeholder": "kommagetrennte Tags",
|
||||
"servers.form.set_default": "Als Standardserver setzen",
|
||||
"servers.form.enabled": "Connector aktivieren",
|
||||
"servers.form.submit": "Server speichern",
|
||||
"servers.form.reset": "Zurücksetzen",
|
||||
"servers.form.success": "Server erfolgreich gespeichert.",
|
||||
"servers.badge.default": "Standard",
|
||||
"servers.badge.enabled": "Aktiv",
|
||||
"servers.badge.disabled": "Deaktiviert",
|
||||
"servers.actions.edit": "Bearbeiten",
|
||||
"servers.actions.set_default": "Als Standard setzen",
|
||||
"servers.actions.enable": "Aktivieren",
|
||||
"servers.actions.disable": "Deaktivieren",
|
||||
"servers.actions.test": "Verbindung testen",
|
||||
"servers.actions.test_success": "Verbindung erfolgreich",
|
||||
"servers.actions.test_failure": "Verbindung fehlgeschlagen",
|
||||
"servers.actions.delete": "Löschen",
|
||||
"servers.actions.delete_confirm": "Diesen Servereintrag löschen?",
|
||||
"servers.form.select_key": "Privaten Schlüssel auswählen",
|
||||
"servers.form.select_key_placeholder": "Manuelle Eingabe",
|
||||
"servers.form.no_keys": "Keine SSH-Schlüssel gefunden; Pfad manuell eingeben",
|
||||
"filter_debug.not_available": "Filter-Debug ist nur für lokale Connectoren verfügbar.",
|
||||
"filter_debug.local_missing": "Das lokale Fail2ban-Filterverzeichnis wurde auf diesem Host nicht gefunden."
|
||||
}
|
||||
|
||||
@@ -7,8 +7,21 @@
|
||||
"restart_banner.button": "Service neu starte",
|
||||
"dashboard.title": "Dashboard",
|
||||
"dashboard.overview": "Übersicht vo de aktive Jails und Blocks",
|
||||
"dashboard.overview_hint": "Bruch d Suechi zum g'sperrti IPs filtere und klick uf es Jail, zum d Konfiguration z'bearbeite.",
|
||||
"dashboard.search_label": "Suech nach g'sperrte IPs",
|
||||
"dashboard.search_placeholder": "Gib d'IP adrässe i, wo du suechsch",
|
||||
"dashboard.external_ip": "Dini ext. IP:",
|
||||
"dashboard.manage_servers": "Server verwalte",
|
||||
"dashboard.no_servers_title": "Kei Fail2ban-Server konfiguriert",
|
||||
"dashboard.no_servers_body": "Füeg en Server dezue zum Fail2ban-Instanze überwache und steuere.",
|
||||
"dashboard.loading_summary": "Lad Zämmefassig…",
|
||||
"dashboard.no_enabled_servers_title": "Kei aktivi Verbindige",
|
||||
"dashboard.no_enabled_servers_body": "Aktivier dr lokale Connector oder registrier ä entfernten Fail2ban-Server für Live-Datä.",
|
||||
"dashboard.errors.summary_failed": "Zämmefassig het nid chönne glade wärde.",
|
||||
"dashboard.cards.active_jails": "Aktivi Jails",
|
||||
"dashboard.cards.total_banned": "Total g'sperrti IPs",
|
||||
"dashboard.cards.new_last_hour": "Neu i dr letschte Stund",
|
||||
"dashboard.cards.total_logged": "Gspeichereti Sperr-Ereigniss",
|
||||
"dashboard.table.jail_name": "Jail-Name",
|
||||
"dashboard.table.total_banned": "Insgsamt g'sperrt",
|
||||
"dashboard.table.new_last_hour": "Neu in dr letschte Stund",
|
||||
@@ -22,6 +35,20 @@
|
||||
"dashboard.no_recent_bans": "Kei aktuelli Sperrvorgäng gfunde.",
|
||||
"dashboard.no_banned_ips": "Kei g'sperrti IPs",
|
||||
"dashboard.unban": "Entsperre",
|
||||
"logs.overview.title": "Interni Log-Übersicht",
|
||||
"logs.overview.subtitle": "Vo Fail2ban-UI gspeichereti Ereigniss über alli Connectorä.",
|
||||
"logs.overview.refresh": "Date aktualisiere",
|
||||
"logs.overview.total_events": "Total gspeichereti Ereigniss",
|
||||
"logs.overview.per_server": "Ereigniss pro Server",
|
||||
"logs.overview.recent_events_title": "Letschti gspeichereti Ereigniss",
|
||||
"logs.overview.recent_empty": "Kei gspeichereti Ereigniss für dä gwählte Server gfunde.",
|
||||
"logs.overview.empty": "No kei Sperr-Ereigniss protokolliert.",
|
||||
"logs.table.server": "Server",
|
||||
"logs.table.count": "Aazahl",
|
||||
"logs.table.jail": "Jail",
|
||||
"logs.table.ip": "IP",
|
||||
"logs.table.time": "Zyt",
|
||||
"logs.table.country": "Land",
|
||||
"filter_debug.title": "Filter Debug",
|
||||
"filter_debug.select_filter": "Wähl en Filter us",
|
||||
"filter_debug.log_lines": "Log-Zile",
|
||||
@@ -34,6 +61,8 @@
|
||||
"settings.language": "Sprach",
|
||||
"settings.enable_debug": "Debug-Modus aktivierä",
|
||||
"settings.alert": "Alarm-Istellige",
|
||||
"settings.callback_url": "Fail2ban Callback-URL",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.destination_email": "Ziil-Email (Alarmempfänger)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Alarm-Länder",
|
||||
@@ -64,8 +93,65 @@
|
||||
"modal.filter_config": "Filter-Konfiguration:",
|
||||
"modal.cancel": "Abbräche",
|
||||
"modal.save": "Speicherä",
|
||||
"modal.close": "Zue",
|
||||
"loading": "Lade...",
|
||||
"dashboard.manage_jails": "Jails ala oder absteue",
|
||||
"modal.manage_jails_title": "Jails ala oder absteue"
|
||||
"modal.manage_jails_title": "Jails ala oder absteue",
|
||||
"servers.selector.label": "Aktiver Server",
|
||||
"servers.selector.empty": "Kei Server konfiguriert",
|
||||
"servers.selector.none": "Kei Server konfiguriert. Bitte füeg ä Fail2ban-Server dezue.",
|
||||
"servers.modal.title": "Fail2ban-Server verwalte",
|
||||
"servers.modal.description": "Registrier entfernti Fail2ban-Instanze und wähl, wie s UI sich verbindet.",
|
||||
"servers.modal.list_title": "Registrierti Server",
|
||||
"servers.modal.list_empty": "Kei Server konfiguriert. Füeg rechts din erschte Fail2ban-Server dezue.",
|
||||
"servers.modal.form_title": "Server hinzuefüege oder bearbeite",
|
||||
"servers.form.name": "Aazeigname",
|
||||
"servers.form.name_placeholder": "Mi Fail2ban-Server",
|
||||
"servers.form.type": "Verbindigstyp",
|
||||
"servers.type.local": "Lokal (gliiche Host)",
|
||||
"servers.type.ssh": "SSH",
|
||||
"servers.type.agent": "API-Agent",
|
||||
"servers.form.host": "Hostname / IP",
|
||||
"servers.form.host_placeholder": "fail2ban.beispiel.ch",
|
||||
"servers.form.port": "Port",
|
||||
"servers.form.port_placeholder": "22",
|
||||
"servers.form.socket_path": "Fail2ban-Socket-Pfad",
|
||||
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
|
||||
"servers.form.log_path": "Fail2ban-Logpfad",
|
||||
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
|
||||
"servers.form.hostname": "Server-Hostname",
|
||||
"servers.form.hostname_placeholder": "optional",
|
||||
"servers.form.ssh_user": "SSH-Benutzer",
|
||||
"servers.form.ssh_user_placeholder": "root",
|
||||
"servers.form.ssh_key": "Pfad zum SSH-Schlüssel",
|
||||
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
|
||||
"servers.form.agent_url": "Agent-URL",
|
||||
"servers.form.agent_url_placeholder": "https://host:9443",
|
||||
"servers.form.agent_secret": "Agent-Secret",
|
||||
"servers.form.agent_secret_placeholder": "teilts Geheimnis",
|
||||
"servers.form.tags": "Tags",
|
||||
"servers.form.tags_placeholder": "Komma-trennte Tags",
|
||||
"servers.form.set_default": "Als Standard-Server setze",
|
||||
"servers.form.enabled": "Connector aktivierä",
|
||||
"servers.form.submit": "Server speichere",
|
||||
"servers.form.reset": "Zruggsetze",
|
||||
"servers.form.success": "Server erfolgriich gspeicheret.",
|
||||
"servers.badge.default": "Standard",
|
||||
"servers.badge.enabled": "Aktiv",
|
||||
"servers.badge.disabled": "Deaktiviert",
|
||||
"servers.actions.edit": "Bearbeite",
|
||||
"servers.actions.set_default": "Als Standard setze",
|
||||
"servers.actions.enable": "Aktivierä",
|
||||
"servers.actions.disable": "Deaktivierä",
|
||||
"servers.actions.test": "Verbindig teste",
|
||||
"servers.actions.test_success": "Verbindig erfolgriich",
|
||||
"servers.actions.test_failure": "Verbindig nöd möglich",
|
||||
"servers.actions.delete": "Lösche",
|
||||
"servers.actions.delete_confirm": "Dä Servereintrag lösche?",
|
||||
"servers.form.select_key": "Priväte Schlissel ufwähle",
|
||||
"servers.form.select_key_placeholder": "Manuäll igäh",
|
||||
"servers.form.no_keys": "Kei SSH-Schlüssel gfunde; Pfad selber igäh",
|
||||
"filter_debug.not_available": "Filter-Debug git's nur für lokal Connectorä.",
|
||||
"filter_debug.local_missing": "S lokale Fail2ban-Filterverzeichnis isch uf däm Host nid gfunde worde."
|
||||
}
|
||||
|
||||
@@ -7,8 +7,21 @@
|
||||
"restart_banner.button": "Restart Service",
|
||||
"dashboard.title": "Dashboard",
|
||||
"dashboard.overview": "Overview active Jails and Blocks",
|
||||
"dashboard.overview_hint": "Use the search to filter banned IPs and click a jail to edit its configuration.",
|
||||
"dashboard.search_label": "Search Banned IPs",
|
||||
"dashboard.search_placeholder": "Enter IP address to search",
|
||||
"dashboard.external_ip": "Your ext. IP:",
|
||||
"dashboard.manage_servers": "Manage Servers",
|
||||
"dashboard.no_servers_title": "No Fail2ban servers configured",
|
||||
"dashboard.no_servers_body": "Add a server to start monitoring and controlling Fail2ban instances.",
|
||||
"dashboard.loading_summary": "Loading summary data…",
|
||||
"dashboard.errors.summary_failed": "Failed to load summary from server.",
|
||||
"dashboard.no_enabled_servers_title": "No active connectors",
|
||||
"dashboard.no_enabled_servers_body": "Enable the local connector or register a remote Fail2ban server to see live data.",
|
||||
"dashboard.cards.active_jails": "Active Jails",
|
||||
"dashboard.cards.total_banned": "Total Banned IPs",
|
||||
"dashboard.cards.new_last_hour": "New Last Hour",
|
||||
"dashboard.cards.total_logged": "Stored Ban Events",
|
||||
"dashboard.table.jail_name": "Jail Name",
|
||||
"dashboard.table.total_banned": "Total Banned",
|
||||
"dashboard.table.new_last_hour": "New Last Hour",
|
||||
@@ -22,6 +35,20 @@
|
||||
"dashboard.no_recent_bans": "No recent bans found.",
|
||||
"dashboard.no_banned_ips": "No banned IPs",
|
||||
"dashboard.unban": "Unban",
|
||||
"logs.overview.title": "Internal Log Overview",
|
||||
"logs.overview.subtitle": "Events stored by Fail2ban-UI across all connectors.",
|
||||
"logs.overview.refresh": "Refresh data",
|
||||
"logs.overview.total_events": "Total stored events",
|
||||
"logs.overview.per_server": "Events per server",
|
||||
"logs.overview.recent_events_title": "Recent stored events",
|
||||
"logs.overview.recent_empty": "No stored events found for the selected server.",
|
||||
"logs.overview.empty": "No ban events recorded yet.",
|
||||
"logs.table.server": "Server",
|
||||
"logs.table.count": "Count",
|
||||
"logs.table.jail": "Jail",
|
||||
"logs.table.ip": "IP",
|
||||
"logs.table.time": "Time",
|
||||
"logs.table.country": "Country",
|
||||
"filter_debug.title": "Filter Debug",
|
||||
"filter_debug.select_filter": "Select a Filter",
|
||||
"filter_debug.log_lines": "Log Lines",
|
||||
@@ -34,6 +61,8 @@
|
||||
"settings.language": "Language",
|
||||
"settings.enable_debug": "Enable Debug Log",
|
||||
"settings.alert": "Alert Settings",
|
||||
"settings.callback_url": "Fail2ban Callback URL",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.destination_email": "Destination Email (Alerts Receiver)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Alert Countries",
|
||||
@@ -64,8 +93,65 @@
|
||||
"modal.filter_config": "Filter Config:",
|
||||
"modal.cancel": "Cancel",
|
||||
"modal.save": "Save",
|
||||
"modal.close": "Close",
|
||||
"loading": "Loading...",
|
||||
"dashboard.manage_jails": "Manage Jails",
|
||||
"modal.manage_jails_title": "Manage Jails"
|
||||
"modal.manage_jails_title": "Manage Jails",
|
||||
"servers.selector.label": "Active Server",
|
||||
"servers.selector.empty": "No servers configured",
|
||||
"servers.selector.none": "No server configured. Please add a Fail2ban server.",
|
||||
"servers.modal.title": "Manage Fail2ban Servers",
|
||||
"servers.modal.description": "Register remote Fail2ban instances and choose how the UI connects to them.",
|
||||
"servers.modal.list_title": "Registered Servers",
|
||||
"servers.modal.list_empty": "No servers configured yet. Add your first Fail2ban server using the form on the right.",
|
||||
"servers.modal.form_title": "Add or Update Server",
|
||||
"servers.form.name": "Display Name",
|
||||
"servers.form.name_placeholder": "My Fail2ban server",
|
||||
"servers.form.type": "Connection Type",
|
||||
"servers.type.local": "Local (same host)",
|
||||
"servers.type.ssh": "SSH",
|
||||
"servers.type.agent": "API Agent",
|
||||
"servers.form.host": "Hostname / IP",
|
||||
"servers.form.host_placeholder": "fail2ban.example.com",
|
||||
"servers.form.port": "Port",
|
||||
"servers.form.port_placeholder": "22",
|
||||
"servers.form.socket_path": "Fail2ban Socket Path",
|
||||
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
|
||||
"servers.form.log_path": "Fail2ban Log Path",
|
||||
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
|
||||
"servers.form.hostname": "Server Hostname",
|
||||
"servers.form.hostname_placeholder": "optional",
|
||||
"servers.form.ssh_user": "SSH User",
|
||||
"servers.form.ssh_user_placeholder": "root",
|
||||
"servers.form.ssh_key": "SSH Private Key Path",
|
||||
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
|
||||
"servers.form.agent_url": "Agent URL",
|
||||
"servers.form.agent_url_placeholder": "https://host:9443",
|
||||
"servers.form.agent_secret": "Agent Secret",
|
||||
"servers.form.agent_secret_placeholder": "shared secret token",
|
||||
"servers.form.tags": "Tags",
|
||||
"servers.form.tags_placeholder": "comma,separated,tags",
|
||||
"servers.form.set_default": "Set as default server",
|
||||
"servers.form.enabled": "Enable connector",
|
||||
"servers.form.submit": "Save Server",
|
||||
"servers.form.reset": "Reset",
|
||||
"servers.form.success": "Server saved successfully.",
|
||||
"servers.badge.default": "Default",
|
||||
"servers.badge.enabled": "Enabled",
|
||||
"servers.badge.disabled": "Disabled",
|
||||
"servers.actions.edit": "Edit",
|
||||
"servers.actions.set_default": "Set default",
|
||||
"servers.actions.enable": "Enable",
|
||||
"servers.actions.disable": "Disable",
|
||||
"servers.actions.test": "Test connection",
|
||||
"servers.actions.test_success": "Connection successful",
|
||||
"servers.actions.test_failure": "Connection failed",
|
||||
"servers.actions.delete": "Delete",
|
||||
"servers.actions.delete_confirm": "Delete this server entry?",
|
||||
"servers.form.select_key": "Select Private Key",
|
||||
"servers.form.select_key_placeholder": "Manual entry",
|
||||
"servers.form.no_keys": "No SSH keys found; enter path manually",
|
||||
"filter_debug.not_available": "Filter debug is only available for local connectors.",
|
||||
"filter_debug.local_missing": "The local Fail2ban filter directory was not found on this host."
|
||||
}
|
||||
|
||||
@@ -7,8 +7,21 @@
|
||||
"restart_banner.button": "Reiniciar servicio",
|
||||
"dashboard.title": "Panel de control",
|
||||
"dashboard.overview": "Resumen de Jails y Bloqueos activos",
|
||||
"dashboard.overview_hint": "Usa la búsqueda para filtrar IPs bloqueadas y haz clic en un jail para editar su configuración.",
|
||||
"dashboard.search_label": "Buscar IP bloqueadas",
|
||||
"dashboard.search_placeholder": "Introduce la dirección IP a buscar",
|
||||
"dashboard.external_ip": "Tu IP ext.:",
|
||||
"dashboard.manage_servers": "Administrar servidores",
|
||||
"dashboard.no_servers_title": "No hay servidores Fail2ban configurados",
|
||||
"dashboard.no_servers_body": "Añade un servidor para empezar a supervisar y controlar instancias de Fail2ban.",
|
||||
"dashboard.loading_summary": "Cargando resumen…",
|
||||
"dashboard.no_enabled_servers_title": "Sin conectores activos",
|
||||
"dashboard.no_enabled_servers_body": "Activa el conector local o registra un servidor Fail2ban remoto para ver datos en vivo.",
|
||||
"dashboard.errors.summary_failed": "No se pudo cargar el resumen desde el servidor.",
|
||||
"dashboard.cards.active_jails": "Jails activos",
|
||||
"dashboard.cards.total_banned": "IPs bloqueadas totales",
|
||||
"dashboard.cards.new_last_hour": "Nuevas en la última hora",
|
||||
"dashboard.cards.total_logged": "Eventos de bloqueo almacenados",
|
||||
"dashboard.table.jail_name": "Nombre del Jail",
|
||||
"dashboard.table.total_banned": "Total bloqueadas",
|
||||
"dashboard.table.new_last_hour": "Nuevas en la última hora",
|
||||
@@ -22,6 +35,20 @@
|
||||
"dashboard.no_recent_bans": "No se encontraron bloqueos recientes.",
|
||||
"dashboard.no_banned_ips": "No hay IP bloqueadas",
|
||||
"dashboard.unban": "Desbloquear",
|
||||
"logs.overview.title": "Resumen interno de registros",
|
||||
"logs.overview.subtitle": "Eventos almacenados por Fail2ban-UI a través de todos los conectores.",
|
||||
"logs.overview.refresh": "Actualizar datos",
|
||||
"logs.overview.total_events": "Eventos almacenados totales",
|
||||
"logs.overview.per_server": "Eventos por servidor",
|
||||
"logs.overview.recent_events_title": "Eventos almacenados recientes",
|
||||
"logs.overview.recent_empty": "No se encontraron eventos almacenados para el servidor seleccionado.",
|
||||
"logs.overview.empty": "Aún no se han registrado eventos de bloqueo.",
|
||||
"logs.table.server": "Servidor",
|
||||
"logs.table.count": "Cantidad",
|
||||
"logs.table.jail": "Jail",
|
||||
"logs.table.ip": "IP",
|
||||
"logs.table.time": "Hora",
|
||||
"logs.table.country": "País",
|
||||
"filter_debug.title": "Depuración de filtros",
|
||||
"filter_debug.select_filter": "Selecciona un filtro",
|
||||
"filter_debug.log_lines": "Líneas de log",
|
||||
@@ -34,6 +61,8 @@
|
||||
"settings.language": "Idioma",
|
||||
"settings.enable_debug": "Habilitar el modo de depuración",
|
||||
"settings.alert": "Configuración de alertas",
|
||||
"settings.callback_url": "URL de retorno de Fail2ban",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.destination_email": "Correo electrónico de destino (receptor de alertas)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Países para alerta",
|
||||
@@ -64,7 +93,64 @@
|
||||
"modal.filter_config": "Configuración del filtro:",
|
||||
"modal.cancel": "Cancelar",
|
||||
"modal.save": "Guardar",
|
||||
"modal.close": "Cerrar",
|
||||
"loading": "Cargando...",
|
||||
"dashboard.manage_jails": "Administrar jails",
|
||||
"modal.manage_jails_title": "Administrar jails"
|
||||
"modal.manage_jails_title": "Administrar jails",
|
||||
"servers.selector.label": "Servidor activo",
|
||||
"servers.selector.empty": "No hay servidores configurados",
|
||||
"servers.selector.none": "No hay servidor configurado. Añade un servidor Fail2ban.",
|
||||
"servers.modal.title": "Administrar servidores Fail2ban",
|
||||
"servers.modal.description": "Registra instancias remotas de Fail2ban y elige cómo se conecta la interfaz.",
|
||||
"servers.modal.list_title": "Servidores registrados",
|
||||
"servers.modal.list_empty": "No hay servidores configurados. Añade tu primer servidor Fail2ban usando el formulario.",
|
||||
"servers.modal.form_title": "Añadir o actualizar servidor",
|
||||
"servers.form.name": "Nombre para mostrar",
|
||||
"servers.form.name_placeholder": "Mi servidor Fail2ban",
|
||||
"servers.form.type": "Tipo de conexión",
|
||||
"servers.type.local": "Local (mismo host)",
|
||||
"servers.type.ssh": "SSH",
|
||||
"servers.type.agent": "Agente API",
|
||||
"servers.form.host": "Nombre de host / IP",
|
||||
"servers.form.host_placeholder": "fail2ban.ejemplo.com",
|
||||
"servers.form.port": "Puerto",
|
||||
"servers.form.port_placeholder": "22",
|
||||
"servers.form.socket_path": "Ruta del socket de Fail2ban",
|
||||
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
|
||||
"servers.form.log_path": "Ruta del log de Fail2ban",
|
||||
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
|
||||
"servers.form.hostname": "Nombre de host del servidor",
|
||||
"servers.form.hostname_placeholder": "opcional",
|
||||
"servers.form.ssh_user": "Usuario SSH",
|
||||
"servers.form.ssh_user_placeholder": "root",
|
||||
"servers.form.ssh_key": "Ruta de la clave SSH",
|
||||
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
|
||||
"servers.form.agent_url": "URL del agente",
|
||||
"servers.form.agent_url_placeholder": "https://host:9443",
|
||||
"servers.form.agent_secret": "Secreto del agente",
|
||||
"servers.form.agent_secret_placeholder": "token compartido",
|
||||
"servers.form.tags": "Etiquetas",
|
||||
"servers.form.tags_placeholder": "etiquetas separadas por comas",
|
||||
"servers.form.set_default": "Establecer como servidor predeterminado",
|
||||
"servers.form.enabled": "Habilitar conector",
|
||||
"servers.form.submit": "Guardar servidor",
|
||||
"servers.form.reset": "Restablecer",
|
||||
"servers.form.success": "Servidor guardado correctamente.",
|
||||
"servers.badge.default": "Predeterminado",
|
||||
"servers.badge.enabled": "Habilitado",
|
||||
"servers.badge.disabled": "Deshabilitado",
|
||||
"servers.actions.edit": "Editar",
|
||||
"servers.actions.set_default": "Establecer predeterminado",
|
||||
"servers.actions.enable": "Habilitar",
|
||||
"servers.actions.disable": "Deshabilitar",
|
||||
"servers.actions.test": "Probar conexión",
|
||||
"servers.actions.test_success": "Conexión exitosa",
|
||||
"servers.actions.test_failure": "Conexión fallida",
|
||||
"servers.actions.delete": "Eliminar",
|
||||
"servers.actions.delete_confirm": "¿Eliminar este servidor?",
|
||||
"servers.form.select_key": "Seleccionar clave privada",
|
||||
"servers.form.select_key_placeholder": "Entrada manual",
|
||||
"servers.form.no_keys": "No se encontraron claves SSH; introduzca la ruta manualmente",
|
||||
"filter_debug.not_available": "La depuración de filtros solo está disponible para conectores locales.",
|
||||
"filter_debug.local_missing": "No se encontró el directorio de filtros local de Fail2ban en este host."
|
||||
}
|
||||
|
||||
@@ -7,8 +7,21 @@
|
||||
"restart_banner.button": "Redémarrer le service",
|
||||
"dashboard.title": "Tableau de bord",
|
||||
"dashboard.overview": "Vue d'ensemble des jails et blocages actifs",
|
||||
"dashboard.overview_hint": "Utilisez la recherche pour filtrer les IP bloquées et cliquez sur un jail pour modifier sa configuration.",
|
||||
"dashboard.search_label": "Rechercher des IP bloquées",
|
||||
"dashboard.search_placeholder": "Entrez l'adresse IP à rechercher",
|
||||
"dashboard.external_ip": "Votre IP ext. :",
|
||||
"dashboard.manage_servers": "Gérer les serveurs",
|
||||
"dashboard.no_servers_title": "Aucun serveur Fail2ban configuré",
|
||||
"dashboard.no_servers_body": "Ajoutez un serveur pour commencer à superviser et contrôler les instances Fail2ban.",
|
||||
"dashboard.loading_summary": "Chargement du résumé…",
|
||||
"dashboard.no_enabled_servers_title": "Aucun connecteur actif",
|
||||
"dashboard.no_enabled_servers_body": "Activez le connecteur local ou enregistrez un serveur Fail2ban distant pour voir les données en direct.",
|
||||
"dashboard.errors.summary_failed": "Impossible de charger le résumé depuis le serveur.",
|
||||
"dashboard.cards.active_jails": "Jails actifs",
|
||||
"dashboard.cards.total_banned": "Total d'IPs bloquées",
|
||||
"dashboard.cards.new_last_hour": "Nouvelles dans la dernière heure",
|
||||
"dashboard.cards.total_logged": "Événements de blocage enregistrés",
|
||||
"dashboard.table.jail_name": "Nom du Jail",
|
||||
"dashboard.table.total_banned": "Total bloqués",
|
||||
"dashboard.table.new_last_hour": "Nouveaux dans la dernière heure",
|
||||
@@ -22,6 +35,20 @@
|
||||
"dashboard.no_recent_bans": "Aucun blocage récent trouvé.",
|
||||
"dashboard.no_banned_ips": "Aucune IP bloquée",
|
||||
"dashboard.unban": "Débloquer",
|
||||
"logs.overview.title": "Vue d'ensemble interne des journaux",
|
||||
"logs.overview.subtitle": "Événements enregistrés par Fail2ban-UI sur l'ensemble des connecteurs.",
|
||||
"logs.overview.refresh": "Actualiser les données",
|
||||
"logs.overview.total_events": "Total d'événements enregistrés",
|
||||
"logs.overview.per_server": "Événements par serveur",
|
||||
"logs.overview.recent_events_title": "Événements enregistrés récents",
|
||||
"logs.overview.recent_empty": "Aucun événement enregistré trouvé pour le serveur sélectionné.",
|
||||
"logs.overview.empty": "Aucun événement de blocage n'a encore été enregistré.",
|
||||
"logs.table.server": "Serveur",
|
||||
"logs.table.count": "Nombre",
|
||||
"logs.table.jail": "Jail",
|
||||
"logs.table.ip": "IP",
|
||||
"logs.table.time": "Heure",
|
||||
"logs.table.country": "Pays",
|
||||
"filter_debug.title": "Débogage des filtres",
|
||||
"filter_debug.select_filter": "Sélectionnez un filtre",
|
||||
"filter_debug.log_lines": "Lignes de log",
|
||||
@@ -34,6 +61,8 @@
|
||||
"settings.language": "Langue",
|
||||
"settings.enable_debug": "Activer le mode débogage",
|
||||
"settings.alert": "Paramètres d'alerte",
|
||||
"settings.callback_url": "URL de rappel Fail2ban",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.destination_email": "Email de destination (récepteur des alertes)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Pays d'alerte",
|
||||
@@ -64,7 +93,64 @@
|
||||
"modal.filter_config": "Configuration du filtre:",
|
||||
"modal.cancel": "Annuler",
|
||||
"modal.save": "Enregistrer",
|
||||
"modal.close": "Fermer",
|
||||
"loading": "Chargement...",
|
||||
"dashboard.manage_jails": "Gérer les jails",
|
||||
"modal.manage_jails_title": "Gérer les jails"
|
||||
"modal.manage_jails_title": "Gérer les jails",
|
||||
"servers.selector.label": "Serveur actif",
|
||||
"servers.selector.empty": "Aucun serveur configuré",
|
||||
"servers.selector.none": "Aucun serveur configuré. Veuillez ajouter un serveur Fail2ban.",
|
||||
"servers.modal.title": "Gérer les serveurs Fail2ban",
|
||||
"servers.modal.description": "Enregistrez des instances Fail2ban distantes et choisissez comment l'interface s'y connecte.",
|
||||
"servers.modal.list_title": "Serveurs enregistrés",
|
||||
"servers.modal.list_empty": "Aucun serveur configuré. Ajoutez votre premier serveur Fail2ban via le formulaire.",
|
||||
"servers.modal.form_title": "Ajouter ou mettre à jour un serveur",
|
||||
"servers.form.name": "Nom à afficher",
|
||||
"servers.form.name_placeholder": "Mon serveur Fail2ban",
|
||||
"servers.form.type": "Type de connexion",
|
||||
"servers.type.local": "Local (même hôte)",
|
||||
"servers.type.ssh": "SSH",
|
||||
"servers.type.agent": "Agent API",
|
||||
"servers.form.host": "Nom d'hôte / IP",
|
||||
"servers.form.host_placeholder": "fail2ban.exemple.com",
|
||||
"servers.form.port": "Port",
|
||||
"servers.form.port_placeholder": "22",
|
||||
"servers.form.socket_path": "Chemin du socket Fail2ban",
|
||||
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
|
||||
"servers.form.log_path": "Chemin du log Fail2ban",
|
||||
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
|
||||
"servers.form.hostname": "Nom d'hôte du serveur",
|
||||
"servers.form.hostname_placeholder": "optionnel",
|
||||
"servers.form.ssh_user": "Utilisateur SSH",
|
||||
"servers.form.ssh_user_placeholder": "root",
|
||||
"servers.form.ssh_key": "Chemin de la clé SSH",
|
||||
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
|
||||
"servers.form.agent_url": "URL de l'agent",
|
||||
"servers.form.agent_url_placeholder": "https://host:9443",
|
||||
"servers.form.agent_secret": "Secret de l'agent",
|
||||
"servers.form.agent_secret_placeholder": "jeton partagé",
|
||||
"servers.form.tags": "Étiquettes",
|
||||
"servers.form.tags_placeholder": "étiquettes séparées par des virgules",
|
||||
"servers.form.set_default": "Définir comme serveur par défaut",
|
||||
"servers.form.enabled": "Activer le connecteur",
|
||||
"servers.form.submit": "Enregistrer le serveur",
|
||||
"servers.form.reset": "Réinitialiser",
|
||||
"servers.form.success": "Serveur enregistré avec succès.",
|
||||
"servers.badge.default": "Par défaut",
|
||||
"servers.badge.enabled": "Activé",
|
||||
"servers.badge.disabled": "Désactivé",
|
||||
"servers.actions.edit": "Modifier",
|
||||
"servers.actions.set_default": "Définir par défaut",
|
||||
"servers.actions.enable": "Activer",
|
||||
"servers.actions.disable": "Désactiver",
|
||||
"servers.actions.test": "Tester la connexion",
|
||||
"servers.actions.test_success": "Connexion réussie",
|
||||
"servers.actions.test_failure": "Échec de la connexion",
|
||||
"servers.actions.delete": "Supprimer",
|
||||
"servers.actions.delete_confirm": "Supprimer ce serveur ?",
|
||||
"servers.form.select_key": "Sélectionner la clé privée",
|
||||
"servers.form.select_key_placeholder": "Saisie manuelle",
|
||||
"servers.form.no_keys": "Aucune clé SSH trouvée ; saisissez le chemin manuellement",
|
||||
"filter_debug.not_available": "Le débogage des filtres n'est disponible que pour les connecteurs locaux.",
|
||||
"filter_debug.local_missing": "Le répertoire de filtres Fail2ban local est introuvable sur cet hôte."
|
||||
}
|
||||
|
||||
@@ -7,8 +7,21 @@
|
||||
"restart_banner.button": "Riavvia il servizio",
|
||||
"dashboard.title": "Cruscotto",
|
||||
"dashboard.overview": "Panoramica dei jail e dei blocchi attivi",
|
||||
"dashboard.overview_hint": "Usa la ricerca per filtrare le IP bloccate e fai clic su un jail per modificarne la configurazione.",
|
||||
"dashboard.search_label": "Cerca IP bloccate",
|
||||
"dashboard.search_placeholder": "Inserisci l'indirizzo IP da cercare",
|
||||
"dashboard.external_ip": "La tua IP ext.:",
|
||||
"dashboard.manage_servers": "Gestisci server",
|
||||
"dashboard.no_servers_title": "Nessun server Fail2ban configurato",
|
||||
"dashboard.no_servers_body": "Aggiungi un server per iniziare a monitorare e controllare le istanze Fail2ban.",
|
||||
"dashboard.loading_summary": "Caricamento del riepilogo…",
|
||||
"dashboard.no_enabled_servers_title": "Nessun connettore attivo",
|
||||
"dashboard.no_enabled_servers_body": "Abilita il connettore locale o registra un server Fail2ban remoto per visualizzare dati in tempo reale.",
|
||||
"dashboard.errors.summary_failed": "Impossibile caricare il riepilogo dal server.",
|
||||
"dashboard.cards.active_jails": "Jail attivi",
|
||||
"dashboard.cards.total_banned": "IP bloccate totali",
|
||||
"dashboard.cards.new_last_hour": "Nuove nell'ultima ora",
|
||||
"dashboard.cards.total_logged": "Eventi di blocco memorizzati",
|
||||
"dashboard.table.jail_name": "Nome del Jail",
|
||||
"dashboard.table.total_banned": "Totale bloccate",
|
||||
"dashboard.table.new_last_hour": "Nuove nell'ultima ora",
|
||||
@@ -22,6 +35,20 @@
|
||||
"dashboard.no_recent_bans": "Nessun blocco recente trovato.",
|
||||
"dashboard.no_banned_ips": "Nessuna IP bloccata",
|
||||
"dashboard.unban": "Sblocca",
|
||||
"logs.overview.title": "Panoramica interna dei log",
|
||||
"logs.overview.subtitle": "Eventi memorizzati da Fail2ban-UI su tutti i connettori.",
|
||||
"logs.overview.refresh": "Aggiorna dati",
|
||||
"logs.overview.total_events": "Eventi memorizzati totali",
|
||||
"logs.overview.per_server": "Eventi per server",
|
||||
"logs.overview.recent_events_title": "Eventi memorizzati recenti",
|
||||
"logs.overview.recent_empty": "Nessun evento memorizzato trovato per il server selezionato.",
|
||||
"logs.overview.empty": "Nessun evento di blocco è stato ancora registrato.",
|
||||
"logs.table.server": "Server",
|
||||
"logs.table.count": "Conteggio",
|
||||
"logs.table.jail": "Jail",
|
||||
"logs.table.ip": "IP",
|
||||
"logs.table.time": "Ora",
|
||||
"logs.table.country": "Paese",
|
||||
"filter_debug.title": "Debug Filtro",
|
||||
"filter_debug.select_filter": "Seleziona un filtro",
|
||||
"filter_debug.log_lines": "Righe di log",
|
||||
@@ -34,6 +61,8 @@
|
||||
"settings.language": "Lingua",
|
||||
"settings.enable_debug": "Abilita debug",
|
||||
"settings.alert": "Impostazioni di allarme",
|
||||
"settings.callback_url": "URL di callback Fail2ban",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.destination_email": "Email di destinazione (ricevente allarmi)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Paesi per allarme",
|
||||
@@ -64,7 +93,64 @@
|
||||
"modal.filter_config": "Configurazione del filtro:",
|
||||
"modal.cancel": "Annulla",
|
||||
"modal.save": "Salva",
|
||||
"modal.close": "Chiudi",
|
||||
"loading": "Caricamento...",
|
||||
"dashboard.manage_jails": "Gestire i jails",
|
||||
"modal.manage_jails_title": "Gestire i jails"
|
||||
"modal.manage_jails_title": "Gestire i jails",
|
||||
"servers.selector.label": "Server attivo",
|
||||
"servers.selector.empty": "Nessun server configurato",
|
||||
"servers.selector.none": "Nessun server configurato. Aggiungi un server Fail2ban.",
|
||||
"servers.modal.title": "Gestisci i server Fail2ban",
|
||||
"servers.modal.description": "Registra istanze Fail2ban remote e scegli come l'interfaccia si connette.",
|
||||
"servers.modal.list_title": "Server registrati",
|
||||
"servers.modal.list_empty": "Nessun server configurato. Aggiungi il tuo primo server Fail2ban tramite il modulo.",
|
||||
"servers.modal.form_title": "Aggiungi o aggiorna un server",
|
||||
"servers.form.name": "Nome visualizzato",
|
||||
"servers.form.name_placeholder": "Il mio server Fail2ban",
|
||||
"servers.form.type": "Tipo di connessione",
|
||||
"servers.type.local": "Locale (stesso host)",
|
||||
"servers.type.ssh": "SSH",
|
||||
"servers.type.agent": "Agente API",
|
||||
"servers.form.host": "Nome host / IP",
|
||||
"servers.form.host_placeholder": "fail2ban.esempio.com",
|
||||
"servers.form.port": "Porta",
|
||||
"servers.form.port_placeholder": "22",
|
||||
"servers.form.socket_path": "Percorso del socket Fail2ban",
|
||||
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
|
||||
"servers.form.log_path": "Percorso del log Fail2ban",
|
||||
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
|
||||
"servers.form.hostname": "Nome host del server",
|
||||
"servers.form.hostname_placeholder": "opzionale",
|
||||
"servers.form.ssh_user": "Utente SSH",
|
||||
"servers.form.ssh_user_placeholder": "root",
|
||||
"servers.form.ssh_key": "Percorso della chiave SSH",
|
||||
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
|
||||
"servers.form.agent_url": "URL dell'agente",
|
||||
"servers.form.agent_url_placeholder": "https://host:9443",
|
||||
"servers.form.agent_secret": "Segreto dell'agente",
|
||||
"servers.form.agent_secret_placeholder": "token condiviso",
|
||||
"servers.form.tags": "Tag",
|
||||
"servers.form.tags_placeholder": "tag separati da virgole",
|
||||
"servers.form.set_default": "Imposta come server predefinito",
|
||||
"servers.form.enabled": "Abilita connettore",
|
||||
"servers.form.submit": "Salva server",
|
||||
"servers.form.reset": "Reimposta",
|
||||
"servers.form.success": "Server salvato correttamente.",
|
||||
"servers.badge.default": "Predefinito",
|
||||
"servers.badge.enabled": "Abilitato",
|
||||
"servers.badge.disabled": "Disabilitato",
|
||||
"servers.actions.edit": "Modifica",
|
||||
"servers.actions.set_default": "Imposta predefinito",
|
||||
"servers.actions.enable": "Abilita",
|
||||
"servers.actions.disable": "Disabilita",
|
||||
"servers.actions.test": "Verifica connessione",
|
||||
"servers.actions.test_success": "Connessione riuscita",
|
||||
"servers.actions.test_failure": "Connessione fallita",
|
||||
"servers.actions.delete": "Elimina",
|
||||
"servers.actions.delete_confirm": "Eliminare questo server?",
|
||||
"servers.form.select_key": "Seleziona chiave privata",
|
||||
"servers.form.select_key_placeholder": "Inserimento manuale",
|
||||
"servers.form.no_keys": "Nessuna chiave SSH trovata; inserire il percorso manualmente",
|
||||
"filter_debug.not_available": "Il debug dei filtri è disponibile solo per i connettori locali.",
|
||||
"filter_debug.local_missing": "La directory dei filtri Fail2ban locale non è stata trovata su questo host."
|
||||
}
|
||||
|
||||
607
internal/storage/storage.go
Normal file
607
internal/storage/storage.go
Normal file
@@ -0,0 +1,607 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
initOnce sync.Once
|
||||
initErr error
|
||||
defaultPath = "fail2ban-ui.db"
|
||||
)
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func intToBool(i int) bool {
|
||||
return i != 0
|
||||
}
|
||||
|
||||
func stringFromNull(ns sql.NullString) string {
|
||||
if ns.Valid {
|
||||
return ns.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func intFromNull(ni sql.NullInt64) int {
|
||||
if ni.Valid {
|
||||
return int(ni.Int64)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type AppSettingsRecord struct {
|
||||
Language string
|
||||
Port int
|
||||
Debug bool
|
||||
CallbackURL string
|
||||
RestartNeeded bool
|
||||
AlertCountriesJSON string
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPFrom string
|
||||
SMTPUseTLS bool
|
||||
BantimeIncrement bool
|
||||
IgnoreIP string
|
||||
Bantime string
|
||||
Findtime string
|
||||
MaxRetry int
|
||||
DestEmail string
|
||||
}
|
||||
|
||||
type ServerRecord struct {
|
||||
ID string
|
||||
Name string
|
||||
Type string
|
||||
Host string
|
||||
Port int
|
||||
SocketPath string
|
||||
LogPath string
|
||||
SSHUser string
|
||||
SSHKeyPath string
|
||||
AgentURL string
|
||||
AgentSecret string
|
||||
Hostname string
|
||||
TagsJSON string
|
||||
IsDefault bool
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// BanEventRecord represents a single ban event stored in the internal database.
|
||||
type BanEventRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
ServerID string `json:"serverId"`
|
||||
ServerName string `json:"serverName"`
|
||||
Jail string `json:"jail"`
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"country"`
|
||||
Hostname string `json:"hostname"`
|
||||
Failures string `json:"failures"`
|
||||
Whois string `json:"whois"`
|
||||
Logs string `json:"logs"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Init initializes the internal storage. Safe to call multiple times.
|
||||
func Init(dbPath string) error {
|
||||
initOnce.Do(func() {
|
||||
if dbPath == "" {
|
||||
dbPath = defaultPath
|
||||
}
|
||||
if err := ensureDirectory(dbPath); err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
db, err = sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout=5000", dbPath))
|
||||
if err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
|
||||
if err = db.Ping(); err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
|
||||
initErr = ensureSchema(context.Background())
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
// Close closes the underlying database if it has been initialised.
|
||||
func Close() error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
return db.Close()
|
||||
}
|
||||
|
||||
func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
|
||||
if db == nil {
|
||||
return AppSettingsRecord{}, false, errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
row := db.QueryRowContext(ctx, `
|
||||
SELECT language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail
|
||||
FROM app_settings
|
||||
WHERE id = 1`)
|
||||
|
||||
var (
|
||||
lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail sql.NullString
|
||||
port, smtpPort, maxretry sql.NullInt64
|
||||
debug, restartNeeded, smtpTLS, bantimeInc sql.NullInt64
|
||||
)
|
||||
|
||||
err := row.Scan(&lang, &port, &debug, &callback, &restartNeeded, &alerts, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &ignoreIP, &bantime, &findtime, &maxretry, &destemail)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AppSettingsRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return AppSettingsRecord{}, false, err
|
||||
}
|
||||
|
||||
rec := AppSettingsRecord{
|
||||
Language: stringFromNull(lang),
|
||||
Port: intFromNull(port),
|
||||
Debug: intToBool(intFromNull(debug)),
|
||||
CallbackURL: stringFromNull(callback),
|
||||
RestartNeeded: intToBool(intFromNull(restartNeeded)),
|
||||
AlertCountriesJSON: stringFromNull(alerts),
|
||||
SMTPHost: stringFromNull(smtpHost),
|
||||
SMTPPort: intFromNull(smtpPort),
|
||||
SMTPUsername: stringFromNull(smtpUser),
|
||||
SMTPPassword: stringFromNull(smtpPass),
|
||||
SMTPFrom: stringFromNull(smtpFrom),
|
||||
SMTPUseTLS: intToBool(intFromNull(smtpTLS)),
|
||||
BantimeIncrement: intToBool(intFromNull(bantimeInc)),
|
||||
IgnoreIP: stringFromNull(ignoreIP),
|
||||
Bantime: stringFromNull(bantime),
|
||||
Findtime: stringFromNull(findtime),
|
||||
MaxRetry: intFromNull(maxretry),
|
||||
DestEmail: stringFromNull(destemail),
|
||||
}
|
||||
|
||||
return rec, true, nil
|
||||
}
|
||||
|
||||
func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error {
|
||||
if db == nil {
|
||||
return errors.New("storage not initialised")
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO app_settings (
|
||||
id, language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
) ON CONFLICT(id) DO UPDATE SET
|
||||
language = excluded.language,
|
||||
port = excluded.port,
|
||||
debug = excluded.debug,
|
||||
callback_url = excluded.callback_url,
|
||||
restart_needed = excluded.restart_needed,
|
||||
alert_countries = excluded.alert_countries,
|
||||
smtp_host = excluded.smtp_host,
|
||||
smtp_port = excluded.smtp_port,
|
||||
smtp_username = excluded.smtp_username,
|
||||
smtp_password = excluded.smtp_password,
|
||||
smtp_from = excluded.smtp_from,
|
||||
smtp_use_tls = excluded.smtp_use_tls,
|
||||
bantime_increment = excluded.bantime_increment,
|
||||
ignore_ip = excluded.ignore_ip,
|
||||
bantime = excluded.bantime,
|
||||
findtime = excluded.findtime,
|
||||
maxretry = excluded.maxretry,
|
||||
destemail = excluded.destemail
|
||||
`, rec.Language,
|
||||
rec.Port,
|
||||
boolToInt(rec.Debug),
|
||||
rec.CallbackURL,
|
||||
boolToInt(rec.RestartNeeded),
|
||||
rec.AlertCountriesJSON,
|
||||
rec.SMTPHost,
|
||||
rec.SMTPPort,
|
||||
rec.SMTPUsername,
|
||||
rec.SMTPPassword,
|
||||
rec.SMTPFrom,
|
||||
boolToInt(rec.SMTPUseTLS),
|
||||
boolToInt(rec.BantimeIncrement),
|
||||
rec.IgnoreIP,
|
||||
rec.Bantime,
|
||||
rec.Findtime,
|
||||
rec.MaxRetry,
|
||||
rec.DestEmail,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func ListServers(ctx context.Context) ([]ServerRecord, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, name, type, host, port, socket_path, log_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, created_at, updated_at
|
||||
FROM servers
|
||||
ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []ServerRecord
|
||||
for rows.Next() {
|
||||
var rec ServerRecord
|
||||
var host, socket, logPath, sshUser, sshKey, agentURL, agentSecret, hostname, tags sql.NullString
|
||||
var name, serverType sql.NullString
|
||||
var created, updated sql.NullString
|
||||
var port sql.NullInt64
|
||||
var isDefault, enabled sql.NullInt64
|
||||
|
||||
if err := rows.Scan(
|
||||
&rec.ID,
|
||||
&name,
|
||||
&serverType,
|
||||
&host,
|
||||
&port,
|
||||
&socket,
|
||||
&logPath,
|
||||
&sshUser,
|
||||
&sshKey,
|
||||
&agentURL,
|
||||
&agentSecret,
|
||||
&hostname,
|
||||
&tags,
|
||||
&isDefault,
|
||||
&enabled,
|
||||
&created,
|
||||
&updated,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rec.Name = stringFromNull(name)
|
||||
rec.Type = stringFromNull(serverType)
|
||||
rec.Host = stringFromNull(host)
|
||||
rec.Port = intFromNull(port)
|
||||
rec.SocketPath = stringFromNull(socket)
|
||||
rec.LogPath = stringFromNull(logPath)
|
||||
rec.SSHUser = stringFromNull(sshUser)
|
||||
rec.SSHKeyPath = stringFromNull(sshKey)
|
||||
rec.AgentURL = stringFromNull(agentURL)
|
||||
rec.AgentSecret = stringFromNull(agentSecret)
|
||||
rec.Hostname = stringFromNull(hostname)
|
||||
rec.TagsJSON = stringFromNull(tags)
|
||||
rec.IsDefault = intToBool(intFromNull(isDefault))
|
||||
rec.Enabled = intToBool(intFromNull(enabled))
|
||||
|
||||
if created.Valid {
|
||||
if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil {
|
||||
rec.CreatedAt = t
|
||||
}
|
||||
}
|
||||
if updated.Valid {
|
||||
if t, err := time.Parse(time.RFC3339Nano, updated.String); err == nil {
|
||||
rec.UpdatedAt = t
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, rec)
|
||||
}
|
||||
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func ReplaceServers(ctx context.Context, servers []ServerRecord) error {
|
||||
if db == nil {
|
||||
return errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM servers`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO servers (
|
||||
id, name, type, host, port, socket_path, log_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, srv := range servers {
|
||||
createdAt := srv.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now().UTC()
|
||||
}
|
||||
updatedAt := srv.UpdatedAt
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = createdAt
|
||||
}
|
||||
if _, err = stmt.ExecContext(ctx,
|
||||
srv.ID,
|
||||
srv.Name,
|
||||
srv.Type,
|
||||
srv.Host,
|
||||
srv.Port,
|
||||
srv.SocketPath,
|
||||
srv.LogPath,
|
||||
srv.SSHUser,
|
||||
srv.SSHKeyPath,
|
||||
srv.AgentURL,
|
||||
srv.AgentSecret,
|
||||
srv.Hostname,
|
||||
srv.TagsJSON,
|
||||
boolToInt(srv.IsDefault),
|
||||
boolToInt(srv.Enabled),
|
||||
createdAt.Format(time.RFC3339Nano),
|
||||
updatedAt.Format(time.RFC3339Nano),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteServer(ctx context.Context, id string) error {
|
||||
if db == nil {
|
||||
return errors.New("storage not initialised")
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `DELETE FROM servers WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordBanEvent stores a ban event in the database.
|
||||
func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
|
||||
if db == nil {
|
||||
return errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
if record.ServerID == "" {
|
||||
return errors.New("server id is required")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if record.CreatedAt.IsZero() {
|
||||
record.CreatedAt = now
|
||||
}
|
||||
if record.OccurredAt.IsZero() {
|
||||
record.OccurredAt = now
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO ban_events (
|
||||
server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := db.ExecContext(
|
||||
ctx,
|
||||
query,
|
||||
record.ServerID,
|
||||
record.ServerName,
|
||||
record.Jail,
|
||||
record.IP,
|
||||
record.Country,
|
||||
record.Hostname,
|
||||
record.Failures,
|
||||
record.Whois,
|
||||
record.Logs,
|
||||
record.OccurredAt.UTC(),
|
||||
record.CreatedAt.UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListBanEvents returns ban events ordered by creation date descending.
|
||||
func ListBanEvents(ctx context.Context, serverID string, limit int, since time.Time) ([]BanEventRecord, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
baseQuery := `
|
||||
SELECT id, server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at
|
||||
FROM ban_events
|
||||
WHERE 1=1`
|
||||
|
||||
args := []any{}
|
||||
if serverID != "" {
|
||||
baseQuery += " AND server_id = ?"
|
||||
args = append(args, serverID)
|
||||
}
|
||||
if !since.IsZero() {
|
||||
baseQuery += " AND occurred_at >= ?"
|
||||
args = append(args, since.UTC())
|
||||
}
|
||||
|
||||
baseQuery += " ORDER BY occurred_at DESC LIMIT ?"
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := db.QueryContext(ctx, baseQuery, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []BanEventRecord
|
||||
for rows.Next() {
|
||||
var rec BanEventRecord
|
||||
if err := rows.Scan(
|
||||
&rec.ID,
|
||||
&rec.ServerID,
|
||||
&rec.ServerName,
|
||||
&rec.Jail,
|
||||
&rec.IP,
|
||||
&rec.Country,
|
||||
&rec.Hostname,
|
||||
&rec.Failures,
|
||||
&rec.Whois,
|
||||
&rec.Logs,
|
||||
&rec.OccurredAt,
|
||||
&rec.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, rec)
|
||||
}
|
||||
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// CountBanEventsByServer returns simple aggregation per server.
|
||||
func CountBanEventsByServer(ctx context.Context, since time.Time) (map[string]int64, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT server_id, COUNT(*)
|
||||
FROM ban_events
|
||||
WHERE 1=1`
|
||||
args := []any{}
|
||||
|
||||
if !since.IsZero() {
|
||||
query += " AND occurred_at >= ?"
|
||||
args = append(args, since.UTC())
|
||||
}
|
||||
|
||||
query += " GROUP BY server_id"
|
||||
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]int64)
|
||||
for rows.Next() {
|
||||
var serverID string
|
||||
var count int64
|
||||
if err := rows.Scan(&serverID, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[serverID] = count
|
||||
}
|
||||
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func ensureSchema(ctx context.Context) error {
|
||||
if db == nil {
|
||||
return errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
const createTable = `
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
language TEXT,
|
||||
port INTEGER,
|
||||
debug INTEGER,
|
||||
callback_url TEXT,
|
||||
restart_needed INTEGER,
|
||||
alert_countries TEXT,
|
||||
smtp_host TEXT,
|
||||
smtp_port INTEGER,
|
||||
smtp_username TEXT,
|
||||
smtp_password TEXT,
|
||||
smtp_from TEXT,
|
||||
smtp_use_tls INTEGER,
|
||||
bantime_increment INTEGER,
|
||||
ignore_ip TEXT,
|
||||
bantime TEXT,
|
||||
findtime TEXT,
|
||||
maxretry INTEGER,
|
||||
destemail TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
type TEXT,
|
||||
host TEXT,
|
||||
port INTEGER,
|
||||
socket_path TEXT,
|
||||
log_path TEXT,
|
||||
ssh_user TEXT,
|
||||
ssh_key_path TEXT,
|
||||
agent_url TEXT,
|
||||
agent_secret TEXT,
|
||||
hostname TEXT,
|
||||
tags TEXT,
|
||||
is_default INTEGER,
|
||||
enabled INTEGER,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ban_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
jail TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
country TEXT,
|
||||
hostname TEXT,
|
||||
failures TEXT,
|
||||
whois TEXT,
|
||||
logs TEXT,
|
||||
occurred_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ban_events_server_id ON ban_events(server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at);
|
||||
`
|
||||
|
||||
_, err := db.ExecContext(ctx, createTable)
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureDirectory(path string) error {
|
||||
if path == ":memory:" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if dir == "." || dir == "" {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
Reference in New Issue
Block a user