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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user