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.
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"`
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.

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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
}

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) (output string, filterPath string, err error)
// Jail configuration operations
GetJailConfig(ctx context.Context, jail string) (string, error)

View File

@@ -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

View File

@@ -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 = '<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() === '') {
html += '<p class="text-gray-400" data-i18n="filter_debug.no_matches">No output received.</p>';
} else {