maintain filters and jails in a local file for overriding the .conf file per node

This commit is contained in:
2025-12-05 16:47:05 +01:00
parent a6ada67e7a
commit fe51f29d6b
3 changed files with 449 additions and 77 deletions

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -177,18 +178,60 @@ func (sc *SSHConnector) Restart(ctx context.Context) error {
}
func (sc *SSHConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
out, err := sc.runRemoteCommand(ctx, []string{"cat", path})
// Validate filter name
jail = strings.TrimSpace(jail)
if jail == "" {
return "", fmt.Errorf("filter name cannot be empty")
}
// Try .local first, then fallback to .conf
localPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail)
confPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
out, err := sc.runRemoteCommand(ctx, []string{"cat", localPath})
if err == nil {
return out, nil
}
// Fallback to .conf
out, err = sc.runRemoteCommand(ctx, []string{"cat", confPath})
if err != nil {
return "", fmt.Errorf("failed to read remote filter config: %w", err)
return "", fmt.Errorf("failed to read remote filter config (tried .local and .conf): %w", err)
}
return out, nil
}
func (sc *SSHConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", path, content)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
// Validate filter name
jail = strings.TrimSpace(jail)
if jail == "" {
return fmt.Errorf("filter name cannot be empty")
}
// Ensure .local file exists (copy from .conf if needed)
localPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail)
confPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
// Check if .local exists, if not, copy from .conf
checkScript := fmt.Sprintf(`
if [ ! -f "%s" ]; then
if [ -f "%s" ]; then
cp "%s" "%s"
else
echo "Error: filter .conf file does not exist: %s" >&2
exit 1
fi
fi
`, localPath, confPath, confPath, localPath, confPath)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", checkScript})
if err != nil {
return fmt.Errorf("failed to ensure filter .local file: %w", err)
}
// Write to .local file
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, content)
_, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
return err
}
@@ -334,19 +377,59 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
}
}
// Parse jail.d directory
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") {
// Parse jail.d directory - prefer .local over .conf files
// First get .local files
jailDLocalCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.local' -type f 2>/dev/null"
jailDLocalList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDLocalCmd})
processedJails := make(map[string]bool)
if err == nil && jailDLocalList != "" {
for _, file := range strings.Split(jailDLocalList, "\n") {
file = strings.TrimSpace(file)
if file == "" {
continue
}
// Skip files that start with . (like .local) - these are invalid
baseName := filepath.Base(file)
if strings.HasPrefix(baseName, ".") {
config.DebugLog("Skipping invalid jail file: %s", file)
continue
}
content, err := sc.runRemoteCommand(ctx, []string{"cat", file})
if err == nil {
jails := parseJailConfigContent(content)
allJails = append(allJails, jails...)
for _, jail := range jails {
// Skip jails with empty names
if jail.JailName != "" {
allJails = append(allJails, jail)
processedJails[jail.JailName] = true
}
}
}
}
}
// Then get .conf files that don't have corresponding .local files
jailDConfCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.conf' -type f 2>/dev/null"
jailDConfList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDConfCmd})
if err == nil && jailDConfList != "" {
for _, file := range strings.Split(jailDConfList, "\n") {
file = strings.TrimSpace(file)
if file == "" {
continue
}
// Extract jail name from filename
baseName := strings.TrimSuffix(filepath.Base(file), ".conf")
// Skip files that start with . (like .conf) - these are invalid
if baseName == "" || strings.HasPrefix(filepath.Base(file), ".") {
config.DebugLog("Skipping invalid jail file: %s", file)
continue
}
// Only process if we haven't already processed this jail from a .local file
if !processedJails[baseName] {
content, err := sc.runRemoteCommand(ctx, []string{"cat", file})
if err == nil {
jails := parseJailConfigContent(content)
allJails = append(allJails, jails...)
}
}
}
}
@@ -362,22 +445,39 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map
return fmt.Errorf("failed to create jail.d directory: %w", err)
}
// Update each jail in its own file
// Update each jail in its own .local 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)
}
// Validate jail name - skip empty or invalid names
jailName = strings.TrimSpace(jailName)
if jailName == "" {
config.DebugLog("Skipping empty jail name in updates map")
continue
}
localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jailName)
confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jailName)
// Ensure .local file exists (copy from .conf if needed)
ensureScript := fmt.Sprintf(`
if [ ! -f "%s" ]; then
if [ -f "%s" ]; then
cp "%s" "%s"
else
echo "[%s]" > "%s"
fi
fi
`, localPath, confPath, confPath, localPath, jailName, localPath)
if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", ensureScript}); err != nil {
return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err)
}
// Read existing .local file
content, err := sc.runRemoteCommand(ctx, []string{"cat", localPath})
if err != nil {
return fmt.Errorf("failed to read jail .local file %s: %w", localPath, err)
}
// Update enabled state in existing file
lines := strings.Split(content, "\n")
var outputLines []string
@@ -421,11 +521,11 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map
}
}
// Write updated content
// Write updated content to .local file
newContent := strings.Join(outputLines, "\n")
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailPath, newContent)
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, newContent)
if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}); err != nil {
return fmt.Errorf("failed to write jail file %s: %w", jailPath, err)
return fmt.Errorf("failed to write jail .local file %s: %w", localPath, err)
}
}
return nil
@@ -522,10 +622,25 @@ fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true
// 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})
// Validate jail name
jail = strings.TrimSpace(jail)
if jail == "" {
return "", fmt.Errorf("jail name cannot be empty")
}
// Try .local first, then fallback to .conf
localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail)
confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail)
out, err := sc.runRemoteCommand(ctx, []string{"cat", localPath})
if err == nil {
return out, nil
}
// Fallback to .conf
out, err = sc.runRemoteCommand(ctx, []string{"cat", confPath})
if err != nil {
// If file doesn't exist, return empty jail section
// If neither exists, return empty jail section
return fmt.Sprintf("[%s]\n", jail), nil
}
return out, nil
@@ -533,14 +648,38 @@ func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string,
// SetJailConfig implements Connector.
func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string) error {
jailPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail)
// Validate jail name
jail = strings.TrimSpace(jail)
if jail == "" {
return fmt.Errorf("jail name cannot be empty")
}
localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail)
confPath := 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)
// Ensure .local file exists (copy from .conf if needed)
ensureScript := fmt.Sprintf(`
if [ ! -f "%s" ]; then
if [ -f "%s" ]; then
cp "%s" "%s"
else
echo "[%s]" > "%s"
fi
fi
`, localPath, confPath, confPath, localPath, jail, localPath)
if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", ensureScript}); err != nil {
return fmt.Errorf("failed to ensure .local file for jail %s: %w", jail, err)
}
// Write to .local file
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, content)
_, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
return err
}

