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
}

111
pkg/web/advanced_actions.go Normal file
View File

@@ -0,0 +1,111 @@
package web
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"github.com/swissmakers/fail2ban-ui/internal/config"
"github.com/swissmakers/fail2ban-ui/internal/integrations"
"github.com/swissmakers/fail2ban-ui/internal/storage"
)
func evaluateAdvancedActions(ctx context.Context, settings config.AppSettings, server config.Fail2banServer, ip string) {
cfg := settings.AdvancedActions
if !cfg.Enabled || cfg.Threshold <= 0 || cfg.Integration == "" {
return
}
count, err := storage.CountBanEventsByIP(ctx, ip, server.ID)
if err != nil {
log.Printf("⚠️ Failed to count ban events for %s: %v", ip, err)
return
}
if int(count) < cfg.Threshold {
return
}
active, err := storage.IsPermanentBlockActive(ctx, ip, cfg.Integration)
if err != nil {
log.Printf("⚠️ Failed to check permanent block for %s: %v", ip, err)
return
}
if active {
return
}
if err := runAdvancedIntegrationAction(ctx, "block", ip, settings, server, map[string]any{
"reason": "automatic_threshold",
"count": count,
"threshold": cfg.Threshold,
}); err != nil {
log.Printf("⚠️ Failed to permanently block %s: %v", ip, err)
}
}
func runAdvancedIntegrationAction(ctx context.Context, action, ip string, settings config.AppSettings, server config.Fail2banServer, details map[string]any) error {
cfg := settings.AdvancedActions
if cfg.Integration == "" {
return fmt.Errorf("no integration configured")
}
integration, ok := integrations.Get(cfg.Integration)
if !ok {
return fmt.Errorf("integration %s not registered", cfg.Integration)
}
logger := func(format string, args ...interface{}) {
if settings.Debug {
log.Printf(format, args...)
}
}
req := integrations.Request{
Context: ctx,
IP: ip,
Config: cfg,
Server: server,
Logger: logger,
}
var err error
switch action {
case "block":
err = integration.BlockIP(req)
case "unblock":
err = integration.UnblockIP(req)
default:
return fmt.Errorf("unsupported action %s", action)
}
status := map[string]string{
"block": "blocked",
"unblock": "unblocked",
}[action]
message := fmt.Sprintf("%s via %s", strings.Title(action), cfg.Integration)
if err != nil {
status = "error"
message = err.Error()
}
if details == nil {
details = map[string]any{}
}
details["action"] = action
detailsBytes, _ := json.Marshal(details)
rec := storage.PermanentBlockRecord{
IP: ip,
Integration: cfg.Integration,
Status: status,
Message: message,
ServerID: server.ID,
Details: string(detailsBytes),
}
if err2 := storage.UpsertPermanentBlock(ctx, rec); err2 != nil {
log.Printf("⚠️ Failed to record permanent block entry: %v", err2)
}
return err
}

View File

