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.
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package fail2ban
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// setNestedMapValue sets a value in a nested map[string]map[string]T structure with mutex protection.
|
|
// It initializes the inner map if nil.
|
|
func setNestedMapValue[T any](mu *sync.Mutex, mp map[string]map[string]T, jail, ip string, value T) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if mp[jail] == nil {
|
|
mp[jail] = make(map[string]T)
|
|
}
|
|
mp[jail][ip] = value
|
|
}
|
|
|
|
// MockClient is a stateful, thread-safe mock implementation of the Client interface for testing.
|
|
type MockClient struct {
|
|
mu sync.Mutex
|
|
Jails map[string]struct{}
|
|
Banned map[string]map[string]time.Time // jail -> ip -> ban time
|
|
Logs []string
|
|
Filters []string
|
|
FilterRuns map[string]string
|
|
// Enhanced features for advanced testing
|
|
StatusAllData string
|
|
StatusJailData map[string]string
|
|
BanRecords []BanRecord
|
|
BanResults map[string]map[string]int // jail -> ip -> result code
|
|
BanErrors map[string]map[string]error // jail -> ip -> error
|
|
UnbanResults map[string]map[string]int // jail -> ip -> result code
|
|
UnbanErrors map[string]map[string]error // jail -> ip -> error
|
|
BannedIPs map[string][]string // jail -> list of banned IPs
|
|
LogLines []string // configurable log lines
|
|
FilterTests map[string]string // filter -> test result
|
|
}
|
|
|
|
// NewMockClient creates a new MockClient with default jails and filters.
|
|
func NewMockClient() *MockClient {
|
|
return &MockClient{
|
|
Jails: map[string]struct{}{"sshd": {}, "apache": {}},
|
|
Banned: make(map[string]map[string]time.Time),
|
|
Logs: []string{},
|
|
Filters: []string{"sshd", "apache"},
|
|
FilterRuns: make(map[string]string),
|
|
StatusAllData: "Mock status for all jails",
|
|
StatusJailData: make(map[string]string),
|
|
BanRecords: []BanRecord{},
|
|
BanResults: make(map[string]map[string]int),
|
|
BanErrors: make(map[string]map[string]error),
|
|
UnbanResults: make(map[string]map[string]int),
|
|
UnbanErrors: make(map[string]map[string]error),
|
|
BannedIPs: make(map[string][]string),
|
|
LogLines: []string{},
|
|
FilterTests: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// ListJails returns the list of available jails.
|
|
func (m *MockClient) ListJails() ([]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
jails := make([]string, 0, len(m.Jails))
|
|
for jail := range m.Jails {
|
|
jails = append(jails, jail)
|
|
}
|
|
// Sort jails for consistent output
|
|
sort.Strings(jails)
|
|
return jails, nil
|
|
}
|
|
|
|
// StatusAll returns a mock status for all jails.
|
|
func (m *MockClient) StatusAll() (string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.StatusAllData, nil
|
|
}
|
|
|
|
// StatusJail returns a mock status for a specific jail.
|
|
func (m *MockClient) StatusJail(jail string) (string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if _, ok := m.Jails[jail]; !ok {
|
|
return "", NewJailNotFoundError(jail)
|
|
}
|
|
if status, ok := m.StatusJailData[jail]; ok {
|
|
return status, nil
|
|
}
|
|
return fmt.Sprintf("Mock status for jail %s", jail), nil
|
|
}
|
|
|
|
// BanIP bans the given IP in the specified jail. Returns 0 if banned, 1 if already banned.
|
|
func (m *MockClient) BanIP(ip, jail string) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Check for configured error
|
|
if m.BanErrors[jail] != nil {
|
|
if err, ok := m.BanErrors[jail][ip]; ok {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
// Check for configured result
|
|
if m.BanResults[jail] != nil {
|
|
if result, ok := m.BanResults[jail][ip]; ok {
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
if _, ok := m.Jails[jail]; !ok {
|
|
return 0, NewJailNotFoundError(jail)
|
|
}
|
|
if m.Banned[jail] == nil {
|
|
m.Banned[jail] = make(map[string]time.Time)
|
|
}
|
|
if _, exists := m.Banned[jail][ip]; exists {
|
|
return 1, nil // Already banned
|
|
}
|
|
m.Banned[jail][ip] = time.Now()
|
|
m.Logs = append(m.Logs, fmt.Sprintf("%s [mock] Ban %s in %s", time.Now().Format(time.RFC3339), ip, jail))
|
|
return 0, nil
|
|
}
|
|
|
|
// UnbanIP unbans the given IP in the specified jail. Returns 0 if unbanned, 1 if already unbanned.
|
|
func (m *MockClient) UnbanIP(ip, jail string) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Check for configured error
|
|
if m.UnbanErrors[jail] != nil {
|
|
if err, ok := m.UnbanErrors[jail][ip]; ok {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
// Check for configured result
|
|
if m.UnbanResults[jail] != nil {
|
|
if result, ok := m.UnbanResults[jail][ip]; ok {
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
if _, ok := m.Jails[jail]; !ok {
|
|
return 0, NewJailNotFoundError(jail)
|
|
}
|
|
if m.Banned[jail] == nil || m.Banned[jail][ip].IsZero() {
|
|
return 1, nil // Already unbanned
|
|
}
|
|
delete(m.Banned[jail], ip)
|
|
m.Logs = append(m.Logs, fmt.Sprintf("%s [mock] Unban %s in %s", time.Now().Format(time.RFC3339), ip, jail))
|
|
return 0, nil
|
|
}
|
|
|
|
// BannedIn returns the list of jails in which the IP is currently banned.
|
|
func (m *MockClient) BannedIn(ip string) ([]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var jails []string
|
|
for jail, ips := range m.Banned {
|
|
if _, ok := ips[ip]; ok {
|
|
jails = append(jails, jail)
|
|
}
|
|
}
|
|
// Sort jails for consistent output
|
|
sort.Strings(jails)
|
|
return jails, nil
|
|
}
|
|
|
|
// GetBanRecords returns ban records for the specified jails.
|
|
func (m *MockClient) GetBanRecords(jails []string) ([]BanRecord, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Use configured ban records if available
|
|
if len(m.BanRecords) > 0 {
|
|
return m.BanRecords, nil
|
|
}
|
|
|
|
var recs []BanRecord
|
|
for _, jail := range jails {
|
|
for ip, t := range m.Banned[jail] {
|
|
recs = append(recs, BanRecord{
|
|
Jail: jail,
|
|
IP: ip,
|
|
BannedAt: t,
|
|
Remaining: "01:00:00:00",
|
|
})
|
|
}
|
|
}
|
|
return recs, nil
|
|
}
|
|
|
|
// GetLogLines returns log lines filtered by jail and/or IP.
|
|
func (m *MockClient) GetLogLines(jail, ip string) ([]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Use configured log lines if available
|
|
if len(m.LogLines) > 0 {
|
|
return m.LogLines, nil
|
|
}
|
|
|
|
var lines []string
|
|
for _, l := range m.Logs {
|
|
if (jail == "" || strings.Contains(l, jail)) && (ip == "" || strings.Contains(l, ip)) {
|
|
lines = append(lines, l)
|
|
}
|
|
}
|
|
return lines, nil
|
|
}
|
|
|
|
// ListFilters returns the available Fail2Ban filters.
|
|
func (m *MockClient) ListFilters() ([]string, error) {
|
|
return m.Filters, nil
|
|
}
|
|
|
|
// TestFilter simulates running fail2ban-regex for the given filter.
|
|
func (m *MockClient) TestFilter(filter string) (string, error) {
|
|
// Check configured filter tests first
|
|
if result, ok := m.FilterTests[filter]; ok {
|
|
return result, nil
|
|
}
|
|
if result, ok := m.FilterRuns[filter]; ok {
|
|
return result, nil
|
|
}
|
|
return "", NewFilterNotFoundError(filter)
|
|
}
|
|
|
|
// Context-aware methods for MockClient (using helpers to reduce boilerplate)
|
|
|
|
// ListJailsWithContext returns a list of jails using the provided context.
|
|
func (m *MockClient) ListJailsWithContext(ctx context.Context) ([]string, error) {
|
|
return wrapWithContext0(m.ListJails)(ctx)
|
|
}
|
|
|
|
// StatusAllWithContext returns the status of all jails using the provided context.
|
|
func (m *MockClient) StatusAllWithContext(ctx context.Context) (string, error) {
|
|
return wrapWithContext0(m.StatusAll)(ctx)
|
|
}
|
|
|
|
// StatusJailWithContext returns the status of the specified jail using the provided context.
|
|
func (m *MockClient) StatusJailWithContext(ctx context.Context, jail string) (string, error) {
|
|
return wrapWithContext1(m.StatusJail)(ctx, jail)
|
|
}
|
|
|
|
// BanIPWithContext bans the specified IP in the given jail using the provided context.
|
|
func (m *MockClient) BanIPWithContext(ctx context.Context, ip, jail string) (int, error) {
|
|
return wrapWithContext2(m.BanIP)(ctx, ip, jail)
|
|
}
|
|
|
|
// UnbanIPWithContext unbans the specified IP from the given jail using the provided context.
|
|
func (m *MockClient) UnbanIPWithContext(ctx context.Context, ip, jail string) (int, error) {
|
|
return wrapWithContext2(m.UnbanIP)(ctx, ip, jail)
|
|
}
|
|
|
|
// BannedInWithContext returns the jails where the IP is banned using the provided context.
|
|
func (m *MockClient) BannedInWithContext(ctx context.Context, ip string) ([]string, error) {
|
|
return wrapWithContext1(m.BannedIn)(ctx, ip)
|
|
}
|
|
|
|
// GetBanRecordsWithContext returns ban records for the specified jails using the provided context.
|
|
func (m *MockClient) GetBanRecordsWithContext(ctx context.Context, jails []string) ([]BanRecord, error) {
|
|
return wrapWithContext1(m.GetBanRecords)(ctx, jails)
|
|
}
|
|
|
|
// GetLogLinesWithContext returns log lines for the specified jail and IP using the provided context.
|
|
func (m *MockClient) GetLogLinesWithContext(ctx context.Context, jail, ip string) ([]string, error) {
|
|
return wrapWithContext2(m.GetLogLines)(ctx, jail, ip)
|
|
}
|
|
|
|
// ListFiltersWithContext returns a list of available filters using the provided context.
|
|
func (m *MockClient) ListFiltersWithContext(ctx context.Context) ([]string, error) {
|
|
return wrapWithContext0(m.ListFilters)(ctx)
|
|
}
|
|
|
|
// TestFilterWithContext tests the specified filter using the provided context.
|
|
func (m *MockClient) TestFilterWithContext(ctx context.Context, filter string) (string, error) {
|
|
return wrapWithContext1(m.TestFilter)(ctx, filter)
|
|
}
|
|
|
|
// Reset clears all bans and logs in the mock (for test isolation).
|
|
func (m *MockClient) Reset() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.Banned = make(map[string]map[string]time.Time)
|
|
m.Logs = []string{}
|
|
}
|
|
|
|
// Helper methods for test configuration
|
|
|
|
// SetBanError configures an error to return for BanIP(ip, jail).
|
|
func (m *MockClient) SetBanError(jail, ip string, err error) {
|
|
setNestedMapValue(&m.mu, m.BanErrors, jail, ip, err)
|
|
}
|
|
|
|
// SetBanResult configures a result code to return for BanIP(ip, jail).
|
|
func (m *MockClient) SetBanResult(jail, ip string, result int) {
|
|
setNestedMapValue(&m.mu, m.BanResults, jail, ip, result)
|
|
}
|
|
|
|
// SetUnbanError configures an error to return for UnbanIP(ip, jail).
|
|
func (m *MockClient) SetUnbanError(jail, ip string, err error) {
|
|
setNestedMapValue(&m.mu, m.UnbanErrors, jail, ip, err)
|
|
}
|
|
|
|
// SetUnbanResult configures a result code to return for UnbanIP(ip, jail).
|
|
func (m *MockClient) SetUnbanResult(jail, ip string, result int) {
|
|
setNestedMapValue(&m.mu, m.UnbanResults, jail, ip, result)
|
|
}
|
|
|
|
// SetStatusJailData configures the status data for a specific jail.
|
|
func (m *MockClient) SetStatusJailData(jail, status string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.StatusJailData[jail] = status
|
|
}
|
|
|
|
// SetFilterTest configures the test result for a filter.
|
|
func (m *MockClient) SetFilterTest(filter, result string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.FilterTests[filter] = result
|
|
}
|