View File

@@ -24,6 +24,8 @@ import (
"path/filepath"
"sort"
"strings"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
// GetFilterConfig returns the filter configuration using the default connector.
@@ -44,39 +46,126 @@ func SetFilterConfig(jail, newContent string) error {
return conn.SetFilterConfig(context.Background(), jail, newContent)
}
// GetFilterConfigLocal reads a filter configuration from the local filesystem.
func GetFilterConfigLocal(jail string) (string, error) {
configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf")
content, err := os.ReadFile(configPath)
if err != nil {
return "", fmt.Errorf("failed to read config for jail %s: %v", jail, err)
// ensureFilterLocalFile ensures that a .local file exists for the given filter.
// If .local doesn't exist, it copies from .conf if available.
// Returns error if neither .local nor .conf exists (filters must have base .conf).
func ensureFilterLocalFile(filterName string) error {
// Validate filter name - must not be empty
filterName = strings.TrimSpace(filterName)
if filterName == "" {
return fmt.Errorf("filter name cannot be empty")
}
return string(content), nil
filterDPath := "/etc/fail2ban/filter.d"
localPath := filepath.Join(filterDPath, filterName+".local")
confPath := filepath.Join(filterDPath, filterName+".conf")
// Check if .local already exists
if _, err := os.Stat(localPath); err == nil {
config.DebugLog("Filter .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 filter config from .conf to .local: %s -> %s", confPath, localPath)
content, err := os.ReadFile(confPath)
if err != nil {
return fmt.Errorf("failed to read filter .conf file %s: %w", confPath, err)
}
if err := os.WriteFile(localPath, content, 0644); err != nil {
return fmt.Errorf("failed to write filter .local file %s: %w", localPath, err)
}
config.DebugLog("Successfully copied filter config to .local file")
return nil
}
// Neither exists, return error (filters must have base .conf)
return fmt.Errorf("filter .conf file does not exist: %s (filters must have a base .conf file)", confPath)
}
// readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf.
func readFilterConfigWithFallback(filterName string) (string, error) {
// Validate filter name - must not be empty
filterName = strings.TrimSpace(filterName)
if filterName == "" {
return "", fmt.Errorf("filter name cannot be empty")
}
filterDPath := "/etc/fail2ban/filter.d"
localPath := filepath.Join(filterDPath, filterName+".local")
confPath := filepath.Join(filterDPath, filterName+".conf")
// Try .local first
if content, err := os.ReadFile(localPath); err == nil {
config.DebugLog("Reading filter config from .local: %s", localPath)
return string(content), nil
}
// Fallback to .conf
if content, err := os.ReadFile(confPath); err == nil {
config.DebugLog("Reading filter config from .conf: %s", confPath)
return string(content), nil
}
// Neither exists, return error
return "", fmt.Errorf("filter config not found: neither %s nor %s exists", localPath, confPath)
}
// GetFilterConfigLocal reads a filter configuration from the local filesystem.
// Prefers .local over .conf files.
func GetFilterConfigLocal(jail string) (string, error) {
return readFilterConfigWithFallback(jail)
}
// SetFilterConfigLocal writes the filter configuration to the local filesystem.
// Always writes to .local file, ensuring it exists first by copying from .conf if needed.
func SetFilterConfigLocal(jail, newContent string) error {
configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf")
if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write config for jail %s: %v", jail, err)
// Ensure .local file exists (copy from .conf if needed)
if err := ensureFilterLocalFile(jail); err != nil {
return err
}
localPath := filepath.Join("/etc/fail2ban/filter.d", jail+".local")
if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write filter .local file for %s: %w", jail, err)
}
config.DebugLog("Successfully wrote filter config to .local file: %s", localPath)
return nil
}
// GetFiltersLocal returns a list of filter names from /etc/fail2ban/filter.d
// Returns unique filter names from both .conf and .local files (prefers .local if both exist)
func GetFiltersLocal() ([]string, error) {
dir := "/etc/fail2ban/filter.d"
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("failed to read filter directory: %w", err)
}
var filters []string
filterMap := make(map[string]bool)
// First pass: collect all .local files (these take precedence)
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".local") {
name := strings.TrimSuffix(entry.Name(), ".local")
filterMap[name] = true
}
}
// Second pass: collect .conf files that don't have corresponding .local files
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".conf") {
name := strings.TrimSuffix(entry.Name(), ".conf")
filters = append(filters, name)
if !filterMap[name] {
filterMap[name] = true
}
}
}
var filters []string
for name := range filterMap {
filters = append(filters, name)
}
sort.Strings(filters)
return filters, nil
}

