mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 05:53:15 +02:00
Fix filter debug pharsing, add normalizion and write temp-files to check with fail2ban-regex
This commit is contained in:
@@ -278,16 +278,16 @@ 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, error) {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"filterName": filterName,
|
"filterName": filterName,
|
||||||
"logLines": logLines,
|
"logLines": logLines,
|
||||||
}
|
}
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Matches []string `json:"matches"`
|
Output string `json:"output"`
|
||||||
}
|
}
|
||||||
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 nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
return resp.Matches, nil
|
return resp.Output, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,7 +248,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, error) {
|
||||||
return TestFilterLocal(filterName, logLines)
|
return TestFilterLocal(filterName, logLines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -426,52 +426,51 @@ func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sort.Strings(filters)
|
||||||
return filters, nil
|
return filters, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, error) {
|
||||||
if len(logLines) == 0 {
|
cleaned := normalizeLogLines(logLines)
|
||||||
return []string{}, nil
|
if len(cleaned) == 0 {
|
||||||
|
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 nil, 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, "..", "")
|
||||||
|
|
||||||
// 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)
|
filterPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName)
|
||||||
|
|
||||||
var matches []string
|
const heredocMarker = "F2B_FILTER_TEST_LOG"
|
||||||
for _, logLine := range logLines {
|
logContent := strings.Join(cleaned, "\n")
|
||||||
logLine = strings.TrimSpace(logLine)
|
|
||||||
if logLine == "" {
|
script := fmt.Sprintf(`
|
||||||
continue
|
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
|
||||||
}
|
}
|
||||||
// Use fail2ban-regex: log line as string, filter file path
|
return out, nil
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseJailConfigContent parses jail configuration content and returns JailInfo slice.
|
// parseJailConfigContent parses jail configuration content and returns JailInfo slice.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,38 +77,57 @@ func GetFiltersLocal() ([]string, error) {
|
|||||||
filters = append(filters, name)
|
filters = append(filters, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sort.Strings(filters)
|
||||||
return filters, nil
|
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
|
// TestFilterLocal tests a filter against log lines using fail2ban-regex
|
||||||
func TestFilterLocal(filterName string, logLines []string) ([]string, error) {
|
// Returns the full output of fail2ban-regex command
|
||||||
if len(logLines) == 0 {
|
func TestFilterLocal(filterName string, logLines []string) (string, error) {
|
||||||
return []string{}, nil
|
cleaned := normalizeLogLines(logLines)
|
||||||
|
if len(cleaned) == 0 {
|
||||||
|
return "No log lines provided.\n", nil
|
||||||
}
|
}
|
||||||
filterPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf")
|
filterPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf")
|
||||||
if _, err := os.Stat(filterPath); err != nil {
|
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
|
// Create a temporary log file with all log lines
|
||||||
var matches []string
|
tmpFile, err := os.CreateTemp("", "fail2ban-test-*.log")
|
||||||
for _, logLine := range logLines {
|
if err != nil {
|
||||||
logLine = strings.TrimSpace(logLine)
|
return "", fmt.Errorf("failed to create temporary log file: %w", err)
|
||||||
if logLine == "" {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
cmd := exec.Command("fail2ban-regex", logLine, filterPath)
|
defer os.Remove(tmpFile.Name())
|
||||||
out, err := cmd.CombinedOutput()
|
defer tmpFile.Close()
|
||||||
output := strings.ToLower(string(out))
|
|
||||||
// fail2ban-regex returns success (exit 0) if the line matches
|
// Write all log lines to the temp file
|
||||||
// Look for "matched" or "success" in output
|
for _, logLine := range cleaned {
|
||||||
if err == nil {
|
if _, err := tmpFile.WriteString(logLine + "\n"); err != nil {
|
||||||
if strings.Contains(output, "matched") ||
|
return "", fmt.Errorf("failed to write to temporary log file: %w", err)
|
||||||
strings.Contains(output, "success") ||
|
|
||||||
strings.Contains(output, "1 matched") {
|
|
||||||
matches = append(matches, logLine)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
tmpFile.Close()
|
||||||
return matches, nil
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager orchestrates all connectors for configured Fail2ban servers.
|
// Manager orchestrates all connectors for configured Fail2ban servers.
|
||||||
|
|||||||
@@ -823,12 +823,12 @@ func TestFilterHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
matches, err := conn.TestFilter(c.Request.Context(), req.FilterName, req.LogLines)
|
output, 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{"matches": matches})
|
c.JSON(http.StatusOK, gin.H{"output": output})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON
|
// ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON
|
||||||
|
|||||||
@@ -305,7 +305,7 @@
|
|||||||
<button class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700 transition-colors" onclick="testSelectedFilter()" data-i18n="filter_debug.test_filter">Test Filter</button>
|
<button class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700 transition-colors" onclick="testSelectedFilter()" data-i18n="filter_debug.test_filter">Test Filter</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="testResults" class="bg-white rounded-lg shadow p-6"></div>
|
<div id="testResults" class="hidden bg-gray-900 rounded-lg shadow p-6 text-white font-mono text-sm"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- ********************* Filter-Debug Page END *********************** -->
|
<!-- ********************* Filter-Debug Page END *********************** -->
|
||||||
|
|
||||||
@@ -677,7 +677,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<textarea id="jailConfigTextarea"
|
<textarea id="jailConfigTextarea"
|
||||||
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 h-96 font-mono text-sm bg-gray-900 text-green-400 resize-none overflow-auto"
|
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-96 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
@@ -693,7 +693,7 @@
|
|||||||
aria-label="Filter configuration editor"
|
aria-label="Filter configuration editor"
|
||||||
name="filter-config-editor"
|
name="filter-config-editor"
|
||||||
inputmode="text"
|
inputmode="text"
|
||||||
style="caret-color: #4ade80; line-height: 1.5; tab-size: 2; width: 100%; min-width: 100%; max-width: 100%; box-sizing: border-box; -webkit-appearance: none; appearance: none;"
|
style="caret-color: #ffffff; line-height: 1.5; tab-size: 2; width: 100%; min-width: 100%; max-width: 100%; box-sizing: border-box; -webkit-appearance: none; appearance: none;"
|
||||||
wrap="off"
|
wrap="off"
|
||||||
onfocus="preventExtensionInterference(this);"></textarea>
|
onfocus="preventExtensionInterference(this);"></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -866,7 +866,7 @@
|
|||||||
<span data-i18n="logs.modal.whois_title">Whois Information</span> - <span id="whoisModalIP"></span>
|
<span data-i18n="logs.modal.whois_title">Whois Information</span> - <span id="whoisModalIP"></span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<pre id="whoisModalContent" class="w-full border border-gray-300 rounded-md px-3 py-2 bg-gray-900 text-green-400 font-mono text-xs overflow-x-auto" style="max-height: 70vh; white-space: pre-wrap; word-wrap: break-word;"></pre>
|
<pre id="whoisModalContent" class="w-full border border-gray-300 rounded-md px-3 py-2 bg-gray-900 text-white font-mono text-xs overflow-x-auto" style="max-height: 70vh; white-space: pre-wrap; word-wrap: break-word;"></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -898,7 +898,7 @@
|
|||||||
<span data-i18n="logs.modal.jail">Jail:</span> <span id="logsModalJail" class="font-semibold"></span>
|
<span data-i18n="logs.modal.jail">Jail:</span> <span id="logsModalJail" class="font-semibold"></span>
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<pre id="logsModalContent" class="w-full border border-gray-300 rounded-md px-3 py-2 bg-gray-900 text-green-400 font-mono text-xs overflow-x-auto" style="max-height: 70vh; white-space: pre-wrap; word-wrap: break-word;"></pre>
|
<pre id="logsModalContent" class="w-full border border-gray-300 rounded-md px-3 py-2 bg-gray-900 text-white font-mono text-xs overflow-x-auto" style="max-height: 70vh; white-space: pre-wrap; word-wrap: break-word;"></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3085,17 +3085,27 @@
|
|||||||
// Called when clicking "Test Filter" button
|
// Called when clicking "Test Filter" button
|
||||||
function testSelectedFilter() {
|
function testSelectedFilter() {
|
||||||
const filterName = document.getElementById('filterSelect').value;
|
const filterName = document.getElementById('filterSelect').value;
|
||||||
const lines = document.getElementById('logLinesTextarea').value.split('\n');
|
const lines = document.getElementById('logLinesTextarea').value.split('\n').filter(line => line.trim() !== '');
|
||||||
|
|
||||||
if (!filterName) {
|
if (!filterName) {
|
||||||
showToast('Please select a filter.', 'info');
|
showToast('Please select a filter.', 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
showToast('Please enter at least one log line to test.', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide results initially
|
||||||
|
const testResultsEl = document.getElementById('testResults');
|
||||||
|
testResultsEl.classList.add('hidden');
|
||||||
|
testResultsEl.innerHTML = '';
|
||||||
|
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
fetch('/api/filters/test', {
|
fetch(withServerParam('/api/filters/test'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: serverHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
filterName: filterName,
|
filterName: filterName,
|
||||||
logLines: lines
|
logLines: lines
|
||||||
@@ -3107,7 +3117,7 @@
|
|||||||
showToast('Error testing filter: ' + data.error, 'error');
|
showToast('Error testing filter: ' + data.error, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderTestResults(data.matches);
|
renderTestResults(data.output || '');
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
showToast('Error testing filter: ' + err, 'error');
|
showToast('Error testing filter: ' + err, 'error');
|
||||||
@@ -3115,22 +3125,21 @@
|
|||||||
.finally(() => showLoading(false));
|
.finally(() => showLoading(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTestResults(matches) {
|
function renderTestResults(output) {
|
||||||
let html = '<h5 class="text-lg font-medium text-gray-900 mb-4" data-i18n="filter_debug.test_results_title">Test Results</h5>';
|
const testResultsEl = document.getElementById('testResults');
|
||||||
if (!matches || matches.length === 0) {
|
let html = '<h5 class="text-lg font-medium text-white mb-4" data-i18n="filter_debug.test_results_title">Test Results</h5>';
|
||||||
html += '<p class="text-gray-500" data-i18n="filter_debug.no_matches">No matches found.</p>';
|
if (!output || output.trim() === '') {
|
||||||
|
html += '<p class="text-gray-400" data-i18n="filter_debug.no_matches">No output received.</p>';
|
||||||
} else {
|
} else {
|
||||||
html += '<ul>';
|
html += '<pre class="text-white whitespace-pre-wrap overflow-x-auto">' + escapeHtml(output) + '</pre>';
|
||||||
matches.forEach(m => {
|
|
||||||
html += '<li>' + m + '</li>';
|
|
||||||
});
|
|
||||||
html += '</ul>';
|
|
||||||
}
|
}
|
||||||
document.getElementById('testResults').innerHTML = html;
|
testResultsEl.innerHTML = html;
|
||||||
|
testResultsEl.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// When showing the filter section
|
// When showing the filter section
|
||||||
function showFilterSection() {
|
function showFilterSection() {
|
||||||
|
const testResultsEl = document.getElementById('testResults');
|
||||||
if (!currentServerId) {
|
if (!currentServerId) {
|
||||||
var notice = document.getElementById('filterNotice');
|
var notice = document.getElementById('filterNotice');
|
||||||
if (notice) {
|
if (notice) {
|
||||||
@@ -3139,11 +3148,13 @@
|
|||||||
}
|
}
|
||||||
document.getElementById('filterSelect').innerHTML = '';
|
document.getElementById('filterSelect').innerHTML = '';
|
||||||
document.getElementById('logLinesTextarea').value = '';
|
document.getElementById('logLinesTextarea').value = '';
|
||||||
document.getElementById('testResults').innerHTML = '';
|
testResultsEl.innerHTML = '';
|
||||||
|
testResultsEl.classList.add('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadFilters();
|
loadFilters();
|
||||||
document.getElementById('testResults').innerHTML = '';
|
testResultsEl.innerHTML = '';
|
||||||
|
testResultsEl.classList.add('hidden');
|
||||||
document.getElementById('logLinesTextarea').value = '';
|
document.getElementById('logLinesTextarea').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user