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

@@ -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
}