View File

@@ -16,6 +16,81 @@ var (
migrationOnce sync.Once
)
// 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.
func readJailConfigWithFallback(jailName string) (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")
// Try .local first
if content, err := os.ReadFile(localPath); err == nil {
config.DebugLog("Reading jail config from .local: %s", localPath)
return string(content), nil
}
// Fallback to .conf
if content, err := os.ReadFile(confPath); err == nil {
config.DebugLog("Reading jail config from .conf: %s", confPath)
return string(content), nil
}
// Neither exists, return empty section
config.DebugLog("Neither .local nor .conf exists for jail %s, returning empty section", jailName)
return fmt.Sprintf("[%s]\n", jailName), 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.
func GetAllJails() ([]JailInfo, error) {
@@ -38,16 +113,53 @@ func GetAllJails() ([]JailInfo, error) {
}
// Parse jails from jail.d directory
// Prefer .local files over .conf files (if both exist for same jail, use .local)
jailDPath := "/etc/fail2ban/jail.d"
if _, err := os.Stat(jailDPath); err == nil {
files, err := os.ReadDir(jailDPath)
if err == nil {
// Track which jails we've already processed (from .local files)
processedJails := make(map[string]bool)
// First pass: process all .local files
for _, f := range files {
if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" {
if !f.IsDir() && filepath.Ext(f.Name()) == ".local" {
jailName := strings.TrimSuffix(f.Name(), ".local")
// Skip files that start with . (like .local) - these are invalid
if jailName == "" || strings.HasPrefix(f.Name(), ".") {
config.DebugLog("Skipping invalid jail file: %s", f.Name())
continue
}
fullPath := filepath.Join(jailDPath, f.Name())
dJails, err := parseJailConfigFile(fullPath)
if err == nil {
jails = append(jails, dJails...)
for _, jail := range dJails {
// Skip jails with empty names
if jail.JailName != "" {
jails = append(jails, jail)
processedJails[jail.JailName] = true
}
}
}
}
}
// Second pass: process .conf files that don't have corresponding .local files
for _, f := range files {
if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" {
jailName := strings.TrimSuffix(f.Name(), ".conf")
// Skip files that start with . (like .conf) - these are invalid
if jailName == "" || strings.HasPrefix(f.Name(), ".") {
config.DebugLog("Skipping invalid jail file: %s", f.Name())
continue
}
// Only process if we haven't already processed this jail from a .local file
if !processedJails[jailName] {
fullPath := filepath.Join(jailDPath, f.Name())
dJails, err := parseJailConfigFile(fullPath)
if err == nil {
jails = append(jails, dJails...)
}
}
}
}
@@ -88,15 +200,24 @@ func parseJailConfigFile(path string) ([]JailInfo, error) {
})
}
// Start a new jail section.
currentJail = strings.Trim(line, "[]")
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
}
// Reset to default for the new section.
enabled = true
} else if strings.HasPrefix(strings.ToLower(line), "enabled") {
// Expect format: enabled = true/false
parts := strings.Split(line, "=")
if len(parts) == 2 {
value := strings.TrimSpace(parts[1])
enabled = strings.EqualFold(value, "true")
// 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")
}
}
}
}
@@ -111,7 +232,8 @@ func parseJailConfigFile(path string) ([]JailInfo, error) {
}
// UpdateJailEnabledStates updates the enabled state for each jail based on the provided updates map.
// Updates only the corresponding file in /etc/fail2ban/jail.d/ for each jail.
// 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.
func UpdateJailEnabledStates(updates map[string]bool) error {
config.DebugLog("UpdateJailEnabledStates called with %d updates: %+v", len(updates), updates)
jailDPath := "/etc/fail2ban/jail.d"
@@ -121,16 +243,29 @@ func UpdateJailEnabledStates(updates map[string]bool) error {
return fmt.Errorf("failed to create jail.d directory: %w", err)
}
// Update each jail in its own file
// Update each jail in its own .local file
for jailName, enabled := range updates {
// Validate jail name - skip empty or invalid names
jailName = strings.TrimSpace(jailName)
if jailName == "" {
config.DebugLog("Skipping empty jail name in updates map")
continue
}
config.DebugLog("Processing jail: %s, enabled: %t", jailName, enabled)
jailFilePath := filepath.Join(jailDPath, jailName+".conf")
// 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")
config.DebugLog("Jail file path: %s", jailFilePath)
// Read existing file if it exists
// Read existing .local file
content, err := os.ReadFile(jailFilePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to read jail file %s: %w", jailFilePath, err)
if err != nil {
return fmt.Errorf("failed to read jail .local file %s: %w", jailFilePath, err)
}
var lines []string
@@ -352,30 +487,34 @@ func parseJailConfigFileOnlyDefault(path string) ([]JailInfo, error) {
return jails, scanner.Err()
}
// GetJailConfig reads the full jail configuration from /etc/fail2ban/jail.d/{jailName}.conf
// GetJailConfig reads the full jail configuration from /etc/fail2ban/jail.d/{jailName}.local
// Falls back to .conf if .local doesn't exist.
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)
// Validate jail name
jailName = strings.TrimSpace(jailName)
if jailName == "" {
return "", fmt.Errorf("jail name cannot be empty")
}
config.DebugLog("GetJailConfig called for jail: %s", jailName)
content, err := readJailConfigWithFallback(jailName)
if err != nil {
config.DebugLog("Failed to read jail config: %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
return content, nil
}
// SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.conf
// SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.local
// Ensures .local file exists first by copying from .conf if needed.
func SetJailConfig(jailName, content string) error {
// Validate jail name
jailName = strings.TrimSpace(jailName)
if jailName == "" {
return fmt.Errorf("jail name cannot be empty")
}
config.DebugLog("SetJailConfig called for jail: %s, content length: %d", jailName, len(content))
jailDPath := "/etc/fail2ban/jail.d"
@@ -387,6 +526,11 @@ func SetJailConfig(jailName, content string) error {
}
config.DebugLog("jail.d directory ensured")
// 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)
}
// 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)
@@ -466,13 +610,13 @@ func SetJailConfig(jailName, content string) error {
}
}
jailFilePath := filepath.Join(jailDPath, jailName+".conf")
jailFilePath := filepath.Join(jailDPath, jailName+".local")
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")
config.DebugLog("Jail config written successfully to .local file")
return nil
}