diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 4c55835..90158ef 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -4,39 +4,38 @@ services: fail2ban-ui: # Use pre-built image from registry image: registry.swissmakers.ch/infra/fail2ban-ui:latest - + # Or build from source (uncomment to use): # build: # context: . # dockerfile: Dockerfile - + container_name: fail2ban-ui network_mode: host restart: unless-stopped - + environment: # Custom port (optional, defaults to 8080) # Change this to use a different port for the web interface - PORT=8080 - + volumes: # Required: Configuration and database storage # Stores SQLite database, application settings, and SSH keys - /opt/podman-fail2ban-ui:/config:Z - + # Required: Fail2Ban configuration directory # Needed for managing local Fail2Ban instance - /etc/fail2ban:/etc/fail2ban:Z - + # Required: Fail2Ban socket directory # Needed for local Fail2Ban control socket access - /var/run/fail2ban:/var/run/fail2ban - + # Optional: System logs (read-only) # Useful for filter testing and log analysis (or if planned to integrate fal2ban directly in this container) - /var/log:/var/log:ro - + # Optional: GeoIP databases (read-only) # Enables geographic IP analysis features (GeoIP must be installed and configured on the host) - /usr/share/GeoIP:/usr/share/GeoIP:ro - diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index bb6ef14..a4a41a0 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -291,3 +291,32 @@ func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, log } return resp.Output, nil } + +// GetJailConfig implements Connector. +func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, error) { + var resp struct { + Config string `json:"config"` + } + if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), &resp); err != nil { + return "", err + } + return resp.Config, nil +} + +// SetJailConfig implements Connector. +func (ac *AgentConnector) SetJailConfig(ctx context.Context, jail, content string) error { + payload := map[string]string{"config": content} + return ac.put(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), payload, nil) +} + +// TestLogpath implements Connector. +func (ac *AgentConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) { + payload := map[string]string{"logpath": logpath} + var resp struct { + Files []string `json:"files"` + } + if err := ac.post(ctx, "/v1/jails/test-logpath", payload, &resp); err != nil { + return []string{}, nil // Return empty on error + } + return resp.Files, nil +} diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go index ec6326d..60359bf 100644 --- a/internal/fail2ban/connector_local.go +++ b/internal/fail2ban/connector_local.go @@ -252,6 +252,21 @@ func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, log return TestFilterLocal(filterName, logLines) } +// GetJailConfig implements Connector. +func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, error) { + return GetJailConfig(jail) +} + +// SetJailConfig implements Connector. +func (lc *LocalConnector) SetJailConfig(ctx context.Context, jail, content string) error { + return SetJailConfig(jail, content) +} + +// TestLogpath implements Connector. +func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) { + return TestLogpath(logpath) +} + 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 f160a00..264f8f0 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -318,18 +318,24 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string { // GetAllJails implements Connector. func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { - // Read jail.local and jail.d files remotely + // Read jail.local (DEFAULT only) and jail.d files remotely var allJails []JailInfo - // Parse jail.local + // Parse jail.local (only DEFAULT section, skip other jails) jailLocalContent, err := sc.runRemoteCommand(ctx, []string{"cat", "/etc/fail2ban/jail.local"}) if err == nil { + // Filter to only include DEFAULT section jails (though DEFAULT itself isn't returned as a jail) jails := parseJailConfigContent(jailLocalContent) - allJails = append(allJails, jails...) + // Filter out DEFAULT section - we only want actual jails + for _, jail := range jails { + if jail.JailName != "DEFAULT" { + allJails = append(allJails, jail) + } + } } // Parse jail.d directory - jailDCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.conf' -type f" + jailDCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.conf' -type f 2>/dev/null" jailDList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDCmd}) if err == nil && jailDList != "" { for _, file := range strings.Split(jailDList, "\n") { @@ -350,38 +356,79 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { // 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{"cat", "/etc/fail2ban/jail.local"}) + // Ensure jail.d directory exists + _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", "/etc/fail2ban/jail.d"}) if err != nil { - return fmt.Errorf("failed to read jail.local: %w", err) + return fmt.Errorf("failed to create jail.d directory: %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) + // Update each jail in its own file + for jailName, enabled := range updates { + jailPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jailName) + + // Read existing file if it exists + content, err := sc.runRemoteCommand(ctx, []string{"cat", jailPath}) + if err != nil { + // File doesn't exist, create new one + newContent := fmt.Sprintf("[%s]\nenabled = %t\n", jailName, enabled) + cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailPath, newContent) + if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}); err != nil { + return fmt.Errorf("failed to write jail file %s: %w", jailPath, err) + } + continue + } + + // Update enabled state in existing file + lines := strings.Split(content, "\n") + var outputLines []string + var foundEnabled bool + 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(strings.ToLower(trimmed), "enabled") { + if currentJail == jailName { + outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) + foundEnabled = true + } else { + outputLines = append(outputLines, line) + } } else { outputLines = append(outputLines, line) } - } else { - outputLines = append(outputLines, line) + } + + // If enabled line not found, add it after the jail section header + if !foundEnabled { + var newLines []string + for i, line := range outputLines { + newLines = append(newLines, line) + if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) { + newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled)) + if i+1 < len(outputLines) { + newLines = append(newLines, outputLines[i+1:]...) + } + break + } + } + if len(newLines) > len(outputLines) { + outputLines = newLines + } else { + outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) + } + } + + // Write updated content + newContent := strings.Join(outputLines, "\n") + cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailPath, newContent) + if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}); err != nil { + return fmt.Errorf("failed to write jail file %s: %w", jailPath, err) } } - - // Write back - newContent := strings.Join(outputLines, "\n") - cmd := fmt.Sprintf("cat <<'EOF' | tee /etc/fail2ban/jail.local >/dev/null\n%s\nEOF", newContent) - _, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) - return err + return nil } // GetFilters implements Connector. @@ -473,16 +520,94 @@ fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true return out, nil } +// GetJailConfig implements Connector. +func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, error) { + jailPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail) + out, err := sc.runRemoteCommand(ctx, []string{"cat", jailPath}) + if err != nil { + // If file doesn't exist, return empty jail section + return fmt.Sprintf("[%s]\n", jail), nil + } + return out, nil +} + +// SetJailConfig implements Connector. +func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string) error { + jailPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail) + // Ensure jail.d directory exists + _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", "/etc/fail2ban/jail.d"}) + if err != nil { + return fmt.Errorf("failed to create jail.d directory: %w", err) + } + + cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailPath, content) + _, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) + return err +} + +// TestLogpath implements Connector. +func (sc *SSHConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) { + if logpath == "" { + return []string{}, nil + } + + logpath = strings.TrimSpace(logpath) + hasWildcard := strings.ContainsAny(logpath, "*?[") + + var script string + if hasWildcard { + // Use find with glob pattern + script = fmt.Sprintf(` +set -e +LOGPATH=%q +# Use find for glob patterns +find $(dirname "$LOGPATH") -maxdepth 1 -path "$LOGPATH" -type f 2>/dev/null | sort +`, logpath) + } else { + // Check if it's a directory or file + script = fmt.Sprintf(` +set -e +LOGPATH=%q +if [ -d "$LOGPATH" ]; then + find "$LOGPATH" -maxdepth 1 -type f 2>/dev/null | sort +elif [ -f "$LOGPATH" ]; then + echo "$LOGPATH" +fi +`, logpath) + } + + out, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script}) + if err != nil { + return []string{}, nil // Return empty on error + } + + var matches []string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line != "" { + matches = append(matches, line) + } + } + 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 + + // Sections that should be ignored (not jails) + ignoredSections := map[string]bool{ + "DEFAULT": true, + "INCLUDES": true, + } + for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { - if currentJail != "" && currentJail != "DEFAULT" { + if currentJail != "" && !ignoredSections[currentJail] { jails = append(jails, JailInfo{ JailName: currentJail, Enabled: enabled, @@ -498,7 +623,7 @@ func parseJailConfigContent(content string) []JailInfo { } } } - if currentJail != "" && currentJail != "DEFAULT" { + if currentJail != "" && !ignoredSections[currentJail] { jails = append(jails, JailInfo{ JailName: currentJail, Enabled: enabled, diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go index 9f40cf8..e137caa 100644 --- a/internal/fail2ban/jail_management.go +++ b/internal/fail2ban/jail_management.go @@ -6,36 +6,48 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/swissmakers/fail2ban-ui/internal/config" ) -// GetAllJails reads jails from both /etc/fail2ban/jail.local and /etc/fail2ban/jail.d directory. +var ( + migrationOnce sync.Once +) + +// GetAllJails reads jails from /etc/fail2ban/jail.local (DEFAULT only) and /etc/fail2ban/jail.d directory. +// Automatically migrates legacy jails from jail.local to jail.d on first call. func GetAllJails() ([]JailInfo, error) { + // Run migration once if needed + migrationOnce.Do(func() { + if err := MigrateJailsToJailD(); err != nil { + config.DebugLog("Migration warning: %v", err) + } + }) + var jails []JailInfo - // Parse jails from jail.local + // Parse only DEFAULT section from jail.local (skip other jails) localPath := "/etc/fail2ban/jail.local" - localJails, err := parseJailConfigFile(localPath) - if err != nil { - return nil, fmt.Errorf("failed to parse %s: %w", localPath, err) + if _, err := os.Stat(localPath); err == nil { + defaultJails, err := parseJailConfigFileOnlyDefault(localPath) + if err == nil { + jails = append(jails, defaultJails...) + } } - config.DebugLog("############################") - config.DebugLog(fmt.Sprintf("%+v", localJails)) - config.DebugLog("############################") - jails = append(jails, localJails...) - - // Parse jails from jail.d directory, if it exists + // Parse jails from jail.d directory jailDPath := "/etc/fail2ban/jail.d" - files, err := os.ReadDir(jailDPath) - if err == nil { - for _, f := range files { - if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" { - fullPath := filepath.Join(jailDPath, f.Name()) - dJails, err := parseJailConfigFile(fullPath) - if err == nil { - jails = append(jails, dJails...) + if _, err := os.Stat(jailDPath); err == nil { + files, err := os.ReadDir(jailDPath) + if err == nil { + for _, f := range files { + if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" { + fullPath := filepath.Join(jailDPath, f.Name()) + dJails, err := parseJailConfigFile(fullPath) + if err == nil { + jails = append(jails, dJails...) + } } } } @@ -56,13 +68,19 @@ func parseJailConfigFile(path string) ([]JailInfo, error) { scanner := bufio.NewScanner(file) var currentJail string + // Sections that should be ignored (not jails) + ignoredSections := map[string]bool{ + "DEFAULT": true, + "INCLUDES": true, + } + // default value is true if "enabled" is missing; we set it for each section. enabled := true for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { // When a new section starts, save the previous jail if exists. - if currentJail != "" && currentJail != "DEFAULT" { + if currentJail != "" && !ignoredSections[currentJail] { jails = append(jails, JailInfo{ JailName: currentJail, Enabled: enabled, @@ -82,7 +100,7 @@ func parseJailConfigFile(path string) ([]JailInfo, error) { } } // Add the final jail if one exists. - if currentJail != "" && currentJail != "DEFAULT" { + if currentJail != "" && !ignoredSections[currentJail] { jails = append(jails, JailInfo{ JailName: currentJail, Enabled: enabled, @@ -92,24 +110,83 @@ func parseJailConfigFile(path string) ([]JailInfo, error) { } // UpdateJailEnabledStates updates the enabled state for each jail based on the provided updates map. -// It updates /etc/fail2ban/jail.local and attempts to update any jail.d files as well. +// Updates only the corresponding file in /etc/fail2ban/jail.d/ for each jail. func UpdateJailEnabledStates(updates map[string]bool) error { - // Update jail.local file - localPath := "/etc/fail2ban/jail.local" - if err := updateJailConfigFile(localPath, updates); err != nil { - return fmt.Errorf("failed to update %s: %w", localPath, err) - } - // Update jail.d files (if any) jailDPath := "/etc/fail2ban/jail.d" - files, err := os.ReadDir(jailDPath) - if err == nil { - for _, f := range files { - if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" { - fullPath := filepath.Join(jailDPath, f.Name()) - // Ignore error here, as jail.d files might not need to be updated. - _ = updateJailConfigFile(fullPath, updates) + + // Ensure jail.d directory exists + if err := os.MkdirAll(jailDPath, 0755); err != nil { + return fmt.Errorf("failed to create jail.d directory: %w", err) + } + + // Update each jail in its own file + for jailName, enabled := range updates { + jailFilePath := filepath.Join(jailDPath, jailName+".conf") + + // Read existing file if it exists + content, err := os.ReadFile(jailFilePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read jail file %s: %w", jailFilePath, err) + } + + var lines []string + if len(content) > 0 { + lines = strings.Split(string(content), "\n") + } else { + // Create new file with jail section + lines = []string{fmt.Sprintf("[%s]", jailName)} + } + + // Update or add enabled line + var outputLines []string + var foundEnabled bool + 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(strings.ToLower(trimmed), "enabled") { + if currentJail == jailName { + outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) + foundEnabled = true + } else { + outputLines = append(outputLines, line) + } + } else { + outputLines = append(outputLines, line) } } + + // If enabled line not found, add it after the jail section header + if !foundEnabled { + var newLines []string + for i, line := range outputLines { + newLines = append(newLines, line) + if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) { + // Insert enabled line after the section header + newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled)) + // Add remaining lines + if i+1 < len(outputLines) { + newLines = append(newLines, outputLines[i+1:]...) + } + break + } + } + if len(newLines) > len(outputLines) { + outputLines = newLines + } else { + // Fallback: append at the end + outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) + } + } + + // Write updated content + newContent := strings.Join(outputLines, "\n") + if err := os.WriteFile(jailFilePath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) + } } return nil } @@ -148,3 +225,380 @@ func updateJailConfigFile(path string, updates map[string]bool) error { newContent := strings.Join(outputLines, "\n") return os.WriteFile(path, []byte(newContent), 0644) } + +// MigrateJailsToJailD migrates all non-DEFAULT jails from jail.local to individual files in jail.d/. +// Creates a backup of jail.local before migration. If a jail already exists in jail.d, jail.local takes precedence. +func MigrateJailsToJailD() error { + localPath := "/etc/fail2ban/jail.local" + jailDPath := "/etc/fail2ban/jail.d" + + // Check if jail.local exists + if _, err := os.Stat(localPath); os.IsNotExist(err) { + return nil // Nothing to migrate + } + + // Read jail.local content + content, err := os.ReadFile(localPath) + if err != nil { + return fmt.Errorf("failed to read jail.local: %w", err) + } + + // Parse content to extract sections + sections, defaultContent, err := parseJailSections(string(content)) + if err != nil { + return fmt.Errorf("failed to parse jail.local: %w", err) + } + + // If no non-DEFAULT jails found, nothing to migrate + if len(sections) == 0 { + return nil + } + + // Create backup of jail.local + backupPath := localPath + ".backup." + fmt.Sprintf("%d", os.Getpid()) + if err := os.WriteFile(backupPath, content, 0644); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + config.DebugLog("Created backup of jail.local at %s", backupPath) + + // Ensure jail.d directory exists + if err := os.MkdirAll(jailDPath, 0755); err != nil { + return fmt.Errorf("failed to create jail.d directory: %w", err) + } + + // Write each jail to its own file in jail.d/ + for jailName, jailContent := range sections { + jailFilePath := filepath.Join(jailDPath, jailName+".conf") + + // Check if file already exists + if _, err := os.Stat(jailFilePath); err == nil { + // File exists - jail.local takes precedence, so overwrite + config.DebugLog("Overwriting existing jail file %s with content from jail.local", jailFilePath) + } + + // Write jail content to file + if err := os.WriteFile(jailFilePath, []byte(jailContent), 0644); err != nil { + return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) + } + } + + // Rewrite jail.local with only DEFAULT section + newLocalContent := defaultContent + if !strings.HasSuffix(newLocalContent, "\n") { + newLocalContent += "\n" + } + if err := os.WriteFile(localPath, []byte(newLocalContent), 0644); err != nil { + return fmt.Errorf("failed to rewrite jail.local: %w", err) + } + + config.DebugLog("Migration completed: moved %d jails to jail.d/", len(sections)) + return nil +} + +// parseJailSections parses jail.local content and returns: +// - map of jail name to jail content (excluding DEFAULT and INCLUDES) +// - DEFAULT section content +func parseJailSections(content string) (map[string]string, string, error) { + sections := make(map[string]string) + var defaultContent strings.Builder + + // Sections that should be ignored (not jails) + ignoredSections := map[string]bool{ + "DEFAULT": true, + "INCLUDES": true, + } + + scanner := bufio.NewScanner(strings.NewReader(content)) + var currentSection string + var currentContent strings.Builder + inDefault := false + + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + // Save previous section + if currentSection != "" { + sectionContent := strings.TrimSpace(currentContent.String()) + if inDefault { + defaultContent.WriteString(sectionContent) + if !strings.HasSuffix(sectionContent, "\n") { + defaultContent.WriteString("\n") + } + } else if !ignoredSections[currentSection] { + // Only save if it's not an ignored section + sections[currentSection] = sectionContent + } + } + + // Start new section + currentSection = strings.Trim(trimmed, "[]") + currentContent.Reset() + currentContent.WriteString(line) + currentContent.WriteString("\n") + inDefault = (currentSection == "DEFAULT") + } else { + currentContent.WriteString(line) + currentContent.WriteString("\n") + } + } + + // Save final section + if currentSection != "" { + sectionContent := strings.TrimSpace(currentContent.String()) + if inDefault { + defaultContent.WriteString(sectionContent) + } else if !ignoredSections[currentSection] { + // Only save if it's not an ignored section + sections[currentSection] = sectionContent + } + } + + return sections, defaultContent.String(), scanner.Err() +} + +// parseJailConfigFileOnlyDefault parses only the DEFAULT section from a jail config file. +func parseJailConfigFileOnlyDefault(path string) ([]JailInfo, error) { + var jails []JailInfo + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + // We scan through the file but don't return any jails from DEFAULT section + // This function exists to validate the file can be read + for scanner.Scan() { + // Just scan through - we don't need to parse anything for DEFAULT + } + + // We don't return DEFAULT as a jail, but we could if needed + // For now, we only return jails from DEFAULT if they're explicitly listed (unlikely) + return jails, scanner.Err() +} + +// GetJailConfig reads the full jail configuration from /etc/fail2ban/jail.d/{jailName}.conf +func GetJailConfig(jailName string) (string, error) { + config.DebugLog("GetJailConfig called for jail: %s", jailName) + jailDPath := "/etc/fail2ban/jail.d" + jailFilePath := filepath.Join(jailDPath, jailName+".conf") + config.DebugLog("Reading jail config from: %s", jailFilePath) + + content, err := os.ReadFile(jailFilePath) + if err != nil { + if os.IsNotExist(err) { + config.DebugLog("Jail config file does not exist, returning empty section") + // Return empty jail section if file doesn't exist + return fmt.Sprintf("[%s]\n", jailName), nil + } + config.DebugLog("Failed to read jail config file: %v", err) + return "", fmt.Errorf("failed to read jail config for %s: %w", jailName, err) + } + + config.DebugLog("Jail config read successfully, length: %d", len(content)) + return string(content), nil +} + +// SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.conf +func SetJailConfig(jailName, content string) error { + config.DebugLog("SetJailConfig called for jail: %s, content length: %d", jailName, len(content)) + + jailDPath := "/etc/fail2ban/jail.d" + + // Ensure jail.d directory exists + if err := os.MkdirAll(jailDPath, 0755); err != nil { + config.DebugLog("Failed to create jail.d directory: %v", err) + return fmt.Errorf("failed to create jail.d directory: %w", err) + } + config.DebugLog("jail.d directory ensured") + + // Validate and fix the jail section header + // The content might start with comments, so we need to find the section header + trimmed := strings.TrimSpace(content) + if trimmed == "" { + config.DebugLog("Content is empty, creating minimal jail config") + content = fmt.Sprintf("[%s]\n", jailName) + } else { + expectedSection := fmt.Sprintf("[%s]", jailName) + lines := strings.Split(content, "\n") + sectionFound := false + sectionIndex := -1 + var sectionIndices []int + + // Find all section headers in the content + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + if strings.HasPrefix(trimmedLine, "[") && strings.HasSuffix(trimmedLine, "]") { + sectionIndices = append(sectionIndices, i) + if trimmedLine == expectedSection { + if !sectionFound { + sectionIndex = i + sectionFound = true + config.DebugLog("Correct section header found at line %d", i) + } else { + config.DebugLog("Duplicate correct section header found at line %d, will remove", i) + } + } else { + config.DebugLog("Incorrect section header found at line %d: %s (expected %s)", i, trimmedLine, expectedSection) + if sectionIndex == -1 { + sectionIndex = i + } + } + } + } + + // Remove duplicate section headers (keep only the first correct one) + if len(sectionIndices) > 1 { + config.DebugLog("Found %d section headers, removing duplicates", len(sectionIndices)) + var newLines []string + keptFirst := false + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + isSectionHeader := strings.HasPrefix(trimmedLine, "[") && strings.HasSuffix(trimmedLine, "]") + + if isSectionHeader { + if !keptFirst && trimmedLine == expectedSection { + // Keep the first correct section header + newLines = append(newLines, expectedSection) + keptFirst = true + config.DebugLog("Keeping section header at line %d", i) + } else { + // Skip duplicate or incorrect section headers + config.DebugLog("Removing duplicate/incorrect section header at line %d: %s", i, trimmedLine) + continue + } + } else { + newLines = append(newLines, line) + } + } + lines = newLines + } + + if !sectionFound { + if sectionIndex >= 0 { + // Replace incorrect section header + config.DebugLog("Replacing incorrect section header at line %d", sectionIndex) + lines[sectionIndex] = expectedSection + } else { + // No section header found, prepend it + config.DebugLog("No section header found, prepending %s", expectedSection) + lines = append([]string{expectedSection}, lines...) + } + content = strings.Join(lines, "\n") + } else { + // Section header is correct, but we may have removed duplicates + content = strings.Join(lines, "\n") + } + } + + jailFilePath := filepath.Join(jailDPath, jailName+".conf") + config.DebugLog("Writing jail config to: %s", jailFilePath) + if err := os.WriteFile(jailFilePath, []byte(content), 0644); err != nil { + config.DebugLog("Failed to write jail config: %v", err) + return fmt.Errorf("failed to write jail config for %s: %w", jailName, err) + } + config.DebugLog("Jail config written successfully") + + return nil +} + +// TestLogpath tests a logpath pattern and returns matching files. +// Supports wildcards/glob patterns (e.g., /var/log/*.log) and directory paths. +func TestLogpath(logpath string) ([]string, error) { + if logpath == "" { + return []string{}, nil + } + + // Trim whitespace + logpath = strings.TrimSpace(logpath) + + // Check if it's a glob pattern (contains *, ?, or [) + hasWildcard := strings.ContainsAny(logpath, "*?[") + + var matches []string + + if hasWildcard { + // Use filepath.Glob for pattern matching + matched, err := filepath.Glob(logpath) + if err != nil { + return nil, fmt.Errorf("invalid glob pattern: %w", err) + } + matches = matched + } else { + // Check if it's a directory + info, err := os.Stat(logpath) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil // Path doesn't exist, return empty + } + return nil, fmt.Errorf("failed to stat path: %w", err) + } + + if info.IsDir() { + // List files in directory + entries, err := os.ReadDir(logpath) + if err != nil { + return nil, fmt.Errorf("failed to read directory: %w", err) + } + for _, entry := range entries { + if !entry.IsDir() { + fullPath := filepath.Join(logpath, entry.Name()) + matches = append(matches, fullPath) + } + } + } else { + // It's a file, return it + matches = []string{logpath} + } + } + + return matches, nil +} + +// parseJailSection extracts the logpath from a jail configuration content. +func parseJailSection(content string, jailName string) (string, error) { + // This function can be used to extract specific settings from jail config + // For now, we'll use it to find logpath + scanner := bufio.NewScanner(strings.NewReader(content)) + var inTargetJail bool + var jailContent strings.Builder + + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + currentJail := strings.Trim(trimmed, "[]") + if currentJail == jailName { + inTargetJail = true + jailContent.Reset() + jailContent.WriteString(line) + jailContent.WriteString("\n") + } else { + inTargetJail = false + } + } else if inTargetJail { + jailContent.WriteString(line) + jailContent.WriteString("\n") + } + } + + return jailContent.String(), scanner.Err() +} + +// ExtractLogpathFromJailConfig extracts the logpath value from jail configuration content. +func ExtractLogpathFromJailConfig(jailContent string) string { + scanner := bufio.NewScanner(strings.NewReader(jailContent)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(strings.ToLower(line), "logpath") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[1]) + } + } + } + return "" +} diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go index 03543c2..d767be8 100644 --- a/internal/fail2ban/manager.go +++ b/internal/fail2ban/manager.go @@ -29,6 +29,11 @@ type Connector interface { // Filter operations GetFilters(ctx context.Context) ([]string, error) TestFilter(ctx context.Context, filterName string, logLines []string) (string, error) + + // Jail configuration operations + GetJailConfig(ctx context.Context, jail string) (string, error) + SetJailConfig(ctx context.Context, jail, content string) error + TestLogpath(ctx context.Context, logpath string) ([]string, error) } // Manager orchestrates all connectors for configured Fail2ban servers. diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index ef42dd8..6d13173 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -664,58 +664,172 @@ func IndexHandler(c *gin.Context) { }) } -// GetJailFilterConfigHandler returns the raw filter config for a given jail +// GetJailFilterConfigHandler returns both the filter config and jail config for a given jail func GetJailFilterConfigHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("GetJailFilterConfigHandler called (handlers.go)") // entry point jail := c.Param("jail") + config.DebugLog("Jail name: %s", jail) + conn, err := resolveConnector(c) if err != nil { + config.DebugLog("Failed to resolve connector: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - cfg, err := conn.GetFilterConfig(c.Request.Context(), jail) + config.DebugLog("Connector resolved: %s", conn.Server().Name) + + config.DebugLog("Loading filter config for jail: %s", jail) + filterCfg, err := conn.GetFilterConfig(c.Request.Context(), jail) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + config.DebugLog("Failed to load filter config: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load filter config: " + err.Error()}) return } + config.DebugLog("Filter config loaded, length: %d", len(filterCfg)) + + config.DebugLog("Loading jail config for jail: %s", jail) + jailCfg, err := conn.GetJailConfig(c.Request.Context(), jail) + if err != nil { + config.DebugLog("Failed to load jail config: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + err.Error()}) + return + } + config.DebugLog("Jail config loaded, length: %d", len(jailCfg)) + c.JSON(http.StatusOK, gin.H{ "jail": jail, - "config": cfg, + "filter": filterCfg, + "jailConfig": jailCfg, }) } -// SetJailFilterConfigHandler overwrites the current filter config with new content +// SetJailFilterConfigHandler overwrites both the filter config and jail config with new content func SetJailFilterConfigHandler(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + config.DebugLog("PANIC in SetJailFilterConfigHandler: %v", r) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Internal server error: %v", r)}) + } + }() + config.DebugLog("----------------------------") config.DebugLog("SetJailFilterConfigHandler called (handlers.go)") // entry point jail := c.Param("jail") + config.DebugLog("Jail name: %s", jail) + + conn, err := resolveConnector(c) + if err != nil { + config.DebugLog("Failed to resolve connector: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + config.DebugLog("Connector resolved: %s (type: %s)", conn.Server().Name, conn.Server().Type) + + // Parse JSON body (containing both filter and jail content) + var req struct { + Filter string `json:"filter"` + Jail string `json:"jail"` + } + if err := c.ShouldBindJSON(&req); err != nil { + config.DebugLog("Failed to parse JSON body: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body: " + err.Error()}) + return + } + config.DebugLog("Request parsed - Filter length: %d, Jail length: %d", len(req.Filter), len(req.Jail)) + if len(req.Filter) > 0 { + config.DebugLog("Filter preview (first 100 chars): %s", req.Filter[:min(100, len(req.Filter))]) + } + if len(req.Jail) > 0 { + config.DebugLog("Jail preview (first 100 chars): %s", req.Jail[:min(100, len(req.Jail))]) + } + + // Save filter config + if req.Filter != "" { + config.DebugLog("Saving filter config for jail: %s", jail) + if err := conn.SetFilterConfig(c.Request.Context(), jail, req.Filter); err != nil { + config.DebugLog("Failed to save filter config: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save filter config: " + err.Error()}) + return + } + config.DebugLog("Filter config saved successfully") + } else { + config.DebugLog("No filter config provided, skipping") + } + + // Save jail config + if req.Jail != "" { + config.DebugLog("Saving jail config for jail: %s", jail) + if err := conn.SetJailConfig(c.Request.Context(), jail, req.Jail); err != nil { + config.DebugLog("Failed to save jail config: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save jail config: " + err.Error()}) + return + } + config.DebugLog("Jail config saved successfully") + } else { + config.DebugLog("No jail config provided, skipping") + } + + // Reload fail2ban + config.DebugLog("Reloading fail2ban") + if err := conn.Reload(c.Request.Context()); err != nil { + config.DebugLog("Failed to reload fail2ban: %v", err) + // Still return success but warn about reload failure + // The config was saved successfully, user can manually reload + c.JSON(http.StatusOK, gin.H{ + "message": "Config saved successfully, but fail2ban reload failed", + "warning": "Please check the fail2ban configuration and reload manually: " + err.Error(), + }) + return + } + config.DebugLog("Fail2ban reloaded successfully") + + c.JSON(http.StatusOK, gin.H{"message": "Filter and jail config updated and fail2ban reloaded"}) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// TestLogpathHandler tests a logpath and returns matching files +func TestLogpathHandler(c *gin.Context) { + config.DebugLog("----------------------------") + config.DebugLog("TestLogpathHandler called (handlers.go)") // entry point + jail := c.Param("jail") conn, err := resolveConnector(c) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Parse JSON body (containing the new filter content) - var req struct { - Config string `json:"config"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) + // Get jail config to extract logpath + jailCfg, err := conn.GetJailConfig(c.Request.Context(), jail) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + err.Error()}) return } - if err := conn.SetFilterConfig(c.Request.Context(), jail, req.Config); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + // Extract logpath from jail config + logpath := fail2ban.ExtractLogpathFromJailConfig(jailCfg) + if logpath == "" { + c.JSON(http.StatusOK, gin.H{"files": []string{}, "message": "No logpath configured for this jail"}) return } - if err := conn.Reload(c.Request.Context()); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "filter saved but reload failed: " + err.Error()}) + // Test the logpath + files, err := conn.TestLogpath(c.Request.Context(), logpath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test logpath: " + err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"message": "Filter updated and fail2ban reloaded"}) + c.JSON(http.StatusOK, gin.H{ + "logpath": logpath, + "files": files, + }) } // ManageJailsHandler returns a list of all jails (from jail.local and jail.d) diff --git a/pkg/web/routes.go b/pkg/web/routes.go index ef4d58e..32c5617 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -34,6 +34,7 @@ func RegisterRoutes(r *gin.Engine) { // Routes for jail-filter management (TODO: rename API-call) api.GET("/jails/:jail/config", GetJailFilterConfigHandler) api.POST("/jails/:jail/config", SetJailFilterConfigHandler) + api.POST("/jails/:jail/logpath/test", TestLogpathHandler) // Routes for jail management api.GET("/jails/manage", ManageJailsHandler) diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 3391eba..f0ed523 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -627,39 +627,80 @@