diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index d8568b7..fce66eb 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -308,11 +308,14 @@ func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) { } // TestFilter implements Connector. -func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string) (string, string, error) { +func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) { payload := map[string]any{ "filterName": filterName, "logLines": logLines, } + if filterContent != "" { + payload["filterContent"] = filterContent + } var resp struct { Output string `json:"output"` FilterPath string `json:"filterPath"` diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go index 3790e3e..fb21fdf 100644 --- a/internal/fail2ban/connector_local.go +++ b/internal/fail2ban/connector_local.go @@ -312,8 +312,8 @@ func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) { } // TestFilter implements Connector. -func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string) (string, string, error) { - return TestFilterLocal(filterName, logLines) +func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) { + return TestFilterLocal(filterName, logLines, filterContent) } // GetJailConfig implements Connector. diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index 7808dfe..e2abde2 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -966,8 +966,163 @@ func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { return filters, nil } +// resolveFilterIncludesRemote resolves filter includes by reading included files from remote server +// This is similar to resolveFilterIncludes but reads files via SSH instead of local filesystem +func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterContent string, filterDPath string, currentFilterName string) (string, error) { + lines := strings.Split(filterContent, "\n") + var beforeFiles []string + var afterFiles []string + var inIncludesSection bool + var mainContent strings.Builder + + // Parse the filter content to find [INCLUDES] section + for i, line := range lines { + trimmed := strings.TrimSpace(line) + + // Check for [INCLUDES] section + if strings.HasPrefix(trimmed, "[INCLUDES]") { + inIncludesSection = true + continue + } + + // Check for end of [INCLUDES] section (next section starts) + if inIncludesSection && strings.HasPrefix(trimmed, "[") { + inIncludesSection = false + } + + // Parse before and after directives + if inIncludesSection { + if strings.HasPrefix(strings.ToLower(trimmed), "before") { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + file := strings.TrimSpace(parts[1]) + if file != "" { + beforeFiles = append(beforeFiles, file) + } + } + continue + } + if strings.HasPrefix(strings.ToLower(trimmed), "after") { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + file := strings.TrimSpace(parts[1]) + if file != "" { + afterFiles = append(afterFiles, file) + } + } + continue + } + } + + // Collect main content (everything except [INCLUDES] section) + if !inIncludesSection { + if i > 0 { + mainContent.WriteString("\n") + } + mainContent.WriteString(line) + } + } + + // Extract variables from main filter content first + mainContentStr := mainContent.String() + mainVariables := extractVariablesFromContent(mainContentStr) + + // Build combined content: before files + main filter + after files + var combined strings.Builder + + // Helper function to read remote file + readRemoteFilterFile := func(baseName string) (string, error) { + localPath := filepath.Join(filterDPath, baseName+".local") + confPath := filepath.Join(filterDPath, baseName+".conf") + + // Try .local first + content, err := sc.readRemoteFile(ctx, localPath) + if err == nil { + config.DebugLog("Loading included filter file from .local: %s", localPath) + return content, nil + } + + // Fallback to .conf + content, err = sc.readRemoteFile(ctx, confPath) + if err == nil { + config.DebugLog("Loading included filter file from .conf: %s", confPath) + return content, nil + } + + return "", fmt.Errorf("could not load included filter file '%s' or '%s'", localPath, confPath) + } + + // Load and append before files, removing duplicates that exist in main filter + for _, fileName := range beforeFiles { + // Remove any existing extension to get base name + baseName := fileName + if strings.HasSuffix(baseName, ".local") { + baseName = strings.TrimSuffix(baseName, ".local") + } else if strings.HasSuffix(baseName, ".conf") { + baseName = strings.TrimSuffix(baseName, ".conf") + } + + // Skip if this is the same filter (avoid self-inclusion) + if baseName == currentFilterName { + config.DebugLog("Skipping self-inclusion of filter '%s' in before files", baseName) + continue + } + + contentStr, err := readRemoteFilterFile(baseName) + if err != nil { + config.DebugLog("Warning: %v", err) + continue // Skip if file doesn't exist + } + + // Remove variables from included file that are defined in main filter (main filter takes precedence) + cleanedContent := removeDuplicateVariables(contentStr, mainVariables) + combined.WriteString(cleanedContent) + if !strings.HasSuffix(cleanedContent, "\n") { + combined.WriteString("\n") + } + combined.WriteString("\n") + } + + // Append main filter content (unchanged - this is what the user is editing) + combined.WriteString(mainContentStr) + if !strings.HasSuffix(mainContentStr, "\n") { + combined.WriteString("\n") + } + + // Load and append after files, also removing duplicates that exist in main filter + for _, fileName := range afterFiles { + // Remove any existing extension to get base name + baseName := fileName + if strings.HasSuffix(baseName, ".local") { + baseName = strings.TrimSuffix(baseName, ".local") + } else if strings.HasSuffix(baseName, ".conf") { + baseName = strings.TrimSuffix(baseName, ".conf") + } + + // Note: Self-inclusion in "after" directive is intentional in fail2ban + // (e.g., after = apache-common.local is standard pattern for .local files) + // So we always load it, even if it's the same filter name + + contentStr, err := readRemoteFilterFile(baseName) + if err != nil { + config.DebugLog("Warning: %v", err) + continue // Skip if file doesn't exist + } + + // Remove variables from included file that are defined in main filter (main filter takes precedence) + cleanedContent := removeDuplicateVariables(contentStr, mainVariables) + combined.WriteString("\n") + combined.WriteString(cleanedContent) + if !strings.HasSuffix(cleanedContent, "\n") { + combined.WriteString("\n") + } + } + + return combined.String(), nil +} + // TestFilter implements Connector. -func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string) (string, string, error) { +func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) { cleaned := normalizeLogLines(logLines) if len(cleaned) == 0 { return "No log lines provided.\n", "", nil @@ -989,9 +1144,52 @@ func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLi confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf") const heredocMarker = "F2B_FILTER_TEST_LOG" + const filterContentMarker = "F2B_FILTER_CONTENT" logContent := strings.Join(cleaned, "\n") - script := fmt.Sprintf(` + var script string + if filterContent != "" { + // Resolve filter includes locally (same approach as local connector) + // This avoids complex Python scripts and heredoc issues + filterDPath := filepath.Join(fail2banPath, "filter.d") + + // First, we need to create a remote-aware version of resolveFilterIncludes + // that can read included files from the remote server + resolvedContent, err := sc.resolveFilterIncludesRemote(ctx, filterContent, filterDPath, filterName) + if err != nil { + config.DebugLog("Warning: failed to resolve filter includes remotely, using original content: %v", err) + resolvedContent = filterContent + } + + // Ensure it ends with a newline for proper parsing + if !strings.HasSuffix(resolvedContent, "\n") { + resolvedContent += "\n" + } + + // Base64 encode resolved filter content to avoid any heredoc/escaping issues + resolvedContentB64 := base64.StdEncoding.EncodeToString([]byte(resolvedContent)) + + // Simple script: just write the resolved content to temp file and test + script = fmt.Sprintf(` +set -e +TMPFILTER=$(mktemp /tmp/fail2ban-filter-XXXXXX.conf) +trap 'rm -f "$TMPFILTER"' EXIT + +# Write resolved filter content to temp file using base64 decode +echo '%[1]s' | base64 -d > "$TMPFILTER" + +FILTER_PATH="$TMPFILTER" +echo "FILTER_PATH:$FILTER_PATH" +TMPFILE=$(mktemp /tmp/fail2ban-test-XXXXXX.log) +trap 'rm -f "$TMPFILE" "$TMPFILTER"' EXIT +cat <<'%[2]s' > "$TMPFILE" +%[3]s +%[2]s +fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true +`, resolvedContentB64, heredocMarker, logContent) + } else { + // Use existing filter file + script = fmt.Sprintf(` set -e LOCAL_PATH=%[1]q CONF_PATH=%[2]q @@ -1012,6 +1210,7 @@ cat <<'%[3]s' > "$TMPFILE" %[3]s fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true `, localPath, confPath, heredocMarker, logContent) + } out, err := sc.runRemoteCommand(ctx, []string{script}) if err != nil { diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go index 3ed40f1..0bf3732 100644 --- a/internal/fail2ban/filter_management.go +++ b/internal/fail2ban/filter_management.go @@ -90,6 +90,33 @@ func ensureFilterLocalFile(filterName string) error { return nil } +// RemoveComments removes all lines that start with # (comments) from filter content +// and trims leading/trailing empty newlines +// This is exported for use in handlers that need to display filter content without comments +func RemoveComments(content string) string { + lines := strings.Split(content, "\n") + var result []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + // Skip lines that start with # (comments) + if !strings.HasPrefix(trimmed, "#") { + result = append(result, line) + } + } + + // Remove leading empty lines + for len(result) > 0 && strings.TrimSpace(result[0]) == "" { + result = result[1:] + } + + // Remove trailing empty lines + for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" { + result = result[:len(result)-1] + } + + return strings.Join(result, "\n") +} + // readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf. // Returns (content, filePath, error) func readFilterConfigWithFallback(filterName string) (string, string, error) { @@ -353,28 +380,322 @@ func normalizeLogLines(logLines []string) []string { return cleaned } +// extractVariablesFromContent extracts variable names from [DEFAULT] section of filter content +func extractVariablesFromContent(content string) map[string]bool { + variables := make(map[string]bool) + lines := strings.Split(content, "\n") + inDefaultSection := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Check for [DEFAULT] section + if strings.HasPrefix(trimmed, "[DEFAULT]") { + inDefaultSection = true + continue + } + + // Check for end of [DEFAULT] section (next section starts) + if inDefaultSection && strings.HasPrefix(trimmed, "[") { + inDefaultSection = false + continue + } + + // Extract variable name from [DEFAULT] section + if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + varName := strings.TrimSpace(parts[0]) + if varName != "" { + variables[varName] = true + } + } + } + } + + return variables +} + +// removeDuplicateVariables removes variable definitions from included content that already exist in main filter +func removeDuplicateVariables(includedContent string, mainVariables map[string]bool) string { + lines := strings.Split(includedContent, "\n") + var result strings.Builder + inDefaultSection := false + removedCount := 0 + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + originalLine := line + + // Check for [DEFAULT] section + if strings.HasPrefix(trimmed, "[DEFAULT]") { + inDefaultSection = true + result.WriteString(originalLine) + result.WriteString("\n") + continue + } + + // Check for end of [DEFAULT] section (next section starts) + if inDefaultSection && strings.HasPrefix(trimmed, "[") { + inDefaultSection = false + result.WriteString(originalLine) + result.WriteString("\n") + continue + } + + // In [DEFAULT] section, check if variable already exists in main filter + if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + varName := strings.TrimSpace(parts[0]) + if mainVariables[varName] { + // Skip this line - variable will be defined in main filter (takes precedence) + removedCount++ + config.DebugLog("Removing variable '%s' from included file (will be overridden by main filter)", varName) + continue + } + } + } + + result.WriteString(originalLine) + result.WriteString("\n") + } + + if removedCount > 0 { + config.DebugLog("Removed %d variable definitions from included file (overridden by main filter)", removedCount) + } + + return result.String() +} + +// resolveFilterIncludes parses the filter content to find [INCLUDES] section +// and loads the included files, combining them with the main filter content. +// Returns: combined content with before files + main filter + after files +// Duplicate variables in main filter are removed if they exist in included files +// currentFilterName: name of the current filter being tested (to avoid self-inclusion) +func resolveFilterIncludes(filterContent string, filterDPath string, currentFilterName string) (string, error) { + lines := strings.Split(filterContent, "\n") + var beforeFiles []string + var afterFiles []string + var inIncludesSection bool + var mainContent strings.Builder + + // Parse the filter content to find [INCLUDES] section + for i, line := range lines { + trimmed := strings.TrimSpace(line) + + // Check for [INCLUDES] section + if strings.HasPrefix(trimmed, "[INCLUDES]") { + inIncludesSection = true + continue + } + + // Check for end of [INCLUDES] section (next section starts) + if inIncludesSection && strings.HasPrefix(trimmed, "[") { + inIncludesSection = false + } + + // Parse before and after directives + if inIncludesSection { + if strings.HasPrefix(strings.ToLower(trimmed), "before") { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + file := strings.TrimSpace(parts[1]) + if file != "" { + beforeFiles = append(beforeFiles, file) + } + } + continue + } + if strings.HasPrefix(strings.ToLower(trimmed), "after") { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + file := strings.TrimSpace(parts[1]) + if file != "" { + afterFiles = append(afterFiles, file) + } + } + continue + } + } + + // Collect main content (everything except [INCLUDES] section) + if !inIncludesSection { + if i > 0 { + mainContent.WriteString("\n") + } + mainContent.WriteString(line) + } + } + + // Extract variables from main filter content first + mainContentStr := mainContent.String() + mainVariables := extractVariablesFromContent(mainContentStr) + + // Build combined content: before files + main filter + after files + var combined strings.Builder + + // Load and append before files, removing duplicates that exist in main filter + for _, fileName := range beforeFiles { + // Remove any existing extension to get base name + baseName := fileName + if strings.HasSuffix(baseName, ".local") { + baseName = strings.TrimSuffix(baseName, ".local") + } else if strings.HasSuffix(baseName, ".conf") { + baseName = strings.TrimSuffix(baseName, ".conf") + } + + // Skip if this is the same filter (avoid self-inclusion) + if baseName == currentFilterName { + config.DebugLog("Skipping self-inclusion of filter '%s' in before files", baseName) + continue + } + + // Always try .local first, then .conf (matching fail2ban's behavior) + localPath := filepath.Join(filterDPath, baseName+".local") + confPath := filepath.Join(filterDPath, baseName+".conf") + + var content []byte + var err error + var filePath string + + // Try .local first + if content, err = os.ReadFile(localPath); err == nil { + filePath = localPath + config.DebugLog("Loading included filter file from .local: %s", filePath) + } else if content, err = os.ReadFile(confPath); err == nil { + filePath = confPath + config.DebugLog("Loading included filter file from .conf: %s", filePath) + } else { + config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err) + continue // Skip if neither file exists + } + + contentStr := string(content) + // Remove variables from included file that are defined in main filter (main filter takes precedence) + cleanedContent := removeDuplicateVariables(contentStr, mainVariables) + combined.WriteString(cleanedContent) + if !strings.HasSuffix(cleanedContent, "\n") { + combined.WriteString("\n") + } + combined.WriteString("\n") + } + + // Append main filter content (unchanged - this is what the user is editing) + combined.WriteString(mainContentStr) + if !strings.HasSuffix(mainContentStr, "\n") { + combined.WriteString("\n") + } + + // Load and append after files, also removing duplicates that exist in main filter + for _, fileName := range afterFiles { + // Remove any existing extension to get base name + baseName := fileName + if strings.HasSuffix(baseName, ".local") { + baseName = strings.TrimSuffix(baseName, ".local") + } else if strings.HasSuffix(baseName, ".conf") { + baseName = strings.TrimSuffix(baseName, ".conf") + } + + // Note: Self-inclusion in "after" directive is intentional in fail2ban + // (e.g., after = apache-common.local is standard pattern for .local files) + // So we always load it, even if it's the same filter name + + // Always try .local first, then .conf (matching fail2ban's behavior) + localPath := filepath.Join(filterDPath, baseName+".local") + confPath := filepath.Join(filterDPath, baseName+".conf") + + var content []byte + var err error + var filePath string + + // Try .local first + if content, err = os.ReadFile(localPath); err == nil { + filePath = localPath + config.DebugLog("Loading included filter file from .local: %s", filePath) + } else if content, err = os.ReadFile(confPath); err == nil { + filePath = confPath + config.DebugLog("Loading included filter file from .conf: %s", filePath) + } else { + config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err) + continue // Skip if neither file exists + } + + contentStr := string(content) + // Remove variables from included file that are defined in main filter (main filter takes precedence) + cleanedContent := removeDuplicateVariables(contentStr, mainVariables) + combined.WriteString("\n") + combined.WriteString(cleanedContent) + if !strings.HasSuffix(cleanedContent, "\n") { + combined.WriteString("\n") + } + } + + return combined.String(), nil +} + // TestFilterLocal tests a filter against log lines using fail2ban-regex // 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) { +// If filterContent is provided, it creates a temporary filter file and uses that instead +func TestFilterLocal(filterName string, logLines []string, filterContent string) (string, string, error) { cleaned := normalizeLogLines(logLines) if len(cleaned) == 0 { return "No log lines provided.\n", "", nil } - // 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) + var tempFilterFile *os.File + var err error + + // If custom filter content is provided, create a temporary filter file + if filterContent != "" { + tempFilterFile, err = os.CreateTemp("", "fail2ban-filter-*.conf") + if err != nil { + return "", "", fmt.Errorf("failed to create temporary filter file: %w", err) + } + defer os.Remove(tempFilterFile.Name()) + defer tempFilterFile.Close() + + // Resolve filter includes to get complete filter content with all dependencies + filterDPath := "/etc/fail2ban/filter.d" + contentToWrite, err := resolveFilterIncludes(filterContent, filterDPath, filterName) + if err != nil { + config.DebugLog("Warning: failed to resolve filter includes, using original content: %v", err) + contentToWrite = filterContent + } + + // Ensure it ends with a newline for proper parsing + if !strings.HasSuffix(contentToWrite, "\n") { + contentToWrite += "\n" + } + + if _, err := tempFilterFile.WriteString(contentToWrite); err != nil { + return "", "", fmt.Errorf("failed to write temporary filter file: %w", err) + } + + // Ensure the file is synced to disk + if err := tempFilterFile.Sync(); err != nil { + return "", "", fmt.Errorf("failed to sync temporary filter file: %w", err) + } + + tempFilterFile.Close() + filterPath = tempFilterFile.Name() + config.DebugLog("TestFilterLocal: using custom filter content from temporary file: %s (size: %d bytes, includes resolved: %v)", filterPath, len(contentToWrite), err == nil) } else { - return "", "", fmt.Errorf("filter %s not found (checked both .local and .conf): %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") + + 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 diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go index cc02f4e..8d65aa1 100644 --- a/internal/fail2ban/manager.go +++ b/internal/fail2ban/manager.go @@ -29,7 +29,7 @@ type Connector interface { // Filter operations GetFilters(ctx context.Context) ([]string, error) - TestFilter(ctx context.Context, filterName string, logLines []string) (output string, filterPath string, err error) + TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (output string, filterPath string, err error) // Jail configuration operations GetJailConfig(ctx context.Context, jail string) (string, string, error) // Returns (config, filePath, error) diff --git a/internal/locales/de.json b/internal/locales/de.json index 4572e27..fd13306 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -101,6 +101,11 @@ "logs.modal.insights_recurring_empty": "Keine wiederkehrenden IPs erkannt.", "filter_debug.title": "Filter-Debug", "filter_debug.select_filter": "Wählen Sie einen Filter", + "filter_debug.filter_content": "Filter-Inhalt", + "filter_debug.filter_content_hint": "Bearbeiten Sie den Filter-Regex zum Testen. Änderungen sind temporär und werden nicht gespeichert.", + "filter_debug.filter_content_hint_readonly": "Filter-Inhalt wird schreibgeschützt angezeigt. Klicken Sie auf 'Bearbeiten', um diesen für Tests zu ändern. Änderungen sind temporär und werden nicht gespeichert.", + "filter_debug.edit_filter": "Bearbeiten", + "filter_debug.cancel_edit": "Abbrechen", "filter_debug.log_lines": "Logzeilen", "filter_debug.log_lines_placeholder": "Geben Sie die Logzeilen hier ein...", "filter_debug.test_filter": "Filter testen", diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index 99045cf..371125f 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -101,6 +101,11 @@ "logs.modal.insights_recurring_empty": "Ke wiederkehrendi IPs erkannt.", "filter_debug.title": "Filter Debug", "filter_debug.select_filter": "Wähl ä Filter us", + "filter_debug.filter_content": "Filter-Inhalt", + "filter_debug.filter_content_hint": "Bearbeit dr Fiuter-Regex unge zum Testä. Änderigä si temporär und wärde nid gspeichert.", + "filter_debug.filter_content_hint_readonly": "Fiuter-Inhalt wird schribgschützt azeigt. Klick uf 'Bearbeitä', zum abändärä. Änderigä si temporär und wärde nid gspeichert.", + "filter_debug.edit_filter": "Bearbeitä", + "filter_debug.cancel_edit": "Abbrächä", "filter_debug.log_lines": "Log-Zile", "filter_debug.log_lines_placeholder": "Füeg ä Log-Zile ii...", "filter_debug.test_filter": "Filter teste", diff --git a/internal/locales/en.json b/internal/locales/en.json index 8d9f16b..009b099 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -101,6 +101,11 @@ "logs.modal.insights_recurring_empty": "No recurring IPs detected.", "filter_debug.title": "Filter Debug", "filter_debug.select_filter": "Select a Filter", + "filter_debug.filter_content": "Filter Content", + "filter_debug.filter_content_hint": "Edit the filter regex below for testing. Changes are temporary and not saved.", + "filter_debug.filter_content_hint_readonly": "Filter content is shown read-only. Click 'Edit' to modify for testing. Changes are temporary and not saved.", + "filter_debug.edit_filter": "Edit", + "filter_debug.cancel_edit": "Cancel", "filter_debug.log_lines": "Log Lines", "filter_debug.log_lines_placeholder": "Enter log lines here...", "filter_debug.test_filter": "Test Filter", diff --git a/internal/locales/es.json b/internal/locales/es.json index f936782..2a8e43c 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -101,6 +101,11 @@ "logs.modal.insights_recurring_empty": "No se detectaron IPs recurrentes.", "filter_debug.title": "Depuración de filtros", "filter_debug.select_filter": "Selecciona un filtro", + "filter_debug.filter_content": "Contenido del filtro", + "filter_debug.filter_content_hint": "Edita el regex del filtro a continuación para las pruebas. Los cambios son temporales y no se guardan.", + "filter_debug.filter_content_hint_readonly": "El contenido del filtro se muestra de solo lectura. Haz clic en 'Editar' para modificar para las pruebas. Los cambios son temporales y no se guardan.", + "filter_debug.edit_filter": "Editar", + "filter_debug.cancel_edit": "Cancelar", "filter_debug.log_lines": "Líneas de log", "filter_debug.log_lines_placeholder": "Introduce las líneas de log aquí...", "filter_debug.test_filter": "Probar filtro", diff --git a/internal/locales/fr.json b/internal/locales/fr.json index 6598fee..5263cae 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -101,6 +101,11 @@ "logs.modal.insights_recurring_empty": "Aucune IP récurrente détectée.", "filter_debug.title": "Débogage des filtres", "filter_debug.select_filter": "Sélectionnez un filtre", + "filter_debug.filter_content": "Contenu du filtre", + "filter_debug.filter_content_hint": "Modifiez le regex du filtre ci-dessous pour les tests. Les modifications sont temporaires et ne sont pas enregistrées.", + "filter_debug.filter_content_hint_readonly": "Le contenu du filtre est affiché en lecture seule. Cliquez sur 'Modifier' pour modifier pour les tests. Les modifications sont temporaires et ne sont pas enregistrées.", + "filter_debug.edit_filter": "Modifier", + "filter_debug.cancel_edit": "Annuler", "filter_debug.log_lines": "Lignes de log", "filter_debug.log_lines_placeholder": "Entrez les lignes de log ici...", "filter_debug.test_filter": "Tester le filtre", diff --git a/internal/locales/it.json b/internal/locales/it.json index 4e25417..53e50aa 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -101,6 +101,11 @@ "logs.modal.insights_recurring_empty": "Nessun IP ricorrente rilevato.", "filter_debug.title": "Debug Filtro", "filter_debug.select_filter": "Seleziona un filtro", + "filter_debug.filter_content": "Contenuto del filtro", + "filter_debug.filter_content_hint": "Modifica il regex del filtro qui sotto per i test. Le modifiche sono temporanee e non vengono salvate.", + "filter_debug.filter_content_hint_readonly": "Il contenuto del filtro è mostrato in sola lettura. Clicca su 'Modifica' per modificare per i test. Le modifiche sono temporanee e non vengono salvate.", + "filter_debug.edit_filter": "Modifica", + "filter_debug.cancel_edit": "Annulla", "filter_debug.log_lines": "Righe di log", "filter_debug.log_lines_placeholder": "Inserisci qui le righe di log...", "filter_debug.test_filter": "Testa filtro", diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index b684351..48b1e16 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1982,6 +1982,31 @@ func ListFiltersHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"filters": filters}) } +func GetFilterContentHandler(c *gin.Context) { + config.DebugLog("----------------------------") + config.DebugLog("GetFilterContentHandler called (handlers.go)") + filterName := c.Param("filter") + conn, err := resolveConnector(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + content, filePath, err := conn.GetFilterConfig(c.Request.Context(), filterName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get filter content: " + err.Error()}) + return + } + + // Remove comments for display in Filter Debug page only + content = fail2ban.RemoveComments(content) + + c.JSON(http.StatusOK, gin.H{ + "content": content, + "filterPath": filePath, + }) +} + func TestFilterHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("TestFilterHandler called (handlers.go)") // entry point @@ -1991,15 +2016,16 @@ func TestFilterHandler(c *gin.Context) { return } var req struct { - FilterName string `json:"filterName"` - LogLines []string `json:"logLines"` + FilterName string `json:"filterName"` + LogLines []string `json:"logLines"` + FilterContent string `json:"filterContent"` // Optional: if provided, use this instead of reading from file } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) return } - output, filterPath, err := conn.TestFilter(c.Request.Context(), req.FilterName, req.LogLines) + output, filterPath, err := conn.TestFilter(c.Request.Context(), req.FilterName, req.LogLines, req.FilterContent) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test filter: " + err.Error()}) return diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 29fd876..3f97876 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -62,6 +62,7 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) { // Filter debugger endpoints api.GET("/filters", ListFiltersHandler) + api.GET("/filters/:filter/content", GetFilterContentHandler) api.POST("/filters/test", TestFilterHandler) api.POST("/filters", CreateFilterHandler) api.DELETE("/filters/:filter", DeleteFilterHandler) diff --git a/pkg/web/static/js/filters.js b/pkg/web/static/js/filters.js index fa32fd3..e98e130 100644 --- a/pkg/web/static/js/filters.js +++ b/pkg/web/static/js/filters.js @@ -43,9 +43,28 @@ function loadFilters() { select.setAttribute('data-listener-added', 'true'); select.addEventListener('change', function() { if (deleteBtn) deleteBtn.disabled = !select.value; + // Load filter content when a filter is selected + if (select.value) { + loadFilterContent(select.value); + } else { + const filterContentTextarea = document.getElementById('filterContentTextarea'); + const editBtn = document.getElementById('editFilterContentBtn'); + if (filterContentTextarea) { + filterContentTextarea.value = ''; + filterContentTextarea.readOnly = true; + filterContentTextarea.classList.add('bg-gray-50'); + filterContentTextarea.classList.remove('bg-white'); + } + if (editBtn) editBtn.classList.add('hidden'); + updateFilterContentHints(false); + } }); } if (deleteBtn) deleteBtn.disabled = !select.value; + // If a filter is already selected (e.g., first one by default), load its content + if (select.value) { + loadFilterContent(select.value); + } } }) .catch(err => { @@ -54,9 +73,93 @@ function loadFilters() { .finally(() => showLoading(false)); } +function loadFilterContent(filterName) { + const filterContentTextarea = document.getElementById('filterContentTextarea'); + const editBtn = document.getElementById('editFilterContentBtn'); + if (!filterContentTextarea) return; + + showLoading(true); + fetch(withServerParam('/api/filters/' + encodeURIComponent(filterName) + '/content'), { + headers: serverHeaders() + }) + .then(res => res.json()) + .then(data => { + if (data.error) { + showToast('Error loading filter content: ' + data.error, 'error'); + filterContentTextarea.value = ''; + filterContentTextarea.readOnly = true; + if (editBtn) editBtn.classList.add('hidden'); + updateFilterContentHints(false); + return; + } + filterContentTextarea.value = data.content || ''; + filterContentTextarea.readOnly = true; // Keep it readonly by default + filterContentTextarea.classList.add('bg-gray-50'); + filterContentTextarea.classList.remove('bg-white'); + if (editBtn) editBtn.classList.remove('hidden'); + updateFilterContentHints(false); + }) + .catch(err => { + showToast('Error loading filter content: ' + err, 'error'); + filterContentTextarea.value = ''; + filterContentTextarea.readOnly = true; + if (editBtn) editBtn.classList.add('hidden'); + updateFilterContentHints(false); + }) + .finally(() => showLoading(false)); +} + +function toggleFilterContentEdit() { + const filterContentTextarea = document.getElementById('filterContentTextarea'); + const editBtn = document.getElementById('editFilterContentBtn'); + if (!filterContentTextarea) return; + + if (filterContentTextarea.readOnly) { + // Make editable + filterContentTextarea.readOnly = false; + filterContentTextarea.classList.remove('bg-gray-50'); + filterContentTextarea.classList.add('bg-white'); + if (editBtn) { + editBtn.textContent = t('filter_debug.cancel_edit', 'Cancel'); + editBtn.classList.remove('bg-blue-600', 'hover:bg-blue-700'); + editBtn.classList.add('bg-gray-600', 'hover:bg-gray-700'); + } + updateFilterContentHints(true); + } else { + // Make readonly + filterContentTextarea.readOnly = true; + filterContentTextarea.classList.add('bg-gray-50'); + filterContentTextarea.classList.remove('bg-white'); + if (editBtn) { + editBtn.textContent = t('filter_debug.edit_filter', 'Edit'); + editBtn.classList.remove('bg-gray-600', 'hover:bg-gray-700'); + editBtn.classList.add('bg-blue-600', 'hover:bg-blue-700'); + } + updateFilterContentHints(false); + } +} + +function updateFilterContentHints(isEditable) { + const readonlyHint = document.querySelector('p[data-i18n="filter_debug.filter_content_hint_readonly"]'); + const editableHint = document.getElementById('filterContentHintEditable'); + + if (isEditable) { + if (readonlyHint) readonlyHint.classList.add('hidden'); + if (editableHint) editableHint.classList.remove('hidden'); + } else { + if (readonlyHint) readonlyHint.classList.remove('hidden'); + if (editableHint) editableHint.classList.add('hidden'); + } + + if (typeof updateTranslations === 'function') { + updateTranslations(); + } +} + function testSelectedFilter() { const filterName = document.getElementById('filterSelect').value; const lines = document.getElementById('logLinesTextarea').value.split('\n').filter(line => line.trim() !== ''); + const filterContentTextarea = document.getElementById('filterContentTextarea'); if (!filterName) { showToast('Please select a filter.', 'info'); @@ -74,13 +177,24 @@ function testSelectedFilter() { testResultsEl.innerHTML = ''; showLoading(true); + const requestBody = { + filterName: filterName, + logLines: lines + }; + + // Only include filter content if textarea is editable (not readonly) + // If readonly, test the original filter from server + if (filterContentTextarea && !filterContentTextarea.readOnly) { + const filterContent = filterContentTextarea.value.trim(); + if (filterContent) { + requestBody.filterContent = filterContent; + } + } + fetch(withServerParam('/api/filters/test'), { method: 'POST', headers: serverHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ - filterName: filterName, - logLines: lines - }) + body: JSON.stringify(requestBody) }) .then(res => res.json()) .then(data => { @@ -122,6 +236,7 @@ function renderTestResults(output, filterPath) { function showFilterSection() { const testResultsEl = document.getElementById('testResults'); + const filterContentTextarea = document.getElementById('filterContentTextarea'); if (!currentServerId) { var notice = document.getElementById('filterNotice'); if (notice) { @@ -130,6 +245,10 @@ function showFilterSection() { } document.getElementById('filterSelect').innerHTML = ''; document.getElementById('logLinesTextarea').value = ''; + if (filterContentTextarea) { + filterContentTextarea.value = ''; + filterContentTextarea.readOnly = true; + } testResultsEl.innerHTML = ''; testResultsEl.classList.add('hidden'); document.getElementById('deleteFilterBtn').disabled = true; @@ -139,12 +258,37 @@ function showFilterSection() { testResultsEl.innerHTML = ''; testResultsEl.classList.add('hidden'); document.getElementById('logLinesTextarea').value = ''; - // Add change listener to enable/disable delete button + const editBtn = document.getElementById('editFilterContentBtn'); + if (filterContentTextarea) { + filterContentTextarea.value = ''; + filterContentTextarea.readOnly = true; + filterContentTextarea.classList.add('bg-gray-50'); + filterContentTextarea.classList.remove('bg-white'); + } + if (editBtn) editBtn.classList.add('hidden'); + updateFilterContentHints(false); + // Add change listener to enable/disable delete button and load filter content const filterSelect = document.getElementById('filterSelect'); const deleteBtn = document.getElementById('deleteFilterBtn'); - filterSelect.addEventListener('change', function() { - deleteBtn.disabled = !filterSelect.value; - }); + if (!filterSelect.hasAttribute('data-listener-added')) { + filterSelect.setAttribute('data-listener-added', 'true'); + filterSelect.addEventListener('change', function() { + deleteBtn.disabled = !filterSelect.value; + if (filterSelect.value) { + loadFilterContent(filterSelect.value); + } else { + const editBtn = document.getElementById('editFilterContentBtn'); + if (filterContentTextarea) { + filterContentTextarea.value = ''; + filterContentTextarea.readOnly = true; + filterContentTextarea.classList.add('bg-gray-50'); + filterContentTextarea.classList.remove('bg-white'); + } + if (editBtn) editBtn.classList.add('hidden'); + updateFilterContentHints(false); + } + }); + } } function openCreateFilterModal() { @@ -234,6 +378,16 @@ function deleteFilter() { document.getElementById('testResults').innerHTML = ''; document.getElementById('testResults').classList.add('hidden'); document.getElementById('logLinesTextarea').value = ''; + const filterContentTextarea = document.getElementById('filterContentTextarea'); + const editBtn = document.getElementById('editFilterContentBtn'); + if (filterContentTextarea) { + filterContentTextarea.value = ''; + filterContentTextarea.readOnly = true; + filterContentTextarea.classList.add('bg-gray-50'); + filterContentTextarea.classList.remove('bg-white'); + } + if (editBtn) editBtn.classList.add('hidden'); + updateFilterContentHints(false); }) .catch(function(err) { console.error('Error deleting filter:', err); diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 842c9ec..f9cd3fa 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -163,6 +163,18 @@ + +
Filter content is shown read-only. Click 'Edit' to modify for testing. Changes are temporary and not saved.
+ + +