In test filters also priorize .local filters over .conf filters and return always also the explicit used filter path in results

This commit is contained in:
2025-12-06 13:11:15 +01:00
parent 5a3c59ae0d
commit 8268e37651
7 changed files with 101 additions and 31 deletions

View File

@@ -278,18 +278,25 @@ func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
} }
// TestFilter implements Connector. // 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{ payload := map[string]any{
"filterName": filterName, "filterName": filterName,
"logLines": logLines, "logLines": logLines,
} }
var resp struct { 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 { 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. // GetJailConfig implements Connector.

View File

@@ -254,7 +254,7 @@ func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) {
} }
// TestFilter implements Connector. // 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) return TestFilterLocal(filterName, logLines)
} }

View File

@@ -579,46 +579,85 @@ func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) {
} }
// TestFilter implements Connector. // 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) cleaned := normalizeLogLines(logLines)
if len(cleaned) == 0 { 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 // Sanitize filter name to prevent path traversal
filterName = strings.TrimSpace(filterName) filterName = strings.TrimSpace(filterName)
if filterName == "" { if filterName == "" {
return "", fmt.Errorf("filter name cannot be empty") return "", "", fmt.Errorf("filter name cannot be empty")
} }
// Remove any path components // Remove any path components
filterName = strings.ReplaceAll(filterName, "/", "") filterName = strings.ReplaceAll(filterName, "/", "")
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" const heredocMarker = "F2B_FILTER_TEST_LOG"
logContent := strings.Join(cleaned, "\n") logContent := strings.Join(cleaned, "\n")
script := fmt.Sprintf(` script := fmt.Sprintf(`
set -e set -e
FILTER_PATH=%[1]q LOCAL_PATH=%[1]q
if [ ! -f "$FILTER_PATH" ]; then CONF_PATH=%[2]q
echo "Filter not found: $FILTER_PATH" >&2 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 exit 1
fi fi
echo "FILTER_PATH:$FILTER_PATH"
TMPFILE=$(mktemp /tmp/fail2ban-test-XXXXXX.log) TMPFILE=$(mktemp /tmp/fail2ban-test-XXXXXX.log)
trap 'rm -f "$TMPFILE"' EXIT trap 'rm -f "$TMPFILE"' EXIT
cat <<'%[2]s' > "$TMPFILE" cat <<'%[3]s' > "$TMPFILE"
%[4]s
%[3]s %[3]s
%[2]s
fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true
`, filterPath, heredocMarker, logContent) `, localPath, confPath, heredocMarker, logContent)
out, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script}) out, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script})
if err != nil { 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. // GetJailConfig implements Connector.

View File

@@ -183,21 +183,33 @@ func normalizeLogLines(logLines []string) []string {
} }
// TestFilterLocal tests a filter against log lines using fail2ban-regex // TestFilterLocal tests a filter against log lines using fail2ban-regex
// Returns the full output of fail2ban-regex command // Returns the full output of fail2ban-regex command and the filter path used
func TestFilterLocal(filterName string, logLines []string) (string, error) { // Uses .local file if it exists, otherwise falls back to .conf file
func TestFilterLocal(filterName string, logLines []string) (string, string, error) {
cleaned := normalizeLogLines(logLines) cleaned := normalizeLogLines(logLines)
if len(cleaned) == 0 { 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 { // Try .local first, then fallback to .conf
return "", fmt.Errorf("filter %s not found: %w", filterName, err) 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 // Create a temporary log file with all log lines
tmpFile, err := os.CreateTemp("", "fail2ban-test-*.log") tmpFile, err := os.CreateTemp("", "fail2ban-test-*.log")
if err != nil { 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 os.Remove(tmpFile.Name())
defer tmpFile.Close() defer tmpFile.Close()
@@ -205,7 +217,7 @@ func TestFilterLocal(filterName string, logLines []string) (string, error) {
// Write all log lines to the temp file // Write all log lines to the temp file
for _, logLine := range cleaned { for _, logLine := range cleaned {
if _, err := tmpFile.WriteString(logLine + "\n"); err != nil { 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() 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) // 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 // The output contains useful information even when there are no matches
return output, nil return output, filterPath, nil
} }

View File

@@ -28,7 +28,7 @@ type Connector interface {
// Filter operations // Filter operations
GetFilters(ctx context.Context) ([]string, error) 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 // Jail configuration operations
GetJailConfig(ctx context.Context, jail string) (string, error) GetJailConfig(ctx context.Context, jail string) (string, error)

View File

@@ -1325,12 +1325,15 @@ func TestFilterHandler(c *gin.Context) {
return 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test filter: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test filter: " + err.Error()})
return 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 // ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON

View File

@@ -78,7 +78,7 @@ function testSelectedFilter() {
showToast('Error testing filter: ' + data.error, 'error'); showToast('Error testing filter: ' + data.error, 'error');
return; return;
} }
renderTestResults(data.output || ''); renderTestResults(data.output || '', data.filterPath || '');
}) })
.catch(err => { .catch(err => {
showToast('Error testing filter: ' + err, 'error'); showToast('Error testing filter: ' + err, 'error');
@@ -86,9 +86,18 @@ function testSelectedFilter() {
.finally(() => showLoading(false)); .finally(() => showLoading(false));
} }
function renderTestResults(output) { function renderTestResults(output, filterPath) {
const testResultsEl = document.getElementById('testResults'); const testResultsEl = document.getElementById('testResults');
let html = '<h5 class="text-lg font-medium text-white mb-4" data-i18n="filter_debug.test_results_title">Test Results</h5>'; let html = '<h5 class="text-lg font-medium text-white mb-4" data-i18n="filter_debug.test_results_title">Test Results</h5>';
// Show which filter file was used
if (filterPath) {
html += '<div class="mb-3 p-2 bg-gray-800 rounded text-sm">';
html += '<span class="text-gray-400">Used Filter (exact file):</span> ';
html += '<span class="text-yellow-300 font-mono">' + escapeHtml(filterPath) + '</span>';
html += '</div>';
}
if (!output || output.trim() === '') { if (!output || output.trim() === '') {
html += '<p class="text-gray-400" data-i18n="filter_debug.no_matches">No output received.</p>'; html += '<p class="text-gray-400" data-i18n="filter_debug.no_matches">No output received.</p>';
} else { } else {