2025-02-26 16:55:21 +01:00
|
|
|
package fail2ban
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2025-12-04 19:42:43 +01:00
|
|
|
"regexp"
|
2025-02-26 16:55:21 +01:00
|
|
|
"strings"
|
2025-12-03 20:43:44 +01:00
|
|
|
"sync"
|
2025-12-06 15:32:28 +01:00
|
|
|
"time"
|
2025-02-26 16:55:21 +01:00
|
|
|
|
|
|
|
|
"github.com/swissmakers/fail2ban-ui/internal/config"
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-03 20:43:44 +01:00
|
|
|
var (
|
|
|
|
|
migrationOnce sync.Once
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-05 16:47:05 +01:00
|
|
|
// ensureJailLocalFile ensures that a .local file exists for the given jail.
|
|
|
|
|
// If .local doesn't exist, it copies from .conf if available, or creates a minimal section.
|
|
|
|
|
func ensureJailLocalFile(jailName string) error {
|
|
|
|
|
// Validate jail name - must not be empty
|
|
|
|
|
jailName = strings.TrimSpace(jailName)
|
|
|
|
|
if jailName == "" {
|
|
|
|
|
return fmt.Errorf("jail name cannot be empty")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
jailDPath := "/etc/fail2ban/jail.d"
|
|
|
|
|
localPath := filepath.Join(jailDPath, jailName+".local")
|
|
|
|
|
confPath := filepath.Join(jailDPath, jailName+".conf")
|
|
|
|
|
|
|
|
|
|
// Check if .local already exists
|
|
|
|
|
if _, err := os.Stat(localPath); err == nil {
|
|
|
|
|
config.DebugLog("Jail .local file already exists: %s", localPath)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to copy from .conf if it exists
|
|
|
|
|
if _, err := os.Stat(confPath); err == nil {
|
|
|
|
|
config.DebugLog("Copying jail config from .conf to .local: %s -> %s", confPath, localPath)
|
|
|
|
|
content, err := os.ReadFile(confPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to read jail .conf file %s: %w", confPath, err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(localPath, content, 0644); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to write jail .local file %s: %w", localPath, err)
|
|
|
|
|
}
|
|
|
|
|
config.DebugLog("Successfully copied jail config to .local file")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Neither exists, create minimal section
|
|
|
|
|
config.DebugLog("Creating minimal jail .local file: %s", localPath)
|
|
|
|
|
if err := os.MkdirAll(jailDPath, 0755); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to create jail.d directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
minimalContent := fmt.Sprintf("[%s]\n", jailName)
|
|
|
|
|
if err := os.WriteFile(localPath, []byte(minimalContent), 0644); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to create jail .local file %s: %w", localPath, err)
|
|
|
|
|
}
|
|
|
|
|
config.DebugLog("Successfully created minimal jail .local file")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// readJailConfigWithFallback reads jail config from .local first, then falls back to .conf.
|
2025-12-30 01:10:49 +01:00
|
|
|
// Returns (content, filePath, error)
|
|
|
|
|
func readJailConfigWithFallback(jailName string) (string, string, error) {
|
2025-12-05 16:47:05 +01:00
|
|
|
// Validate jail name - must not be empty
|
|
|
|
|
jailName = strings.TrimSpace(jailName)
|
|
|
|
|
if jailName == "" {
|
2025-12-30 01:10:49 +01:00
|
|
|
return "", "", fmt.Errorf("jail name cannot be empty")
|
2025-12-05 16:47:05 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
jailDPath := "/etc/fail2ban/jail.d"
|
|
|
|
|
localPath := filepath.Join(jailDPath, jailName+".local")
|
|
|
|
|
confPath := filepath.Join(jailDPath, jailName+".conf")
|
|
|
|
|
|
|
|
|
|
// Try .local first
|
|
|
|
|
if content, err := os.ReadFile(localPath); err == nil {
|
|
|
|
|
config.DebugLog("Reading jail config from .local: %s", localPath)
|
2025-12-30 01:10:49 +01:00
|
|
|
return string(content), localPath, nil
|
2025-12-05 16:47:05 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback to .conf
|
|
|
|
|
if content, err := os.ReadFile(confPath); err == nil {
|
|
|
|
|
config.DebugLog("Reading jail config from .conf: %s", confPath)
|
2025-12-30 01:10:49 +01:00
|
|
|
return string(content), confPath, nil
|
2025-12-05 16:47:05 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-30 01:10:49 +01:00
|
|
|
// Neither exists, return empty section with .local path (will be created on save)
|
2025-12-05 16:47:05 +01:00
|
|
|
config.DebugLog("Neither .local nor .conf exists for jail %s, returning empty section", jailName)
|
2025-12-30 01:10:49 +01:00
|
|
|
return fmt.Sprintf("[%s]\n", jailName), localPath, nil
|
2025-12-05 16:47:05 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-30 01:10:49 +01:00
|
|
|
// ValidateJailName validates a jail name format.
|
|
|
|
|
// Returns an error if the name is invalid (empty, contains invalid characters, or is reserved).
|
|
|
|
|
func ValidateJailName(name string) error {
|
|
|
|
|
name = strings.TrimSpace(name)
|
|
|
|
|
if name == "" {
|
|
|
|
|
return fmt.Errorf("jail name cannot be empty")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reserved names that should not be used
|
|
|
|
|
reservedNames := map[string]bool{
|
|
|
|
|
"DEFAULT": true,
|
|
|
|
|
"INCLUDES": true,
|
|
|
|
|
}
|
|
|
|
|
if reservedNames[strings.ToUpper(name)] {
|
|
|
|
|
return fmt.Errorf("jail name '%s' is reserved and cannot be used", name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for invalid characters (only alphanumeric, dash, underscore allowed)
|
|
|
|
|
invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
|
|
|
|
if invalidChars.MatchString(name) {
|
|
|
|
|
return fmt.Errorf("jail name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ListJailFiles lists all jail config files in the specified directory.
|
|
|
|
|
// Returns full paths to .local and .conf files.
|
|
|
|
|
func ListJailFiles(directory string) ([]string, error) {
|
|
|
|
|
var files []string
|
|
|
|
|
|
|
|
|
|
entries, err := os.ReadDir(directory)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to read jail directory %s: %w", directory, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, entry := range entries {
|
|
|
|
|
if entry.IsDir() {
|
|
|
|
|
continue
|
2025-12-03 20:43:44 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-30 01:10:49 +01:00
|
|
|
name := entry.Name()
|
|
|
|
|
// Skip hidden files and invalid names
|
|
|
|
|
if strings.HasPrefix(name, ".") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2025-02-26 16:55:21 +01:00
|
|
|
|
2025-12-30 01:10:49 +01:00
|
|
|
// Only include .local and .conf files
|
|
|
|
|
if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") {
|
|
|
|
|
fullPath := filepath.Join(directory, name)
|
|
|
|
|
files = append(files, fullPath)
|
2025-12-03 20:43:44 +01:00
|
|
|
}
|
2025-02-26 16:55:21 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-30 01:10:49 +01:00
|
|
|
return files, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DiscoverJailsFromFiles discovers all jails from the filesystem.
|
|
|
|
|
// Reads from /etc/fail2ban/jail.d/ directory, preferring .local files over .conf files.
|
|
|
|
|
// Returns all jails found (enabled and disabled).
|
|
|
|
|
func DiscoverJailsFromFiles() ([]JailInfo, error) {
|
2025-02-26 16:55:21 +01:00
|
|
|
jailDPath := "/etc/fail2ban/jail.d"
|
2025-12-30 01:10:49 +01:00
|
|
|
|
|
|
|
|
// Check if directory exists
|
|
|
|
|
if _, err := os.Stat(jailDPath); os.IsNotExist(err) {
|
|
|
|
|
// Directory doesn't exist, return empty list
|
|
|
|
|
return []JailInfo{}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// List all jail files
|
|
|
|
|
files, err := ListJailFiles(jailDPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var allJails []JailInfo
|
|
|
|
|
processedFiles := make(map[string]bool) // Track base names to avoid duplicates
|
|
|
|
|
processedJails := make(map[string]bool) // Track jail names to avoid duplicates
|
|
|
|
|
|
|
|
|
|
// First pass: process all .local files
|
|
|
|
|
for _, filePath := range files {
|
|
|
|
|
if !strings.HasSuffix(filePath, ".local") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filename := filepath.Base(filePath)
|
|
|
|
|
baseName := strings.TrimSuffix(filename, ".local")
|
|
|
|
|
if baseName == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip if we've already processed this base name
|
|
|
|
|
if processedFiles[baseName] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
processedFiles[baseName] = true
|
|
|
|
|
|
|
|
|
|
// Parse the file
|
|
|
|
|
jails, err := parseJailConfigFile(filePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
config.DebugLog("Failed to parse jail file %s: %v", filePath, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add jails from this file
|
|
|
|
|
for _, jail := range jails {
|
|
|
|
|
if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] {
|
|
|
|
|
allJails = append(allJails, jail)
|
|
|
|
|
processedJails[jail.JailName] = true
|
2025-12-05 16:47:05 +01:00
|
|
|
}
|
2025-12-30 01:10:49 +01:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-05 16:47:05 +01:00
|
|
|
|
2025-12-30 01:10:49 +01:00
|
|
|
// Second pass: process .conf files that don't have corresponding .local files
|
|
|
|
|
for _, filePath := range files {
|
|
|
|
|
if !strings.HasSuffix(filePath, ".conf") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filename := filepath.Base(filePath)
|
|
|
|
|
baseName := strings.TrimSuffix(filename, ".conf")
|
|
|
|
|
if baseName == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip if we've already processed a .local file with the same base name
|
|
|
|
|
if processedFiles[baseName] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
processedFiles[baseName] = true
|
|
|
|
|
|
|
|
|
|
// Parse the file
|
|
|
|
|
jails, err := parseJailConfigFile(filePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
config.DebugLog("Failed to parse jail file %s: %v", filePath, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add jails from this file
|
|
|
|
|
for _, jail := range jails {
|
|
|
|
|
if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] {
|
|
|
|
|
allJails = append(allJails, jail)
|
|
|
|
|
processedJails[jail.JailName] = true
|
2025-02-26 16:55:21 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-30 01:10:49 +01:00
|
|
|
|
|
|
|
|
return allJails, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CreateJail creates a new jail in jail.d/{name}.local.
|
|
|
|
|
// If the jail already exists, it will be overwritten.
|
|
|
|
|
func CreateJail(jailName, content string) error {
|
|
|
|
|
if err := ValidateJailName(jailName); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
jailDPath := "/etc/fail2ban/jail.d"
|
|
|
|
|
localPath := filepath.Join(jailDPath, jailName+".local")
|
|
|
|
|
|
|
|
|
|
// Ensure directory exists
|
|
|
|
|
if err := os.MkdirAll(jailDPath, 0755); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to create jail.d directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate content starts with correct section header
|
|
|
|
|
trimmed := strings.TrimSpace(content)
|
|
|
|
|
expectedSection := fmt.Sprintf("[%s]", jailName)
|
|
|
|
|
if !strings.HasPrefix(trimmed, expectedSection) {
|
|
|
|
|
// Prepend the section header if missing
|
|
|
|
|
content = expectedSection + "\n" + content
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write the file
|
|
|
|
|
if err := os.WriteFile(localPath, []byte(content), 0644); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to create jail file %s: %w", localPath, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
config.DebugLog("Created jail file: %s", localPath)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DeleteJail deletes a jail's .local and .conf files from jail.d/ if they exist.
|
|
|
|
|
// Both files are deleted to ensure complete removal of the jail configuration.
|
|
|
|
|
func DeleteJail(jailName string) error {
|
|
|
|
|
if err := ValidateJailName(jailName); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
jailDPath := "/etc/fail2ban/jail.d"
|
|
|
|
|
localPath := filepath.Join(jailDPath, jailName+".local")
|
|
|
|
|
confPath := filepath.Join(jailDPath, jailName+".conf")
|
|
|
|
|
|
|
|
|
|
var deletedFiles []string
|
|
|
|
|
var lastErr error
|
|
|
|
|
|
|
|
|
|
// Delete .local file if it exists
|
|
|
|
|
if _, err := os.Stat(localPath); err == nil {
|
|
|
|
|
if err := os.Remove(localPath); err != nil {
|
|
|
|
|
lastErr = fmt.Errorf("failed to delete jail file %s: %w", localPath, err)
|
|
|
|
|
} else {
|
|
|
|
|
deletedFiles = append(deletedFiles, localPath)
|
|
|
|
|
config.DebugLog("Deleted jail file: %s", localPath)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete .conf file if it exists
|
|
|
|
|
if _, err := os.Stat(confPath); err == nil {
|
|
|
|
|
if err := os.Remove(confPath); err != nil {
|
|
|
|
|
lastErr = fmt.Errorf("failed to delete jail file %s: %w", confPath, err)
|
|
|
|
|
} else {
|
|
|
|
|
deletedFiles = append(deletedFiles, confPath)
|
|
|
|
|
config.DebugLog("Deleted jail file: %s", confPath)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no files were deleted and no error occurred, it means neither file existed
|
|
|
|
|
if len(deletedFiles) == 0 && lastErr == nil {
|
|
|
|
|
return fmt.Errorf("jail file %s or %s does not exist", localPath, confPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return the last error if any occurred
|
|
|
|
|
if lastErr != nil {
|
|
|
|
|
return lastErr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
// Now uses DiscoverJailsFromFiles() for file-based discovery.
|
|
|
|
|
func GetAllJails() ([]JailInfo, error) {
|
|
|
|
|
// Run migration once if needed
|
|
|
|
|
migrationOnce.Do(func() {
|
|
|
|
|
if err := MigrateJailsToJailD(); err != nil {
|
|
|
|
|
config.DebugLog("Migration warning: %v", err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Discover jails from filesystem
|
|
|
|
|
jails, err := DiscoverJailsFromFiles()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to discover jails from files: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-26 16:55:21 +01:00
|
|
|
return jails, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseJailConfigFile parses a jail configuration file and returns a slice of JailInfo.
|
|
|
|
|
// It assumes each jail section is defined by [JailName] and that an "enabled" line may exist.
|
|
|
|
|
func parseJailConfigFile(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)
|
|
|
|
|
var currentJail string
|
|
|
|
|
|
2025-12-03 20:43:44 +01:00
|
|
|
// Sections that should be ignored (not jails)
|
|
|
|
|
ignoredSections := map[string]bool{
|
|
|
|
|
"DEFAULT": true,
|
|
|
|
|
"INCLUDES": true,
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-26 16:55:21 +01:00
|
|
|
// 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.
|
2025-12-03 20:43:44 +01:00
|
|
|
if currentJail != "" && !ignoredSections[currentJail] {
|
2025-02-26 16:55:21 +01:00
|
|
|
jails = append(jails, JailInfo{
|
|
|
|
|
JailName: currentJail,
|
|
|
|
|
Enabled: enabled,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
// Start a new jail section.
|
2025-12-05 16:47:05 +01:00
|
|
|
currentJail = strings.TrimSpace(strings.Trim(line, "[]"))
|
|
|
|
|
// Skip empty jail names (e.g., from malformed config files with [])
|
|
|
|
|
if currentJail == "" {
|
|
|
|
|
currentJail = "" // Reset to empty to skip this section
|
|
|
|
|
enabled = true
|
|
|
|
|
continue
|
|
|
|
|
}
|
2025-02-26 16:55:21 +01:00
|
|
|
// Reset to default for the new section.
|
|
|
|
|
enabled = true
|
|
|
|
|
} else if strings.HasPrefix(strings.ToLower(line), "enabled") {
|
2025-12-05 16:47:05 +01:00
|
|
|
// Only process enabled line if we have a valid jail name
|
|
|
|
|
if currentJail != "" {
|
|
|
|
|
// Expect format: enabled = true/false
|
|
|
|
|
parts := strings.Split(line, "=")
|
|
|
|
|
if len(parts) == 2 {
|
|
|
|
|
value := strings.TrimSpace(parts[1])
|
|
|
|
|
enabled = strings.EqualFold(value, "true")
|
|
|
|
|
}
|
2025-02-26 16:55:21 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Add the final jail if one exists.
|
2025-12-03 20:43:44 +01:00
|
|
|
if currentJail != "" && !ignoredSections[currentJail] {
|
2025-02-26 16:55:21 +01:00
|
|
|
jails = append(jails, JailInfo{
|
|
|
|
|
JailName: currentJail,
|
|
|
|
|
Enabled: enabled,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return jails, scanner.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// UpdateJailEnabledStates updates the enabled state for each jail based on the provided updates map.
|
2025-12-05 16:47:05 +01:00
|
|
|
// Updates only the corresponding .local file in /etc/fail2ban/jail.d/ for each jail.
|
|
|
|
|
// Creates .local file by copying from .conf if needed, preserving original .conf files.
|
2025-02-26 16:55:21 +01:00
|
|
|
func UpdateJailEnabledStates(updates map[string]bool) error {
|
2025-12-05 14:30:28 +01:00
|
|
|
config.DebugLog("UpdateJailEnabledStates called with %d updates: %+v", len(updates), updates)
|
2025-02-26 16:55:21 +01:00
|
|
|
jailDPath := "/etc/fail2ban/jail.d"
|
2025-12-03 20:43:44 +01:00
|
|
|
|
|
|
|
|
// Ensure jail.d directory exists
|
|
|
|
|
if err := os.MkdirAll(jailDPath, 0755); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to create jail.d directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 16:47:05 +01:00
|
|
|
// Update each jail in its own .local file
|
2025-12-03 20:43:44 +01:00
|
|
|
for jailName, enabled := range updates {
|
2025-12-05 16:47:05 +01:00
|
|
|
// Validate jail name - skip empty or invalid names
|
|
|
|
|
jailName = strings.TrimSpace(jailName)
|
|
|
|
|
if jailName == "" {
|
|
|
|
|
config.DebugLog("Skipping empty jail name in updates map")
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 14:30:28 +01:00
|
|
|
config.DebugLog("Processing jail: %s, enabled: %t", jailName, enabled)
|
2025-12-05 16:47:05 +01:00
|
|
|
|
|
|
|
|
// Ensure .local file exists (copy from .conf if needed)
|
|
|
|
|
if err := ensureJailLocalFile(jailName); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
jailFilePath := filepath.Join(jailDPath, jailName+".local")
|
2025-12-05 14:30:28 +01:00
|
|
|
config.DebugLog("Jail file path: %s", jailFilePath)
|
2025-12-03 20:43:44 +01:00
|
|
|
|
2025-12-05 16:47:05 +01:00
|
|
|
// Read existing .local file
|
2025-12-03 20:43:44 +01:00
|
|
|
content, err := os.ReadFile(jailFilePath)
|
2025-12-05 16:47:05 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to read jail .local file %s: %w", jailFilePath, err)
|
2025-12-03 20:43:44 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2025-02-26 16:55:21 +01:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-03 20:43:44 +01:00
|
|
|
|
|
|
|
|
// 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")
|
2025-12-05 14:30:28 +01:00
|
|
|
if !strings.HasSuffix(newContent, "\n") {
|
|
|
|
|
newContent += "\n"
|
|
|
|
|
}
|
2025-12-03 20:43:44 +01:00
|
|
|
if err := os.WriteFile(jailFilePath, []byte(newContent), 0644); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err)
|
|
|
|
|
}
|
2025-12-05 14:30:28 +01:00
|
|
|
config.DebugLog("Updated jail %s: enabled = %t (file: %s)", jailName, enabled, jailFilePath)
|
2025-02-26 16:55:21 +01:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 20:43:44 +01:00
|
|
|
// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 15:32:28 +01:00
|
|
|
// parseJailSectionsUncommented parses jail.local content and returns:
|
|
|
|
|
// - map of jail name to jail content (excluding DEFAULT, INCLUDES, and commented sections)
|
|
|
|
|
// - DEFAULT section content (including commented lines)
|
|
|
|
|
// Only extracts non-commented jail sections
|
|
|
|
|
func parseJailSectionsUncommented(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
|
|
|
|
|
sectionIsCommented := false
|
|
|
|
|
|
|
|
|
|
for scanner.Scan() {
|
|
|
|
|
line := scanner.Text()
|
|
|
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
|
|
|
|
|
|
// Check if this is a section header
|
|
|
|
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
|
|
|
|
// Check if the section is commented
|
|
|
|
|
originalLine := strings.TrimSpace(line)
|
|
|
|
|
isCommented := strings.HasPrefix(originalLine, "#")
|
|
|
|
|
|
|
|
|
|
// Save previous section
|
|
|
|
|
if currentSection != "" {
|
|
|
|
|
sectionContent := strings.TrimSpace(currentContent.String())
|
|
|
|
|
if inDefault {
|
|
|
|
|
// Always include DEFAULT section content (even if commented)
|
|
|
|
|
defaultContent.WriteString(sectionContent)
|
|
|
|
|
if !strings.HasSuffix(sectionContent, "\n") {
|
|
|
|
|
defaultContent.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
} else if !ignoredSections[currentSection] && !sectionIsCommented {
|
|
|
|
|
// Only save non-commented, non-ignored sections
|
|
|
|
|
sections[currentSection] = sectionContent
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start new section
|
|
|
|
|
if isCommented {
|
|
|
|
|
// Remove the # from the section name
|
|
|
|
|
sectionName := strings.Trim(trimmed, "[]")
|
|
|
|
|
if strings.HasPrefix(sectionName, "#") {
|
|
|
|
|
sectionName = strings.TrimSpace(strings.TrimPrefix(sectionName, "#"))
|
|
|
|
|
}
|
|
|
|
|
currentSection = sectionName
|
|
|
|
|
sectionIsCommented = true
|
|
|
|
|
} else {
|
|
|
|
|
currentSection = strings.Trim(trimmed, "[]")
|
|
|
|
|
sectionIsCommented = false
|
|
|
|
|
}
|
|
|
|
|
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] && !sectionIsCommented {
|
|
|
|
|
// Only save if it's not an ignored section and not commented
|
|
|
|
|
sections[currentSection] = sectionContent
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sections, defaultContent.String(), scanner.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MigrateJailsFromJailLocal migrates non-commented jail sections from jail.local to jail.d/*.local files.
|
|
|
|
|
// This should be called when a server is added or enabled to migrate legacy jails.
|
|
|
|
|
func MigrateJailsFromJailLocal() 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 non-commented sections
|
|
|
|
|
sections, defaultContent, err := parseJailSectionsUncommented(string(content))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to parse jail.local: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no non-commented, non-DEFAULT jails found, nothing to migrate
|
|
|
|
|
if len(sections) == 0 {
|
|
|
|
|
config.DebugLog("No jails to migrate from jail.local")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create backup of jail.local
|
|
|
|
|
backupPath := localPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix())
|
|
|
|
|
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 .local file in jail.d/
|
|
|
|
|
migratedCount := 0
|
|
|
|
|
for jailName, jailContent := range sections {
|
|
|
|
|
// Skip empty jail names
|
|
|
|
|
if jailName == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
jailFilePath := filepath.Join(jailDPath, jailName+".local")
|
|
|
|
|
|
|
|
|
|
// Check if .local file already exists
|
|
|
|
|
if _, err := os.Stat(jailFilePath); err == nil {
|
|
|
|
|
// File already exists - skip migration for this jail
|
|
|
|
|
config.DebugLog("Skipping migration for jail %s: .local file already exists", jailName)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure enabled = false is set by default for migrated jails
|
|
|
|
|
// Check if enabled is already set in the content
|
|
|
|
|
enabledSet := strings.Contains(jailContent, "enabled") || strings.Contains(jailContent, "Enabled")
|
|
|
|
|
if !enabledSet {
|
|
|
|
|
// Add enabled = false at the beginning of the jail section
|
|
|
|
|
// Find the first line after [jailName]
|
|
|
|
|
lines := strings.Split(jailContent, "\n")
|
|
|
|
|
modifiedContent := ""
|
|
|
|
|
for i, line := range lines {
|
|
|
|
|
modifiedContent += line + "\n"
|
|
|
|
|
// After the section header, add enabled = false
|
|
|
|
|
if i == 0 && strings.HasPrefix(strings.TrimSpace(line), "[") && strings.HasSuffix(strings.TrimSpace(line), "]") {
|
|
|
|
|
modifiedContent += "enabled = false\n"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
jailContent = modifiedContent
|
|
|
|
|
} else {
|
|
|
|
|
// If enabled is set, ensure it's false by replacing any enabled = true
|
|
|
|
|
jailContent = regexp.MustCompile(`(?m)^\s*enabled\s*=\s*true\s*$`).ReplaceAllString(jailContent, "enabled = false")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write jail content to .local file
|
|
|
|
|
if err := os.WriteFile(jailFilePath, []byte(jailContent), 0644); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err)
|
|
|
|
|
}
|
|
|
|
|
config.DebugLog("Migrated jail %s to %s (enabled = false)", jailName, jailFilePath)
|
|
|
|
|
migratedCount++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only rewrite jail.local if we actually migrated something
|
|
|
|
|
if migratedCount > 0 {
|
|
|
|
|
// Rewrite jail.local with only DEFAULT section and commented jails
|
|
|
|
|
// We need to preserve commented sections, so we'll reconstruct the file
|
|
|
|
|
newLocalContent := defaultContent
|
|
|
|
|
|
|
|
|
|
// Add back commented sections that weren't migrated
|
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
|
|
|
|
var inCommentedJail bool
|
|
|
|
|
var commentedJailContent strings.Builder
|
|
|
|
|
var commentedJailName string
|
|
|
|
|
for scanner.Scan() {
|
|
|
|
|
line := scanner.Text()
|
|
|
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
|
|
|
|
|
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
|
|
|
|
// Check if this is a commented section
|
|
|
|
|
originalLine := strings.TrimSpace(line)
|
|
|
|
|
if strings.HasPrefix(originalLine, "#[") {
|
|
|
|
|
// Save previous commented jail if any
|
|
|
|
|
if inCommentedJail && commentedJailName != "" {
|
|
|
|
|
newLocalContent += commentedJailContent.String()
|
|
|
|
|
}
|
|
|
|
|
inCommentedJail = true
|
|
|
|
|
commentedJailContent.Reset()
|
|
|
|
|
commentedJailName = strings.Trim(trimmed, "[]")
|
|
|
|
|
if strings.HasPrefix(commentedJailName, "#") {
|
|
|
|
|
commentedJailName = strings.TrimSpace(strings.TrimPrefix(commentedJailName, "#"))
|
|
|
|
|
}
|
|
|
|
|
commentedJailContent.WriteString(line)
|
|
|
|
|
commentedJailContent.WriteString("\n")
|
|
|
|
|
} else {
|
|
|
|
|
// Non-commented section - save previous commented jail if any
|
|
|
|
|
if inCommentedJail && commentedJailName != "" {
|
|
|
|
|
newLocalContent += commentedJailContent.String()
|
|
|
|
|
inCommentedJail = false
|
|
|
|
|
commentedJailContent.Reset()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if inCommentedJail {
|
|
|
|
|
commentedJailContent.WriteString(line)
|
|
|
|
|
commentedJailContent.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Save final commented jail if any
|
|
|
|
|
if inCommentedJail && commentedJailName != "" {
|
|
|
|
|
newLocalContent += commentedJailContent.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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/", migratedCount)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 16:47:05 +01:00
|
|
|
// GetJailConfig reads the full jail configuration from /etc/fail2ban/jail.d/{jailName}.local
|
|
|
|
|
// Falls back to .conf if .local doesn't exist.
|
2025-12-30 01:10:49 +01:00
|
|
|
func GetJailConfig(jailName string) (string, string, error) {
|
2025-12-05 16:47:05 +01:00
|
|
|
// Validate jail name
|
|
|
|
|
jailName = strings.TrimSpace(jailName)
|
|
|
|
|
if jailName == "" {
|
2025-12-30 01:10:49 +01:00
|
|
|
return "", "", fmt.Errorf("jail name cannot be empty")
|
2025-12-05 16:47:05 +01:00
|
|
|
}
|
2025-12-03 20:43:44 +01:00
|
|
|
|
2025-12-05 16:47:05 +01:00
|
|
|
config.DebugLog("GetJailConfig called for jail: %s", jailName)
|
2025-12-30 01:10:49 +01:00
|
|
|
content, filePath, err := readJailConfigWithFallback(jailName)
|
2025-12-03 20:43:44 +01:00
|
|
|
if err != nil {
|
2025-12-05 16:47:05 +01:00
|
|
|
config.DebugLog("Failed to read jail config: %v", err)
|
2025-12-30 01:10:49 +01:00
|
|
|
return "", "", fmt.Errorf("failed to read jail config for %s: %w", jailName, err)
|
2025-12-03 20:43:44 +01:00
|
|
|
}
|
2025-12-30 01:10:49 +01:00
|
|
|
config.DebugLog("Jail config read successfully, length: %d, file: %s", len(content), filePath)
|
|
|
|
|
return content, filePath, nil
|
2025-12-03 20:43:44 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 16:47:05 +01:00
|
|
|
// SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.local
|
|
|
|
|
// Ensures .local file exists first by copying from .conf if needed.
|
2025-12-03 20:43:44 +01:00
|
|
|
func SetJailConfig(jailName, content string) error {
|
2025-12-05 16:47:05 +01:00
|
|
|
// Validate jail name
|
|
|
|
|
jailName = strings.TrimSpace(jailName)
|
|
|
|
|
if jailName == "" {
|
|
|
|
|
return fmt.Errorf("jail name cannot be empty")
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 20:43:44 +01:00
|
|
|
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")
|
|
|
|
|
|
2025-12-05 16:47:05 +01:00
|
|
|
// Ensure .local file exists (copy from .conf if needed)
|
|
|
|
|
if err := ensureJailLocalFile(jailName); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 20:43:44 +01:00
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 16:47:05 +01:00
|
|
|
jailFilePath := filepath.Join(jailDPath, jailName+".local")
|
2025-12-03 20:43:44 +01:00
|
|
|
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)
|
|
|
|
|
}
|
2025-12-05 16:47:05 +01:00
|
|
|
config.DebugLog("Jail config written successfully to .local file")
|
2025-12-03 20:43:44 +01:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestLogpath tests a logpath pattern and returns matching files.
|
|
|
|
|
// Supports wildcards/glob patterns (e.g., /var/log/*.log) and directory paths.
|
2025-12-05 23:21:08 +01:00
|
|
|
// This function tests the path as-is without variable resolution.
|
2025-12-03 20:43:44 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 23:21:08 +01:00
|
|
|
// TestLogpathWithResolution resolves variables in logpath and tests the resolved path.
|
|
|
|
|
// Returns the original path, resolved path, matching files, and any error.
|
|
|
|
|
func TestLogpathWithResolution(logpath string) (originalPath, resolvedPath string, files []string, err error) {
|
|
|
|
|
originalPath = strings.TrimSpace(logpath)
|
|
|
|
|
if originalPath == "" {
|
|
|
|
|
return originalPath, "", []string{}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve variables
|
|
|
|
|
resolvedPath, err = ResolveLogpathVariables(originalPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return originalPath, "", nil, fmt.Errorf("failed to resolve logpath variables: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If resolution didn't change the path, resolvedPath will be the same
|
|
|
|
|
if resolvedPath == "" {
|
|
|
|
|
resolvedPath = originalPath
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Test the resolved path
|
|
|
|
|
files, err = TestLogpath(resolvedPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return originalPath, resolvedPath, nil, fmt.Errorf("failed to test logpath: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return originalPath, resolvedPath, files, nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 21:02:48 +01:00
|
|
|
// ExtractLogpathFromJailConfig extracts the logpath value from jail configuration content.
|
|
|
|
|
func ExtractLogpathFromJailConfig(jailContent string) string {
|
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(jailContent))
|
2025-12-03 20:43:44 +01:00
|
|
|
for scanner.Scan() {
|
2025-12-03 21:02:48 +01:00
|
|
|
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])
|
2025-12-03 20:43:44 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-03 21:02:48 +01:00
|
|
|
return ""
|
2025-12-03 20:43:44 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-03 21:02:48 +01:00
|
|
|
// ExtractFilterFromJailConfig extracts the filter name from jail configuration content.
|
|
|
|
|
// Handles formats like: filter = sshd, filter = sshd[mode=aggressive], etc.
|
|
|
|
|
// Returns the base filter name (without parameters in brackets).
|
|
|
|
|
func ExtractFilterFromJailConfig(jailContent string) string {
|
2025-12-03 20:43:44 +01:00
|
|
|
scanner := bufio.NewScanner(strings.NewReader(jailContent))
|
|
|
|
|
for scanner.Scan() {
|
|
|
|
|
line := strings.TrimSpace(scanner.Text())
|
2025-12-03 21:02:48 +01:00
|
|
|
// Skip comments
|
|
|
|
|
if strings.HasPrefix(line, "#") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if strings.HasPrefix(strings.ToLower(line), "filter") {
|
2025-12-03 20:43:44 +01:00
|
|
|
parts := strings.SplitN(line, "=", 2)
|
|
|
|
|
if len(parts) == 2 {
|
2025-12-03 21:02:48 +01:00
|
|
|
filterValue := strings.TrimSpace(parts[1])
|
|
|
|
|
// Extract base filter name (before [ if present)
|
|
|
|
|
if idx := strings.Index(filterValue, "["); idx >= 0 {
|
|
|
|
|
filterValue = filterValue[:idx]
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(filterValue)
|
2025-12-03 20:43:44 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2025-12-04 19:42:43 +01:00
|
|
|
|
|
|
|
|
// UpdateDefaultSettingsLocal updates specific keys in the [DEFAULT] section of /etc/fail2ban/jail.local
|
|
|
|
|
// with the provided settings, preserving all other content including the ui-custom-action section.
|
|
|
|
|
// Removes commented lines (starting with #) before applying updates.
|
|
|
|
|
func UpdateDefaultSettingsLocal(settings config.AppSettings) error {
|
|
|
|
|
config.DebugLog("UpdateDefaultSettingsLocal called")
|
|
|
|
|
localPath := "/etc/fail2ban/jail.local"
|
|
|
|
|
|
|
|
|
|
// Read existing file if it exists
|
|
|
|
|
var existingContent string
|
|
|
|
|
if content, err := os.ReadFile(localPath); err == nil {
|
|
|
|
|
existingContent = string(content)
|
|
|
|
|
} else if !os.IsNotExist(err) {
|
|
|
|
|
return fmt.Errorf("failed to read jail.local: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove commented lines (lines starting with #) but preserve:
|
|
|
|
|
// - Banner lines (containing "Fail2Ban-UI" or "fail2ban-ui")
|
|
|
|
|
// - action_mwlg and action override lines
|
|
|
|
|
lines := strings.Split(existingContent, "\n")
|
|
|
|
|
var uncommentedLines []string
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
|
// Keep empty lines, banner lines, action_mwlg lines, action override lines, and lines that don't start with #
|
|
|
|
|
isBanner := strings.Contains(line, "Fail2Ban-UI") || strings.Contains(line, "fail2ban-ui")
|
|
|
|
|
isActionMwlg := strings.Contains(trimmed, "action_mwlg")
|
|
|
|
|
isActionOverride := strings.Contains(trimmed, "action = %(action_mwlg)s")
|
|
|
|
|
if trimmed == "" || !strings.HasPrefix(trimmed, "#") || isBanner || isActionMwlg || isActionOverride {
|
|
|
|
|
uncommentedLines = append(uncommentedLines, line)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
existingContent = strings.Join(uncommentedLines, "\n")
|
|
|
|
|
|
|
|
|
|
// Convert IgnoreIPs array to space-separated string
|
|
|
|
|
ignoreIPStr := strings.Join(settings.IgnoreIPs, " ")
|
|
|
|
|
if ignoreIPStr == "" {
|
|
|
|
|
ignoreIPStr = "127.0.0.1/8 ::1"
|
|
|
|
|
}
|
|
|
|
|
// Set default banaction values if not set
|
|
|
|
|
banaction := settings.Banaction
|
|
|
|
|
if banaction == "" {
|
|
|
|
|
banaction = "iptables-multiport"
|
|
|
|
|
}
|
|
|
|
|
banactionAllports := settings.BanactionAllports
|
|
|
|
|
if banactionAllports == "" {
|
|
|
|
|
banactionAllports = "iptables-allports"
|
|
|
|
|
}
|
|
|
|
|
// Define the keys we want to update
|
|
|
|
|
keysToUpdate := map[string]string{
|
2025-12-15 18:57:50 +01:00
|
|
|
"enabled": fmt.Sprintf("enabled = %t", settings.DefaultJailEnable),
|
2025-12-04 19:42:43 +01:00
|
|
|
"bantime.increment": fmt.Sprintf("bantime.increment = %t", settings.BantimeIncrement),
|
|
|
|
|
"ignoreip": fmt.Sprintf("ignoreip = %s", ignoreIPStr),
|
|
|
|
|
"bantime": fmt.Sprintf("bantime = %s", settings.Bantime),
|
|
|
|
|
"findtime": fmt.Sprintf("findtime = %s", settings.Findtime),
|
|
|
|
|
"maxretry": fmt.Sprintf("maxretry = %d", settings.Maxretry),
|
|
|
|
|
"destemail": fmt.Sprintf("destemail = %s", settings.Destemail),
|
|
|
|
|
"banaction": fmt.Sprintf("banaction = %s", banaction),
|
|
|
|
|
"banaction_allports": fmt.Sprintf("banaction_allports = %s", banactionAllports),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track which keys we've updated
|
|
|
|
|
keysUpdated := make(map[string]bool)
|
|
|
|
|
|
|
|
|
|
// Parse existing content and update only specific keys in DEFAULT section
|
|
|
|
|
if existingContent == "" {
|
2025-12-04 20:36:23 +01:00
|
|
|
// File doesn't exist, create new one with banner and DEFAULT section
|
|
|
|
|
var newLines []string
|
|
|
|
|
newLines = append(newLines, strings.Split(strings.TrimRight(config.JailLocalBanner(), "\n"), "\n")...)
|
|
|
|
|
newLines = append(newLines, "[DEFAULT]")
|
2025-12-15 18:57:50 +01:00
|
|
|
for _, key := range []string{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "destemail", "banaction", "banaction_allports"} {
|
2025-12-04 20:36:23 +01:00
|
|
|
newLines = append(newLines, keysToUpdate[key])
|
2025-12-04 19:42:43 +01:00
|
|
|
}
|
2025-12-04 20:36:23 +01:00
|
|
|
newLines = append(newLines, "")
|
|
|
|
|
newContent := strings.Join(newLines, "\n")
|
2025-12-04 19:42:43 +01:00
|
|
|
if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to write jail.local: %w", err)
|
|
|
|
|
}
|
2025-12-04 20:36:23 +01:00
|
|
|
config.DebugLog("Created new jail.local with banner and DEFAULT section")
|
2025-12-04 19:42:43 +01:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse and update only specific keys in DEFAULT section
|
|
|
|
|
lines = strings.Split(existingContent, "\n")
|
|
|
|
|
var outputLines []string
|
|
|
|
|
inDefault := false
|
|
|
|
|
defaultSectionFound := false
|
|
|
|
|
|
2025-12-04 20:36:23 +01:00
|
|
|
// Always add the full banner at the start
|
|
|
|
|
outputLines = append(outputLines, strings.Split(strings.TrimRight(config.JailLocalBanner(), "\n"), "\n")...)
|
|
|
|
|
|
|
|
|
|
// Skip everything before [DEFAULT] section (old banner, comments, empty lines)
|
|
|
|
|
foundSection := false
|
2025-12-04 19:42:43 +01:00
|
|
|
for _, line := range lines {
|
|
|
|
|
trimmed := strings.TrimSpace(line)
|
2025-12-04 20:36:23 +01:00
|
|
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
|
|
|
|
// Found a section - stop skipping and process this line
|
|
|
|
|
foundSection = true
|
|
|
|
|
}
|
|
|
|
|
if !foundSection {
|
|
|
|
|
// Skip lines before any section (old banner, comments, empty lines)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2025-12-04 19:42:43 +01:00
|
|
|
|
2025-12-04 20:36:23 +01:00
|
|
|
// Process lines after we found a section
|
2025-12-04 19:42:43 +01:00
|
|
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
|
|
|
|
sectionName := strings.Trim(trimmed, "[]")
|
|
|
|
|
if sectionName == "DEFAULT" {
|
|
|
|
|
// Start of DEFAULT section
|
|
|
|
|
inDefault = true
|
|
|
|
|
defaultSectionFound = true
|
|
|
|
|
outputLines = append(outputLines, line)
|
|
|
|
|
} else {
|
|
|
|
|
// Other section - stop DEFAULT mode
|
|
|
|
|
inDefault = false
|
|
|
|
|
outputLines = append(outputLines, line)
|
|
|
|
|
}
|
|
|
|
|
} else if inDefault {
|
|
|
|
|
// We're in DEFAULT section - check if this line is a key we need to update
|
|
|
|
|
keyUpdated := false
|
|
|
|
|
for key, newValue := range keysToUpdate {
|
|
|
|
|
// Check if this line contains the key (with or without spaces around =)
|
|
|
|
|
keyPattern := "^\\s*" + regexp.QuoteMeta(key) + "\\s*="
|
|
|
|
|
if matched, _ := regexp.MatchString(keyPattern, trimmed); matched {
|
|
|
|
|
outputLines = append(outputLines, newValue)
|
|
|
|
|
keysUpdated[key] = true
|
|
|
|
|
keyUpdated = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !keyUpdated {
|
|
|
|
|
// Keep the line as-is (might be other DEFAULT settings or action_mwlg)
|
|
|
|
|
outputLines = append(outputLines, line)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Keep lines outside DEFAULT section (preserves ui-custom-action and other content)
|
|
|
|
|
outputLines = append(outputLines, line)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If DEFAULT section wasn't found, create it at the beginning
|
|
|
|
|
if !defaultSectionFound {
|
|
|
|
|
defaultLines := []string{"[DEFAULT]"}
|
2025-12-15 18:57:50 +01:00
|
|
|
for _, key := range []string{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "destemail"} {
|
2025-12-04 19:42:43 +01:00
|
|
|
defaultLines = append(defaultLines, keysToUpdate[key])
|
|
|
|
|
}
|
|
|
|
|
defaultLines = append(defaultLines, "")
|
|
|
|
|
outputLines = append(defaultLines, outputLines...)
|
|
|
|
|
} else {
|
|
|
|
|
// Add any missing keys to the DEFAULT section
|
2025-12-15 18:57:50 +01:00
|
|
|
for _, key := range []string{"enabled", "bantime.increment", "ignoreip", "bantime", "findtime", "maxretry", "destemail", "banaction", "banaction_allports"} {
|
2025-12-04 19:42:43 +01:00
|
|
|
if !keysUpdated[key] {
|
|
|
|
|
// Find the DEFAULT section and insert after it
|
|
|
|
|
for i, line := range outputLines {
|
|
|
|
|
if strings.TrimSpace(line) == "[DEFAULT]" {
|
|
|
|
|
// Insert after [DEFAULT] header
|
|
|
|
|
outputLines = append(outputLines[:i+1], append([]string{keysToUpdate[key]}, outputLines[i+1:]...)...)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newContent := strings.Join(outputLines, "\n")
|
|
|
|
|
if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to write jail.local: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
config.DebugLog("Updated specific keys in DEFAULT section of jail.local")
|
|
|
|
|
return nil
|
|
|
|
|
}
|