@@ -548,6 +548,8 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
log.Printf("⚠️ Failed to record ban event: %v", err)
}
evaluateAdvancedActions(ctx, settings, server, ip)
// Check if country is in alert list
displayCountry := country
if displayCountry == "" {
@@ -692,6 +694,72 @@ func ManageJailsHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"jails": jails})
}
// ListPermanentBlocksHandler exposes the permanent block log.
func ListPermanentBlocksHandler(c *gin.Context) {
limit := 100
if limitStr := c.DefaultQuery("limit", "100"); limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
limit = parsed
}
}
records, err := storage.ListPermanentBlocks(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"blocks": records})
}
// AdvancedActionsTestHandler allows manual block/unblock tests.
func AdvancedActionsTestHandler(c *gin.Context) {
var req struct {
Action string `json:"action"`
IP string `json:"ip"`
ServerID string `json:"serverId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if req.IP == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"})
return
}
action := strings.ToLower(req.Action)
if action == "" {
action = "block"
}
if action != "block" && action != "unblock" {
c.JSON(http.StatusBadRequest, gin.H{"error": "action must be block or unblock"})
return
}
settings := config.GetSettings()
server := config.Fail2banServer{}
if req.ServerID != "" {
if srv, ok := config.GetServerByID(req.ServerID); ok {
server = srv
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "server not found"})
return
}
}
err := runAdvancedIntegrationAction(
c.Request.Context(),
action,
req.IP,
settings,
server,
map[string]any{"manual": true},
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Action %s completed for %s", action, req.IP)})
}
// UpdateJailManagementHandler updates the enabled state for each jail.
// Expected JSON format: { "JailName1": true, "JailName2": false, ... }
// After updating, the Fail2ban service is restarted.

View File

@@ -43,6 +43,8 @@ func RegisterRoutes(r *gin.Engine) {
api.GET("/settings", GetSettingsHandler)
api.POST("/settings", UpdateSettingsHandler)
api.POST("/settings/test-email", TestEmailHandler)
api.GET("/advanced-actions/blocks", ListPermanentBlocksHandler)
api.POST("/advanced-actions/test", AdvancedActionsTestHandler)
// Fail2ban servers management
api.GET("/servers", ListServersHandler)

View File

@@ -357,6 +357,106 @@
</div>
</div>
<!-- Advanced Actions -->
<div class="bg-white rounded-lg shadow p-6">
<div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 mb-2" data-i18n="settings.advanced.title">Advanced Actions for Recurring Offenders</h3>
<p class="text-sm text-gray-500" data-i18n="settings.advanced.description">Automatically add recurring offenders to an external firewall once they hit a specific threshold.</p>
</div>
<div class="flex gap-2">
<button type="button" class="px-3 py-2 text-sm rounded border border-gray-300 text-gray-700 hover:bg-gray-50" onclick="refreshPermanentBlockLog()" data-i18n="settings.advanced.refresh_log">Refresh Log</button>
<button type="button" class="px-3 py-2 text-sm rounded border border-blue-600 text-blue-600 hover:bg-blue-50" onclick="openAdvancedTestModal()" data-i18n="settings.advanced.test_button">Test Integration</button>
</div>
</div>
<div class="mt-4 space-y-4">
<div class="flex items-center">
<input type="checkbox" id="advancedActionsEnabled" class="h-4 w-4 text-blue-600 border-gray-300 rounded">
<label for="advancedActionsEnabled" class="ml-2 text-sm text-gray-700" data-i18n="settings.advanced.enable">Enable automatic permanent blocking</label>
</div>
<div>
<label for="advancedThreshold" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.threshold">Threshold before permanent block</label>
<input type="number" id="advancedThreshold" min="1" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="5">
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.advanced.threshold_hint">If an IP is banned at least this many times it will be forwarded to the selected firewall integration.</p>
</div>
<div>
<label for="advancedIntegrationSelect" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.integration">Integration</label>
<select id="advancedIntegrationSelect" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" data-i18n="settings.advanced.integration_none">Select integration</option>
<option value="mikrotik">Mikrotik</option>
<option value="pfsense">pfSense</option>
</select>
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.advanced.integration_hint">Choose where permanent bans should be synchronized.</p>
</div>
<div id="advancedMikrotikFields" class="hidden space-y-4">
<p class="text-sm text-gray-500" data-i18n="settings.advanced.mikrotik.note">Provide SSH credentials and the address list where IPs should be added.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700" for="mikrotikHost" data-i18n="settings.advanced.mikrotik.host">Host</label>
<input id="mikrotikHost" type="text" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="mikrotikPort" data-i18n="settings.advanced.mikrotik.port">Port</label>
<input id="mikrotikPort" type="number" min="1" max="65535" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500" placeholder="22">
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="mikrotikUsername" data-i18n="settings.advanced.mikrotik.username">SSH Username</label>
<input id="mikrotikUsername" type="text" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="mikrotikPassword" data-i18n="settings.advanced.mikrotik.password">SSH Password</label>
<input id="mikrotikPassword" type="password" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="mikrotikSSHKey" data-i18n="settings.advanced.mikrotik.key">SSH Key Path (optional)</label>
<input id="mikrotikSSHKey" type="text" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="mikrotikList" data-i18n="settings.advanced.mikrotik.list">Address List Name</label>
<input id="mikrotikList" type="text" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500" placeholder="fail2ban-permanent">
</div>
</div>
</div>
<div id="advancedPfSenseFields" class="hidden space-y-4">
<p class="text-sm text-gray-500" data-i18n="settings.advanced.pfsense.note">Requires the pfSense API package. Enter the API credentials and alias to manage.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700" for="pfSenseBaseURL" data-i18n="settings.advanced.pfsense.base_url">Base URL</label>
<input id="pfSenseBaseURL" type="url" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500" placeholder="https://firewall.local">
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="pfSenseToken" data-i18n="settings.advanced.pfsense.token">API Token</label>
<input id="pfSenseToken" type="text" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="pfSenseSecret" data-i18n="settings.advanced.pfsense.secret">API Secret</label>
<input id="pfSenseSecret" type="text" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="pfSenseAlias" data-i18n="settings.advanced.pfsense.alias">Alias Name</label>
<input id="pfSenseAlias" type="text" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500">
</div>
<div class="flex items-center">
<input type="checkbox" id="pfSenseSkipTLS" class="h-4 w-4 text-blue-600 border-gray-300 rounded">
<label for="pfSenseSkipTLS" class="ml-2 text-sm text-gray-700" data-i18n="settings.advanced.pfsense.skip_tls">Skip TLS verification (self-signed)</label>
</div>
</div>
</div>
</div>
<div class="mt-6">
<h4 class="text-md font-semibold text-gray-800 mb-2" data-i18n="settings.advanced.log_title">Permanent Block Log</h4>
<div id="permanentBlockLog" class="overflow-x-auto border border-gray-200 rounded-md">
<p class="text-sm text-gray-500 p-4" data-i18n="settings.advanced.log_empty">No permanent blocks recorded yet.</p>
</div>
</div>
</div>
<!-- Alert Settings Group -->
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.alert">Alert Settings</h3>
@@ -664,15 +764,11 @@
<!-- Modal Templates START -->
<!-- ******************************************************************* -->
<!-- Jail Config Modal -->
<div id="jailConfigModal" class="hidden fixed inset-0 overflow-hidden" style="z-index: 60;">
<div class="flex items-center justify-center min-h-screen pt-4 px-2 sm:px-4 pb-20 text-center">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div id="jailConfigModal" class="hidden fixed inset-0 z-50 overflow-y-auto" style="z-index: 60;">
<div class="relative flex min-h-full w-full items-center justify-center p-2 sm:p-4">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="relative inline-block align-bottom bg-white rounded-lg text-left shadow-xl transform transition-all my-4 sm:my-8 align-middle w-full max-w-full max-h-screen overflow-y-auto" style="max-width: 90vw; max-height: calc(100vh - 2rem);">
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all my-4 sm:my-8 max-h-screen overflow-y-auto" style="max-width: 90vw; max-height: calc(100vh - 2rem);">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
@@ -714,14 +810,10 @@
<!-- Manage Jails Modal -->
<div id="manageJailsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-xl sm:w-full">
<div class="relative z-10 w-full max-w-xl rounded-lg bg-white text-left shadow-xl transition-all">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
@@ -743,14 +835,10 @@
<!-- Server Manager Modal -->
<div id="serverManagerModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full">
<div class="relative z-10 w-full max-w-5xl rounded-lg bg-white text-left shadow-xl transition-all">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-6">
<div class="flex flex-col gap-6">
<div>
@@ -855,14 +943,10 @@
<!-- Whois Modal -->
<div id="whoisModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="relative z-10 w-full max-w-4xl rounded-lg bg-white text-left shadow-xl transition-all">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
@@ -882,16 +966,46 @@
</div>
</div>
<!-- Advanced Actions Test Modal -->
<div id="advancedTestModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<div class="relative z-10 w-full max-w-lg rounded-lg bg-white text-left shadow-xl transition-all">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900" data-i18n="settings.advanced.test_title">Test Advanced Integration</h3>
<div class="mt-4 space-y-4">
<div>
<label for="advancedTestIP" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.test_ip">IP address</label>
<input type="text" id="advancedTestIP" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="203.0.113.10">
</div>
<div>
<label for="advancedTestServer" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.test_server">Optional server</label>
<select id="advancedTestServer" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" data-i18n="settings.advanced.test_server_none">Use global integration settings</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-3">
<button type="button" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm" onclick="submitAdvancedTest('block')" data-i18n="settings.advanced.test_block">Block IP</button>
<button type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" onclick="submitAdvancedTest('unblock')" data-i18n="settings.advanced.test_unblock">Remove IP</button>
<button type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('advancedTestModal')" data-i18n="modal.close">Close</button>
</div>
</div>
</div>
</div>
<!-- Logs Modal -->
<div id="logsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="relative z-10 w-full max-w-4xl rounded-lg bg-white text-left shadow-xl transition-all">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
@@ -916,14 +1030,10 @@
<!-- Ban Insights Modal -->
<div id="banInsightsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="relative z-10 w-full max-w-4xl rounded-lg bg-white text-left shadow-xl transition-all">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
@@ -2977,6 +3087,9 @@
document.getElementById('findTime').value = data.findtime || '';
document.getElementById('maxRetry').value = data.maxretry || '';
document.getElementById('ignoreIP').value = data.ignoreip || '';
applyAdvancedActionsSettings(data.advancedActions || {});
loadPermanentBlockLog();
})
.catch(err => {
showToast('Error loading settings: ' + err, 'error');
@@ -3015,7 +3128,8 @@
findtime: document.getElementById('findTime').value.trim(),
maxretry: parseInt(document.getElementById('maxRetry').value, 10) || 3,
ignoreip: document.getElementById('ignoreIP').value.trim(),
smtp: smtpSettings
smtp: smtpSettings,
advancedActions: collectAdvancedActionsSettings()
};
fetch('/api/settings', {
@@ -3164,6 +3278,201 @@
testResultsEl.classList.remove('hidden');
}
function applyAdvancedActionsSettings(cfg) {
cfg = cfg || {};
document.getElementById('advancedActionsEnabled').checked = !!cfg.enabled;
document.getElementById('advancedThreshold').value = cfg.threshold || 5;
const integrationSelect = document.getElementById('advancedIntegrationSelect');
integrationSelect.value = cfg.integration || '';
const mk = cfg.mikrotik || {};
document.getElementById('mikrotikHost').value = mk.host || '';
document.getElementById('mikrotikPort').value = mk.port || 22;
document.getElementById('mikrotikUsername').value = mk.username || '';
document.getElementById('mikrotikPassword').value = mk.password || '';
document.getElementById('mikrotikSSHKey').value = mk.sshKeyPath || '';
document.getElementById('mikrotikList').value = mk.addressList || 'fail2ban-permanent';
const pf = cfg.pfSense || {};
document.getElementById('pfSenseBaseURL').value = pf.baseUrl || '';
document.getElementById('pfSenseToken').value = pf.apiToken || '';
document.getElementById('pfSenseSecret').value = pf.apiSecret || '';
document.getElementById('pfSenseAlias').value = pf.alias || '';
document.getElementById('pfSenseSkipTLS').checked = !!pf.skipTLSVerify;
updateAdvancedIntegrationFields();
}
function collectAdvancedActionsSettings() {
return {
enabled: document.getElementById('advancedActionsEnabled').checked,
threshold: parseInt(document.getElementById('advancedThreshold').value, 10) || 5,
integration: document.getElementById('advancedIntegrationSelect').value,
mikrotik: {
host: document.getElementById('mikrotikHost').value.trim(),
port: parseInt(document.getElementById('mikrotikPort').value, 10) || 22,
username: document.getElementById('mikrotikUsername').value.trim(),
password: document.getElementById('mikrotikPassword').value,
sshKeyPath: document.getElementById('mikrotikSSHKey').value.trim(),
addressList: document.getElementById('mikrotikList').value.trim() || 'fail2ban-permanent',
},
pfSense: {
baseUrl: document.getElementById('pfSenseBaseURL').value.trim(),
apiToken: document.getElementById('pfSenseToken').value.trim(),
apiSecret: document.getElementById('pfSenseSecret').value.trim(),
alias: document.getElementById('pfSenseAlias').value.trim(),
skipTLSVerify: document.getElementById('pfSenseSkipTLS').checked,
}
};
}
function updateAdvancedIntegrationFields() {
const selected = document.getElementById('advancedIntegrationSelect').value;
document.getElementById('advancedMikrotikFields').classList.toggle('hidden', selected !== 'mikrotik');
document.getElementById('advancedPfSenseFields').classList.toggle('hidden', selected !== 'pfsense');
}
function loadPermanentBlockLog() {
fetch('/api/advanced-actions/blocks')
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Error loading permanent block log: ' + data.error, 'error');
return;
}
renderPermanentBlockLog(data.blocks || []);
})
.catch(err => {
showToast('Error loading permanent block log: ' + err, 'error');
});
}
function renderPermanentBlockLog(blocks) {
const container = document.getElementById('permanentBlockLog');
if (!container) return;
if (!blocks.length) {
container.innerHTML = '<p class="text-sm text-gray-500 p-4" data-i18n="settings.advanced.log_empty">No permanent blocks recorded yet.</p>';
if (typeof updateTranslations === 'function') updateTranslations();
return;
}
let rows = blocks.map(block => {
const statusClass = block.status === 'blocked'
? 'text-green-600'
: (block.status === 'unblocked' ? 'text-gray-500' : 'text-red-600');
const message = block.message ? escapeHtml(block.message) : '';
return `
<tr class="border-t">
<td class="px-3 py-2 font-mono text-sm">${escapeHtml(block.ip)}</td>
<td class="px-3 py-2 text-sm">${escapeHtml(block.integration)}</td>
<td class="px-3 py-2 text-sm ${statusClass}">${escapeHtml(block.status)}</td>
<td class="px-3 py-2 text-sm">${message || '&nbsp;'}</td>
<td class="px-3 py-2 text-xs text-gray-500">${escapeHtml(block.serverId || '')}</td>
<td class="px-3 py-2 text-xs text-gray-500">${block.updatedAt ? new Date(block.updatedAt).toLocaleString() : ''}</td>
<td class="px-3 py-2 text-right">
<button class="text-sm text-blue-600 hover:text-blue-800" onclick="advancedUnblockIP('${escapeHtml(block.ip)}')" data-i18n="settings.advanced.unblock_btn">Remove</button>
</td>
</tr>`;
}).join('');
container.innerHTML = `
<table class="min-w-full text-sm">
<thead class="bg-gray-50 text-left">
<tr>
<th class="px-3 py-2" data-i18n="settings.advanced.log_ip">IP</th>
<th class="px-3 py-2" data-i18n="settings.advanced.log_integration">Integration</th>
<th class="px-3 py-2" data-i18n="settings.advanced.log_status">Status</th>
<th class="px-3 py-2" data-i18n="settings.advanced.log_message">Message</th>
<th class="px-3 py-2" data-i18n="settings.advanced.log_server">Server</th>
<th class="px-3 py-2" data-i18n="settings.advanced.log_updated">Updated</th>
<th class="px-3 py-2 text-right" data-i18n="settings.advanced.log_actions">Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
if (typeof updateTranslations === 'function') updateTranslations();
}
function refreshPermanentBlockLog() {
loadPermanentBlockLog();
}
function openAdvancedTestModal() {
populateAdvancedTestServers();
document.getElementById('advancedTestIP').value = '';
document.getElementById('advancedTestServer').value = '';
openModal('advancedTestModal');
}
function populateAdvancedTestServers() {
const select = document.getElementById('advancedTestServer');
if (!select) return;
const value = select.value;
select.innerHTML = '';
const baseOption = document.createElement('option');
baseOption.value = '';
baseOption.textContent = t('settings.advanced.test_server_none', 'Use global integration settings');
select.appendChild(baseOption);
serversCache.forEach(server => {
const opt = document.createElement('option');
opt.value = server.id;
opt.textContent = server.name || server.id;
select.appendChild(opt);
});
select.value = value;
}
function submitAdvancedTest(action) {
const ipValue = document.getElementById('advancedTestIP').value.trim();
if (!ipValue) {
showToast('Please enter an IP address.', 'info');
return;
}
const serverId = document.getElementById('advancedTestServer').value;
showLoading(true);
fetch('/api/advanced-actions/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action, ip: ipValue, serverId: serverId })
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Advanced action failed: ' + data.error, 'error');
} else {
showToast(data.message || 'Action completed', 'success');
loadPermanentBlockLog();
}
})
.catch(err => showToast('Advanced action failed: ' + err, 'error'))
.finally(() => {
showLoading(false);
closeModal('advancedTestModal');
});
}
function advancedUnblockIP(ip) {
if (!ip) return;
fetch('/api/advanced-actions/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'unblock', ip: ip })
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Failed to remove IP: ' + data.error, 'error');
} else {
showToast(data.message || 'IP removed', 'success');
loadPermanentBlockLog();
}
})
.catch(err => showToast('Failed to remove IP: ' + err, 'error'));
}
const advancedIntegrationSelect = document.getElementById('advancedIntegrationSelect');
if (advancedIntegrationSelect) {
advancedIntegrationSelect.addEventListener('change', updateAdvancedIntegrationFields);
}
// When showing the filter section
function showFilterSection() {
const testResultsEl = document.getElementById('testResults');