First steps to implement a advanced-actions function to block recurring offenders before fail2ban

This commit is contained in:
2025-11-18 15:02:50 +01:00
parent aa28738d43
commit 2fcc30b1b6
15 changed files with 1391 additions and 114 deletions

View File

@@ -49,13 +49,14 @@ type SMTPSettings struct {
// AppSettings holds the main UI settings and Fail2ban configuration
type AppSettings struct {
Language string `json:"language"`
Port int `json:"port"`
Debug bool `json:"debug"`
RestartNeeded bool `json:"restartNeeded"`
AlertCountries []string `json:"alertCountries"`
SMTP SMTPSettings `json:"smtp"`
CallbackURL string `json:"callbackUrl"`
Language string `json:"language"`
Port int `json:"port"`
Debug bool `json:"debug"`
RestartNeeded bool `json:"restartNeeded"`
AlertCountries []string `json:"alertCountries"`
SMTP SMTPSettings `json:"smtp"`
CallbackURL string `json:"callbackUrl"`
AdvancedActions AdvancedActionsConfig `json:"advancedActions"`
Servers []Fail2banServer `json:"servers"`
@@ -69,6 +70,56 @@ type AppSettings struct {
//Sender string `json:"sender"`
}
type AdvancedActionsConfig struct {
Enabled bool `json:"enabled"`
Threshold int `json:"threshold"`
Integration string `json:"integration"`
Mikrotik MikrotikIntegrationSettings `json:"mikrotik"`
PfSense PfSenseIntegrationSettings `json:"pfSense"`
}
type MikrotikIntegrationSettings struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
SSHKeyPath string `json:"sshKeyPath"`
AddressList string `json:"addressList"`
}
type PfSenseIntegrationSettings struct {
BaseURL string `json:"baseUrl"`
APIToken string `json:"apiToken"`
APISecret string `json:"apiSecret"`
Alias string `json:"alias"`
SkipTLSVerify bool `json:"skipTLSVerify"`
}
func defaultAdvancedActionsConfig() AdvancedActionsConfig {
return AdvancedActionsConfig{
Enabled: false,
Threshold: 5,
Integration: "",
Mikrotik: MikrotikIntegrationSettings{
Port: 22,
AddressList: "fail2ban-permanent",
},
}
}
func normalizeAdvancedActionsConfig(cfg AdvancedActionsConfig) AdvancedActionsConfig {
if cfg.Threshold <= 0 {
cfg.Threshold = 5
}
if cfg.Mikrotik.Port <= 0 {
cfg.Mikrotik.Port = 22
}
if cfg.Mikrotik.AddressList == "" {
cfg.Mikrotik.AddressList = "fail2ban-permanent"
}
return cfg
}
// init paths to key-files
const (
settingsFile = "fail2ban-ui-settings.json" // this file is created, relatively to where the app was started
@@ -301,6 +352,12 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) {
currentSettings.AlertCountries = countries
}
}
if rec.AdvancedActionsJSON != "" {
var adv AdvancedActionsConfig
if err := json.Unmarshal([]byte(rec.AdvancedActionsJSON), &adv); err == nil {
currentSettings.AdvancedActions = adv
}
}
}
func applyServerRecordsLocked(records []storage.ServerRecord) {
@@ -346,25 +403,31 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
return storage.AppSettingsRecord{}, err
}
advancedBytes, err := json.Marshal(currentSettings.AdvancedActions)
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,
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,
AdvancedActionsJSON: string(advancedBytes),
}, nil
}
@@ -465,6 +528,11 @@ func setDefaultsLocked() {
currentSettings.IgnoreIP = "127.0.0.1/8 ::1"
}
if (currentSettings.AdvancedActions == AdvancedActionsConfig{}) {
currentSettings.AdvancedActions = defaultAdvancedActionsConfig()
}
currentSettings.AdvancedActions = normalizeAdvancedActionsConfig(currentSettings.AdvancedActions)
normalizeServersLocked()
}

View File

