mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 14:03:15 +02:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user