mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 05:53:15 +02:00
First steps to implement a advanced-actions function to block recurring offenders before fail2ban
This commit is contained in:
@@ -56,6 +56,7 @@ type AppSettings struct {
|
|||||||
AlertCountries []string `json:"alertCountries"`
|
AlertCountries []string `json:"alertCountries"`
|
||||||
SMTP SMTPSettings `json:"smtp"`
|
SMTP SMTPSettings `json:"smtp"`
|
||||||
CallbackURL string `json:"callbackUrl"`
|
CallbackURL string `json:"callbackUrl"`
|
||||||
|
AdvancedActions AdvancedActionsConfig `json:"advancedActions"`
|
||||||
|
|
||||||
Servers []Fail2banServer `json:"servers"`
|
Servers []Fail2banServer `json:"servers"`
|
||||||
|
|
||||||
@@ -69,6 +70,56 @@ type AppSettings struct {
|
|||||||
//Sender string `json:"sender"`
|
//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
|
// init paths to key-files
|
||||||
const (
|
const (
|
||||||
settingsFile = "fail2ban-ui-settings.json" // this file is created, relatively to where the app was started
|
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
|
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) {
|
func applyServerRecordsLocked(records []storage.ServerRecord) {
|
||||||
@@ -346,6 +403,11 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
|
|||||||
return storage.AppSettingsRecord{}, err
|
return storage.AppSettingsRecord{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
advancedBytes, err := json.Marshal(currentSettings.AdvancedActions)
|
||||||
|
if err != nil {
|
||||||
|
return storage.AppSettingsRecord{}, err
|
||||||
|
}
|
||||||
|
|
||||||
return storage.AppSettingsRecord{
|
return storage.AppSettingsRecord{
|
||||||
Language: currentSettings.Language,
|
Language: currentSettings.Language,
|
||||||
Port: currentSettings.Port,
|
Port: currentSettings.Port,
|
||||||
@@ -365,6 +427,7 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
|
|||||||
Findtime: currentSettings.Findtime,
|
Findtime: currentSettings.Findtime,
|
||||||
MaxRetry: currentSettings.Maxretry,
|
MaxRetry: currentSettings.Maxretry,
|
||||||
DestEmail: currentSettings.Destemail,
|
DestEmail: currentSettings.Destemail,
|
||||||
|
AdvancedActionsJSON: string(advancedBytes),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,6 +528,11 @@ func setDefaultsLocked() {
|
|||||||
currentSettings.IgnoreIP = "127.0.0.1/8 ::1"
|
currentSettings.IgnoreIP = "127.0.0.1/8 ::1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentSettings.AdvancedActions == AdvancedActionsConfig{}) {
|
||||||
|
currentSettings.AdvancedActions = defaultAdvancedActionsConfig()
|
||||||
|
}
|
||||||
|
currentSettings.AdvancedActions = normalizeAdvancedActionsConfig(currentSettings.AdvancedActions)
|
||||||
|
|
||||||
normalizeServersLocked()
|
normalizeServersLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
122
internal/integrations/mikrotik.go
Normal file
122
internal/integrations/mikrotik.go
Normal 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
|
||||||
|
}
|
||||||
112
internal/integrations/pfsense.go
Normal file
112
internal/integrations/pfsense.go
Normal 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
|
||||||
|
}
|
||||||
61
internal/integrations/types.go
Normal file
61
internal/integrations/types.go
Normal 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
|
||||||
|
}
|
||||||
@@ -118,6 +118,45 @@
|
|||||||
"settings.default_max_retry_placeholder": "Geben Sie die maximale Anzahl der Versuche ein",
|
"settings.default_max_retry_placeholder": "Geben Sie die maximale Anzahl der Versuche ein",
|
||||||
"settings.ignore_ips": "IP-Adressen ignorieren",
|
"settings.ignore_ips": "IP-Adressen ignorieren",
|
||||||
"settings.ignore_ips_placeholder": "IP-Adressen, getrennt durch Leerzeichen",
|
"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",
|
"settings.save": "Speichern",
|
||||||
"modal.filter_config": "Filter-Konfiguration:",
|
"modal.filter_config": "Filter-Konfiguration:",
|
||||||
"modal.filter_config_edit": "Filter bearbeiten",
|
"modal.filter_config_edit": "Filter bearbeiten",
|
||||||
|
|||||||
@@ -118,6 +118,45 @@
|
|||||||
"settings.default_max_retry_placeholder": "Gib d'maximal Versüech ii",
|
"settings.default_max_retry_placeholder": "Gib d'maximal Versüech ii",
|
||||||
"settings.ignore_ips": "IPs ignorierä",
|
"settings.ignore_ips": "IPs ignorierä",
|
||||||
"settings.ignore_ips_placeholder": "IPs, getrennt dur e Leerzeichä",
|
"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ä",
|
"settings.save": "Speicherä",
|
||||||
"modal.filter_config": "Filter-Konfiguration:",
|
"modal.filter_config": "Filter-Konfiguration:",
|
||||||
"modal.filter_config_edit": "Filter bearbeite",
|
"modal.filter_config_edit": "Filter bearbeite",
|
||||||
|
|||||||
@@ -118,6 +118,45 @@
|
|||||||
"settings.default_max_retry_placeholder": "Enter maximum retries",
|
"settings.default_max_retry_placeholder": "Enter maximum retries",
|
||||||
"settings.ignore_ips": "Ignore IPs",
|
"settings.ignore_ips": "Ignore IPs",
|
||||||
"settings.ignore_ips_placeholder": "IPs to ignore, separated by spaces",
|
"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",
|
"settings.save": "Save",
|
||||||
"modal.filter_config": "Filter Config:",
|
"modal.filter_config": "Filter Config:",
|
||||||
"modal.filter_config_edit": "Edit Filter",
|
"modal.filter_config_edit": "Edit Filter",
|
||||||
|
|||||||
@@ -118,6 +118,45 @@
|
|||||||
"settings.default_max_retry_placeholder": "Introduce el número máximo de reintentos",
|
"settings.default_max_retry_placeholder": "Introduce el número máximo de reintentos",
|
||||||
"settings.ignore_ips": "Ignorar IPs",
|
"settings.ignore_ips": "Ignorar IPs",
|
||||||
"settings.ignore_ips_placeholder": "IPs a ignorar, separadas por espacios",
|
"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",
|
"settings.save": "Guardar",
|
||||||
"modal.filter_config": "Configuración del filtro:",
|
"modal.filter_config": "Configuración del filtro:",
|
||||||
"modal.filter_config_edit": "Editar filtro",
|
"modal.filter_config_edit": "Editar filtro",
|
||||||
|
|||||||
@@ -118,6 +118,45 @@
|
|||||||
"settings.default_max_retry_placeholder": "Entrez le nombre maximal de réessais",
|
"settings.default_max_retry_placeholder": "Entrez le nombre maximal de réessais",
|
||||||
"settings.ignore_ips": "Ignorer les IPs",
|
"settings.ignore_ips": "Ignorer les IPs",
|
||||||
"settings.ignore_ips_placeholder": "IPs à ignorer, séparées par des espaces",
|
"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 l’inté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 à l’inté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 d’adresses 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 d’alias",
|
||||||
|
"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 l’intégration avancée",
|
||||||
|
"settings.advanced.test_ip": "Adresse IP",
|
||||||
|
"settings.advanced.test_server": "Serveur optionnel",
|
||||||
|
"settings.advanced.test_server_none": "Utiliser l’intégration globale",
|
||||||
|
"settings.advanced.test_block": "Bloquer l’IP",
|
||||||
|
"settings.advanced.test_unblock": "Retirer l’IP",
|
||||||
"settings.save": "Enregistrer",
|
"settings.save": "Enregistrer",
|
||||||
"modal.filter_config": "Configuration du filtre:",
|
"modal.filter_config": "Configuration du filtre:",
|
||||||
"modal.filter_config_edit": "Modifier le filtre",
|
"modal.filter_config_edit": "Modifier le filtre",
|
||||||
|
|||||||
@@ -118,6 +118,45 @@
|
|||||||
"settings.default_max_retry_placeholder": "Inserisci il numero massimo di tentativi",
|
"settings.default_max_retry_placeholder": "Inserisci il numero massimo di tentativi",
|
||||||
"settings.ignore_ips": "Ignora IP",
|
"settings.ignore_ips": "Ignora IP",
|
||||||
"settings.ignore_ips_placeholder": "IP da ignorare, separate da spazi",
|
"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 all’integrazione.",
|
||||||
|
"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 l’accesso 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",
|
"settings.save": "Salva",
|
||||||
"modal.filter_config": "Configurazione del filtro:",
|
"modal.filter_config": "Configurazione del filtro:",
|
||||||
"modal.filter_config_edit": "Modifica filtro",
|
"modal.filter_config_edit": "Modifica filtro",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type AppSettingsRecord struct {
|
|||||||
Findtime string
|
Findtime string
|
||||||
MaxRetry int
|
MaxRetry int
|
||||||
DestEmail string
|
DestEmail string
|
||||||
|
AdvancedActionsJSON string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerRecord struct {
|
type ServerRecord struct {
|
||||||
@@ -112,6 +113,18 @@ type RecurringIPStat struct {
|
|||||||
LastSeen time.Time `json:"lastSeen"`
|
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.
|
// Init initializes the internal storage. Safe to call multiple times.
|
||||||
func Init(dbPath string) error {
|
func Init(dbPath string) error {
|
||||||
initOnce.Do(func() {
|
initOnce.Do(func() {
|
||||||
@@ -154,17 +167,17 @@ func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
row := db.QueryRowContext(ctx, `
|
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
|
FROM app_settings
|
||||||
WHERE id = 1`)
|
WHERE id = 1`)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail sql.NullString
|
lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, advancedActions sql.NullString
|
||||||
port, smtpPort, maxretry sql.NullInt64
|
port, smtpPort, maxretry sql.NullInt64
|
||||||
debug, restartNeeded, smtpTLS, bantimeInc 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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return AppSettingsRecord{}, false, nil
|
return AppSettingsRecord{}, false, nil
|
||||||
}
|
}
|
||||||
@@ -191,6 +204,7 @@ WHERE id = 1`)
|
|||||||
Findtime: stringFromNull(findtime),
|
Findtime: stringFromNull(findtime),
|
||||||
MaxRetry: intFromNull(maxretry),
|
MaxRetry: intFromNull(maxretry),
|
||||||
DestEmail: stringFromNull(destemail),
|
DestEmail: stringFromNull(destemail),
|
||||||
|
AdvancedActionsJSON: stringFromNull(advancedActions),
|
||||||
}
|
}
|
||||||
|
|
||||||
return rec, true, nil
|
return rec, true, nil
|
||||||
@@ -202,9 +216,9 @@ func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error {
|
|||||||
}
|
}
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO app_settings (
|
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 (
|
) VALUES (
|
||||||
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
) ON CONFLICT(id) DO UPDATE SET
|
) ON CONFLICT(id) DO UPDATE SET
|
||||||
language = excluded.language,
|
language = excluded.language,
|
||||||
port = excluded.port,
|
port = excluded.port,
|
||||||
@@ -223,7 +237,8 @@ INSERT INTO app_settings (
|
|||||||
bantime = excluded.bantime,
|
bantime = excluded.bantime,
|
||||||
findtime = excluded.findtime,
|
findtime = excluded.findtime,
|
||||||
maxretry = excluded.maxretry,
|
maxretry = excluded.maxretry,
|
||||||
destemail = excluded.destemail
|
destemail = excluded.destemail,
|
||||||
|
advanced_actions = excluded.advanced_actions
|
||||||
`, rec.Language,
|
`, rec.Language,
|
||||||
rec.Port,
|
rec.Port,
|
||||||
boolToInt(rec.Debug),
|
boolToInt(rec.Debug),
|
||||||
@@ -242,6 +257,7 @@ INSERT INTO app_settings (
|
|||||||
rec.Findtime,
|
rec.Findtime,
|
||||||
rec.MaxRetry,
|
rec.MaxRetry,
|
||||||
rec.DestEmail,
|
rec.DestEmail,
|
||||||
|
rec.AdvancedActionsJSON,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -566,6 +582,33 @@ WHERE 1=1`
|
|||||||
return total, nil
|
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.
|
// CountBanEventsByCountry returns aggregation per country code, optionally filtered by server.
|
||||||
func CountBanEventsByCountry(ctx context.Context, since time.Time, serverID string) (map[string]int64, error) {
|
func CountBanEventsByCountry(ctx context.Context, since time.Time, serverID string) (map[string]int64, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
@@ -695,7 +738,8 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
|||||||
bantime TEXT,
|
bantime TEXT,
|
||||||
findtime TEXT,
|
findtime TEXT,
|
||||||
maxretry INTEGER,
|
maxretry INTEGER,
|
||||||
destemail TEXT
|
destemail TEXT,
|
||||||
|
advanced_actions TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS servers (
|
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_server_id ON ban_events(server_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at);
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,3 +827,128 @@ func ensureDirectory(path string) error {
|
|||||||
}
|
}
|
||||||
return os.MkdirAll(dir, 0o755)
|
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
111
pkg/web/advanced_actions.go
Normal 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
|
||||||
|
}
|
||||||
@@ -548,6 +548,8 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
|
|||||||
log.Printf("⚠️ Failed to record ban event: %v", err)
|
log.Printf("⚠️ Failed to record ban event: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
evaluateAdvancedActions(ctx, settings, server, ip)
|
||||||
|
|
||||||
// Check if country is in alert list
|
// Check if country is in alert list
|
||||||
displayCountry := country
|
displayCountry := country
|
||||||
if displayCountry == "" {
|
if displayCountry == "" {
|
||||||
@@ -692,6 +694,72 @@ func ManageJailsHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"jails": jails})
|
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.
|
// UpdateJailManagementHandler updates the enabled state for each jail.
|
||||||
// Expected JSON format: { "JailName1": true, "JailName2": false, ... }
|
// Expected JSON format: { "JailName1": true, "JailName2": false, ... }
|
||||||
// After updating, the Fail2ban service is restarted.
|
// After updating, the Fail2ban service is restarted.
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ func RegisterRoutes(r *gin.Engine) {
|
|||||||
api.GET("/settings", GetSettingsHandler)
|
api.GET("/settings", GetSettingsHandler)
|
||||||
api.POST("/settings", UpdateSettingsHandler)
|
api.POST("/settings", UpdateSettingsHandler)
|
||||||
api.POST("/settings/test-email", TestEmailHandler)
|
api.POST("/settings/test-email", TestEmailHandler)
|
||||||
|
api.GET("/advanced-actions/blocks", ListPermanentBlocksHandler)
|
||||||
|
api.POST("/advanced-actions/test", AdvancedActionsTestHandler)
|
||||||
|
|
||||||
// Fail2ban servers management
|
// Fail2ban servers management
|
||||||
api.GET("/servers", ListServersHandler)
|
api.GET("/servers", ListServersHandler)
|
||||||
|
|||||||
@@ -357,6 +357,106 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Alert Settings Group -->
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<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>
|
<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 -->
|
<!-- Modal Templates START -->
|
||||||
<!-- ******************************************************************* -->
|
<!-- ******************************************************************* -->
|
||||||
<!-- Jail Config Modal -->
|
<!-- Jail Config Modal -->
|
||||||
<div id="jailConfigModal" class="hidden fixed inset-0 overflow-hidden" style="z-index: 60;">
|
<div id="jailConfigModal" class="hidden fixed inset-0 z-50 overflow-y-auto" 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="relative flex min-h-full w-full items-center justify-center p-2 sm:p-4">
|
||||||
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<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="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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<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="sm:flex sm:items-start">
|
||||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||||
@@ -714,14 +810,10 @@
|
|||||||
|
|
||||||
<!-- Manage Jails Modal -->
|
<!-- Manage Jails Modal -->
|
||||||
<div id="manageJailsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<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="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<div class="relative z-10 w-full max-w-xl rounded-lg bg-white text-left shadow-xl transition-all">
|
||||||
|
|
||||||
<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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<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="sm:flex sm:items-start">
|
||||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||||
@@ -743,14 +835,10 @@
|
|||||||
|
|
||||||
<!-- Server Manager Modal -->
|
<!-- Server Manager Modal -->
|
||||||
<div id="serverManagerModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<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="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<div class="relative z-10 w-full max-w-5xl rounded-lg bg-white text-left shadow-xl transition-all">
|
||||||
|
|
||||||
<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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-6">
|
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-6">
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -855,14 +943,10 @@
|
|||||||
|
|
||||||
<!-- Whois Modal -->
|
<!-- Whois Modal -->
|
||||||
<div id="whoisModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<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="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<div class="relative z-10 w-full max-w-4xl rounded-lg bg-white text-left shadow-xl transition-all">
|
||||||
|
|
||||||
<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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<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="sm:flex sm:items-start">
|
||||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||||
@@ -882,16 +966,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs Modal -->
|
<!-- Advanced Actions Test Modal -->
|
||||||
<div id="logsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<div id="advancedTestModal" 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="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
<div class="absolute inset-0 bg-gray-500 opacity-75"></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>
|
</div>
|
||||||
|
|
||||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<!-- Logs Modal -->
|
||||||
|
<div id="logsModal" 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="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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div class="sm:flex sm:items-start">
|
<div class="sm:flex sm:items-start">
|
||||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||||
@@ -916,14 +1030,10 @@
|
|||||||
|
|
||||||
<!-- Ban Insights Modal -->
|
<!-- Ban Insights Modal -->
|
||||||
<div id="banInsightsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<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="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<div class="relative z-10 w-full max-w-4xl rounded-lg bg-white text-left shadow-xl transition-all">
|
||||||
|
|
||||||
<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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<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="sm:flex sm:items-start">
|
||||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
<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('findTime').value = data.findtime || '';
|
||||||
document.getElementById('maxRetry').value = data.maxretry || '';
|
document.getElementById('maxRetry').value = data.maxretry || '';
|
||||||
document.getElementById('ignoreIP').value = data.ignoreip || '';
|
document.getElementById('ignoreIP').value = data.ignoreip || '';
|
||||||
|
|
||||||
|
applyAdvancedActionsSettings(data.advancedActions || {});
|
||||||
|
loadPermanentBlockLog();
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
showToast('Error loading settings: ' + err, 'error');
|
showToast('Error loading settings: ' + err, 'error');
|
||||||
@@ -3015,7 +3128,8 @@
|
|||||||
findtime: document.getElementById('findTime').value.trim(),
|
findtime: document.getElementById('findTime').value.trim(),
|
||||||
maxretry: parseInt(document.getElementById('maxRetry').value, 10) || 3,
|
maxretry: parseInt(document.getElementById('maxRetry').value, 10) || 3,
|
||||||
ignoreip: document.getElementById('ignoreIP').value.trim(),
|
ignoreip: document.getElementById('ignoreIP').value.trim(),
|
||||||
smtp: smtpSettings
|
smtp: smtpSettings,
|
||||||
|
advancedActions: collectAdvancedActionsSettings()
|
||||||
};
|
};
|
||||||
|
|
||||||
fetch('/api/settings', {
|
fetch('/api/settings', {
|
||||||
@@ -3164,6 +3278,201 @@
|
|||||||
testResultsEl.classList.remove('hidden');
|
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 || ' '}</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
|
// When showing the filter section
|
||||||
function showFilterSection() {
|
function showFilterSection() {
|
||||||
const testResultsEl = document.getElementById('testResults');
|
const testResultsEl = document.getElementById('testResults');
|
||||||
|
|||||||
Reference in New Issue
Block a user