@@ -0,0 +1,122 @@
package integrations
import (
"fmt"
"net"
"os"
"time"
"golang.org/x/crypto/ssh"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
type mikrotikIntegration struct{}
func init() {
Register(&mikrotikIntegration{})
}
func (m *mikrotikIntegration) ID() string {
return "mikrotik"
}
func (m *mikrotikIntegration) DisplayName() string {
return "Mikrotik RouterOS"
}
func (m *mikrotikIntegration) Validate(cfg config.AdvancedActionsConfig) error {
if cfg.Mikrotik.Host == "" {
return fmt.Errorf("mikrotik host is required")
}
if cfg.Mikrotik.Username == "" {
return fmt.Errorf("mikrotik username is required")
}
if cfg.Mikrotik.Password == "" && cfg.Mikrotik.SSHKeyPath == "" {
return fmt.Errorf("mikrotik password or SSH key path is required")
}
if cfg.Mikrotik.AddressList == "" {
return fmt.Errorf("mikrotik address list is required")
}
return nil
}
func (m *mikrotikIntegration) BlockIP(req Request) error {
if err := m.Validate(req.Config); err != nil {
return err
}
cmd := fmt.Sprintf(`/ip firewall address-list add list=%s address=%s comment="Fail2ban-UI permanent block"`,
req.Config.Mikrotik.AddressList, req.IP)
return m.runCommand(req, cmd)
}
func (m *mikrotikIntegration) UnblockIP(req Request) error {
if err := m.Validate(req.Config); err != nil {
return err
}
cmd := fmt.Sprintf(`/ip firewall address-list remove [/ip firewall address-list find address=%s list=%s]`,
req.IP, req.Config.Mikrotik.AddressList)
return m.runCommand(req, cmd)
}
func (m *mikrotikIntegration) runCommand(req Request, command string) error {
cfg := req.Config.Mikrotik
authMethods := []ssh.AuthMethod{}
if cfg.Password != "" {
authMethods = append(authMethods, ssh.Password(cfg.Password))
}
if cfg.SSHKeyPath != "" {
key, err := os.ReadFile(cfg.SSHKeyPath)
if err != nil {
return fmt.Errorf("failed to read mikrotik ssh key: %w", err)
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return fmt.Errorf("failed to parse mikrotik ssh key: %w", err)
}
authMethods = append(authMethods, ssh.PublicKeys(signer))
}
if len(authMethods) == 0 {
return fmt.Errorf("no authentication method available for mikrotik")
}
port := cfg.Port
if port == 0 {
port = 22
}
clientCfg := &ssh.ClientConfig{
User: cfg.Username,
Auth: authMethods,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second,
}
address := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", port))
client, err := ssh.Dial("tcp", address, clientCfg)
if err != nil {
return fmt.Errorf("failed to connect to mikrotik: %w", err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("failed to create mikrotik ssh session: %w", err)
}
defer session.Close()
if req.Logger != nil {
req.Logger("Running Mikrotik command: %s", command)
}
output, err := session.CombinedOutput(command)
if err != nil {
return fmt.Errorf("mikrotik command failed: %w (output: %s)", err, string(output))
}
if req.Logger != nil {
req.Logger("Mikrotik command output: %s", string(output))
}
return nil
}

View File

@@ -0,0 +1,112 @@
package integrations
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
type pfSenseIntegration struct{}
func init() {
Register(&pfSenseIntegration{})
}
func (p *pfSenseIntegration) ID() string {
return "pfsense"
}
func (p *pfSenseIntegration) DisplayName() string {
return "pfSense"
}
func (p *pfSenseIntegration) Validate(cfg config.AdvancedActionsConfig) error {
if cfg.PfSense.BaseURL == "" {
return fmt.Errorf("pfSense base URL is required")
}
if cfg.PfSense.APIToken == "" || cfg.PfSense.APISecret == "" {
return fmt.Errorf("pfSense API token and secret are required")
}
if cfg.PfSense.Alias == "" {
return fmt.Errorf("pfSense alias is required")
}
return nil
}
func (p *pfSenseIntegration) BlockIP(req Request) error {
if err := p.Validate(req.Config); err != nil {
return err
}
payload := map[string]any{
"alias": req.Config.PfSense.Alias,
"ip": req.IP,
"descr": "Fail2ban-UI permanent block",
}
return p.callAPI(req, "add", payload)
}
func (p *pfSenseIntegration) UnblockIP(req Request) error {
if err := p.Validate(req.Config); err != nil {
return err
}
payload := map[string]any{
"alias": req.Config.PfSense.Alias,
"ip": req.IP,
}
return p.callAPI(req, "delete", payload)
}
func (p *pfSenseIntegration) callAPI(req Request, action string, payload map[string]any) error {
cfg := req.Config.PfSense
payload["action"] = action
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to encode pfSense payload: %w", err)
}
apiURL := strings.TrimSuffix(cfg.BaseURL, "/") + "/api/v1/firewall/alias/ip"
httpClient := &http.Client{
Timeout: 10 * time.Second,
}
if cfg.SkipTLSVerify {
httpClient.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 - user controlled
}
}
reqLogger := "pfSense"
if req.Logger != nil {
req.Logger("Calling pfSense API %s action=%s payload=%s", apiURL, action, string(data))
}
httpReq, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("failed to create pfSense request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-API-Key", cfg.APIToken)
httpReq.Header.Set("X-API-Secret", cfg.APISecret)
resp, err := httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("pfSense request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("pfSense request failed: status %s", resp.Status)
}
if req.Logger != nil {
req.Logger("%s API call succeeded", reqLogger)
}
return nil
}

