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.
893 lines
24 KiB
Go
893 lines
24 KiB
Go
package fail2ban
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ivuorinen/f2b/shared"
|
|
)
|
|
|
|
func TestListJails(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
statusOutput string
|
|
expectedJails []string
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "parse single jail",
|
|
statusOutput: "Status\n|- Number of jail: 1\n`- Jail list: sshd",
|
|
expectedJails: []string{"sshd"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "parse multiple jails",
|
|
statusOutput: "Status\n|- Number of jail: 3\n`- Jail list: sshd, apache, nginx",
|
|
expectedJails: []string{"sshd", "apache", "nginx"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "parse jails with extra spaces",
|
|
statusOutput: "Status\n|- Number of jail: 2\n`- Jail list: sshd , apache ",
|
|
expectedJails: []string{"sshd", "apache"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "no jail list found",
|
|
statusOutput: "Status\n|- Number of jail: 0",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "empty jail list",
|
|
statusOutput: "Status\n|- Number of jail: 0\n`- Jail list: ",
|
|
expectError: false,
|
|
expectedJails: []string{},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Set up mock environment with sudo privileges
|
|
_, cleanup := SetupMockEnvironmentWithSudo(t, true)
|
|
defer cleanup()
|
|
|
|
// Configure specific responses for this test
|
|
mock := GetRunner().(*MockRunner)
|
|
mock.SetResponse("fail2ban-client status", []byte(tt.statusOutput))
|
|
mock.SetResponse("sudo fail2ban-client status", []byte(tt.statusOutput))
|
|
|
|
if tt.expectError {
|
|
// For error cases, we expect NewClient to fail
|
|
_, err := NewClient(shared.DefaultLogDir, shared.DefaultFilterDir)
|
|
AssertError(t, err, true, tt.name)
|
|
return
|
|
}
|
|
|
|
client, err := NewClient(shared.DefaultLogDir, shared.DefaultFilterDir)
|
|
AssertError(t, err, false, "create client")
|
|
|
|
jails, err := client.ListJails()
|
|
AssertError(t, err, false, "list jails")
|
|
|
|
if len(jails) != len(tt.expectedJails) {
|
|
t.Errorf("expected %d jails, got %d", len(tt.expectedJails), len(jails))
|
|
}
|
|
|
|
for i, expected := range tt.expectedJails {
|
|
if i >= len(jails) || jails[i] != expected {
|
|
t.Errorf("expected jail %q at index %d, got %q", expected, i, jails[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStatusAll(t *testing.T) {
|
|
// Set up mock environment with sudo privileges
|
|
_, cleanup := SetupMockEnvironmentWithSudo(t, true)
|
|
defer cleanup()
|
|
|
|
// Configure specific responses for this test
|
|
expectedOutput := "Status\n|- Number of jail: 1\n`- Jail list: sshd"
|
|
mock := GetRunner().(*MockRunner)
|
|
mock.SetResponse("fail2ban-client status", []byte(expectedOutput))
|
|
mock.SetResponse("sudo fail2ban-client status", []byte(expectedOutput))
|
|
|
|
client, err := NewClient(shared.DefaultLogDir, shared.DefaultFilterDir)
|
|
AssertError(t, err, false, "create client")
|
|
|
|
output, err := client.StatusAll()
|
|
AssertError(t, err, false, "status all")
|
|
|
|
if output != expectedOutput {
|
|
t.Errorf("expected %q, got %q", expectedOutput, output)
|
|
}
|
|
}
|
|
|
|
func TestStatusJail(t *testing.T) {
|
|
// Set up mock environment with sudo privileges
|
|
_, cleanup := SetupMockEnvironmentWithSudo(t, true)
|
|
defer cleanup()
|
|
|
|
// Configure specific responses for this test
|
|
mock := GetRunner().(*MockRunner)
|
|
expectedOutput := "Status for the jail: sshd\n|- Filter\n" +
|
|
"|- Currently failed: 0\n|- Total failed: 5\n|- Currently banned: 1\n|- Total banned: 1"
|
|
mock.SetResponse("fail2ban-client status sshd", []byte(expectedOutput))
|
|
mock.SetResponse("sudo fail2ban-client status sshd", []byte(expectedOutput))
|
|
|
|
client, err := NewClient(shared.DefaultLogDir, shared.DefaultFilterDir)
|
|
AssertError(t, err, false, "create client")
|
|
|
|
output, err := client.StatusJail("sshd")
|
|
AssertError(t, err, false, "status jail")
|
|
|
|
if output != expectedOutput {
|
|
t.Errorf("expected %q, got %q", expectedOutput, output)
|
|
}
|
|
}
|
|
|
|
func TestBanIP(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ip string
|
|
jail string
|
|
mockResponse string
|
|
expectedCode int
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "successful ban",
|
|
ip: "192.168.1.100",
|
|
jail: "sshd",
|
|
mockResponse: "0",
|
|
expectedCode: 0,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "already banned",
|
|
ip: "192.168.1.100",
|
|
jail: "sshd",
|
|
mockResponse: "1",
|
|
expectedCode: 1,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "ban command error",
|
|
ip: "192.168.1.100",
|
|
jail: "sshd",
|
|
mockResponse: "",
|
|
expectedCode: 0,
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Set up mock environment with sudo privileges
|
|
_, cleanup := SetupMockEnvironmentWithSudo(t, true)
|
|
defer cleanup()
|
|
|
|
// Configure specific responses for this test
|
|
mock := GetRunner().(*MockRunner)
|
|
if tt.expectError {
|
|
mock.SetError(
|
|
fmt.Sprintf("sudo fail2ban-client set %s banip %s", tt.jail, tt.ip),
|
|
fmt.Errorf("command failed"),
|
|
)
|
|
} else {
|
|
mock.SetResponse(
|
|
fmt.Sprintf("sudo fail2ban-client set %s banip %s", tt.jail, tt.ip),
|
|
[]byte(tt.mockResponse),
|
|
)
|
|
}
|
|
|
|
client, err := NewClient(shared.DefaultLogDir, shared.DefaultFilterDir)
|
|
AssertError(t, err, false, "create client")
|
|
|
|
code, err := client.BanIP(tt.ip, tt.jail)
|
|
|
|
AssertError(t, err, tt.expectError, tt.name)
|
|
if tt.expectError {
|
|
return
|
|
}
|
|
|
|
if code != tt.expectedCode {
|
|
t.Errorf("expected code %d, got %d", tt.expectedCode, code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnbanIP(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ip string
|
|
jail string
|
|
mockResponse string
|
|
expectedCode int
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "successful unban",
|
|
ip: "192.168.1.100",
|
|
jail: "sshd",
|
|
mockResponse: "0",
|
|
expectedCode: 0,
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "already unbanned",
|
|
ip: "192.168.1.100",
|
|
jail: "sshd",
|
|
mockResponse: "1",
|
|
expectedCode: 1,
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Set up mock environment with sudo privileges
|
|
_, cleanup := SetupMockEnvironmentWithSudo(t, true)
|
|
defer cleanup()
|
|
|
|
// Configure specific responses for this test
|
|
mock := GetRunner().(*MockRunner)
|
|
mock.SetResponse(
|
|
fmt.Sprintf("sudo fail2ban-client set %s unbanip %s", tt.jail, tt.ip),
|
|
[]byte(tt.mockResponse),
|
|
)
|
|
|
|
client, err := NewClient(shared.DefaultLogDir, shared.DefaultFilterDir)
|
|
AssertError(t, err, false, "create client")
|
|
|
|
code, err := client.UnbanIP(tt.ip, tt.jail)
|
|
|
|
AssertError(t, err, tt.expectError, tt.name)
|
|
if tt.expectError {
|
|
return
|
|
}
|
|
|
|
if code != tt.expectedCode {
|
|
t.Errorf("expected code %d, got %d", tt.expectedCode, code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBannedIn(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ip string
|
|
mockResponse string
|
|
expectedJails []string
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "ip banned in single jail",
|
|
ip: "192.168.1.100",
|
|
mockResponse: `["sshd"]`,
|
|
expectedJails: []string{"sshd"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "ip banned in multiple jails",
|
|
ip: "192.168.1.100",
|
|
mockResponse: `["sshd", "apache"]`,
|
|
expectedJails: []string{"sshd", "apache"},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "ip not banned",
|
|
ip: "192.168.1.100",
|
|
mockResponse: `[]`,
|
|
expectedJails: []string{},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "empty response",
|
|
ip: "192.168.1.100",
|
|
mockResponse: "",
|
|
expectedJails: []string{},
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Set up mock environment with sudo privileges
|
|
_, cleanup := SetupMockEnvironmentWithSudo(t, true)
|
|
defer cleanup()
|
|
|
|
// Configure specific responses for this test
|
|
mock := GetRunner().(*MockRunner)
|
|
mock.SetResponse(fmt.Sprintf("fail2ban-client banned %s", tt.ip), []byte(tt.mockResponse))
|
|
mock.SetResponse(fmt.Sprintf("sudo fail2ban-client banned %s", tt.ip), []byte(tt.mockResponse))
|
|
|
|
client, err := NewClient(shared.DefaultLogDir, shared.DefaultFilterDir)
|
|
AssertError(t, err, false, "create client")
|
|
|
|
jails, err := client.BannedIn(tt.ip)
|
|
|
|
AssertError(t, err, tt.expectError, tt.name)
|
|
if tt.expectError {
|
|
return
|
|
}
|
|
|
|
if len(jails) != len(tt.expectedJails) {
|
|
t.Errorf("expected %d jails, got %d", len(tt.expectedJails), len(jails))
|
|
}
|
|
|
|
for i, expected := range tt.expectedJails {
|
|
if i >= len(jails) || jails[i] != expected {
|
|
t.Errorf("expected jail %q at index %d, got %q", expected, i, jails[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetBanRecords(t *testing.T) {
|
|
// Set up mock environment with sudo privileges
|
|
_, cleanup := SetupMockEnvironmentWithSudo(t, true)
|
|
defer cleanup()
|
|
|
|
// Configure specific responses for this test
|
|
mock := GetRunner().(*MockRunner)
|
|
// Mock ban records response
|
|
banTime := time.Now().Add(-1 * time.Hour)
|
|
unbanTime := time.Now().Add(1 * time.Hour)
|
|
mockBanOutput := fmt.Sprintf("192.168.1.100 %s + %s",
|
|
banTime.Format("2006-01-02 15:04:05"),
|
|
unbanTime.Format("2006-01-02 15:04:05"))
|
|
mock.SetResponse("sudo fail2ban-client get sshd banip --with-time", []byte(mockBanOutput))
|
|
|
|
client, err := NewClient(shared.DefaultLogDir, shared.DefaultFilterDir)
|
|
AssertError(t, err, false, "create client")
|
|
|
|
records, err := client.GetBanRecords([]string{"sshd"})
|
|
AssertError(t, err, false, "get ban records")
|
|
|
|
if len(records) != 1 {
|
|
t.Errorf("expected 1 record, got %d", len(records))
|
|
}
|
|
|
|
if len(records) > 0 {
|
|
record := records[0]
|
|
if record.Jail != "sshd" {
|
|
t.Errorf("expected jail 'sshd', got %q", record.Jail)
|
|
}
|
|
if record.IP != "192.168.1.100" {
|
|
t.Errorf("expected IP '192.168.1.100', got %q", record.IP)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetLogLines(t *testing.T) {
|
|
// Create a temporary test log directory
|
|
tempDir := t.TempDir()
|
|
SetLogDir(tempDir)
|
|
|
|
// Create test log files
|
|
logContent := `2024-01-01 12:00:00,123 fail2ban.filter [1234]: INFO [sshd] Found 192.168.1.100 - 2024-01-01 12:00:00
|
|
2024-01-01 12:01:00,456 fail2ban.actions [1234]: NOTICE [sshd] Ban 192.168.1.100
|
|
2024-01-01 12:02:00,789 fail2ban.filter [1234]: INFO [apache] Found 192.168.1.101 - 2024-01-01 12:02:00`
|
|
|
|
err := os.WriteFile(filepath.Join(tempDir, "fail2ban.log"), []byte(logContent), 0600)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test log file: %v", err)
|
|
}
|
|
|
|
mock := NewMockRunner()
|
|
StandardMockSetup(mock)
|
|
SetRunner(mock)
|
|
|
|
tests := []struct {
|
|
name string
|
|
jail string
|
|
ip string
|
|
expectedLines int
|
|
}{
|
|
{
|
|
name: "all logs",
|
|
jail: "",
|
|
ip: "",
|
|
expectedLines: 3,
|
|
},
|
|
{
|
|
name: "filter by jail",
|
|
jail: "sshd",
|
|
ip: "",
|
|
expectedLines: 2,
|
|
},
|
|
{
|
|
name: "filter by IP",
|
|
jail: "",
|
|
ip: "192.168.1.100",
|
|
expectedLines: 2,
|
|
},
|
|
{
|
|
name: "filter by jail and IP",
|
|
jail: "sshd",
|
|
ip: "192.168.1.100",
|
|
expectedLines: 2,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
lines, err := GetLogLines(context.Background(), tt.jail, tt.ip)
|
|
AssertError(t, err, false, "get log lines")
|
|
|
|
if len(lines) != tt.expectedLines {
|
|
t.Errorf("expected %d lines, got %d", tt.expectedLines, len(lines))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestGetLogLinesWithLimitPrefersRecent(t *testing.T) {
|
|
originalDir := GetLogDir()
|
|
SetLogDir(t.TempDir())
|
|
defer SetLogDir(originalDir)
|
|
|
|
logDir := GetLogDir()
|
|
oldPath := filepath.Join(logDir, "fail2ban.log.1")
|
|
newPath := filepath.Join(logDir, "fail2ban.log")
|
|
|
|
// Older rotated log with more entries than the requested limit
|
|
oldContent := "old-entry-1\nold-entry-2\nold-entry-3\n"
|
|
if err := os.WriteFile(oldPath, []byte(oldContent), 0o600); err != nil {
|
|
t.Fatalf("failed to create rotated log: %v", err)
|
|
}
|
|
|
|
// Current log with the most recent entries
|
|
newContent := "new-entry-1\nnew-entry-2\n"
|
|
if err := os.WriteFile(newPath, []byte(newContent), 0o600); err != nil {
|
|
t.Fatalf("failed to create current log: %v", err)
|
|
}
|
|
|
|
lines, err := GetLogLinesWithLimit(context.Background(), "", "", 2)
|
|
if err != nil {
|
|
t.Fatalf("GetLogLinesWithLimit returned error: %v", err)
|
|
}
|
|
|
|
expected := []string{"new-entry-1", "new-entry-2"}
|
|
if !reflect.DeepEqual(lines, expected) {
|
|
t.Fatalf("expected %v, got %v", expected, lines)
|
|
}
|
|
|
|
client := &RealClient{LogDir: logDir}
|
|
clientLines, err := client.GetLogLinesWithLimit("", "", 2)
|
|
if err != nil {
|
|
t.Fatalf("RealClient.GetLogLinesWithLimit returned error: %v", err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(clientLines, expected) {
|
|
t.Fatalf("client expected %v, got %v", expected, clientLines)
|
|
}
|
|
}
|
|
|
|
func TestListFilters(t *testing.T) {
|
|
// Set ALLOW_DEV_PATHS for test to use temp directory
|
|
t.Setenv("ALLOW_DEV_PATHS", "true")
|
|
|
|
// Create a temporary test filter directory
|
|
tempDir := t.TempDir()
|
|
filterDir := filepath.Join(tempDir, "filter.d")
|
|
err := os.MkdirAll(filterDir, 0750)
|
|
if err != nil {
|
|
t.Fatalf("failed to create filter directory: %v", err)
|
|
}
|
|
|
|
// Create test filter files
|
|
filterFiles := []string{"sshd.conf", "apache.conf", "nginx.conf", "readme.txt"}
|
|
for _, file := range filterFiles {
|
|
err := os.WriteFile(filepath.Join(filterDir, file), []byte("# test filter"), 0600)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test filter file: %v", err)
|
|
}
|
|
}
|
|
|
|
// Mock the filter directory path
|
|
mock := NewMockRunner()
|
|
mock.SetResponse("fail2ban-client -V", []byte("0.11.2"))
|
|
mock.SetResponse("fail2ban-client ping", []byte("pong"))
|
|
mock.SetResponse("fail2ban-client status", []byte("Status\n|- Number of jail: 1\n`- Jail list: sshd"))
|
|
SetRunner(mock)
|
|
|
|
// Create client with the temporary filter directory
|
|
client, err := NewClient(shared.DefaultLogDir, filterDir)
|
|
AssertError(t, err, false, "create client")
|
|
|
|
// Test ListFilters with the temporary directory
|
|
filters, err := client.ListFilters()
|
|
AssertError(t, err, false, "list filters")
|
|
|
|
// Should find only .conf files (sshd, apache, nginx - not readme.txt)
|
|
expectedFilters := []string{"apache", "nginx", "sshd"}
|
|
if len(filters) != len(expectedFilters) {
|
|
t.Errorf("Expected %d filters, got %d: %v", len(expectedFilters), len(filters), filters)
|
|
}
|
|
|
|
// Check that all expected filters are present (order may vary)
|
|
for _, expected := range expectedFilters {
|
|
found := false
|
|
for _, actual := range filters {
|
|
if actual == expected {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Expected filter %q not found in %v", expected, filters)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTestFilter(t *testing.T) {
|
|
// Set ALLOW_DEV_PATHS for test to use temp directory
|
|
t.Setenv("ALLOW_DEV_PATHS", "true")
|
|
|
|
// Create a temporary test filter file
|
|
tempDir := t.TempDir()
|
|
filterName := "test-filter"
|
|
filterPath := filepath.Join(tempDir, filterName+".conf")
|
|
filterContent := `[Definition]
|
|
failregex = Failed password for .* from <HOST>
|
|
logpath = /var/log/auth.log`
|
|
|
|
err := os.WriteFile(filterPath, []byte(filterContent), 0600)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test filter file: %v", err)
|
|
}
|
|
|
|
// Set up mock environment with sudo privileges
|
|
_, cleanup := SetupMockEnvironmentWithSudo(t, true)
|
|
defer cleanup()
|
|
|
|
// Configure specific responses for this test
|
|
mock := GetRunner().(*MockRunner)
|
|
expectedOutput := "Running tests on fail2ban-regex\nResults: 5 matches found"
|
|
mock.SetResponse("fail2ban-regex /var/log/auth.log "+filterPath, []byte(expectedOutput))
|
|
mock.SetResponse("sudo fail2ban-regex /var/log/auth.log "+filterPath, []byte(expectedOutput))
|
|
|
|
// Create client with the temp directory as the filter directory
|
|
client, err := NewClient(shared.DefaultLogDir, tempDir)
|
|
AssertError(t, err, false, "create client")
|
|
|
|
// Test the actual created filter
|
|
output, err := client.TestFilter(filterName)
|
|
AssertError(t, err, false, "test filter should succeed")
|
|
|
|
if output != expectedOutput {
|
|
t.Errorf("expected output %q, got %q", expectedOutput, output)
|
|
}
|
|
|
|
// Also test that a nonexistent filter fails appropriately
|
|
_, err = client.TestFilter("nonexistent")
|
|
if err == nil {
|
|
t.Error("TestFilter should fail for nonexistent filter")
|
|
}
|
|
}
|
|
|
|
func TestVersionComparison(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
versionOutput string
|
|
expectError bool
|
|
errorSubstring string
|
|
}{
|
|
{
|
|
name: "prefixed supported version",
|
|
versionOutput: "Fail2Ban v0.11.2",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "plain supported version",
|
|
versionOutput: "0.12.0",
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "unsupported version",
|
|
versionOutput: "Fail2Ban v0.10.9",
|
|
expectError: true,
|
|
errorSubstring: "fail2ban >=0.11.0 required",
|
|
},
|
|
{
|
|
name: "unparseable version",
|
|
versionOutput: "unexpected output",
|
|
expectError: true,
|
|
errorSubstring: "failed to parse fail2ban version",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, cleanup := SetupMockEnvironmentWithSudo(t, true)
|
|
defer cleanup()
|
|
|
|
mock := GetRunner().(*MockRunner)
|
|
mock.SetResponse("fail2ban-client -V", []byte(tt.versionOutput))
|
|
mock.SetResponse("sudo fail2ban-client -V", []byte(tt.versionOutput))
|
|
|
|
if !tt.expectError {
|
|
mock.SetResponse("fail2ban-client ping", []byte("pong"))
|
|
mock.SetResponse("sudo fail2ban-client ping", []byte("pong"))
|
|
statusOutput := []byte("Status\n|- Number of jail: 1\n`- Jail list: sshd")
|
|
mock.SetResponse("fail2ban-client status", statusOutput)
|
|
mock.SetResponse("sudo fail2ban-client status", statusOutput)
|
|
}
|
|
|
|
_, err := NewClient(shared.DefaultLogDir, shared.DefaultFilterDir)
|
|
|
|
AssertError(t, err, tt.expectError, tt.name)
|
|
if tt.expectError && tt.errorSubstring != "" {
|
|
if err == nil || !strings.Contains(err.Error(), tt.errorSubstring) {
|
|
t.Fatalf("expected error containing %q, got %v", tt.errorSubstring, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractFail2BanVersion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expect string
|
|
expectErr bool
|
|
}{
|
|
{
|
|
name: "prefixed output",
|
|
input: "Fail2Ban v0.11.2",
|
|
expect: "0.11.2",
|
|
},
|
|
{
|
|
name: "with extra context",
|
|
input: "fail2ban 0.12.0 (Python 3)",
|
|
expect: "0.12.0",
|
|
},
|
|
{
|
|
name: "plain version",
|
|
input: "0.13.1",
|
|
expect: "0.13.1",
|
|
},
|
|
{
|
|
name: "leading v",
|
|
input: "v1.0.0",
|
|
expect: "1.0.0",
|
|
},
|
|
{
|
|
name: "invalid output",
|
|
input: "not a version",
|
|
expectErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
version, err := ExtractFail2BanVersion(tt.input)
|
|
if tt.expectErr {
|
|
if err == nil {
|
|
t.Fatalf("expected error for input %q", tt.input)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for input %q: %v", tt.input, err)
|
|
}
|
|
if version != tt.expect {
|
|
t.Fatalf("expected version %q, got %q", tt.expect, version)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetFilterDir(_ *testing.T) {
|
|
originalDir := "/etc/fail2ban/filter.d" // Assume this is the default
|
|
testDir := "/custom/filter/dir"
|
|
|
|
// Set a custom filter directory
|
|
SetFilterDir(testDir)
|
|
|
|
// Test that the directory change affects filter operations
|
|
// Since SetFilterDir doesn't return anything, we test indirectly
|
|
// by checking that it doesn't panic and can be called multiple times
|
|
SetFilterDir(testDir)
|
|
SetFilterDir("/another/dir")
|
|
SetFilterDir(originalDir)
|
|
|
|
// Test with empty string
|
|
SetFilterDir("")
|
|
|
|
// Test with relative path
|
|
SetFilterDir("./filters")
|
|
|
|
// No assertions needed as SetFilterDir is a simple setter
|
|
// The fact that it doesn't panic is sufficient
|
|
}
|
|
|
|
func TestIsValidFilter(t *testing.T) {
|
|
valid := []string{"sshd", "nginx-error", "custom.filter"}
|
|
invalid := []string{"../evil", "bad/name", "bad\\name", "", ".."}
|
|
for _, f := range valid {
|
|
if err := ValidateFilter(f); err != nil {
|
|
t.Errorf("expected filter %s to be valid, got error: %v", f, err)
|
|
}
|
|
}
|
|
for _, f := range invalid {
|
|
if err := ValidateFilter(f); err == nil {
|
|
t.Errorf("expected filter %s to be invalid", f)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCompareVersions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
v1 string
|
|
v2 string
|
|
expected int
|
|
}{
|
|
{
|
|
name: "equal versions",
|
|
v1: "1.0.0",
|
|
v2: "1.0.0",
|
|
expected: 0,
|
|
},
|
|
{
|
|
name: "v1 less than v2",
|
|
v1: "0.11.0",
|
|
v2: "1.0.0",
|
|
expected: -1,
|
|
},
|
|
{
|
|
name: "v1 greater than v2",
|
|
v1: "1.2.0",
|
|
v2: "1.0.0",
|
|
expected: 1,
|
|
},
|
|
{
|
|
name: "patch version difference",
|
|
v1: "1.0.1",
|
|
v2: "1.0.2",
|
|
expected: -1,
|
|
},
|
|
{
|
|
name: "prerelease versions",
|
|
v1: "1.0.0-alpha",
|
|
v2: "1.0.0",
|
|
expected: -1,
|
|
},
|
|
{
|
|
name: "invalid version strings fallback to string comparison",
|
|
v1: "invalid.version",
|
|
v2: "another.invalid",
|
|
expected: 1, // "invalid.version" > "another.invalid" lexicographically
|
|
},
|
|
{
|
|
name: "mixed valid and invalid",
|
|
v1: "1.0.0",
|
|
v2: "invalid",
|
|
expected: -1, // string comparison: "1.0.0" < "invalid"
|
|
},
|
|
{
|
|
name: "version with build metadata",
|
|
v1: "1.0.0+build.1",
|
|
v2: "1.0.0+build.2",
|
|
expected: 0, // build metadata should be ignored in semantic versioning
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := CompareVersions(tt.v1, tt.v2)
|
|
if result != tt.expected {
|
|
t.Errorf("CompareVersions(%q, %q) = %d, expected %d", tt.v1, tt.v2, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetBanRecordsWithInvalidTimes(t *testing.T) {
|
|
// Set up mock environment with sudo privileges
|
|
_, cleanup := SetupMockEnvironmentWithSudo(t, true)
|
|
defer cleanup()
|
|
|
|
// Get mock runner for configuration
|
|
mockRunner := GetRunner().(*MockRunner)
|
|
|
|
// Create client
|
|
client := &RealClient{
|
|
Path: "fail2ban-client",
|
|
Jails: []string{"sshd"},
|
|
LogDir: "/var/log",
|
|
FilterDir: "/etc/fail2ban/filter.d",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockResponse string
|
|
expectedCount int
|
|
expectSkipped bool
|
|
}{
|
|
{
|
|
name: "valid times",
|
|
mockResponse: "192.168.1.100 2023-01-01 12:00:00 + 2023-01-01 13:00:00 extra field\n" +
|
|
"192.168.1.101 2023-01-01 14:00:00 + 2023-01-01 15:00:00 extra field",
|
|
expectedCount: 2,
|
|
expectSkipped: false,
|
|
},
|
|
{
|
|
name: "invalid ban time - entry should be skipped",
|
|
mockResponse: "192.168.1.100 invalid-date 12:00:00 + 2023-01-01 13:00:00 extra field\n" +
|
|
"192.168.1.101 2023-01-01 14:00:00 + 2023-01-01 15:00:00 extra field",
|
|
expectedCount: 1,
|
|
expectSkipped: true,
|
|
},
|
|
{
|
|
name: "invalid unban time - entry should use fallback",
|
|
mockResponse: "192.168.1.100 2023-01-01 12:00:00 + invalid-time 13:00:00 extra field\n" +
|
|
"192.168.1.101 2023-01-01 14:00:00 + 2023-01-01 15:00:00 extra field",
|
|
expectedCount: 2,
|
|
expectSkipped: false,
|
|
},
|
|
{
|
|
name: "both times invalid - entry should be skipped",
|
|
mockResponse: "192.168.1.100 invalid-date 12:00:00 + invalid-time 13:00:00 extra field\n" +
|
|
"192.168.1.101 2023-01-01 14:00:00 + 2023-01-01 15:00:00 extra field",
|
|
expectedCount: 1,
|
|
expectSkipped: true,
|
|
},
|
|
{
|
|
name: "short format fallback",
|
|
mockResponse: "192.168.1.100 banned extra field\n" +
|
|
"192.168.1.101 also banned extra",
|
|
expectedCount: 2,
|
|
expectSkipped: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Set up mock response (the command uses sudo)
|
|
mockRunner.SetResponse("sudo fail2ban-client get sshd banip --with-time", []byte(tt.mockResponse))
|
|
|
|
// Get ban records
|
|
records, err := client.GetBanRecords([]string{"sshd"})
|
|
if err != nil {
|
|
t.Fatalf("GetBanRecords failed: %v", err)
|
|
}
|
|
|
|
// Check count
|
|
if len(records) != tt.expectedCount {
|
|
t.Errorf("expected %d records, got %d", tt.expectedCount, len(records))
|
|
}
|
|
|
|
// For entries with invalid unban time (but valid ban time), verify fallback worked
|
|
if tt.name == "invalid unban time - entry should use fallback" && len(records) > 0 {
|
|
// The first record should have a reasonable remaining time (not zero)
|
|
if records[0].Remaining == "00:00:00:00" {
|
|
t.Errorf("expected fallback time calculation, got zero remaining time")
|
|
}
|
|
}
|
|
|
|
// For entries using short format fallback
|
|
if tt.name == "short format fallback" && len(records) > 0 {
|
|
for _, record := range records {
|
|
if record.Remaining != "unknown" {
|
|
t.Errorf("expected 'unknown' remaining time for short format, got %s", record.Remaining)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|