mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 14:03:15 +02:00
Fix PFSence integration and add also GET-modify-PATCH patterns
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,21 @@ import (
|
||||
|
||||
type pfSenseIntegration struct{}
|
||||
|
||||
// FirewallAliasResponse represents the response structure from pfSense API
|
||||
type FirewallAliasResponse struct {
|
||||
Data FirewallAlias `json:"data"`
|
||||
}
|
||||
|
||||
// FirewallAlias represents a firewall alias in pfSense
|
||||
type FirewallAlias struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Descr string `json:"descr"`
|
||||
Address []string `json:"address"`
|
||||
Detail []string `json:"detail"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(&pfSenseIntegration{})
|
||||
}
|
||||
@@ -44,36 +60,22 @@ 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)
|
||||
return p.modifyAliasIP(req, req.IP, "Fail2ban-UI permanent block", true)
|
||||
}
|
||||
|
||||
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)
|
||||
return p.modifyAliasIP(req, req.IP, "", false)
|
||||
}
|
||||
|
||||
func (p *pfSenseIntegration) callAPI(req Request, action string, payload map[string]any) error {
|
||||
// modifyAliasIP implements the GET-modify-PATCH pattern for pfSense alias management
|
||||
func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string, add bool) 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/v2/firewall/alias/ip"
|
||||
baseURL := strings.TrimSuffix(cfg.BaseURL, "/")
|
||||
|
||||
// Create HTTP client
|
||||
httpClient := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
@@ -83,48 +85,228 @@ func (p *pfSenseIntegration) callAPI(req Request, action string, payload map[str
|
||||
}
|
||||
}
|
||||
|
||||
reqLogger := "pfSense"
|
||||
if req.Logger != nil {
|
||||
req.Logger("Calling pfSense API %s action=%s payload=%s", apiURL, action, string(data))
|
||||
// GET the alias by name
|
||||
alias, err := p.getAliasByName(httpClient, baseURL, cfg.APIToken, cfg.Alias, req.Logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get alias %s: %w", cfg.Alias, err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(data))
|
||||
// Modify the address array
|
||||
if add {
|
||||
// Check if IP already exists
|
||||
ipExists := false
|
||||
for _, addr := range alias.Address {
|
||||
if addr == ip {
|
||||
ipExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ipExists {
|
||||
alias.Address = append(alias.Address, ip)
|
||||
if description != "" {
|
||||
// Add description to detail array, matching the address array length
|
||||
alias.Detail = append(alias.Detail, description)
|
||||
}
|
||||
} else {
|
||||
if req.Logger != nil {
|
||||
req.Logger("IP %s already exists in alias %s", ip, cfg.Alias)
|
||||
}
|
||||
return nil // IP already blocked, consider it success
|
||||
}
|
||||
} else {
|
||||
// Remove IP from address array
|
||||
found := false
|
||||
newAddress := make([]string, 0, len(alias.Address))
|
||||
newDetail := make([]string, 0, len(alias.Detail))
|
||||
for i, addr := range alias.Address {
|
||||
if addr != ip {
|
||||
newAddress = append(newAddress, addr)
|
||||
// Keep corresponding detail if it exists
|
||||
if i < len(alias.Detail) {
|
||||
newDetail = append(newDetail, alias.Detail[i])
|
||||
}
|
||||
} else {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if req.Logger != nil {
|
||||
req.Logger("IP %s not found in alias %s", ip, cfg.Alias)
|
||||
}
|
||||
return nil // IP not in alias, consider it success
|
||||
}
|
||||
alias.Address = newAddress
|
||||
alias.Detail = newDetail
|
||||
}
|
||||
|
||||
// PATCH the alias with updated configuration
|
||||
if err := p.updateAlias(httpClient, baseURL, cfg.APIToken, alias, req.Logger); err != nil {
|
||||
return fmt.Errorf("failed to update alias %s: %w", cfg.Alias, err)
|
||||
}
|
||||
|
||||
// Apply firewall changes
|
||||
if err := p.applyFirewallChanges(httpClient, baseURL, cfg.APIToken, req.Logger); err != nil {
|
||||
// Log warning but don't fail - the alias was updated successfully
|
||||
if req.Logger != nil {
|
||||
req.Logger("Warning: failed to apply firewall changes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Logger != nil {
|
||||
action := "added to"
|
||||
if !add {
|
||||
action = "removed from"
|
||||
}
|
||||
req.Logger("IP %s successfully %s alias %s", ip, action, cfg.Alias)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAliasByName retrieves a firewall alias by name using GET /api/v2/firewall/alias
|
||||
func (p *pfSenseIntegration) getAliasByName(client *http.Client, baseURL, apiToken, aliasName string, logger func(string, ...interface{})) (*FirewallAlias, error) {
|
||||
apiURL := baseURL + "/api/v2/firewall/alias"
|
||||
|
||||
// Add query parameter for alias name
|
||||
u, err := url.Parse(apiURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create pfSense request: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("name", aliasName)
|
||||
u.RawQuery = q.Encode()
|
||||
apiURL = u.String()
|
||||
|
||||
if logger != nil {
|
||||
logger("Calling pfSense API GET %s", apiURL)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create pfSense GET request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("x-api-key", apiToken)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
if netErr, ok := err.(interface {
|
||||
Timeout() bool
|
||||
Error() string
|
||||
}); ok && netErr.Timeout() {
|
||||
return nil, fmt.Errorf("pfSense API request to %s timed out: %w", apiURL, err)
|
||||
}
|
||||
return nil, fmt.Errorf("pfSense API request to %s failed: %w", apiURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("pfSense API GET failed: status %s, response: %s", resp.Status, bodyStr)
|
||||
}
|
||||
|
||||
var aliasResp FirewallAliasResponse
|
||||
if err := json.Unmarshal(bodyBytes, &aliasResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode pfSense alias response: %w", err)
|
||||
}
|
||||
|
||||
return &aliasResp.Data, nil
|
||||
}
|
||||
|
||||
// updateAlias updates a firewall alias using PATCH /api/v2/firewall/alias/{id}
|
||||
func (p *pfSenseIntegration) updateAlias(client *http.Client, baseURL, apiToken string, alias *FirewallAlias, logger func(string, ...interface{})) error {
|
||||
apiURL := fmt.Sprintf("%s/api/v2/firewall/alias/%d", baseURL, alias.ID)
|
||||
|
||||
// Prepare PATCH payload - only include fields that can be updated
|
||||
patchPayload := map[string]interface{}{
|
||||
"name": alias.Name,
|
||||
"type": alias.Type,
|
||||
"descr": alias.Descr,
|
||||
"address": alias.Address,
|
||||
}
|
||||
if len(alias.Detail) > 0 {
|
||||
patchPayload["detail"] = alias.Detail
|
||||
}
|
||||
|
||||
data, err := json.Marshal(patchPayload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode pfSense PATCH payload: %w", err)
|
||||
}
|
||||
|
||||
if logger != nil {
|
||||
logger("Calling pfSense API PATCH %s payload=%s", apiURL, string(data))
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(http.MethodPatch, apiURL, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create pfSense PATCH request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
// pfSense REST API v2 uses KeyAuth with x-api-key header only
|
||||
httpReq.Header.Set("x-api-key", cfg.APIToken)
|
||||
httpReq.Header.Set("x-api-key", apiToken)
|
||||
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
// Provide more specific error messages for connection issues
|
||||
if netErr, ok := err.(interface {
|
||||
Timeout() bool
|
||||
Error() string
|
||||
}); ok && netErr.Timeout() {
|
||||
return fmt.Errorf("pfSense API request to %s timed out: %w", apiURL, err)
|
||||
}
|
||||
return fmt.Errorf("pfSense API request to %s failed: %w (check base URL, network connectivity, and API credentials)", apiURL, err)
|
||||
return fmt.Errorf("pfSense API request to %s failed: %w", apiURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body for better error messages
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
if bodyStr != "" {
|
||||
return fmt.Errorf("pfSense API request failed: status %s, response: %s", resp.Status, bodyStr)
|
||||
}
|
||||
return fmt.Errorf("pfSense API request failed: status %s (check API credentials and alias name)", resp.Status)
|
||||
return fmt.Errorf("pfSense API PATCH failed: status %s, response: %s", resp.Status, bodyStr)
|
||||
}
|
||||
|
||||
if req.Logger != nil {
|
||||
req.Logger("%s API call succeeded", reqLogger)
|
||||
if bodyStr != "" {
|
||||
req.Logger("%s API response: %s", reqLogger, bodyStr)
|
||||
}
|
||||
if logger != nil {
|
||||
logger("pfSense API PATCH succeeded: %s", bodyStr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyFirewallChanges applies firewall changes using POST /api/v2/firewall/apply
|
||||
func (p *pfSenseIntegration) applyFirewallChanges(client *http.Client, baseURL, apiToken string, logger func(string, ...interface{})) error {
|
||||
apiURL := baseURL + "/api/v2/firewall/apply"
|
||||
|
||||
if logger != nil {
|
||||
logger("Calling pfSense API POST %s to apply firewall changes", apiURL)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(http.MethodPost, apiURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create pfSense apply request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("x-api-key", apiToken)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
if netErr, ok := err.(interface {
|
||||
Timeout() bool
|
||||
Error() string
|
||||
}); ok && netErr.Timeout() {
|
||||
return fmt.Errorf("pfSense API request to %s timed out: %w", apiURL, err)
|
||||
}
|
||||
return fmt.Errorf("pfSense API request to %s failed: %w", apiURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("pfSense API apply failed: status %s, response: %s", resp.Status, bodyStr)
|
||||
}
|
||||
|
||||
if logger != nil {
|
||||
logger("pfSense API apply succeeded: %s", bodyStr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user