mirror of
https://github.com/ivuorinen/f2b.git
synced 2026-01-26 03:13:58 +00:00
* Go rewrite * chore(cr): apply suggestions Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Ismo Vuorinen <ismo@ivuorinen.net> * 📝 CodeRabbit Chat: Add NoOpClient to fail2ban and initialize when skip flag is true * 📝 CodeRabbit Chat: Fix malformed if-else structure and add no-op client for skip-only commands * 📝 CodeRabbit Chat: Fix malformed if-else structure and add no-op client for skip-only commands * fix(main): correct no-op branch syntax (#10) * chore(gitignore): ignore env and binary files (#11) * chore(config): remove indent_size for go files (#12) * feat(cli): inject version via ldflags (#13) * fix(security): validate filter parameter to prevent path traversal (#15) * chore(repo): anchor ignore for build artifacts (#16) * chore(ci): use golangci-lint action (#17) * feat(fail2ban): expose GetLogDir (#19) * test(cmd): improve IP mock validation (#20) * chore(ci): update golanglint * fix(ci): golanglint * fix(ci): correct args indentation in pr-lint workflow (#21) * fix(ci): avoid duplicate releases (#22) * refactor(fail2ban): remove test check from OSRunner (#23) * refactor(fail2ban): make log and filter dirs configurable (#24) * fix(ci): create single release per tag (#14) Signed-off-by: Ismo Vuorinen <ismo@ivuorinen.net> * chore(dev): add codex setup script (#27) * chore(lint): enable staticcheck (#26) * chore(ci): verify golangci config (#28) * refactor(cmd): centralize env config (#29) * chore(dev): add pre-commit config (#30) * fix(ci): disable cgo in cross compile (#31) * fix(ci): fail on formatting issues (#32) * feat(cmd): add context to logs watch (#33) * chore: fixes, roadmap, claude.md, linting * chore: fixes, linting * fix(ci): gh actions update, fixes and tweaks * chore: use reviewdog actionlint * chore: use wow-rp-addons/actions-editorconfig-check * chore: combine agent instructions, add comments, fixes * chore: linting, fixes, go revive * chore(deps): update pre-commit hooks * chore: bump go to 1.21, pin workflows * fix: install tools in lint.yml * fix: sudo timeout * fix: service command injection * fix: memory exhaustion with large logs * fix: enhanced path traversal and file security vulns * fix: race conditions * fix: context support * chore: simplify fail2ban/ code * feat: major refactoring with GoReleaser integration and code consolidation - Add GoReleaser configuration for automated multi-platform releases - Support for Linux, macOS, Windows, and BSD builds - Docker images, Homebrew tap, and Linux packages (.deb, .rpm, .apk) - GitHub Actions workflow for release automation - Consolidate duplicate code and improve architecture - Extract common command helpers to cmd/helpers.go (~230 lines) - Remove duplicate MockClient implementation from tests (~250 lines) - Create context wrapper helpers in fail2ban/context_helpers.go - Standardize error messages in fail2ban/errors.go - Enhance validation and security - Add proper IP address validation with fail2ban.ValidateIP - Fix path traversal and command injection vulnerabilities - Improve thread-safety in MockClient with consistent ordering - Optimize documentation - Reduce CLAUDE.md from 190 to 81 lines (57% reduction) - Reduce TODO.md from 633 to 93 lines (85% reduction) - Move README.md to root directory with installation instructions - Improve test reliability - Fix race conditions and test flakiness - Add sorting to ensure deterministic test output - Enhance MockClient with configurable behavior * feat: comprehensive code quality improvements and documentation reorganization This commit represents a major overhaul of code quality, documentation structure, and development tooling: **Documentation & Structure:** - Move CODE_OF_CONDUCT.md from .github to root directory - Reorganize documentation with dedicated docs/ directory - Create comprehensive architecture, security, and testing documentation - Update all references and cross-links for new documentation structure **Code Quality & Linting:** - Add 120-character line length limit across all files via EditorConfig - Enable comprehensive linting with golines, lll, usetesting, gosec, and revive - Fix all 86 revive linter issues (unused parameters, missing export comments) - Resolve security issues (file permissions 0644 → 0600, gosec warnings) - Replace deprecated os.Setenv with t.Setenv in all tests - Configure golangci-lint with auto-fix capabilities and formatter integration **Development Tooling:** - Enhance pre-commit configuration with additional hooks and formatters - Update GoReleaser configuration with improved YAML formatting - Improve GitHub workflows and issue templates for CLI-specific context - Add comprehensive Makefile with proper dependency checking **Testing & Security:** - Standardize mock patterns and context wrapper implementations - Enhance error handling with centralized error constants - Improve concurrent access testing for thread safety * perf: implement major performance optimizations with comprehensive test coverage This commit introduces three significant performance improvements along with complete linting compliance and robust test coverage: **Performance Optimizations:** 1. **Time Parsing Cache (8.6x improvement)** - Add TimeParsingCache with sync.Map for caching parsed times - Implement object pooling for string builders to reduce allocations - Create optimized BanRecordParser with pooled string slices 2. **Gzip Detection Consolidation (55x improvement)** - Consolidate ~100 lines of duplicate gzip detection logic - Fast-path extension checking before magic byte detection - Unified GzipDetector with comprehensive file handling utilities 3. **Parallel Processing (2.5-5.0x improvement)** - Generic WorkerPool implementation for concurrent operations - Smart fallback to sequential processing for single operations - Context-aware cancellation support for long-running tasks - Applied to ban/unban operations across multiple jails **New Files Added:** - fail2ban/time_parser.go: Cached time parsing with global instances - fail2ban/ban_record_parser.go: Optimized ban record parsing - fail2ban/gzip_detection.go: Unified gzip handling utilities - fail2ban/parallel_processing.go: Generic parallel processing framework - cmd/parallel_operations.go: Command-level parallel operation support **Code Quality & Linting:** - Resolve all golangci-lint issues (0 remaining) - Add proper #nosec annotations for legitimate file operations - Implement sentinel errors replacing nil/nil anti-pattern - Fix context parameter handling and error checking **Comprehensive Test Coverage:** - 500+ lines of new tests with benchmarks validating all improvements - Concurrent access testing for thread safety - Edge case handling and error condition testing - Performance benchmarks demonstrating measured improvements **Modified Files:** - fail2ban/fail2ban.go: Integration with new optimized parsers - fail2ban/logs.go: Use consolidated gzip detection (-91 lines) - cmd/ban.go & cmd/unban.go: Add conditional parallel processing * test: comprehensive test infrastructure overhaul with real test data Major improvements to test code quality and organization: • Added comprehensive test data infrastructure with 6 anonymized log files • Extracted common test helpers reducing ~200 lines to ~50 reusable functions • Enhanced ban record parser tests with real production log patterns • Improved gzip detection tests with actual compressed test data • Added integration tests for full log processing and concurrent operations • Updated .gitignore to allow testdata log files while excluding others • Updated TODO.md to reflect completed test infrastructure improvements * fix: comprehensive security hardening and critical bug fixes Security Enhancements: - Add command injection protection with allowlist validation for all external commands - Add security documentation to gzip functions warning about path traversal risks - Complete TODO.md security audit - all critical vulnerabilities addressed Bug Fixes: - Fix negative index access vulnerability in parallel operations (prevent panic) - Fix parsing inconsistency between BannedIn and BannedInWithContext functions - Fix nil error handling in concurrent log reading tests - Fix benchmark error simulation to measure actual performance vs error paths Implementation Details: - Add ValidateCommand() with allowlist for fail2ban-client, fail2ban-regex, service, systemctl, sudo - Integrate command validation into all OSRunner methods before execution - Replace manual string parsing with ParseBracketedList() for consistency - Add bounds checking (index >= 0) to prevent negative array access - Replace nil error with descriptive error message in concurrent error channels - Update banFunc in benchmark to return success instead of permanent errors Test Coverage: - Add comprehensive security validation tests with injection attempt patterns - Add parallel operations safety tests with index validation - Add parsing consistency tests between context/non-context functions - Add error handling demonstration tests for concurrent operations - Add gzip function security requirement documentation tests * perf: implement ultra-optimized log and ban record parsing with significant performance gains Major performance improvements to core fail2ban processing with comprehensive benchmarking: Performance Achievements: • Ban record parsing: 15% faster, 39% less memory, 45% fewer allocations • Log processing: 27% faster, 64% less memory, 32% fewer allocations • Cache performance: 624x faster cache hits with zero allocations • String pooling: 4.7x improvement with zero memory allocations Core Optimizations: • Object pooling (sync.Pool) for string slices, scanner buffers, and line buffers • Comprehensive caching (sync.Map) for gzip detection, file info, and path patterns • Fast path optimizations with extension-based gzip detection • Byte-level operations to reduce string allocations in filtering • Ultra-optimized parsers with smart field parsing and efficient time handling New Files: • fail2ban/ban_record_parser_optimized.go - High-performance ban record parser • fail2ban/log_performance_optimized.go - Ultra-optimized log processor with caching • fail2ban/ban_record_parser_benchmark_test.go - Ban record parsing benchmarks • fail2ban/log_performance_benchmark_test.go - Log performance benchmarks • fail2ban/ban_record_parser_compatibility_test.go - Compatibility verification tests Updated: • fail2ban/fail2ban.go - Integration with ultra-optimized parsers • TODO.md - Marked performance optimization tasks as completed * fix(ci): install dev dependencies for pre-commit * refactor: streamline pre-commit config and extract test helpers - Replace local hooks with upstream pre-commit repositories for better maintainability - Add new hooks: shellcheck, shfmt, checkov for enhanced code quality - Extract common test helpers into dedicated test_helpers.go to reduce duplication - Add warning logs for unreadable log files in fail2ban and logs packages - Remove hard-coded GID checks in sudo.go for better cross-platform portability - Update golangci-lint installation method in Makefile * fix(security): path traversal, log file validation * feat: complete pre-release modernization with comprehensive testing - Remove all deprecated legacy functions and dead code paths - Add security hardening with sanitized error messages - Implement comprehensive performance benchmarks and security audit tests - Mark all pre-release modernization tasks as completed (10/10) - Update project documentation to reflect full completion status * fix(ci): linting, and update gosec install source * feat: implement comprehensive test framework with 60-70% code reduction Major test infrastructure modernization: - Create fluent CommandTestBuilder framework for streamlined test creation - Add MockClientBuilder pattern for advanced mock configuration - Standardize table test field naming (expectedOut→wantOutput, expectError→wantError) - Consolidate test code: 3,796 insertions, 3,104 deletions (net +692 lines with enhanced functionality) Framework achievements: - 168+ tests passing with zero regressions - 5 cmd test files fully migrated to new framework - 63 field name standardizations applied - Advanced mock patterns with fluent interface File organization improvements: - Rename all test files with consistent prefixes (cmd_*, fail2ban_*, main_*) - Split monolithic test files into focused, maintainable modules - Eliminate cmd_test.go (622 lines) and main_test.go (825 lines) - Create specialized test files for better organization Documentation enhancements: - Update docs/testing.md with complete framework documentation - Optimize TODO.md from 231→72 lines (69% token reduction) - Add comprehensive migration guides and best practices Test framework components: - command_test_framework.go: Core fluent interface implementation - MockClientBuilder: Advanced mock configuration with builder pattern - table_test_standards.go: Standardized field naming conventions - Enhanced test helpers with error checking consolidation * chore: fixes, .go-version, linting * fix(ci) editorconfig in .pre-commit-config.yaml * fix: too broad gitignore * chore: update fail2ban/fail2ban_path_security_test.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Ismo Vuorinen <ismo@ivuorinen.net> * chore: code review fixes * chore: code review fixes * fix: more code review fixes * fix: more code review fixes * feat: cleanup, fixes, testing * chore: minor config file updates - Add quotes to F2B_TIMEOUT value in .env.example for clarity - Remove testdata log exception from .gitignore (simplified) * feat: implement comprehensive monitoring with structured logging and metrics - Add structured logging with context propagation throughout codebase - Implement ContextualLogger with request tracking and operation timing - Add context values for operation, IP, jail, command, and request ID - Integrate with existing logrus logging infrastructure - Add request/response timing metrics collection - Create comprehensive Metrics system with atomic counters - Track command executions, ban/unban operations, and client operations - Implement latency distribution buckets for performance analysis - Add validation cache hit/miss tracking - Enhance ban/unban commands with structured logging - Add LogOperation wrapper for automatic timing and context - Log individual jail operations with success/failure status - Integrate metrics recording with ban/unban operations - Add new 'metrics' command to expose collected metrics - Support both plain text and JSON output formats - Display system metrics (uptime, memory, goroutines) - Show operation counts, failures, and average latencies - Include latency distribution histograms - Update test infrastructure - Add tests for metrics command - Fix test helper to support persistent flags - Ensure all tests pass with new logging This completes the high-priority performance monitoring and structured logging requirements from TODO.md, providing comprehensive operational visibility into the f2b application. * docs: update TODO.md to reflect completed monitoring work - Mark structured logging and timing metrics as completed - Update test coverage stats (cmd/ improved from 66.4% to 76.8%) - Add completed infrastructure section for today's work - Update current status date and add monitoring to health indicators * feat: complete TODO.md technical debt cleanup Complete all remaining TODO.md tasks with comprehensive implementation: ## 🎯 Validation Caching Implementation - Thread-safe validation cache with sync.RWMutex protection - MetricsRecorder interface to avoid circular dependencies - Cached validation for IP, jail, filter, and command validation - Integration with existing metrics system for cache hit/miss tracking - 100% test coverage for caching functionality ## 🔧 Constants Extraction - Fail2Ban status codes: Fail2BanStatusSuccess, Fail2BanStatusAlreadyProcessed - Command constants: Fail2BanClientCommand, Fail2BanRegexCommand, Fail2BanServerCommand - File permissions: DefaultFilePermissions (0600), DefaultDirectoryPermissions (0750) - Timeout limits: MaxCommandTimeout, MaxFileTimeout, MaxParallelTimeout - Updated all references throughout codebase to use named constants ## 📊 Test Coverage Improvement - Increased fail2ban package coverage from 62.0% to 70.3% (target: 70%+) - Added 6 new comprehensive test files with 200+ additional test cases - Coverage improvements across all major components: - Context helpers, validation cache, mock clients, OS runner methods - Error constructors, timing operations, cache statistics - Thread safety and concurrency testing ## 🛠️ Code Quality & Fixes - Fixed all linting issues (golangci-lint, revive, errcheck) - Resolved unused parameter warnings and error handling - Fixed timing-dependent test failures in worker pool cancellation - Enhanced thread safety in validation caching ## 📈 Final Metrics - Overall test coverage: 72.4% (up from ~65%) - fail2ban package: 70.3% (exceeds 70% target) - cmd package: 76.9% - Zero TODO/FIXME/HACK comments in production code - 100% linting compliance * fix: resolve test framework issues and update documentation - Remove unnecessary defer/recover block in comprehensive_framework_test.go - Fix compilation error in command_test_framework.go variable redeclaration - Update TODO.md to reflect all 12 completed code quality fixes - Clean up dead code and improve test maintainability - Fix linting issues: error handling, code complexity, security warnings - Break down complex test function to reduce cyclomatic complexity * fix: replace dangerous test commands with safe placeholders Replaces actual dangerous commands in test cases with safe placeholder patterns to prevent accidental execution while maintaining comprehensive security testing. - Replace 'rm -rf /', 'cat /etc/passwd' with 'DANGEROUS_RM_COMMAND', 'DANGEROUS_SYSTEM_CALL' - Update GetDangerousCommandPatterns() to recognize both old and new patterns - Enhance filter validation with command injection protection (semicolons, pipes, backticks, dollar signs) - Add package documentation comments for all packages (main, cmd, fail2ban) - Fix GoReleaser static linking configuration for cross-platform builds - Remove Docker platform restriction to enable multi-arch support - Apply code formatting and linting fixes All security validation tests continue to pass with the safe placeholders. * fix: resolve TestMixedConcurrentOperations race condition and command key mismatches The concurrency test was failing due to several issues: 1. **Command Key Mismatch**: Test setup used "sudo test arg" key but MockRunner looked for "test arg" because "test" command doesn't require sudo 2. **Invalid Commands**: Using "test" and "echo" commands that aren't in the fail2ban command allowlist, causing validation failures 3. **Race Conditions**: Multiple goroutines setting different MockRunners simultaneously, overwriting responses **Solution:** - Replace invalid test commands ("test", "echo") with valid fail2ban commands ("fail2ban-client status", "fail2ban-client -V") - Pre-configure shared MockRunner with all required response keys for both sudo and non-sudo execution paths - Improve test structure to reduce race conditions between setup and execution All tests now pass reliably, resolving the CI failure. * fix: address code quality issues and improve test coverage - Replace unsafe type assertion with comma-ok idiom in logging - Fix TestTestFilter to use created filter instead of nonexistent - Add warning logs for invalid log level configurations - Update TestVersionCommand to use consistent test framework pattern - Remove unused LoggerContextKey constant - Add version command support to test framework - Fix trailing whitespace in test files * feat: add timeout handling and multi-architecture Docker support * test: enhance path traversal security test coverage * chore: comprehensive documentation update and linting fixes Updated all documentation to reflect current capabilities including context-aware operations, multi-architecture Docker support, advanced security features, and performance monitoring. Removed unused functions and fixed all linting issues. * fix(lint): .goreleaser.yaml * feat: add markdown link checker and fix all linting issues - Add markdown-link-check to pre-commit hooks with comprehensive configuration - Fix GitHub workflow structure (sync-labels.yml) with proper job setup - Add JSON schemas to all configuration files for better IDE support - Update tool installation in Makefile for markdown-link-check dependency - Fix all revive linting issues (Boolean literals, defer in loop, if-else simplification, method naming) - Resolve broken relative link in CONTRIBUTING.md - Configure rate limiting and ignore patterns for GitHub URLs - Enhance CLAUDE.md with link checking documentation * fix(ci): sync-labels permissions * docs: comprehensive documentation update reflecting current project status - Updated TODO.md to show production-ready status with 21 commands - Enhanced README.md with enterprise-grade features and capabilities - Added performance monitoring and timeout configuration to FAQ - Updated CLAUDE.md with accurate project architecture overview - Fixed all line length issues to meet EditorConfig requirements - Added .mega-linter.yml configuration for enhanced linting * fix: address CodeRabbitAI review feedback - Split .goreleaser.yaml builds for static/dynamic linking by architecture - Update docs to accurately reflect 7 path traversal patterns (not 17) - Fix containsPathTraversal to allow valid absolute paths - Replace runnerCombinedRunWithSudoContext with RunnerCombinedOutputWithSudoContext - Fix ldflags to use uppercase Version variable name - Remove duplicate test coverage metrics in TODO.md - Fix .markdown-link-check.json schema violations - Add v8r JSON validator to pre-commit hooks * chore(ci): update workflows, switch v8r to check-jsonschema * fix: restrict static linking to amd64 only in .goreleaser.yaml - Move arm64 from static to dynamic build configuration - Static linking now only applies to linux/amd64 - Prevents build failures due to missing static libc on ARM64 - All architectures remain supported with appropriate linking * fix(ci): caching * fix(ci): python caching with pip, node with npm * fix(ci): no caching for node then * fix(ci): no requirements.txt, no cache * refactor: address code review feedback - Pin Alpine base image to v3.20 for reproducible builds - Remove redundant --platform flags in GoReleaser Docker configs - Fix unused parameters in concurrency test goroutines - Simplify string search helper using strings.Contains() - Remove redundant error checking logic in security tests --------- Signed-off-by: Ismo Vuorinen <ismo@ivuorinen.net> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
873 lines
27 KiB
Go
873 lines
27 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"
|
|
)
|
|
|
|
const (
|
|
// DefaultLogDir is the default directory for fail2ban logs
|
|
DefaultLogDir = "/var/log"
|
|
// DefaultFilterDir is the default directory for fail2ban filters
|
|
DefaultFilterDir = "/etc/fail2ban/filter.d"
|
|
// AllFilter represents all jails/IPs filter
|
|
AllFilter = "all"
|
|
// DefaultMaxFileSize is the default maximum file size for log reading (100MB)
|
|
DefaultMaxFileSize = 100 * 1024 * 1024
|
|
// DefaultLogLinesLimit is the default limit for log lines returned
|
|
DefaultLogLinesLimit = 1000
|
|
)
|
|
|
|
var logDir = DefaultLogDir // base directory for fail2ban logs
|
|
var logDirMu sync.RWMutex // protects logDir from concurrent access
|
|
var filterDir = 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
|
|
}
|
|
|
|
// Runner executes system commands.
|
|
// Implementations may use sudo or other mechanisms as needed.
|
|
type Runner interface {
|
|
CombinedOutput(name string, args ...string) ([]byte, error)
|
|
CombinedOutputWithSudo(name string, args ...string) ([]byte, error)
|
|
// Context-aware versions for timeout and cancellation support
|
|
CombinedOutputWithContext(ctx context.Context, name string, args ...string) ([]byte, error)
|
|
CombinedOutputWithSudoContext(ctx context.Context, name string, args ...string) ([]byte, error)
|
|
}
|
|
|
|
// OSRunner runs commands locally.
|
|
type OSRunner struct{}
|
|
|
|
// CombinedOutput executes a command without sudo.
|
|
func (r *OSRunner) CombinedOutput(name string, args ...string) ([]byte, error) {
|
|
// Validate command for security
|
|
if err := CachedValidateCommand(name); err != nil {
|
|
return nil, fmt.Errorf("command validation failed: %w", err)
|
|
}
|
|
// Validate arguments for security
|
|
if err := ValidateArguments(args); err != nil {
|
|
return nil, fmt.Errorf("argument validation failed: %w", err)
|
|
}
|
|
return exec.Command(name, args...).CombinedOutput()
|
|
}
|
|
|
|
// CombinedOutputWithContext executes a command without sudo with context support.
|
|
func (r *OSRunner) CombinedOutputWithContext(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
// Validate command for security
|
|
if err := CachedValidateCommand(name); err != nil {
|
|
return nil, fmt.Errorf("command validation failed: %w", err)
|
|
}
|
|
// Validate arguments for security
|
|
if err := ValidateArguments(args); err != nil {
|
|
return nil, fmt.Errorf("argument validation failed: %w", 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) {
|
|
// Validate command for security
|
|
if err := CachedValidateCommand(name); err != nil {
|
|
return nil, fmt.Errorf("command validation failed: %w", err)
|
|
}
|
|
// Validate arguments for security
|
|
if err := ValidateArguments(args); err != nil {
|
|
return nil, fmt.Errorf("argument validation failed: %w", err)
|
|
}
|
|
|
|
checker := GetSudoChecker()
|
|
|
|
// If already root, no need for sudo
|
|
if checker.IsRoot() {
|
|
return exec.Command(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.Command("sudo", sudoArgs...).CombinedOutput()
|
|
}
|
|
|
|
// Otherwise run without sudo
|
|
return exec.Command(name, args...).CombinedOutput()
|
|
}
|
|
|
|
// CombinedOutputWithSudoContext executes a command with sudo if needed, with context support.
|
|
func (r *OSRunner) CombinedOutputWithSudoContext(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
// Validate command for security
|
|
if err := CachedValidateCommand(name); err != nil {
|
|
return nil, fmt.Errorf("command validation failed: %w", err)
|
|
}
|
|
// Validate arguments for security
|
|
if err := ValidateArguments(args); err != nil {
|
|
return nil, fmt.Errorf("argument validation failed: %w", 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, "sudo", 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...)
|
|
|
|
globalRunnerManager.mu.RLock()
|
|
runner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
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...)
|
|
|
|
globalRunnerManager.mu.RLock()
|
|
runner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
output, err := runner.CombinedOutputWithSudo(name, args...)
|
|
timer.Finish(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) {
|
|
timer := NewTimedOperation("RunnerCombinedOutputWithContext", name, args...)
|
|
|
|
globalRunnerManager.mu.RLock()
|
|
runner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
output, err := runner.CombinedOutputWithContext(ctx, name, args...)
|
|
timer.FinishWithContext(ctx, err)
|
|
|
|
return output, err
|
|
}
|
|
|
|
// 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) {
|
|
timer := NewTimedOperation("RunnerCombinedOutputWithSudoContext", name, args...)
|
|
|
|
globalRunnerManager.mu.RLock()
|
|
runner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
output, err := runner.CombinedOutputWithSudoContext(ctx, name, args...)
|
|
timer.FinishWithContext(ctx, err)
|
|
|
|
return output, err
|
|
}
|
|
|
|
// 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) {
|
|
// Prevent actual sudo execution in tests
|
|
if name == "sudo" {
|
|
return nil, fmt.Errorf("sudo should not be called directly in tests")
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
key := name + " " + strings.Join(args, " ")
|
|
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...)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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) {
|
|
// Check if context is canceled
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Delegate to the non-context version for simplicity in tests
|
|
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) {
|
|
// Check if context is canceled
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Delegate to the non-context version for simplicity in tests
|
|
return m.CombinedOutputWithSudo(name, args...)
|
|
}
|
|
|
|
func (c *RealClient) fetchJailsWithContext(ctx context.Context) ([]string, error) {
|
|
currentRunner := GetRunner()
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(ctx, c.Path, "status")
|
|
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, "status")
|
|
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, "status", 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) {
|
|
if err := CachedValidateIP(ip); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := CachedValidateJail(jail); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Check if jail exists
|
|
if err := ValidateJailExists(jail, c.Jails); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
currentRunner := GetRunner()
|
|
out, err := currentRunner.CombinedOutputWithSudo(c.Path, "set", jail, "banip", ip)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to ban IP %s in jail %s: %w", ip, jail, err)
|
|
}
|
|
code := strings.TrimSpace(string(out))
|
|
if code == Fail2BanStatusSuccess {
|
|
return 0, nil
|
|
}
|
|
if code == Fail2BanStatusAlreadyProcessed {
|
|
return 1, nil
|
|
}
|
|
return 0, fmt.Errorf("unexpected output from fail2ban-client: %s", code)
|
|
}
|
|
|
|
// UnbanIP unbans an IP address from the specified jail and returns the unban status code.
|
|
func (c *RealClient) UnbanIP(ip, jail string) (int, error) {
|
|
if err := CachedValidateIP(ip); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := CachedValidateJail(jail); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Check if jail exists
|
|
if err := ValidateJailExists(jail, c.Jails); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
currentRunner := GetRunner()
|
|
out, err := currentRunner.CombinedOutputWithSudo(c.Path, "set", jail, "unbanip", ip)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to unban IP %s in jail %s: %w", ip, jail, err)
|
|
}
|
|
code := strings.TrimSpace(string(out))
|
|
if code == Fail2BanStatusSuccess {
|
|
return 0, nil
|
|
}
|
|
if code == Fail2BanStatusAlreadyProcessed {
|
|
return 1, nil
|
|
}
|
|
return 0, fmt.Errorf("unexpected output from fail2ban-client: %s", code)
|
|
}
|
|
|
|
// BannedIn returns a list of jails where the specified IP address is currently banned.
|
|
func (c *RealClient) BannedIn(ip string) ([]string, error) {
|
|
if err := CachedValidateIP(ip); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
currentRunner := GetRunner()
|
|
out, err := currentRunner.CombinedOutputWithSudo(c.Path, "banned", ip)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if IP %s is banned: %w", ip, err)
|
|
}
|
|
return ParseBracketedList(string(out)), nil
|
|
}
|
|
|
|
// 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] == AllFilter || jails[0] == "") {
|
|
toQuery = c.Jails
|
|
} else {
|
|
toQuery = jails
|
|
}
|
|
|
|
globalRunnerManager.mu.RLock()
|
|
currentRunner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
// 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,
|
|
"get",
|
|
jail,
|
|
"banip",
|
|
"--with-time",
|
|
)
|
|
if err != nil {
|
|
// Log error but continue processing (backward compatibility)
|
|
getLogger().WithError(err).WithField("jail", 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, DefaultLogLinesLimit)
|
|
}
|
|
|
|
// GetLogLinesWithLimit returns log lines with configurable limits for memory management.
|
|
func (c *RealClient) GetLogLinesWithLimit(jail, ip string, maxLines int) ([]string, error) {
|
|
pattern := filepath.Join(c.LogDir, "fail2ban.log*")
|
|
files, err := filepath.Glob(pattern)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(files) == 0 {
|
|
return []string{}, nil
|
|
}
|
|
|
|
// Sort files to read in order (current log first, then rotated logs newest to oldest)
|
|
sort.Strings(files)
|
|
|
|
// Use streaming approach with memory limits
|
|
config := LogReadConfig{
|
|
MaxLines: maxLines,
|
|
MaxFileSize: DefaultMaxFileSize,
|
|
JailFilter: jail,
|
|
IPFilter: ip,
|
|
}
|
|
|
|
var allLines []string
|
|
totalLines := 0
|
|
|
|
for _, fpath := range files {
|
|
if config.MaxLines > 0 && totalLines >= config.MaxLines {
|
|
break
|
|
}
|
|
|
|
// Adjust remaining lines limit
|
|
remainingLines := config.MaxLines - totalLines
|
|
if remainingLines <= 0 {
|
|
break
|
|
}
|
|
|
|
fileConfig := config
|
|
fileConfig.MaxLines = remainingLines
|
|
|
|
lines, err := streamLogFile(fpath, fileConfig)
|
|
if err != nil {
|
|
getLogger().WithError(err).WithField("file", fpath).Error("Failed to read log file")
|
|
continue
|
|
}
|
|
|
|
allLines = append(allLines, lines...)
|
|
totalLines += len(lines)
|
|
}
|
|
|
|
return allLines, nil
|
|
}
|
|
|
|
// 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, ".conf") {
|
|
filters = append(filters, strings.TrimSuffix(name, ".conf"))
|
|
}
|
|
}
|
|
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) {
|
|
globalRunnerManager.mu.RLock()
|
|
currentRunner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(ctx, c.Path, "status")
|
|
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) {
|
|
globalRunnerManager.mu.RLock()
|
|
currentRunner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(ctx, c.Path, "status", jail)
|
|
return string(out), err
|
|
}
|
|
|
|
// BanIPWithContext bans an IP address in the specified jail with context support.
|
|
func (c *RealClient) BanIPWithContext(ctx context.Context, ip, jail string) (int, error) {
|
|
if err := CachedValidateIP(ip); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := CachedValidateJail(jail); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
globalRunnerManager.mu.RLock()
|
|
currentRunner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(ctx, c.Path, "set", jail, "banip", ip)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to ban IP %s in jail %s: %w", ip, jail, err)
|
|
}
|
|
code := strings.TrimSpace(string(out))
|
|
if code == Fail2BanStatusSuccess {
|
|
return 0, nil
|
|
}
|
|
if code == Fail2BanStatusAlreadyProcessed {
|
|
return 1, nil
|
|
}
|
|
return 0, fmt.Errorf("unexpected output from fail2ban-client: %s", code)
|
|
}
|
|
|
|
// UnbanIPWithContext unbans an IP address from the specified jail with context support.
|
|
func (c *RealClient) UnbanIPWithContext(ctx context.Context, ip, jail string) (int, error) {
|
|
if err := CachedValidateIP(ip); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := CachedValidateJail(jail); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
globalRunnerManager.mu.RLock()
|
|
currentRunner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(ctx, c.Path, "set", jail, "unbanip", ip)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to unban IP %s in jail %s: %w", ip, jail, err)
|
|
}
|
|
code := strings.TrimSpace(string(out))
|
|
if code == Fail2BanStatusSuccess {
|
|
return 0, nil
|
|
}
|
|
if code == Fail2BanStatusAlreadyProcessed {
|
|
return 1, nil
|
|
}
|
|
return 0, fmt.Errorf("unexpected output from fail2ban-client: %s", code)
|
|
}
|
|
|
|
// 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(ip); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
globalRunnerManager.mu.RLock()
|
|
currentRunner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
out, err := currentRunner.CombinedOutputWithSudoContext(ctx, c.Path, "banned", 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, 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) {
|
|
// Check context before starting
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
pattern := filepath.Join(c.LogDir, "fail2ban.log*")
|
|
files, err := filepath.Glob(pattern)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(files) == 0 {
|
|
return []string{}, nil
|
|
}
|
|
|
|
// Sort files to read in order (current log first, then rotated logs newest to oldest)
|
|
sort.Strings(files)
|
|
|
|
// Use streaming approach with memory limits and context support
|
|
config := LogReadConfig{
|
|
MaxLines: maxLines,
|
|
MaxFileSize: DefaultMaxFileSize,
|
|
JailFilter: jail,
|
|
IPFilter: ip,
|
|
}
|
|
|
|
var allLines []string
|
|
totalLines := 0
|
|
|
|
for _, fpath := range files {
|
|
// Check context before processing each file
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if config.MaxLines > 0 && totalLines >= config.MaxLines {
|
|
break
|
|
}
|
|
|
|
// Adjust remaining lines limit
|
|
remainingLines := config.MaxLines - totalLines
|
|
if remainingLines <= 0 {
|
|
break
|
|
}
|
|
|
|
fileConfig := config
|
|
fileConfig.MaxLines = remainingLines
|
|
|
|
lines, err := streamLogFileWithContext(ctx, fpath, fileConfig)
|
|
if err != nil {
|
|
if errors.Is(err, ctx.Err()) {
|
|
return nil, err // Return context error immediately
|
|
}
|
|
getLogger().WithError(err).WithField("file", fpath).Error("Failed to read log file")
|
|
continue
|
|
}
|
|
|
|
allLines = append(allLines, lines...)
|
|
totalLines += len(lines)
|
|
}
|
|
|
|
return allLines, nil
|
|
}
|
|
|
|
// 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(filter string) (string, string, error) {
|
|
if err := CachedValidateFilter(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("invalid filter directory: %w", 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(filter)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
globalRunnerManager.mu.RLock()
|
|
currentRunner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
output, err := currentRunner.CombinedOutputWithSudoContext(ctx, 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) {
|
|
cleanPath, logPath, err := c.validateFilterPath(filter)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
globalRunnerManager.mu.RLock()
|
|
currentRunner := globalRunnerManager.runner
|
|
globalRunnerManager.mu.RUnlock()
|
|
|
|
output, err := currentRunner.CombinedOutputWithSudo(Fail2BanRegexCommand, logPath, cleanPath)
|
|
return string(output), err
|
|
}
|