Fix filter debug pharsing, add normalizion and write temp-files to check with fail2ban-regex

This commit is contained in:
2025-11-18 09:25:15 +01:00
parent ced2d0f3e0
commit 5044a589ca
7 changed files with 112 additions and 82 deletions

View File

@@ -278,16 +278,16 @@ func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
}
// TestFilter implements Connector.
func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error) {
func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string) (string, error) {
payload := map[string]any{
"filterName": filterName,
"logLines": logLines,
}
var resp struct {
Matches []string `json:"matches"`
Output string `json:"output"`
}
if err := ac.post(ctx, "/v1/filters/test", payload, &resp); err != nil {
return nil, err
return "", err
}
return resp.Matches, nil
return resp.Output, nil
}

View File

@@ -248,7 +248,7 @@ func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) {
}
// TestFilter implements Connector.
func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error) {
func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string) (string, error) {
return TestFilterLocal(filterName, logLines)
}

View File

@@ -426,52 +426,51 @@ func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) {
}
}
}
sort.Strings(filters)
return filters, nil
}
// TestFilter implements Connector.
func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error) {
if len(logLines) == 0 {
return []string{}, nil
func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string) (string, error) {
cleaned := normalizeLogLines(logLines)
if len(cleaned) == 0 {
return "No log lines provided.\n", nil
}
// Sanitize filter name to prevent path traversal
filterName = strings.TrimSpace(filterName)
if filterName == "" {
return nil, fmt.Errorf("filter name cannot be empty")
return "", fmt.Errorf("filter name cannot be empty")
}
// Remove any path components
filterName = strings.ReplaceAll(filterName, "/", "")
filterName = strings.ReplaceAll(filterName, "..", "")
// Use fail2ban-regex with filter name directly - it handles everything
// Format: fail2ban-regex "log line" /etc/fail2ban/filter.d/filter-name.conf
filterPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName)
var matches []string
for _, logLine := range logLines {
logLine = strings.TrimSpace(logLine)
if logLine == "" {
continue
}
// Use fail2ban-regex: log line as string, filter file path
escapedLine := strconv.Quote(logLine)
escapedPath := strconv.Quote(filterPath)
cmd := fmt.Sprintf("echo %s | fail2ban-regex - %s", escapedLine, escapedPath)
out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", cmd})
// fail2ban-regex returns success (exit 0) if the line matches
// Look for "Lines: 1 lines, 0 ignored, 1 matched" or similar success indicators
if err == nil {
// Check if output indicates a match
output := strings.ToLower(out)
if strings.Contains(output, "matched") ||
strings.Contains(output, "success") ||
strings.Contains(output, "1 matched") {
matches = append(matches, logLine)
}
}
const heredocMarker = "F2B_FILTER_TEST_LOG"
logContent := strings.Join(cleaned, "\n")
script := fmt.Sprintf(`
set -e
FILTER_PATH=%[1]q
if [ ! -f "$FILTER_PATH" ]; then
echo "Filter not found: $FILTER_PATH" >&2
exit 1
fi
TMPFILE=$(mktemp /tmp/fail2ban-test-XXXXXX.log)
trap 'rm -f "$TMPFILE"' EXIT
cat <<'%[2]s' > "$TMPFILE"
%[3]s
%[2]s
fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true
`, filterPath, heredocMarker, logContent)
out, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script})
if err != nil {
return "", err
}
return matches, nil
return out, nil
}
// parseJailConfigContent parses jail configuration content and returns JailInfo slice.

View File

@@ -22,6 +22,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
@@ -76,38 +77,57 @@ func GetFiltersLocal() ([]string, error) {
filters = append(filters, name)
}
}
sort.Strings(filters)
return filters, nil
}
func normalizeLogLines(logLines []string) []string {
var cleaned []string
for _, line := range logLines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
cleaned = append(cleaned, line)
}
return cleaned
}
// TestFilterLocal tests a filter against log lines using fail2ban-regex
func TestFilterLocal(filterName string, logLines []string) ([]string, error) {
if len(logLines) == 0 {
return []string{}, nil
// Returns the full output of fail2ban-regex command
func TestFilterLocal(filterName string, logLines []string) (string, error) {
cleaned := normalizeLogLines(logLines)
if len(cleaned) == 0 {
return "No log lines provided.\n", nil
}
filterPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf")
if _, err := os.Stat(filterPath); err != nil {
return nil, fmt.Errorf("filter %s not found: %w", filterName, err)
return "", fmt.Errorf("filter %s not found: %w", filterName, err)
}
// Use fail2ban-regex with filter file directly - it handles everything
// Format: fail2ban-regex "log line" /etc/fail2ban/filter.d/filter-name.conf
var matches []string
for _, logLine := range logLines {
logLine = strings.TrimSpace(logLine)
if logLine == "" {
continue
}
cmd := exec.Command("fail2ban-regex", logLine, filterPath)
out, err := cmd.CombinedOutput()
output := strings.ToLower(string(out))
// fail2ban-regex returns success (exit 0) if the line matches
// Look for "matched" or "success" in output
if err == nil {
if strings.Contains(output, "matched") ||
strings.Contains(output, "success") ||
strings.Contains(output, "1 matched") {
matches = append(matches, logLine)
}
// Create a temporary log file with all log lines
tmpFile, err := os.CreateTemp("", "fail2ban-test-*.log")
if err != nil {
return "", fmt.Errorf("failed to create temporary log file: %w", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
// Write all log lines to the temp file
for _, logLine := range cleaned {
if _, err := tmpFile.WriteString(logLine + "\n"); err != nil {
return "", fmt.Errorf("failed to write to temporary log file: %w", err)
}
}
return matches, nil
tmpFile.Close()
// Run fail2ban-regex with the log file and filter config
// Format: fail2ban-regex /path/to/logfile /etc/fail2ban/filter.d/filter-name.conf
cmd := exec.Command("fail2ban-regex", tmpFile.Name(), filterPath)
out, _ := cmd.CombinedOutput()
output := string(out)
// Return the full output regardless of exit code (fail2ban-regex may exit non-zero for no matches)
// The output contains useful information even when there are no matches
return output, nil
}

View File

@@ -28,7 +28,7 @@ type Connector interface {
// Filter operations
GetFilters(ctx context.Context) ([]string, error)
TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error)
TestFilter(ctx context.Context, filterName string, logLines []string) (string, error)
}
// Manager orchestrates all connectors for configured Fail2ban servers.