View File

@@ -0,0 +1,61 @@
package integrations
import (
"context"
"fmt"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
// Request represents a block/unblock request for an integration plugin.
type Request struct {
Context context.Context
IP string
Config config.AdvancedActionsConfig
Server config.Fail2banServer
Logger func(format string, args ...interface{})
}
// Integration exposes functionality required by an external firewall vendor.
type Integration interface {
ID() string
DisplayName() string
BlockIP(req Request) error
UnblockIP(req Request) error
Validate(cfg config.AdvancedActionsConfig) error
}
var registry = map[string]Integration{}
// Register adds an integration to the global registry.
func Register(integration Integration) {
if integration == nil {
return
}
registry[integration.ID()] = integration
}
// Get returns the integration by id.
func Get(id string) (Integration, bool) {
integration, ok := registry[id]
return integration, ok
}
// MustGet obtains the integration or panics used during init.
func MustGet(id string) Integration {
integration, ok := Get(id)
if !ok {
panic(fmt.Sprintf("integration %s not registered", id))
}
return integration
}
// Supported returns ids of all registered integrations.
func Supported() []string {
keys := make([]string, 0, len(registry))
for id := range registry {
keys = append(keys, id)
}
return keys
}

View File

@@ -118,6 +118,45 @@
"settings.default_max_retry_placeholder": "Geben Sie die maximale Anzahl der Versuche ein",
"settings.ignore_ips": "IP-Adressen ignorieren",
"settings.ignore_ips_placeholder": "IP-Adressen, getrennt durch Leerzeichen",
"settings.advanced.title": "Erweiterte Aktionen für Wiederholungstäter",
"settings.advanced.description": "Synchronisiere wiederholte Angreifer automatisch mit einer externen Firewall oder Blockliste.",
"settings.advanced.refresh_log": "Protokoll aktualisieren",
"settings.advanced.test_button": "Integration testen",
"settings.advanced.enable": "Automatische permanente Sperre aktivieren",
"settings.advanced.threshold": "Schwelle vor permanenter Sperre",
"settings.advanced.threshold_hint": "Sobald eine IP diesen Wert erreicht, wird sie an die Integration übergeben.",
"settings.advanced.integration": "Integration",
"settings.advanced.integration_none": "Integration auswählen",
"settings.advanced.integration_hint": "Wähle die Firewall oder Appliance, in der permanente Sperren erstellt werden sollen.",
"settings.advanced.mikrotik.note": "SSH-Zugang zum Mikrotik-Router und die Address-Liste angeben, in die IPs eingetragen werden.",
"settings.advanced.mikrotik.host": "Host",
"settings.advanced.mikrotik.port": "Port",
"settings.advanced.mikrotik.username": "SSH-Benutzername",
"settings.advanced.mikrotik.password": "SSH-Passwort",
"settings.advanced.mikrotik.key": "SSH-Key-Pfad (optional)",
"settings.advanced.mikrotik.list": "Address-Listenname",
"settings.advanced.pfsense.note": "Benötigt das pfSense API-Paket. Verwende ein Token mit Alias-Rechten.",
"settings.advanced.pfsense.base_url": "Basis-URL",
"settings.advanced.pfsense.token": "API-Token",
"settings.advanced.pfsense.secret": "API-Secret",
"settings.advanced.pfsense.alias": "Alias-Name",
"settings.advanced.pfsense.skip_tls": "TLS-Validierung überspringen (Self-Signed)",
"settings.advanced.log_title": "Log der permanenten Sperren",
"settings.advanced.log_empty": "Noch keine permanenten Sperren vorhanden.",
"settings.advanced.log_ip": "IP",
"settings.advanced.log_integration": "Integration",
"settings.advanced.log_status": "Status",
"settings.advanced.log_message": "Nachricht",
"settings.advanced.log_server": "Server",
"settings.advanced.log_updated": "Aktualisiert",
"settings.advanced.log_actions": "Aktionen",
"settings.advanced.unblock_btn": "Entfernen",
"settings.advanced.test_title": "Integration testen",
"settings.advanced.test_ip": "IP-Adresse",
"settings.advanced.test_server": "Optionaler Server",
"settings.advanced.test_server_none": "Globale Integration verwenden",
"settings.advanced.test_block": "IP sperren",
"settings.advanced.test_unblock": "IP entfernen",
"settings.save": "Speichern",
"modal.filter_config": "Filter-Konfiguration:",
"modal.filter_config_edit": "Filter bearbeiten",

View File

@@ -118,6 +118,45 @@
"settings.default_max_retry_placeholder": "Gib d'maximal Versüech ii",
"settings.ignore_ips": "IPs ignorierä",
"settings.ignore_ips_placeholder": "IPs, getrennt dur e Leerzeichä",
"settings.advanced.title": "Erwieterti Aktione für Wiederholungstäter",
"settings.advanced.description": "Synchronisiere wiederholti Täters automatisch mit ere externe Firewall oder Sperrlischt.",
"settings.advanced.refresh_log": "Log aktualisiere",
"settings.advanced.test_button": "Integration teste",
"settings.advanced.enable": "Automatischi permanente Sperri aktiviere",
"settings.advanced.threshold": "Schwelle vor de permanente Sperri",
"settings.advanced.threshold_hint": "Sobald e IP die Zah erreitcht, wird sie a d Integration übergeh.",
"settings.advanced.integration": "Integration",
"settings.advanced.integration_none": "Integration uswähle",
"settings.advanced.integration_hint": "Wähl d Firewall oder Appliance, wo d permanente Sperre sött ahlegt werde.",
"settings.advanced.mikrotik.note": "Git d'SSH-Zuegriff uf din Mikrotik-Router a und d Address-Lischt, wo d'Sperre ine chöme.",
"settings.advanced.mikrotik.host": "Host",
"settings.advanced.mikrotik.port": "Port",
"settings.advanced.mikrotik.username": "SSH-Benutzername",
"settings.advanced.mikrotik.password": "SSH-Passwort",
"settings.advanced.mikrotik.key": "SSH-Key-Pfad (optional)",
"settings.advanced.mikrotik.list": "Adress-Lischtname",
"settings.advanced.pfsense.note": "Bruucht s pfSense API-Päckli. Nimm es Token wo Aliase cha bearbeite.",
"settings.advanced.pfsense.base_url": "Basis-URL",
"settings.advanced.pfsense.token": "API-Token",
"settings.advanced.pfsense.secret": "API-Secret",
"settings.advanced.pfsense.alias": "Alias-Name",
"settings.advanced.pfsense.skip_tls": "TLS-Prüfig überspringe (Self-Signed)",
"settings.advanced.log_title": "Log vo de permanente Sperre",
"settings.advanced.log_empty": "No kei permanente Sperre erfasst.",
"settings.advanced.log_ip": "IP",
"settings.advanced.log_integration": "Integration",
"settings.advanced.log_status": "Status",
"settings.advanced.log_message": "Meldig",
"settings.advanced.log_server": "Server",
"settings.advanced.log_updated": "Aktualisiert",
"settings.advanced.log_actions": "Aktione",
"settings.advanced.unblock_btn": "Entferne",
"settings.advanced.test_title": "Integration teste",
"settings.advanced.test_ip": "IP-Adrässe",
"settings.advanced.test_server": "Optionaler Server",
"settings.advanced.test_server_none": "Globali Integration bruuchä",
"settings.advanced.test_block": "IP sperre",
"settings.advanced.test_unblock": "IP entferne",
"settings.save": "Speicherä",
"modal.filter_config": "Filter-Konfiguration:",
"modal.filter_config_edit": "Filter bearbeite",

View File

@@ -118,6 +118,45 @@
"settings.default_max_retry_placeholder": "Enter maximum retries",
"settings.ignore_ips": "Ignore IPs",
"settings.ignore_ips_placeholder": "IPs to ignore, separated by spaces",
"settings.advanced.title": "Advanced Actions for Recurring Offenders",
"settings.advanced.description": "Automatically synchronize recurring offenders to an external firewall or blocklist.",
"settings.advanced.refresh_log": "Refresh Log",
"settings.advanced.test_button": "Test Integration",
"settings.advanced.enable": "Enable automatic permanent blocking",
"settings.advanced.threshold": "Threshold before permanent block",
"settings.advanced.threshold_hint": "Once an IP reaches this number of bans it will be forwarded to the integration.",
"settings.advanced.integration": "Integration",
"settings.advanced.integration_none": "Select integration",
"settings.advanced.integration_hint": "Choose the firewall or appliance where permanent bans should be created.",
"settings.advanced.mikrotik.note": "Provide SSH access to your Mikrotik router and the address list that should contain blocked IPs.",
"settings.advanced.mikrotik.host": "Host",
"settings.advanced.mikrotik.port": "Port",
"settings.advanced.mikrotik.username": "SSH Username",
"settings.advanced.mikrotik.password": "SSH Password",
"settings.advanced.mikrotik.key": "SSH Key Path (optional)",
"settings.advanced.mikrotik.list": "Address List Name",
"settings.advanced.pfsense.note": "Requires the pfSense API package. Use an API token that may edit aliases.",
"settings.advanced.pfsense.base_url": "Base URL",
"settings.advanced.pfsense.token": "API Token",
"settings.advanced.pfsense.secret": "API Secret",
"settings.advanced.pfsense.alias": "Alias Name",
"settings.advanced.pfsense.skip_tls": "Skip TLS verification (self-signed)",
"settings.advanced.log_title": "Permanent Block Log",
"settings.advanced.log_empty": "No permanent blocks recorded yet.",
"settings.advanced.log_ip": "IP",
"settings.advanced.log_integration": "Integration",
"settings.advanced.log_status": "Status",
"settings.advanced.log_message": "Message",
"settings.advanced.log_server": "Server",
"settings.advanced.log_updated": "Updated",
"settings.advanced.log_actions": "Actions",
"settings.advanced.unblock_btn": "Remove",
"settings.advanced.test_title": "Test Advanced Integration",
"settings.advanced.test_ip": "IP address",
"settings.advanced.test_server": "Optional server",
"settings.advanced.test_server_none": "Use global integration settings",
"settings.advanced.test_block": "Block IP",
"settings.advanced.test_unblock": "Remove IP",
"settings.save": "Save",
"modal.filter_config": "Filter Config:",
"modal.filter_config_edit": "Edit Filter",

View File

@@ -118,6 +118,45 @@
"settings.default_max_retry_placeholder": "Introduce el número máximo de reintentos",
"settings.ignore_ips": "Ignorar IPs",
"settings.ignore_ips_placeholder": "IPs a ignorar, separadas por espacios",
"settings.advanced.title": "Acciones avanzadas para reincidentes",
"settings.advanced.description": "Añade automáticamente IPs reincidentes a un firewall o lista de bloqueo externa.",
"settings.advanced.refresh_log": "Actualizar registro",
"settings.advanced.test_button": "Probar integración",
"settings.advanced.enable": "Habilitar bloqueo permanente automático",
"settings.advanced.threshold": "Umbral antes del bloqueo permanente",
"settings.advanced.threshold_hint": "Cuando una IP alcanza este número de bloqueos se enviará a la integración.",
"settings.advanced.integration": "Integración",
"settings.advanced.integration_none": "Selecciona una integración",
"settings.advanced.integration_hint": "Elige el firewall o dispositivo donde crear los bloqueos permanentes.",
"settings.advanced.mikrotik.note": "Proporciona acceso SSH al router Mikrotik y la lista de direcciones de destino.",
"settings.advanced.mikrotik.host": "Host",
"settings.advanced.mikrotik.port": "Puerto",
"settings.advanced.mikrotik.username": "Usuario SSH",
"settings.advanced.mikrotik.password": "Contraseña SSH",
"settings.advanced.mikrotik.key": "Ruta de la clave SSH (opcional)",
"settings.advanced.mikrotik.list": "Nombre de la lista",
"settings.advanced.pfsense.note": "Requiere el paquete de API de pfSense. Usa un token con acceso a alias.",
"settings.advanced.pfsense.base_url": "URL base",
"settings.advanced.pfsense.token": "Token API",
"settings.advanced.pfsense.secret": "Secreto API",
"settings.advanced.pfsense.alias": "Nombre del alias",
"settings.advanced.pfsense.skip_tls": "Omitir verificación TLS (autofirmado)",
"settings.advanced.log_title": "Registro de bloqueos permanentes",
"settings.advanced.log_empty": "Aún no hay bloqueos permanentes.",
"settings.advanced.log_ip": "IP",
"settings.advanced.log_integration": "Integración",
"settings.advanced.log_status": "Estado",
"settings.advanced.log_message": "Mensaje",
"settings.advanced.log_server": "Servidor",
"settings.advanced.log_updated": "Actualizado",
"settings.advanced.log_actions": "Acciones",
"settings.advanced.unblock_btn": "Eliminar",
"settings.advanced.test_title": "Probar integración avanzada",
"settings.advanced.test_ip": "Dirección IP",
"settings.advanced.test_server": "Servidor opcional",
"settings.advanced.test_server_none": "Usar integración global",
"settings.advanced.test_block": "Bloquear IP",
"settings.advanced.test_unblock": "Eliminar IP",
"settings.save": "Guardar",
"modal.filter_config": "Configuración del filtro:",
"modal.filter_config_edit": "Editar filtro",

View File

@@ -118,6 +118,45 @@
"settings.default_max_retry_placeholder": "Entrez le nombre maximal de réessais",
"settings.ignore_ips": "Ignorer les IPs",
"settings.ignore_ips_placeholder": "IPs à ignorer, séparées par des espaces",
"settings.advanced.title": "Actions avancées pour récidivistes",
"settings.advanced.description": "Ajoutez automatiquement les récidivistes à un pare-feu ou une liste de blocage externe.",
"settings.advanced.refresh_log": "Actualiser le journal",
"settings.advanced.test_button": "Tester lintégration",
"settings.advanced.enable": "Activer le blocage permanent automatique",
"settings.advanced.threshold": "Seuil avant blocage permanent",
"settings.advanced.threshold_hint": "Une IP atteignant ce nombre de bans sera envoyée à lintégration.",
"settings.advanced.integration": "Intégration",
"settings.advanced.integration_none": "Choisir une intégration",
"settings.advanced.integration_hint": "Choisissez le pare-feu ou léquipement où créer les blocages permanents.",
"settings.advanced.mikrotik.note": "Fournissez les accès SSH au routeur Mikrotik et la liste dadresses ciblée.",
"settings.advanced.mikrotik.host": "Hôte",
"settings.advanced.mikrotik.port": "Port",
"settings.advanced.mikrotik.username": "Utilisateur SSH",
"settings.advanced.mikrotik.password": "Mot de passe SSH",
"settings.advanced.mikrotik.key": "Chemin de clé SSH (optionnel)",
"settings.advanced.mikrotik.list": "Nom de la liste",
"settings.advanced.pfsense.note": "Nécessite le paquet API pfSense. Utiliser un jeton ayant accès aux alias.",
"settings.advanced.pfsense.base_url": "URL de base",
"settings.advanced.pfsense.token": "Jeton API",
"settings.advanced.pfsense.secret": "Secret API",
"settings.advanced.pfsense.alias": "Nom dalias",
"settings.advanced.pfsense.skip_tls": "Ignorer la vérification TLS (auto-signé)",
"settings.advanced.log_title": "Journal des blocages permanents",
"settings.advanced.log_empty": "Aucun blocage permanent pour le moment.",
"settings.advanced.log_ip": "IP",
"settings.advanced.log_integration": "Intégration",
"settings.advanced.log_status": "Statut",
"settings.advanced.log_message": "Message",
"settings.advanced.log_server": "Serveur",
"settings.advanced.log_updated": "Mis à jour",
"settings.advanced.log_actions": "Actions",
"settings.advanced.unblock_btn": "Retirer",
"settings.advanced.test_title": "Tester lintégration avancée",
"settings.advanced.test_ip": "Adresse IP",
"settings.advanced.test_server": "Serveur optionnel",
"settings.advanced.test_server_none": "Utiliser lintégration globale",
"settings.advanced.test_block": "Bloquer lIP",
"settings.advanced.test_unblock": "Retirer lIP",
"settings.save": "Enregistrer",
"modal.filter_config": "Configuration du filtre:",
"modal.filter_config_edit": "Modifier le filtre",

View File

@@ -118,6 +118,45 @@
"settings.default_max_retry_placeholder": "Inserisci il numero massimo di tentativi",
"settings.ignore_ips": "Ignora IP",
"settings.ignore_ips_placeholder": "IP da ignorare, separate da spazi",
"settings.advanced.title": "Azioni avanzate per ripetuti offensori",
"settings.advanced.description": "Aggiungi automaticamente gli IP ricorrenti a un firewall o blocklist esterna.",
"settings.advanced.refresh_log": "Aggiorna registro",
"settings.advanced.test_button": "Testa integrazione",
"settings.advanced.enable": "Abilita blocco permanente automatico",
"settings.advanced.threshold": "Soglia prima del blocco permanente",
"settings.advanced.threshold_hint": "Quando un IP raggiunge questa soglia verrà inviato allintegrazione.",
"settings.advanced.integration": "Integrazione",
"settings.advanced.integration_none": "Seleziona integrazione",
"settings.advanced.integration_hint": "Scegli il firewall o dispositivo dove creare i blocchi permanenti.",
"settings.advanced.mikrotik.note": "Fornisci laccesso SSH al router Mikrotik e la lista indirizzi di destinazione.",
"settings.advanced.mikrotik.host": "Host",
"settings.advanced.mikrotik.port": "Porta",
"settings.advanced.mikrotik.username": "Utente SSH",
"settings.advanced.mikrotik.password": "Password SSH",
"settings.advanced.mikrotik.key": "Percorso chiave SSH (opzionale)",
"settings.advanced.mikrotik.list": "Nome della lista",
"settings.advanced.pfsense.note": "Richiede il pacchetto API di pfSense. Usa un token con accesso agli alias.",
"settings.advanced.pfsense.base_url": "URL base",
"settings.advanced.pfsense.token": "Token API",
"settings.advanced.pfsense.secret": "Segreto API",
"settings.advanced.pfsense.alias": "Nome alias",
"settings.advanced.pfsense.skip_tls": "Ignora verifica TLS (auto-firmato)",
"settings.advanced.log_title": "Registro dei blocchi permanenti",
"settings.advanced.log_empty": "Nessun blocco permanente ancora registrato.",
"settings.advanced.log_ip": "IP",
"settings.advanced.log_integration": "Integrazione",
"settings.advanced.log_status": "Stato",
"settings.advanced.log_message": "Messaggio",
"settings.advanced.log_server": "Server",
"settings.advanced.log_updated": "Aggiornato",
"settings.advanced.log_actions": "Azioni",
"settings.advanced.unblock_btn": "Rimuovi",
"settings.advanced.test_title": "Testa integrazione avanzata",
"settings.advanced.test_ip": "Indirizzo IP",
"settings.advanced.test_server": "Server opzionale",
"settings.advanced.test_server_none": "Usa integrazione globale",
"settings.advanced.test_block": "Blocca IP",
"settings.advanced.test_unblock": "Rimuovi IP",
"settings.save": "Salva",
"modal.filter_config": "Configurazione del filtro:",
"modal.filter_config_edit": "Modifica filtro",

View File

@@ -47,24 +47,25 @@ func intFromNull(ni sql.NullInt64) int {
}
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
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
AdvancedActionsJSON string
}
type ServerRecord struct {
@@ -112,6 +113,18 @@ type RecurringIPStat struct {
LastSeen time.Time `json:"lastSeen"`
}
type PermanentBlockRecord struct {
ID int64 `json:"id"`
IP string `json:"ip"`
Integration string `json:"integration"`
Status string `json:"status"`
Details string `json:"details"`
Message string `json:"message"`
ServerID string `json:"serverId"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Init initializes the internal storage. Safe to call multiple times.
func Init(dbPath string) error {
initOnce.Do(func() {
@@ -154,17 +167,17 @@ func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
}
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
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, advanced_actions
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
lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, advancedActions 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)
err := row.Scan(&lang, &port, &debug, &callback, &restartNeeded, &alerts, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &ignoreIP, &bantime, &findtime, &maxretry, &destemail, &advancedActions)
if errors.Is(err, sql.ErrNoRows) {
return AppSettingsRecord{}, false, nil
}
@@ -173,24 +186,25 @@ WHERE id = 1`)
}
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),
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),
AdvancedActionsJSON: stringFromNull(advancedActions),
}
return rec, true, nil
@@ -202,9 +216,9 @@ func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error {
}
_, 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
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, advanced_actions
) VALUES (
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) ON CONFLICT(id) DO UPDATE SET
language = excluded.language,
port = excluded.port,
@@ -223,7 +237,8 @@ INSERT INTO app_settings (
bantime = excluded.bantime,
findtime = excluded.findtime,
maxretry = excluded.maxretry,
destemail = excluded.destemail
destemail = excluded.destemail,
advanced_actions = excluded.advanced_actions
`, rec.Language,
rec.Port,
boolToInt(rec.Debug),
@@ -242,6 +257,7 @@ INSERT INTO app_settings (
rec.Findtime,
rec.MaxRetry,
rec.DestEmail,
rec.AdvancedActionsJSON,
)
return err
}
@@ -566,6 +582,33 @@ WHERE 1=1`
return total, nil
}
// CountBanEventsByIP returns total number of ban events for a specific IP and optional server.
func CountBanEventsByIP(ctx context.Context, ip, serverID string) (int64, error) {
if db == nil {
return 0, errors.New("storage not initialised")
}
if ip == "" {
return 0, errors.New("ip is required")
}
query := `
SELECT COUNT(*)
FROM ban_events
WHERE ip = ?`
args := []any{ip}
if serverID != "" {
query += " AND server_id = ?"
args = append(args, serverID)
}
var total int64
if err := db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
// CountBanEventsByCountry returns aggregation per country code, optionally filtered by server.
func CountBanEventsByCountry(ctx context.Context, since time.Time, serverID string) (map[string]int64, error) {
if db == nil {
@@ -695,7 +738,8 @@ CREATE TABLE IF NOT EXISTS app_settings (
bantime TEXT,
findtime TEXT,
maxretry INTEGER,
destemail TEXT
destemail TEXT,
advanced_actions TEXT
);
CREATE TABLE IF NOT EXISTS servers (
@@ -736,6 +780,21 @@ CREATE TABLE IF NOT EXISTS ban_events (
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);
CREATE TABLE IF NOT EXISTS permanent_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
integration TEXT NOT NULL,
status TEXT NOT NULL,
details TEXT,
message TEXT,
server_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(ip, integration)
);
CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status);
`
if _, err := db.ExecContext(ctx, createTable); err != nil {
@@ -749,6 +808,12 @@ CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at)
}
}
if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN advanced_actions TEXT`); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
return err
}
}
return nil
}
@@ -762,3 +827,128 @@ func ensureDirectory(path string) error {
}
return os.MkdirAll(dir, 0o755)
}
// UpsertPermanentBlock records or updates a permanent block entry.
func UpsertPermanentBlock(ctx context.Context, rec PermanentBlockRecord) error {
if db == nil {
return errors.New("storage not initialised")
}
if rec.IP == "" || rec.Integration == "" {
return errors.New("ip and integration are required")
}
now := time.Now().UTC()
if rec.CreatedAt.IsZero() {
rec.CreatedAt = now
}
rec.UpdatedAt = now
if rec.Status == "" {
rec.Status = "blocked"
}
const query = `
INSERT INTO permanent_blocks (ip, integration, status, details, message, server_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ip, integration) DO UPDATE SET
status = excluded.status,
details = excluded.details,
message = excluded.message,
server_id = excluded.server_id,
updated_at = excluded.updated_at`
_, err := db.ExecContext(ctx, query,
rec.IP,
rec.Integration,
rec.Status,
rec.Details,
rec.Message,
rec.ServerID,
rec.CreatedAt.Format(time.RFC3339Nano),
rec.UpdatedAt.Format(time.RFC3339Nano),
)
return err
}
// GetPermanentBlock retrieves a permanent block entry.
func GetPermanentBlock(ctx context.Context, ip, integration string) (PermanentBlockRecord, bool, error) {
if db == nil {
return PermanentBlockRecord{}, false, errors.New("storage not initialised")
}
if ip == "" || integration == "" {
return PermanentBlockRecord{}, false, errors.New("ip and integration are required")
}
row := db.QueryRowContext(ctx, `
SELECT id, ip, integration, status, details, message, server_id, created_at, updated_at
FROM permanent_blocks
WHERE ip = ? AND integration = ?`, ip, integration)
var rec PermanentBlockRecord
var createdAt, updatedAt sql.NullString
if err := row.Scan(&rec.ID, &rec.IP, &rec.Integration, &rec.Status, &rec.Details, &rec.Message, &rec.ServerID, &createdAt, &updatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return PermanentBlockRecord{}, false, nil
}
return PermanentBlockRecord{}, false, err
}
if createdAt.Valid {
if ts, err := time.Parse(time.RFC3339Nano, createdAt.String); err == nil {
rec.CreatedAt = ts
}
}
if updatedAt.Valid {
if ts, err := time.Parse(time.RFC3339Nano, updatedAt.String); err == nil {
rec.UpdatedAt = ts
}
}
return rec, true, nil
}
// ListPermanentBlocks returns recent permanent block entries.
func ListPermanentBlocks(ctx context.Context, limit int) ([]PermanentBlockRecord, error) {
if db == nil {
return nil, errors.New("storage not initialised")
}
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := db.QueryContext(ctx, `
SELECT id, ip, integration, status, details, message, server_id, created_at, updated_at
FROM permanent_blocks
ORDER BY updated_at DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []PermanentBlockRecord
for rows.Next() {
var rec PermanentBlockRecord
var createdAt, updatedAt sql.NullString
if err := rows.Scan(&rec.ID, &rec.IP, &rec.Integration, &rec.Status, &rec.Details, &rec.Message, &rec.ServerID, &createdAt, &updatedAt); err != nil {
return nil, err
}
if createdAt.Valid {
if ts, err := time.Parse(time.RFC3339Nano, createdAt.String); err == nil {
rec.CreatedAt = ts
}
}
if updatedAt.Valid {
if ts, err := time.Parse(time.RFC3339Nano, updatedAt.String); err == nil {
rec.UpdatedAt = ts
}
}
records = append(records, rec)
}
return records, rows.Err()
}
// IsPermanentBlockActive returns true when IP is currently blocked by integration.
func IsPermanentBlockActive(ctx context.Context, ip, integration string) (bool, error) {
rec, found, err := GetPermanentBlock(ctx, ip, integration)
if err != nil || !found {
return false, err
}
return rec.Status == "blocked", nil
}