mirror of
https://github.com/ivuorinen/f2b.git
synced 2026-01-26 03:13:58 +00:00
* refactor: consolidate test helpers and reduce code duplication
- Fix prealloc lint issue in cmd_logswatch_test.go
- Add validateIPAndJails helper to consolidate IP/jail validation
- Add WithTestRunner/WithTestSudoChecker helpers for cleaner test setup
- Replace setupBasicMockResponses duplicates with StandardMockSetup
- Add SetupStandardResponses/SetupJailResponses to MockRunner
- Delegate cmd context helpers to fail2ban implementations
- Document context wrapper pattern in context_helpers.go
* refactor: consolidate duplicate code patterns across cmd and fail2ban packages
Add helper functions to reduce code duplication found by dupl:
- safeCloseFile/safeCloseReader: centralize file close error logging
- createTimeoutContext: consolidate timeout context creation pattern
- withContextCheck: wrap context cancellation checks
- recordOperationMetrics: unify metrics recording for commands/clients
Also includes Phase 1 consolidations:
- copyBuckets helper for metrics snapshots
- Table-driven context extraction in logging
- processWithValidation helper for IP processors
* refactor: consolidate LoggerInterface by embedding LoggerEntry
Both interfaces had identical method signatures. LoggerInterface now
embeds LoggerEntry to eliminate code duplication.
* refactor: consolidate test framework helpers and fix test patterns
- Add checkJSONFieldValue and failMissingJSONField helpers to reduce
duplication in JSON assertion methods
- Add ParallelTimeout to default test config
- Fix test to use WithTestRunner inside test loop for proper mock scoping
* refactor: unify ban/unban operations with OperationType pattern
Introduce OperationType struct to consolidate duplicate ban/unban logic:
- Add ProcessOperation and ProcessOperationWithContext generic functions
- Add ProcessOperationParallel and ProcessOperationParallelWithContext
- Existing ProcessBan*/ProcessUnban* functions now delegate to generic versions
- Reduces ~120 lines of duplicate code between ban and unban operations
* refactor: consolidate time parsing cache pattern
Add ParseWithLayout method to BoundedTimeCache that consolidates the
cache-lookup-parse-store pattern. FastTimeCache and TimeParsingCache
now delegate to this method instead of duplicating the logic.
* refactor: consolidate command execution patterns in fail2ban
- Add validateCommandExecution helper for command/argument validation
- Add runWithTimerContext helper for timed runner operations
- Add executeIPActionWithContext to unify BanIP/UnbanIP implementations
- Reduces duplicate validation and execution boilerplate
* refactor: consolidate logrus adapter with embedded loggerCore
Introduce loggerCore type that provides the 8 standard logging methods
(Debug, Info, Warn, Error, Debugf, Infof, Warnf, Errorf). Both
logrusAdapter and logrusEntryAdapter now embed this type, eliminating
16 duplicate method implementations.
* refactor: consolidate path validation patterns
- Add validateConfigPathWithFallback helper in cmd/config_utils.go
for the validate-or-fallback-with-logging pattern
- Add validateClientPath helper in fail2ban/helpers.go for client
path validation delegation
* fix: add context cancellation checks to wrapper functions
- wrapWithContext0/1/2 now check ctx.Err() before invoking wrapped function
- WithCommand now validates and trims empty command strings
* refactor: extract formatLatencyBuckets for deterministic metrics output
Add formatLatencyBuckets helper that writes latency bucket distribution
with sorted keys for deterministic output, eliminating duplicate
formatting code for command and client latency buckets.
* refactor: add generic setNestedMapValue helper for mock configuration
Add setNestedMapValue[T] generic helper that consolidates the repeated
pattern of mutex-protected nested map initialization and value setting
used by SetBanError, SetBanResult, SetUnbanError, and SetUnbanResult.
* fix: use cmd.Context() for signal propagation and correct mock status
- ExecuteIPCommand now uses cmd.Context() instead of context.Background()
to inherit Cobra's signal cancellation
- MockRunner.SetupJailResponses uses shared.Fail2BanStatusSuccess ("0")
instead of literal "1" for proper success path simulation
* fix: restore operation-specific log messages in ProcessOperationWithContext
Add back Logger.WithFields().Info(opType.Message) call that was lost
during refactoring. This restores the distinction between ban and unban
operation messages (shared.MsgBanResult vs shared.MsgUnbanResult).
* fix: return aggregated errors from parallel operations
Previously, errors from individual parallel operations were silently
swallowed - converted to status strings but never returned to callers.
Now processOperations collects all errors and returns them aggregated
via errors.Join, allowing callers to distinguish partial failures from
complete success while still receiving all results.
* fix: add input validation to processOperations before parallel execution
Validate IP and jail inputs at the start of processOperations() using
fail2ban.CachedValidateIP and CachedValidateJail. This prevents invalid
or malicious inputs (empty values, path traversal attempts, malformed
IPs) from reaching the operation functions. All validation errors are
aggregated and returned before any operations execute.
691 lines
23 KiB
Go
691 lines
23 KiB
Go
// Package fail2ban provides comprehensive functionality for managing fail2ban jails and filters
|
|
// with secure command execution, input validation, caching, and performance optimization.
|
|
package fail2ban
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/ivuorinen/f2b/shared"
|
|
)
|
|
|
|
var logDir = shared.DefaultLogDir // base directory for fail2ban logs
|
|
var logDirMu sync.RWMutex // protects logDir from concurrent access
|
|
var filterDir = shared.DefaultFilterDir
|
|
var filterDirMu sync.RWMutex // protects filterDir from concurrent access
|
|
|
|
// GetFilterDir returns the current filter directory path.
|
|
func GetFilterDir() string {
|
|
filterDirMu.RLock()
|
|
defer filterDirMu.RUnlock()
|
|
return filterDir
|
|
}
|
|
|
|
// SetLogDir sets the directory path for log files.
|
|
func SetLogDir(dir string) {
|
|
logDirMu.Lock()
|
|
defer logDirMu.Unlock()
|
|
logDir = dir
|
|
}
|
|
|
|
// GetLogDir returns the current log directory path.
|
|
func GetLogDir() string {
|
|
logDirMu.RLock()
|
|
defer logDirMu.RUnlock()
|
|
return logDir
|
|
}
|
|
|
|
// SetFilterDir sets the directory path for filter configuration files.
|
|
func SetFilterDir(dir string) {
|
|
filterDirMu.Lock()
|
|
defer filterDirMu.Unlock()
|
|
filterDir = dir
|
|
}
|
|
|
|
// OSRunner runs commands locally.
|
|
type OSRunner struct{}
|
|
|
|
// validateCommandExecution validates command name and arguments before execution.
|
|
// This helper consolidates the duplicate validation pattern used in command execution methods.
|
|
func validateCommandExecution(ctx context.Context, name string, args []string) error {
|
|
if err := CachedValidateCommand(ctx, name); err != nil {
|
|
return fmt.Errorf(shared.ErrCommandValidationFailed, err)
|
|
}
|
|
if err := ValidateArgumentsWithContext(ctx, args); err != nil {
|
|
return fmt.Errorf(shared.ErrArgumentValidationFailed, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CombinedOutput executes a command without sudo.
|
|
func (r *OSRunner) CombinedOutput(name string, args ...string) ([]byte, error) {
|
|
return r.CombinedOutputWithContext(context.Background(), name, args...)
|
|
}
|
|
|
|
// CombinedOutputWithContext executes a command without sudo with context support.
|
|
func (r *OSRunner) CombinedOutputWithContext(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
if err := validateCommandExecution(ctx, name, args); err != nil {
|
|
return nil, err
|
|
}
|
|
return exec.CommandContext(ctx, name, args...).CombinedOutput()
|
|
}
|
|
|
|
// CombinedOutputWithSudo executes a command with sudo if needed.
|
|
func (r *OSRunner) CombinedOutputWithSudo(name string, args ...string) ([]byte, error) {
|
|
return r.CombinedOutputWithSudoContext(context.Background(), name, args...)
|
|
}
|
|
|
|
// CombinedOutputWithSudoContext executes a command with sudo if needed, with context support.
|
|
func (r *OSRunner) CombinedOutputWithSudoContext(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
if err := validateCommandExecution(ctx, name, args); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
checker := GetSudoChecker()
|
|
|
|
// If already root, no need for sudo
|
|
if checker.IsRoot() {
|
|
return exec.CommandContext(ctx, name, args...).CombinedOutput()
|
|
}
|
|
|
|
// If command requires sudo and user has privileges, use sudo
|
|
if RequiresSudo(name, args...) && checker.HasSudoPrivileges() {
|
|
sudoArgs := append([]string{name}, args...)
|
|
// #nosec G204 - This is a legitimate use case for executing fail2ban-client with sudo
|
|
// The command name and arguments are validated by ValidateCommand() and RequiresSudo()
|
|
return exec.CommandContext(ctx, shared.SudoCommand, sudoArgs...).CombinedOutput()
|
|
}
|
|
|
|
// Otherwise run without sudo
|
|
return exec.CommandContext(ctx, name, args...).CombinedOutput()
|
|
}
|
|
|
|
// runnerManager provides thread-safe access to the global Runner.
|
|
type runnerManager struct {
|
|
mu sync.RWMutex
|
|
runner Runner
|
|
}
|
|
|
|
// globalRunnerManager is the singleton instance for managing the global runner.
|
|
var globalRunnerManager = &runnerManager{
|
|
runner: &OSRunner{},
|
|
}
|
|
|
|
// SetRunner injects a custom runner (for tests or alternate backends).
|
|
// SetRunner sets the global command runner instance.
|
|
func SetRunner(r Runner) {
|
|
globalRunnerManager.mu.Lock()
|
|
defer globalRunnerManager.mu.Unlock()
|
|
globalRunnerManager.runner = r
|
|
}
|
|
|
|
// GetRunner returns the current runner (for tests that need access).
|
|
// GetRunner returns the current global command runner instance.
|
|
func GetRunner() Runner {
|
|
globalRunnerManager.mu.RLock()
|
|
defer globalRunnerManager.mu.RUnlock()
|
|
return globalRunnerManager.runner
|
|
}
|
|
|
|
// RunnerCombinedOutput invokes the runner for a command.
|
|
// RunnerCombinedOutput executes a command using the global runner and returns combined stdout/stderr output.
|
|
func RunnerCombinedOutput(name string, args ...string) ([]byte, error) {
|
|
timer := NewTimedOperation("RunnerCombinedOutput", name, args...)
|
|
|
|
runner := GetRunner()
|
|
|
|
output, err := runner.CombinedOutput(name, args...)
|
|
timer.Finish(err)
|
|
|
|
return output, err
|
|
}
|
|
|
|
// RunnerCombinedOutputWithSudo invokes the runner for a command with sudo if needed.
|
|
// RunnerCombinedOutputWithSudo executes a command with sudo privileges using the global runner.
|
|
func RunnerCombinedOutputWithSudo(name string, args ...string) ([]byte, error) {
|
|
timer := NewTimedOperation("RunnerCombinedOutputWithSudo", name, args...)
|
|
|
|
runner := GetRunner()
|
|
|
|
output, err := runner.CombinedOutputWithSudo(name, args...)
|
|
timer.Finish(err)
|
|
|
|
return output, err
|
|
}
|
|
|
|
// runWithTimerContext is a helper that consolidates the common pattern of
|
|
// creating a timer, getting the runner, executing a command, and finishing the timer.
|
|
// This reduces code duplication between RunnerCombinedOutputWithContext and RunnerCombinedOutputWithSudoContext.
|
|
func runWithTimerContext(
|
|
ctx context.Context,
|
|
opName, name string,
|
|
args []string,
|
|
runFn func(Runner, context.Context, string, ...string) ([]byte, error),
|
|
) ([]byte, error) {
|
|
timer := NewTimedOperation(opName, name, args...)
|
|
runner := GetRunner()
|
|
output, err := runFn(runner, ctx, name, args...)
|
|
timer.FinishWithContext(ctx, err)
|
|
return output, err
|
|
}
|
|
|
|
// RunnerCombinedOutputWithContext invokes the runner for a command with context support.
|
|
// RunnerCombinedOutputWithContext executes a command with context using the global runner.
|
|
func RunnerCombinedOutputWithContext(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
return runWithTimerContext(ctx, "RunnerCombinedOutputWithContext", name, args,
|
|
func(r Runner, c context.Context, n string, a ...string) ([]byte, error) {
|
|
return r.CombinedOutputWithContext(c, n, a...)
|
|
})
|
|
}
|
|
|
|
// RunnerCombinedOutputWithSudoContext invokes the runner for a command with sudo and context support.
|
|
// RunnerCombinedOutputWithSudoContext executes a command with sudo privileges and context using the global runner.
|
|
func RunnerCombinedOutputWithSudoContext(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
return runWithTimerContext(ctx, "RunnerCombinedOutputWithSudoContext", name, args,
|
|
func(r Runner, c context.Context, n string, a ...string) ([]byte, error) {
|
|
return r.CombinedOutputWithSudoContext(c, n, a...)
|
|
})
|
|
}
|
|
|
|
// MockRunner is a simple mock for Runner, used in unit tests.
|
|
type MockRunner struct {
|
|
mu sync.Mutex // protects concurrent access to fields
|
|
Responses map[string][]byte
|
|
Errors map[string]error
|
|
CallLog []string
|
|
}
|
|
|
|
// NewMockRunner creates a new MockRunner for testing
|
|
// NewMockRunner creates a new mock runner instance for testing.
|
|
func NewMockRunner() *MockRunner {
|
|
return &MockRunner{
|
|
Responses: make(map[string][]byte),
|
|
Errors: make(map[string]error),
|
|
CallLog: []string{},
|
|
}
|
|
}
|
|
|
|
// CombinedOutput returns a mocked response or error for a command.
|
|
func (m *MockRunner) CombinedOutput(name string, args ...string) ([]byte, error) {
|
|
key := name + " " + strings.Join(args, " ")
|
|
if name == shared.SudoCommand {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.CallLog = append(m.CallLog, key)
|
|
|
|
if err, exists := m.Errors[key]; exists {
|
|
return nil, err
|
|
}
|
|
|
|
if response, exists := m.Responses[key]; exists {
|
|
return response, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("sudo should not be called directly in tests")
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
m.CallLog = append(m.CallLog, key)
|
|
|
|
if err, exists := m.Errors[key]; exists {
|
|
return nil, err
|
|
}
|
|
|
|
if response, exists := m.Responses[key]; exists {
|
|
return response, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("unexpected command: %s", key)
|
|
}
|
|
|
|
// CombinedOutputWithSudo returns a mocked response for sudo commands.
|
|
func (m *MockRunner) CombinedOutputWithSudo(name string, args ...string) ([]byte, error) {
|
|
checker := GetSudoChecker()
|
|
|
|
// If mock checker says we're root, don't use sudo
|
|
if checker.IsRoot() {
|
|
return m.CombinedOutput(name, args...)
|
|
}
|
|
|
|
// If command requires sudo and we have privileges, mock with sudo
|
|
if RequiresSudo(name, args...) && checker.HasSudoPrivileges() {
|
|
sudoKey := "sudo " + name + " " + strings.Join(args, " ")
|
|
|
|
// Check for sudo-specific response first (with lock protection)
|
|
m.mu.Lock()
|
|
m.CallLog = append(m.CallLog, sudoKey)
|
|
|
|
if err, exists := m.Errors[sudoKey]; exists {
|
|
m.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
|
|
if response, exists := m.Responses[sudoKey]; exists {
|
|
m.mu.Unlock()
|
|
return response, nil
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
// Fall back to non-sudo version if sudo version not mocked
|
|
return m.CombinedOutput(name, args...)
|
|
}
|
|
|
|
// Otherwise run without sudo
|
|
return m.CombinedOutput(name, args...)
|
|
}
|
|
|
|
// withContextCheck wraps an operation with context cancellation check.
|
|
// This helper consolidates the duplicate context cancellation pattern.
|
|
func withContextCheck(ctx context.Context, fn func() ([]byte, error)) ([]byte, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
return fn()
|
|
}
|
|
|
|
// SetResponse sets a response for a command.
|
|
func (m *MockRunner) SetResponse(cmd string, response []byte) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.Responses[cmd] = response
|
|
}
|
|
|
|
// SetError sets an error for a command.
|
|
func (m *MockRunner) SetError(cmd string, err error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.Errors[cmd] = err
|
|
}
|
|
|
|
// GetCalls returns the log of commands called.
|
|
func (m *MockRunner) GetCalls() []string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
// Return a copy to prevent external modification
|
|
calls := make([]string, len(m.CallLog))
|
|
copy(calls, m.CallLog)
|
|
return calls
|
|
}
|
|
|
|
// SetupStandardResponses configures comprehensive standard responses for testing.
|
|
// This eliminates the need for repetitive SetResponse calls in individual tests.
|
|
func (m *MockRunner) SetupStandardResponses() {
|
|
StandardMockSetup(m)
|
|
}
|
|
|
|
// SetupJailResponses configures responses for a specific jail.
|
|
// This is useful for tests that focus on a single jail's behavior.
|
|
func (m *MockRunner) SetupJailResponses(jail string) {
|
|
statusResponse := fmt.Sprintf("Status for the jail: %s\n|- Filter\n| |- Currently failed:\t0\n| "+
|
|
"|- Total failed:\t5\n| `- File list:\t/var/log/auth.log\n`- Actions\n "+
|
|
"|- Currently banned:\t1\n |- Total banned:\t2\n `- Banned IP list:\t192.168.1.100", jail)
|
|
|
|
m.SetResponse(fmt.Sprintf("fail2ban-client status %s", jail), []byte(statusResponse))
|
|
m.SetResponse(fmt.Sprintf("sudo fail2ban-client status %s", jail), []byte(statusResponse))
|
|
|
|
// Common ban/unban operations for the jail (use success status, not already-processed)
|
|
m.SetResponse(fmt.Sprintf("fail2ban-client set %s banip 192.168.1.100", jail), []byte(shared.Fail2BanStatusSuccess))
|
|
m.SetResponse(
|
|
fmt.Sprintf("sudo fail2ban-client set %s banip 192.168.1.100", jail),
|
|
[]byte(shared.Fail2BanStatusSuccess),
|
|
)
|
|
m.SetResponse(
|
|
fmt.Sprintf("fail2ban-client set %s unbanip 192.168.1.100", jail),
|
|
[]byte(shared.Fail2BanStatusSuccess),
|
|
)
|
|
m.SetResponse(
|
|
fmt.Sprintf("sudo fail2ban-client set %s unbanip 192.168.1.100", jail),
|
|
[]byte(shared.Fail2BanStatusSuccess),
|
|
)
|
|
}
|
|
|
|
// CombinedOutputWithContext returns a mocked response or error for a command with context support.
|
|
func (m *MockRunner) CombinedOutputWithContext(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
return withContextCheck(ctx, func() ([]byte, error) {
|
|
return m.CombinedOutput(name, args...)
|
|
})
|
|
}
|
|
|
|
// CombinedOutputWithSudoContext returns a mocked response for sudo commands with context support.
|
|
func (m *MockRunner) CombinedOutputWithSudoContext(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
return withContextCheck(ctx, func() ([]byte, error) {
|
|
return m.CombinedOutputWithSudo(name, args...)
|
|
})
|
|
}
|
|
|
|
func (c *RealClient) fetchJailsWithContext(ctx context.Context) ([]string, error) {
|
|
currentRunner := GetRunner()
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(ctx, c.Path, shared.CommandArgStatus)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ParseJailList(string(out))
|
|
}
|
|
|
|
// StatusAll returns the status of all fail2ban jails.
|
|
func (c *RealClient) StatusAll() (string, error) {
|
|
currentRunner := GetRunner()
|
|
out, err := currentRunner.CombinedOutputWithSudo(c.Path, shared.CommandArgStatus)
|
|
return string(out), err
|
|
}
|
|
|
|
// StatusJail returns the status of a specific fail2ban jail.
|
|
func (c *RealClient) StatusJail(j string) (string, error) {
|
|
currentRunner := GetRunner()
|
|
out, err := currentRunner.CombinedOutputWithSudo(c.Path, shared.CommandArgStatus, j)
|
|
return string(out), err
|
|
}
|
|
|
|
// BanIP bans an IP address in the specified jail and returns the ban status code.
|
|
func (c *RealClient) BanIP(ip, jail string) (int, error) {
|
|
return c.BanIPWithContext(context.Background(), ip, jail)
|
|
}
|
|
|
|
// UnbanIP unbans an IP address from the specified jail and returns the unban status code.
|
|
func (c *RealClient) UnbanIP(ip, jail string) (int, error) {
|
|
return c.UnbanIPWithContext(context.Background(), ip, jail)
|
|
}
|
|
|
|
// BannedIn returns a list of jails where the specified IP address is currently banned.
|
|
func (c *RealClient) BannedIn(ip string) ([]string, error) {
|
|
return c.BannedInWithContext(context.Background(), ip)
|
|
}
|
|
|
|
// GetBanRecords retrieves ban records for the specified jails.
|
|
func (c *RealClient) GetBanRecords(jails []string) ([]BanRecord, error) {
|
|
return c.GetBanRecordsWithContext(context.Background(), jails)
|
|
}
|
|
|
|
// getBanRecordsInternal is the internal implementation with context support
|
|
func (c *RealClient) getBanRecordsInternal(ctx context.Context, jails []string) ([]BanRecord, error) {
|
|
var toQuery []string
|
|
if len(jails) == 1 && (jails[0] == shared.AllFilter || jails[0] == "") {
|
|
toQuery = c.Jails
|
|
} else {
|
|
toQuery = jails
|
|
}
|
|
|
|
currentRunner := GetRunner()
|
|
|
|
// Use parallel processing for multiple jails
|
|
allRecords, err := ProcessJailsParallel(
|
|
ctx,
|
|
toQuery,
|
|
func(operationCtx context.Context, jail string) ([]BanRecord, error) {
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(
|
|
operationCtx,
|
|
c.Path,
|
|
shared.ActionGet,
|
|
jail,
|
|
shared.ActionBanIP,
|
|
"--with-time",
|
|
)
|
|
if err != nil {
|
|
// Log error but continue processing (backward compatibility)
|
|
getLogger().WithError(err).WithField(string(shared.ContextKeyJail), jail).
|
|
Warn("Failed to get ban records for jail")
|
|
return []BanRecord{}, nil // Return empty slice instead of error (original behavior)
|
|
}
|
|
|
|
// Use ultra-optimized parser for this jail's records
|
|
jailRecords, parseErr := ParseBanRecordsUltraOptimized(string(out), jail)
|
|
if parseErr != nil {
|
|
// Log parse errors to help with debugging
|
|
getLogger().WithError(parseErr).WithField("jail", jail).
|
|
Warn("Failed to parse ban records for jail")
|
|
return []BanRecord{}, nil // Return empty slice on parse error
|
|
}
|
|
|
|
return jailRecords, nil
|
|
},
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sort.Slice(allRecords, func(i, j int) bool {
|
|
return allRecords[i].BannedAt.Before(allRecords[j].BannedAt)
|
|
})
|
|
return allRecords, nil
|
|
}
|
|
|
|
// GetLogLines retrieves log lines related to an IP address from the specified jail.
|
|
func (c *RealClient) GetLogLines(jail, ip string) ([]string, error) {
|
|
return c.GetLogLinesWithLimit(jail, ip, shared.DefaultLogLinesLimit)
|
|
}
|
|
|
|
// GetLogLinesWithLimit returns log lines with configurable limits for memory management.
|
|
func (c *RealClient) GetLogLinesWithLimit(jail, ip string, maxLines int) ([]string, error) {
|
|
return c.GetLogLinesWithLimitContext(context.Background(), jail, ip, maxLines)
|
|
}
|
|
|
|
// GetLogLinesWithLimitContext returns log lines with configurable limits and context support.
|
|
func (c *RealClient) GetLogLinesWithLimitContext(ctx context.Context, jail, ip string, maxLines int) ([]string, error) {
|
|
if maxLines == 0 {
|
|
return []string{}, nil
|
|
}
|
|
|
|
config := LogReadConfig{
|
|
MaxLines: maxLines,
|
|
MaxFileSize: shared.DefaultMaxFileSize,
|
|
JailFilter: jail,
|
|
IPFilter: ip,
|
|
BaseDir: c.LogDir,
|
|
}
|
|
|
|
return collectLogLines(ctx, c.LogDir, config)
|
|
}
|
|
|
|
// ListFilters returns a list of available fail2ban filter files.
|
|
func (c *RealClient) ListFilters() ([]string, error) {
|
|
entries, err := os.ReadDir(c.FilterDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not list filters: %w", err)
|
|
}
|
|
filters := []string{}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if strings.HasSuffix(name, shared.ConfExtension) {
|
|
filters = append(filters, strings.TrimSuffix(name, shared.ConfExtension))
|
|
}
|
|
}
|
|
return filters, nil
|
|
}
|
|
|
|
// Context-aware implementations for RealClient
|
|
|
|
// ListJailsWithContext returns a list of all fail2ban jails with context support.
|
|
func (c *RealClient) ListJailsWithContext(ctx context.Context) ([]string, error) {
|
|
return wrapWithContext0(c.ListJails)(ctx)
|
|
}
|
|
|
|
// StatusAllWithContext returns the status of all fail2ban jails with context support.
|
|
func (c *RealClient) StatusAllWithContext(ctx context.Context) (string, error) {
|
|
currentRunner := GetRunner()
|
|
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(ctx, c.Path, shared.CommandArgStatus)
|
|
return string(out), err
|
|
}
|
|
|
|
// StatusJailWithContext returns the status of a specific fail2ban jail with context support.
|
|
func (c *RealClient) StatusJailWithContext(ctx context.Context, jail string) (string, error) {
|
|
currentRunner := GetRunner()
|
|
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(ctx, c.Path, shared.CommandArgStatus, jail)
|
|
return string(out), err
|
|
}
|
|
|
|
// executeIPActionWithContext executes a ban/unban IP action with validation and response parsing.
|
|
// It returns (0, nil) for success, (1, nil) if already processed, or an error.
|
|
func (c *RealClient) executeIPActionWithContext(
|
|
ctx context.Context,
|
|
ip, jail, action, errorTemplate string,
|
|
) (int, error) {
|
|
if err := CachedValidateIP(ctx, ip); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := CachedValidateJail(ctx, jail); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
currentRunner := GetRunner()
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(ctx, c.Path, shared.ActionSet, jail, action, ip)
|
|
if err != nil {
|
|
return 0, fmt.Errorf(errorTemplate, ip, jail, err)
|
|
}
|
|
code := strings.TrimSpace(string(out))
|
|
if code == shared.Fail2BanStatusSuccess {
|
|
return 0, nil
|
|
}
|
|
if code == shared.Fail2BanStatusAlreadyProcessed {
|
|
return 1, nil
|
|
}
|
|
return 0, fmt.Errorf(shared.ErrUnexpectedOutput, code)
|
|
}
|
|
|
|
// BanIPWithContext bans an IP address in the specified jail with context support.
|
|
func (c *RealClient) BanIPWithContext(ctx context.Context, ip, jail string) (int, error) {
|
|
return c.executeIPActionWithContext(ctx, ip, jail, shared.ActionBanIP, shared.ErrFailedToBanIP)
|
|
}
|
|
|
|
// UnbanIPWithContext unbans an IP address from the specified jail with context support.
|
|
func (c *RealClient) UnbanIPWithContext(ctx context.Context, ip, jail string) (int, error) {
|
|
return c.executeIPActionWithContext(ctx, ip, jail, shared.ActionUnbanIP, shared.ErrFailedToUnbanIP)
|
|
}
|
|
|
|
// BannedInWithContext returns a list of jails where the specified IP address is currently banned with context support.
|
|
func (c *RealClient) BannedInWithContext(ctx context.Context, ip string) ([]string, error) {
|
|
if err := CachedValidateIP(ctx, ip); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
currentRunner := GetRunner()
|
|
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(ctx, c.Path, shared.ActionBanned, ip)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get banned status for IP %s: %w", ip, err)
|
|
}
|
|
return ParseBracketedList(string(out)), nil
|
|
}
|
|
|
|
// GetBanRecordsWithContext retrieves ban records for the specified jails with context support.
|
|
func (c *RealClient) GetBanRecordsWithContext(ctx context.Context, jails []string) ([]BanRecord, error) {
|
|
return c.getBanRecordsInternal(ctx, jails)
|
|
}
|
|
|
|
// GetLogLinesWithContext retrieves log lines related to an IP address from the specified jail with context support.
|
|
func (c *RealClient) GetLogLinesWithContext(ctx context.Context, jail, ip string) ([]string, error) {
|
|
return c.GetLogLinesWithLimitAndContext(ctx, jail, ip, shared.DefaultLogLinesLimit)
|
|
}
|
|
|
|
// GetLogLinesWithLimitAndContext returns log lines with configurable limits
|
|
// and context support for memory management and timeouts.
|
|
func (c *RealClient) GetLogLinesWithLimitAndContext(
|
|
ctx context.Context,
|
|
jail, ip string,
|
|
maxLines int,
|
|
) ([]string, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if maxLines == 0 {
|
|
return []string{}, nil
|
|
}
|
|
|
|
config := LogReadConfig{
|
|
MaxLines: maxLines,
|
|
MaxFileSize: shared.DefaultMaxFileSize,
|
|
JailFilter: jail,
|
|
IPFilter: ip,
|
|
BaseDir: c.LogDir,
|
|
}
|
|
|
|
return collectLogLines(ctx, c.LogDir, config)
|
|
}
|
|
|
|
// ListFiltersWithContext returns a list of available fail2ban filter files with context support.
|
|
func (c *RealClient) ListFiltersWithContext(ctx context.Context) ([]string, error) {
|
|
return wrapWithContext0(c.ListFilters)(ctx)
|
|
}
|
|
|
|
// validateFilterPath validates filter name and returns secure path and log path
|
|
func (c *RealClient) validateFilterPath(ctx context.Context, filter string) (string, string, error) {
|
|
if err := CachedValidateFilter(ctx, filter); err != nil {
|
|
return "", "", err
|
|
}
|
|
path := filepath.Join(c.FilterDir, filter+".conf")
|
|
|
|
// Additional security check: ensure path doesn't escape filter directory
|
|
cleanPath, err := filepath.Abs(filepath.Clean(path))
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("invalid filter path: %w", err)
|
|
}
|
|
|
|
cleanFilterDir, err := filepath.Abs(filepath.Clean(c.FilterDir))
|
|
if err != nil {
|
|
return "", "", fmt.Errorf(shared.ErrInvalidFilterDirectory, err)
|
|
}
|
|
|
|
// Ensure the resolved path is within the filter directory
|
|
if !strings.HasPrefix(cleanPath, cleanFilterDir+string(filepath.Separator)) {
|
|
return "", "", fmt.Errorf("filter path outside allowed directory")
|
|
}
|
|
|
|
// #nosec G304 - Path is validated, sanitized, and restricted to filter directory above
|
|
data, err := os.ReadFile(cleanPath)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("filter not found: %w", err)
|
|
}
|
|
content := string(data)
|
|
|
|
var logPath string
|
|
var patterns []string
|
|
for _, line := range strings.Split(content, "\n") {
|
|
if strings.HasPrefix(strings.ToLower(line), "logpath") {
|
|
parts := strings.SplitN(line, "=", 2)
|
|
logPath = strings.TrimSpace(parts[1])
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(line), "failregex") {
|
|
parts := strings.SplitN(line, "=", 2)
|
|
patterns = append(patterns, strings.TrimSpace(parts[1]))
|
|
}
|
|
}
|
|
if logPath == "" || len(patterns) == 0 {
|
|
return "", "", errors.New("invalid filter file")
|
|
}
|
|
|
|
return cleanPath, logPath, nil
|
|
}
|
|
|
|
// TestFilterWithContext tests a fail2ban filter against its configured log files with context support.
|
|
func (c *RealClient) TestFilterWithContext(ctx context.Context, filter string) (string, error) {
|
|
cleanPath, logPath, err := c.validateFilterPath(ctx, filter)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
currentRunner := GetRunner()
|
|
|
|
output, err := currentRunner.CombinedOutputWithSudoContext(ctx, shared.Fail2BanRegexCommand, logPath, cleanPath)
|
|
return string(output), err
|
|
}
|
|
|
|
// TestFilter tests a fail2ban filter against its configured log files and returns the test output.
|
|
func (c *RealClient) TestFilter(filter string) (string, error) {
|
|
return c.TestFilterWithContext(context.Background(), filter)
|
|
}
|