diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index 9c2050e..2d1dc1f 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -249,3 +249,45 @@ func (ac *AgentConnector) do(req *http.Request, out any) error { } return json.Unmarshal(data, out) } + +// GetAllJails implements Connector. +func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { + var resp struct { + Jails []JailInfo `json:"jails"` + } + if err := ac.get(ctx, "/v1/jails/all", &resp); err != nil { + return nil, err + } + return resp.Jails, nil +} + +// UpdateJailEnabledStates implements Connector. +func (ac *AgentConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { + return ac.post(ctx, "/v1/jails/update-enabled", updates, nil) +} + +// GetFilters implements Connector. +func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) { + var resp struct { + Filters []string `json:"filters"` + } + if err := ac.get(ctx, "/v1/filters", &resp); err != nil { + return nil, err + } + return resp.Filters, nil +} + +// TestFilter implements Connector. +func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error) { + payload := map[string]any{ + "filterName": filterName, + "logLines": logLines, + } + var resp struct { + Matches []string `json:"matches"` + } + if err := ac.post(ctx, "/v1/filters/test", payload, &resp); err != nil { + return nil, err + } + return resp.Matches, nil +} diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go index 9f4b724..e5bb834 100644 --- a/internal/fail2ban/connector_local.go +++ b/internal/fail2ban/connector_local.go @@ -8,6 +8,7 @@ import ( "os/exec" "sort" "strings" + "sync" "time" "github.com/swissmakers/fail2ban-ui/internal/config" @@ -51,37 +52,61 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) } oneHourAgo := time.Now().Add(-1 * time.Hour) - var results []JailInfo - for _, jail := range jails { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } + + // Use parallel execution for better performance + type jailResult struct { + jail JailInfo + err error + } + results := make(chan jailResult, len(jails)) + var wg sync.WaitGroup - bannedIPs, err := lc.GetBannedIPs(ctx, jail) - if err != nil { - continue - } - newInLastHour := 0 - if events, ok := banHistory[jail]; ok { - for _, e := range events { - if e.Time.After(oneHourAgo) { - newInLastHour++ + for _, jail := range jails { + wg.Add(1) + go func(j string) { + defer wg.Done() + bannedIPs, err := lc.GetBannedIPs(ctx, j) + if err != nil { + results <- jailResult{err: err} + return + } + newInLastHour := 0 + if events, ok := banHistory[j]; ok { + for _, e := range events { + if e.Time.After(oneHourAgo) { + newInLastHour++ + } } } - } - - results = append(results, JailInfo{ - JailName: jail, - TotalBanned: len(bannedIPs), - NewInLastHour: newInLastHour, - BannedIPs: bannedIPs, - Enabled: true, - }) + results <- jailResult{ + jail: JailInfo{ + JailName: j, + TotalBanned: len(bannedIPs), + NewInLastHour: newInLastHour, + BannedIPs: bannedIPs, + Enabled: true, + }, + } + }(jail) } - return results, nil + go func() { + wg.Wait() + close(results) + }() + + var finalResults []JailInfo + for result := range results { + if result.err != nil { + continue + } + finalResults = append(finalResults, result.jail) + } + + sort.SliceStable(finalResults, func(i, j int) bool { + return finalResults[i].JailName < finalResults[j].JailName + }) + return finalResults, nil } // GetBannedIPs implements Connector. @@ -207,6 +232,26 @@ func (lc *LocalConnector) buildFail2banArgs(args ...string) []string { return append(base, args...) } +// GetAllJails implements Connector. +func (lc *LocalConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { + return GetAllJails() +} + +// UpdateJailEnabledStates implements Connector. +func (lc *LocalConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { + return UpdateJailEnabledStates(updates) +} + +// GetFilters implements Connector. +func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) { + return GetFiltersLocal() +} + +// TestFilter implements Connector. +func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error) { + return TestFilterLocal(filterName, logLines) +} + func executeShellCommand(ctx context.Context, command string) (string, error) { parts := strings.Fields(command) if len(parts) == 0 { diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index c119ad8..22ace6b 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -1,6 +1,7 @@ package fail2ban import ( + "bufio" "context" "encoding/base64" "fmt" @@ -8,11 +9,12 @@ import ( "sort" "strconv" "strings" + "sync" "github.com/swissmakers/fail2ban-ui/internal/config" ) -const sshEnsureActionScript = `sudo python3 - <<'PY' +const sshEnsureActionScript = `python3 - <<'PY' import base64 import pathlib @@ -90,24 +92,46 @@ func (sc *SSHConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) { return nil, err } - var infos []JailInfo + // Use parallel execution for better performance + type jailResult struct { + jail JailInfo + err error + } + results := make(chan jailResult, len(jails)) + var wg sync.WaitGroup + for _, jail := range jails { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - ips, err := sc.GetBannedIPs(ctx, jail) - if err != nil { + wg.Add(1) + go func(j string) { + defer wg.Done() + ips, err := sc.GetBannedIPs(ctx, j) + if err != nil { + results <- jailResult{err: err} + return + } + results <- jailResult{ + jail: JailInfo{ + JailName: j, + TotalBanned: len(ips), + NewInLastHour: 0, + BannedIPs: ips, + Enabled: true, + }, + } + }(jail) + } + + go func() { + wg.Wait() + close(results) + }() + + var infos []JailInfo + for result := range results { + if result.err != nil { continue } - infos = append(infos, JailInfo{ - JailName: jail, - TotalBanned: len(ips), - NewInLastHour: 0, - BannedIPs: ips, - Enabled: true, - }) + infos = append(infos, result.jail) } sort.SliceStable(infos, func(i, j int) bool { @@ -177,7 +201,10 @@ func (sc *SSHConnector) ensureAction(ctx context.Context) error { actionConfig := config.BuildFail2banActionConfig(callbackURL) payload := base64.StdEncoding.EncodeToString([]byte(actionConfig)) script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload) - _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script}) + // Base64 encode the entire script to avoid shell escaping issues + scriptB64 := base64.StdEncoding.EncodeToString([]byte(script)) + cmd := fmt.Sprintf("echo %s | base64 -d | sudo bash", scriptB64) + _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) return err } @@ -254,3 +281,176 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string { args = append(args, command...) return args } + +// GetAllJails implements Connector. +func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { + // Read jail.local and jail.d files remotely + var allJails []JailInfo + + // Parse jail.local + jailLocalContent, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", "/etc/fail2ban/jail.local"}) + if err == nil { + jails := parseJailConfigContent(jailLocalContent) + allJails = append(allJails, jails...) + } + + // Parse jail.d directory + jailDList, err := sc.runRemoteCommand(ctx, []string{"sudo", "bash", "-c", "for f in /etc/fail2ban/jail.d/*.conf; do [ -f \"$f\" ] && echo \"$f\"; done"}) + if err == nil && jailDList != "" { + for _, file := range strings.Split(jailDList, "\n") { + file = strings.TrimSpace(file) + if file == "" { + continue + } + content, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", file}) + if err == nil { + jails := parseJailConfigContent(content) + allJails = append(allJails, jails...) + } + } + } + + return allJails, nil +} + +// UpdateJailEnabledStates implements Connector. +func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { + // Read current jail.local + content, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", "/etc/fail2ban/jail.local"}) + if err != nil { + return fmt.Errorf("failed to read jail.local: %w", err) + } + + // Update enabled states + lines := strings.Split(content, "\n") + var outputLines []string + var currentJail string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + currentJail = strings.Trim(trimmed, "[]") + outputLines = append(outputLines, line) + } else if strings.HasPrefix(trimmed, "enabled") { + if val, ok := updates[currentJail]; ok { + outputLines = append(outputLines, fmt.Sprintf("enabled = %t", val)) + delete(updates, currentJail) + } else { + outputLines = append(outputLines, line) + } + } else { + outputLines = append(outputLines, line) + } + } + + // Write back + newContent := strings.Join(outputLines, "\n") + cmd := fmt.Sprintf("cat <<'EOF' | sudo tee /etc/fail2ban/jail.local >/dev/null\n%s\nEOF", newContent) + _, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) + return err +} + +// GetFilters implements Connector. +func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { + list, err := sc.runRemoteCommand(ctx, []string{"sudo", "bash", "-c", "for f in /etc/fail2ban/filter.d/*.conf; do [ -f \"$f\" ] && basename \"$f\" .conf; done"}) + if err != nil { + return nil, fmt.Errorf("failed to list filters: %w", err) + } + var filters []string + for _, line := range strings.Split(list, "\n") { + line = strings.TrimSpace(line) + if line != "" { + filters = append(filters, line) + } + } + return filters, nil +} + +// TestFilter implements Connector. +func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error) { + if len(logLines) == 0 { + return []string{}, nil + } + + // Read filter config remotely + filterPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName) + content, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", filterPath}) + if err != nil { + return nil, fmt.Errorf("filter %s not found: %w", filterName, err) + } + + // Extract failregex + var failregex string + scanner := bufio.NewScanner(strings.NewReader(content)) + inFailregex := false + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "[Definition]") { + inFailregex = true + continue + } + if inFailregex && strings.HasPrefix(line, "failregex") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + failregex = strings.TrimSpace(parts[1]) + } + break + } + if inFailregex && strings.HasPrefix(line, "[") { + break + } + } + if failregex == "" { + return nil, fmt.Errorf("no failregex found in filter %s", filterName) + } + + // Test each log line remotely + var matches []string + for _, logLine := range logLines { + if logLine == "" { + continue + } + // Escape the log line and regex for shell + escapedLine := strconv.Quote(logLine) + escapedRegex := strconv.Quote(failregex) + cmd := fmt.Sprintf("echo %s | sudo fail2ban-regex - %s", escapedLine, escapedRegex) + out, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) + if err == nil && strings.Contains(out, "Success") { + matches = append(matches, logLine) + } + } + return matches, nil +} + +// parseJailConfigContent parses jail configuration content and returns JailInfo slice. +func parseJailConfigContent(content string) []JailInfo { + var jails []JailInfo + scanner := bufio.NewScanner(strings.NewReader(content)) + var currentJail string + enabled := true + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + if currentJail != "" && currentJail != "DEFAULT" { + jails = append(jails, JailInfo{ + JailName: currentJail, + Enabled: enabled, + }) + } + currentJail = strings.Trim(line, "[]") + enabled = true + } else if strings.HasPrefix(strings.ToLower(line), "enabled") { + parts := strings.Split(line, "=") + if len(parts) == 2 { + value := strings.TrimSpace(parts[1]) + enabled = strings.EqualFold(value, "true") + } + } + } + if currentJail != "" && currentJail != "DEFAULT" { + jails = append(jails, JailInfo{ + JailName: currentJail, + Enabled: enabled, + }) + } + return jails +} diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go index b03750b..c625d06 100644 --- a/internal/fail2ban/filter_management.go +++ b/internal/fail2ban/filter_management.go @@ -17,10 +17,13 @@ package fail2ban import ( + "bufio" "context" "fmt" "os" + "os/exec" "path/filepath" + "strings" ) // GetFilterConfig returns the filter configuration using the default connector. @@ -59,3 +62,73 @@ func SetFilterConfigLocal(jail, newContent string) error { } return nil } + +// GetFiltersLocal returns a list of filter names from /etc/fail2ban/filter.d +func GetFiltersLocal() ([]string, error) { + dir := "/etc/fail2ban/filter.d" + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("failed to read filter directory: %w", err) + } + var filters []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".conf") { + name := strings.TrimSuffix(entry.Name(), ".conf") + filters = append(filters, name) + } + } + return filters, nil +} + +// TestFilterLocal tests a filter against log lines using fail2ban-regex +func TestFilterLocal(filterName string, logLines []string) ([]string, error) { + if len(logLines) == 0 { + return []string{}, nil + } + filterPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf") + if _, err := os.Stat(filterPath); err != nil { + return nil, fmt.Errorf("filter %s not found: %w", filterName, err) + } + // Read the filter config to extract the failregex + content, err := os.ReadFile(filterPath) + if err != nil { + return nil, fmt.Errorf("failed to read filter config: %w", err) + } + // Extract failregex from the config + var failregex string + scanner := bufio.NewScanner(strings.NewReader(string(content))) + inFailregex := false + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "[Definition]") { + inFailregex = true + continue + } + if inFailregex && strings.HasPrefix(line, "failregex") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + failregex = strings.TrimSpace(parts[1]) + } + break + } + if inFailregex && strings.HasPrefix(line, "[") { + break + } + } + if failregex == "" { + return nil, fmt.Errorf("no failregex found in filter %s", filterName) + } + // Use fail2ban-regex to test + var matches []string + for _, logLine := range logLines { + if logLine == "" { + continue + } + cmd := exec.Command("fail2ban-regex", logLine, failregex) + out, err := cmd.CombinedOutput() + if err == nil && strings.Contains(string(out), "Success") { + matches = append(matches, logLine) + } + } + return matches, nil +} diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go index 7de95ef..fd31a2a 100644 --- a/internal/fail2ban/manager.go +++ b/internal/fail2ban/manager.go @@ -21,6 +21,14 @@ type Connector interface { GetFilterConfig(ctx context.Context, jail string) (string, error) SetFilterConfig(ctx context.Context, jail, content string) error FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) + + // Jail management + GetAllJails(ctx context.Context) ([]JailInfo, error) + UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error + + // Filter operations + GetFilters(ctx context.Context) ([]string, error) + TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error) } // Manager orchestrates all connectors for configured Fail2ban servers. diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 6dae5b7..97517f9 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -578,9 +578,12 @@ func SetJailFilterConfigHandler(c *gin.Context) { func ManageJailsHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("ManageJailsHandler called (handlers.go)") // entry point - // Get all jails from jail.local and jail.d directories. - // This helper should parse both files and return []fail2ban.JailInfo. - jails, err := fail2ban.GetAllJails() + conn, err := resolveConnector(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + jails, err := conn.GetAllJails(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jails: " + err.Error()}) return @@ -594,21 +597,21 @@ func ManageJailsHandler(c *gin.Context) { func UpdateJailManagementHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("UpdateJailManagementHandler called (handlers.go)") // entry point + conn, err := resolveConnector(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } var updates map[string]bool if err := c.ShouldBindJSON(&updates); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()}) return } // Update jail configuration file(s) with the new enabled states. - if err := fail2ban.UpdateJailEnabledStates(updates); err != nil { + if err := conn.UpdateJailEnabledStates(c.Request.Context(), updates); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update jail settings: " + err.Error()}) return } - // Restart the Fail2ban service. - //if err := fail2ban.RestartFail2ban(); err != nil { - // c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload fail2ban: " + err.Error()}) - // return - //} if err := config.MarkRestartNeeded(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -669,43 +672,35 @@ func ListFiltersHandler(c *gin.Context) { return } server := conn.Server() - if server.Type != "local" { - c.JSON(http.StatusOK, gin.H{"filters": []string{}, "messageKey": "filter_debug.not_available"}) - return - } - - dir := "/etc/fail2ban/filter.d" - if _, statErr := os.Stat(dir); statErr != nil { - if os.IsNotExist(statErr) { - c.JSON(http.StatusOK, gin.H{"filters": []string{}, "messageKey": "filter_debug.local_missing"}) + if server.Type == "local" { + // For local, check if directory exists first + dir := "/etc/fail2ban/filter.d" + if _, statErr := os.Stat(dir); statErr != nil { + if os.IsNotExist(statErr) { + c.JSON(http.StatusOK, gin.H{"filters": []string{}, "messageKey": "filter_debug.local_missing"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read filter directory: " + statErr.Error()}) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read filter directory: " + statErr.Error()}) - return } - files, err := os.ReadDir(dir) + filters, err := conn.GetFilters(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to read filter directory: " + err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list filters: " + err.Error()}) return } - - var filters []string - for _, f := range files { - if !f.IsDir() && strings.HasSuffix(f.Name(), ".conf") { - name := strings.TrimSuffix(f.Name(), ".conf") - filters = append(filters, name) - } - } - c.JSON(http.StatusOK, gin.H{"filters": filters}) } func TestFilterHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("TestFilterHandler called (handlers.go)") // entry point + conn, err := resolveConnector(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } var req struct { FilterName string `json:"filterName"` LogLines []string `json:"logLines"` @@ -715,8 +710,12 @@ func TestFilterHandler(c *gin.Context) { return } - // For now, just pretend nothing matches - c.JSON(http.StatusOK, gin.H{"matches": []string{}}) + matches, 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{"matches": matches}) } // ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON