diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index e7301a5..be7e8f1 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -278,18 +278,25 @@ 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, string, error) { payload := map[string]any{ "filterName": filterName, "logLines": logLines, } var resp struct { - Output string `json:"output"` + Output string `json:"output"` + FilterPath string `json:"filterPath"` } if err := ac.post(ctx, "/v1/filters/test", payload, &resp); err != nil { - return "", err + return "", "", err } - return resp.Output, nil + // If agent doesn't return filterPath, construct it (agent should handle .local priority) + filterPath := resp.FilterPath + if filterPath == "" { + // Default to .conf path (agent should handle .local priority on its side) + filterPath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName) + } + return resp.Output, filterPath, nil } // GetJailConfig implements Connector. diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go index f51b3b7..97cd98e 100644 --- a/internal/fail2ban/connector_local.go +++ b/internal/fail2ban/connector_local.go @@ -254,7 +254,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, string, error) { return TestFilterLocal(filterName, logLines) } diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index c11bd88..158bc81 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -579,46 +579,85 @@ func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { } // TestFilter implements Connector. -func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string) (string, error) { +func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string) (string, string, error) { cleaned := normalizeLogLines(logLines) if len(cleaned) == 0 { - return "No log lines provided.\n", nil + return "No log lines provided.\n", "", nil } // Sanitize filter name to prevent path traversal filterName = strings.TrimSpace(filterName) if filterName == "" { - return "", 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, "..", "") - filterPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName) + // Try .local first, then fallback to .conf + localPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", filterName) + confPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName) 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 +LOCAL_PATH=%[1]q +CONF_PATH=%[2]q +FILTER_PATH="" +if [ -f "$LOCAL_PATH" ]; then + FILTER_PATH="$LOCAL_PATH" +elif [ -f "$CONF_PATH" ]; then + FILTER_PATH="$CONF_PATH" +else + echo "Filter not found: checked both $LOCAL_PATH and $CONF_PATH" >&2 exit 1 fi +echo "FILTER_PATH:$FILTER_PATH" TMPFILE=$(mktemp /tmp/fail2ban-test-XXXXXX.log) trap 'rm -f "$TMPFILE"' EXIT -cat <<'%[2]s' > "$TMPFILE" +cat <<'%[3]s' > "$TMPFILE" +%[4]s %[3]s -%[2]s fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true -`, filterPath, heredocMarker, logContent) +`, localPath, confPath, heredocMarker, logContent) out, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script}) if err != nil { - return "", err + return "", "", err } - return out, nil + + // Extract filter path from output (it's on the first line with FILTER_PATH: prefix) + lines := strings.Split(out, "\n") + var filterPath string + var outputLines []string + foundPathMarker := false + + for _, line := range lines { + if strings.HasPrefix(line, "FILTER_PATH:") { + filterPath = strings.TrimPrefix(line, "FILTER_PATH:") + filterPath = strings.TrimSpace(filterPath) + foundPathMarker = true + // Skip this line from the output + continue + } + outputLines = append(outputLines, line) + } + + // If we didn't find FILTER_PATH marker, try to determine it + if !foundPathMarker || filterPath == "" { + // Check which file exists remotely + localOut, localErr := sc.runRemoteCommand(ctx, []string{"test", "-f", localPath, "&&", "echo", localPath, "||", "echo", ""}) + if localErr == nil && strings.TrimSpace(localOut) != "" { + filterPath = strings.TrimSpace(localOut) + } else { + filterPath = confPath + } + } + + output := strings.Join(outputLines, "\n") + return output, filterPath, nil } // GetJailConfig implements Connector. diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go index 4425f76..070f16c 100644 --- a/internal/fail2ban/filter_management.go +++ b/internal/fail2ban/filter_management.go @@ -183,21 +183,33 @@ func normalizeLogLines(logLines []string) []string { } // TestFilterLocal tests a filter against log lines using fail2ban-regex -// Returns the full output of fail2ban-regex command -func TestFilterLocal(filterName string, logLines []string) (string, error) { +// Returns the full output of fail2ban-regex command and the filter path used +// Uses .local file if it exists, otherwise falls back to .conf file +func TestFilterLocal(filterName string, logLines []string) (string, string, error) { cleaned := normalizeLogLines(logLines) if len(cleaned) == 0 { - return "No log lines provided.\n", nil + return "No log lines provided.\n", "", nil } - filterPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf") - if _, err := os.Stat(filterPath); err != nil { - return "", fmt.Errorf("filter %s not found: %w", filterName, err) + + // Try .local first, then fallback to .conf + localPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".local") + confPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf") + + var filterPath string + if _, err := os.Stat(localPath); err == nil { + filterPath = localPath + config.DebugLog("TestFilterLocal: using .local file: %s", filterPath) + } else if _, err := os.Stat(confPath); err == nil { + filterPath = confPath + config.DebugLog("TestFilterLocal: using .conf file: %s", filterPath) + } else { + return "", "", fmt.Errorf("filter %s not found (checked both .local and .conf): %w", filterName, err) } // 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) + return "", filterPath, fmt.Errorf("failed to create temporary log file: %w", err) } defer os.Remove(tmpFile.Name()) defer tmpFile.Close() @@ -205,7 +217,7 @@ func TestFilterLocal(filterName string, logLines []string) (string, error) { // 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 "", filterPath, fmt.Errorf("failed to write to temporary log file: %w", err) } } tmpFile.Close() @@ -218,5 +230,5 @@ func TestFilterLocal(filterName string, logLines []string) (string, error) { // 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 + return output, filterPath, nil } diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go index 186889e..1e15f67 100644 --- a/internal/fail2ban/manager.go +++ b/internal/fail2ban/manager.go @@ -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) (output string, filterPath string, err error) // Jail configuration operations GetJailConfig(ctx context.Context, jail string) (string, error) diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 8cd965d..72fe67f 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1325,12 +1325,15 @@ func TestFilterHandler(c *gin.Context) { return } - output, err := conn.TestFilter(c.Request.Context(), req.FilterName, req.LogLines) + output, filterPath, err := conn.TestFilter(c.Request.Context(), req.FilterName, req.LogLines) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test filter: " + err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"output": output}) + c.JSON(http.StatusOK, gin.H{ + "output": output, + "filterPath": filterPath, + }) } // ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON diff --git a/pkg/web/static/js/filters.js b/pkg/web/static/js/filters.js index 9aad257..04cf256 100644 --- a/pkg/web/static/js/filters.js +++ b/pkg/web/static/js/filters.js @@ -78,7 +78,7 @@ function testSelectedFilter() { showToast('Error testing filter: ' + data.error, 'error'); return; } - renderTestResults(data.output || ''); + renderTestResults(data.output || '', data.filterPath || ''); }) .catch(err => { showToast('Error testing filter: ' + err, 'error'); @@ -86,9 +86,18 @@ function testSelectedFilter() { .finally(() => showLoading(false)); } -function renderTestResults(output) { +function renderTestResults(output, filterPath) { const testResultsEl = document.getElementById('testResults'); let html = '
No output received.
'; } else {