Fix loading wrong filter problem, implement creation and deletion of filters and jails, fix some css mismatches, update the handlers and routes

This commit is contained in:
2025-12-30 01:10:49 +01:00
parent b9d8f1b39a
commit 84a97eaa96
18 changed files with 1735 additions and 421 deletions

View File

@@ -118,14 +118,21 @@ func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) {
return "restart", nil
}
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
var resp struct {
Config string `json:"config"`
Config string `json:"config"`
FilePath string `json:"filePath"`
}
if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil {
return "", err
return "", "", err
}
return resp.Config, nil
// If agent doesn't return filePath, construct it (agent should handle .local priority)
filePath := resp.FilePath
if filePath == "" {
// Default to .local path (agent should handle .local priority on its side)
filePath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail)
}
return resp.Config, filePath, nil
}
func (ac *AgentConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
@@ -196,6 +203,14 @@ func (ac *AgentConnector) put(ctx context.Context, endpoint string, payload any,
return ac.do(req, out)
}
func (ac *AgentConnector) delete(ctx context.Context, endpoint string, out any) error {
req, err := ac.newRequest(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
return ac.do(req, out)
}
func (ac *AgentConnector) newRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) {
u := *ac.base
u.Path = path.Join(ac.base.Path, strings.TrimPrefix(endpoint, "/"))
@@ -310,14 +325,21 @@ func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, log
}
// GetJailConfig implements Connector.
func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, error) {
func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
var resp struct {
Config string `json:"config"`
Config string `json:"config"`
FilePath string `json:"filePath"`
}
if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), &resp); err != nil {
return "", err
return "", "", err
}
return resp.Config, nil
// If agent doesn't return filePath, construct it (agent should handle .local priority)
filePath := resp.FilePath
if filePath == "" {
// Default to .local path (agent should handle .local priority on its side)
filePath = fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail)
}
return resp.Config, filePath, nil
}
// SetJailConfig implements Connector.
@@ -415,3 +437,31 @@ func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
// For now, we'll try calling it and handle the error gracefully
return ac.post(ctx, "/v1/jails/ensure-structure", nil, nil)
}
// CreateJail implements Connector.
func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content string) error {
payload := map[string]interface{}{
"name": jailName,
"content": content,
}
return ac.post(ctx, "/v1/jails", payload, nil)
}
// DeleteJail implements Connector.
func (ac *AgentConnector) DeleteJail(ctx context.Context, jailName string) error {
return ac.delete(ctx, fmt.Sprintf("/v1/jails/%s", jailName), nil)
}
// CreateFilter implements Connector.
func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, content string) error {
payload := map[string]interface{}{
"name": filterName,
"content": content,
}
return ac.post(ctx, "/v1/filters", payload, nil)
}
// DeleteFilter implements Connector.
func (ac *AgentConnector) DeleteFilter(ctx context.Context, filterName string) error {
return ac.delete(ctx, fmt.Sprintf("/v1/filters/%s", filterName), nil)
}

View File

@@ -199,7 +199,7 @@ func (lc *LocalConnector) Restart(ctx context.Context) error {
}
// GetFilterConfig implements Connector.
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
return GetFilterConfigLocal(jail)
}
@@ -306,7 +306,7 @@ func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, log
}
// GetJailConfig implements Connector.
func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, error) {
func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
return GetJailConfig(jail)
}
@@ -338,6 +338,26 @@ func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error {
return config.EnsureJailLocalStructure()
}
// CreateJail implements Connector.
func (lc *LocalConnector) CreateJail(ctx context.Context, jailName, content string) error {
return CreateJail(jailName, content)
}
// DeleteJail implements Connector.
func (lc *LocalConnector) DeleteJail(ctx context.Context, jailName string) error {
return DeleteJail(jailName)
}
// CreateFilter implements Connector.
func (lc *LocalConnector) CreateFilter(ctx context.Context, filterName, content string) error {
return CreateFilter(filterName, content)
}
// DeleteFilter implements Connector.
func (lc *LocalConnector) DeleteFilter(ctx context.Context, filterName string) error {
return DeleteFilter(filterName)
}
func executeShellCommand(ctx context.Context, command string) (string, error) {
parts := strings.Fields(command)
if len(parts) == 0 {

View File

@@ -21,45 +21,50 @@ import (
const sshEnsureActionScript = `python3 - <<'PY'
import base64
import pathlib
import sys
action_dir = pathlib.Path("/etc/fail2ban/action.d")
action_dir.mkdir(parents=True, exist_ok=True)
action_cfg = base64.b64decode("__PAYLOAD__").decode("utf-8")
(action_dir / "ui-custom-action.conf").write_text(action_cfg)
jail_file = pathlib.Path("/etc/fail2ban/jail.local")
if not jail_file.exists():
jail_file.write_text("[DEFAULT]\n")
lines = jail_file.read_text().splitlines()
already = any("Custom Fail2Ban action applied by fail2ban-ui" in line for line in lines)
if not already:
new_lines = []
inserted = False
for line in lines:
stripped = line.strip()
if stripped.startswith("action") and "ui-custom-action" not in stripped and not inserted:
if not stripped.startswith("#"):
new_lines.append("# " + line)
else:
new_lines.append(line)
new_lines.append("# Custom Fail2Ban action applied by fail2ban-ui")
new_lines.append("action = %(action_mwlg)s")
inserted = True
continue
new_lines.append(line)
if not inserted:
insert_at = None
for idx, value in enumerate(new_lines):
if value.strip().startswith("[DEFAULT]"):
insert_at = idx + 1
break
if insert_at is None:
new_lines.append("[DEFAULT]")
insert_at = len(new_lines)
new_lines.insert(insert_at, "# Custom Fail2Ban action applied by fail2ban-ui")
new_lines.insert(insert_at + 1, "action = %(action_mwlg)s")
jail_file.write_text("\n".join(new_lines) + "\n")
try:
action_dir = pathlib.Path("/etc/fail2ban/action.d")
action_dir.mkdir(parents=True, exist_ok=True)
action_cfg = base64.b64decode("__PAYLOAD__").decode("utf-8")
(action_dir / "ui-custom-action.conf").write_text(action_cfg)
jail_file = pathlib.Path("/etc/fail2ban/jail.local")
if not jail_file.exists():
jail_file.write_text("[DEFAULT]\n")
lines = jail_file.read_text().splitlines()
already = any("Custom Fail2Ban action applied by fail2ban-ui" in line for line in lines)
if not already:
new_lines = []
inserted = False
for line in lines:
stripped = line.strip()
if stripped.startswith("action") and "ui-custom-action" not in stripped and not inserted:
if not stripped.startswith("#"):
new_lines.append("# " + line)
else:
new_lines.append(line)
new_lines.append("# Custom Fail2Ban action applied by fail2ban-ui")
new_lines.append("action = %(action_mwlg)s")
inserted = True
continue
new_lines.append(line)
if not inserted:
insert_at = None
for idx, value in enumerate(new_lines):
if value.strip().startswith("[DEFAULT]"):
insert_at = idx + 1
break
if insert_at is None:
new_lines.append("[DEFAULT]")
insert_at = len(new_lines)
new_lines.insert(insert_at, "# Custom Fail2Ban action applied by fail2ban-ui")
new_lines.insert(insert_at + 1, "action = %(action_mwlg)s")
jail_file.write_text("\n".join(new_lines) + "\n")
except Exception as e:
sys.stderr.write(f"Error: {e}\n")
sys.exit(1)
PY`
// SSHConnector connects to a remote Fail2ban instance over SSH.
@@ -76,8 +81,17 @@ func NewSSHConnector(server config.Fail2banServer) (Connector, error) {
return nil, fmt.Errorf("sshUser is required for ssh connector")
}
conn := &SSHConnector{server: server}
if err := conn.ensureAction(context.Background()); err != nil {
fmt.Printf("warning: failed to ensure remote fail2ban action for %s: %v\n", server.Name, err)
// Use a timeout context to prevent hanging if SSH server isn't ready yet
// The action file can be ensured later when actually needed
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := conn.ensureAction(ctx); err != nil {
// Log warning but don't fail connector creation - action can be ensured later
config.DebugLog("warning: failed to ensure remote fail2ban action for %s during startup (server may not be ready): %v", server.Name, err)
// Don't return error - allow connector to be created even if action setup fails
// The action will be ensured later when UpdateActionFiles is called
}
return conn, nil
}
@@ -186,7 +200,7 @@ func (sc *SSHConnector) Restart(ctx context.Context) error {
// RestartWithMode implements the detailed restart logic for SSH connectors.
func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) {
// First, we try systemd restart on the remote host
out, err := sc.runRemoteCommand(ctx, []string{"sudo", "systemctl", "restart", "fail2ban"})
out, err := sc.runRemoteCommand(ctx, []string{"systemctl", "restart", "fail2ban"})
if err == nil {
if err := sc.checkFail2banHealthyRemote(ctx); err != nil {
return "restart", fmt.Errorf("remote fail2ban health check after systemd restart failed: %w", err)
@@ -211,62 +225,59 @@ func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) {
return "restart", fmt.Errorf("failed to restart fail2ban via systemd on remote: %w (output: %s)", err, out)
}
func (sc *SSHConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
func (sc *SSHConnector) GetFilterConfig(ctx context.Context, filterName string) (string, string, error) {
// Validate filter name
jail = strings.TrimSpace(jail)
if jail == "" {
return "", fmt.Errorf("filter name cannot be empty")
filterName = strings.TrimSpace(filterName)
if filterName == "" {
return "", "", fmt.Errorf("filter name cannot be empty")
}
fail2banPath := sc.getFail2banPath(ctx)
// 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)
localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local")
confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf")
out, err := sc.runRemoteCommand(ctx, []string{"cat", localPath})
content, err := sc.readRemoteFile(ctx, localPath)
if err == nil {
return out, nil
return content, localPath, nil
}
// Fallback to .conf
out, err = sc.runRemoteCommand(ctx, []string{"cat", confPath})
content, err = sc.readRemoteFile(ctx, confPath)
if err != nil {
return "", fmt.Errorf("failed to read remote filter config (tried .local and .conf): %w", err)
return "", "", fmt.Errorf("failed to read remote filter config (tried .local and .conf): %w", err)
}
return out, nil
return content, confPath, nil
}
func (sc *SSHConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
func (sc *SSHConnector) SetFilterConfig(ctx context.Context, filterName, content string) error {
// Validate filter name
jail = strings.TrimSpace(jail)
if jail == "" {
filterName = strings.TrimSpace(filterName)
if filterName == "" {
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)
fail2banPath := sc.getFail2banPath(ctx)
filterDPath := filepath.Join(fail2banPath, "filter.d")
// 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})
// Ensure directory exists
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", filterDPath})
if err != nil {
return fmt.Errorf("failed to create filter.d directory: %w", err)
}
// Ensure .local file exists (copy from .conf if needed)
if err := sc.ensureRemoteLocalFile(ctx, filterDPath, filterName); 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
localPath := filepath.Join(filterDPath, filterName+".local")
if err := sc.writeRemoteFile(ctx, localPath, content); err != nil {
return fmt.Errorf("failed to write filter config: %w", err)
}
return nil
}
func (sc *SSHConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
@@ -395,6 +406,12 @@ func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string)
func (sc *SSHConnector) buildSSHArgs(command []string) []string {
args := []string{"-o", "BatchMode=yes"}
// Add connection timeout to prevent hanging
args = append(args,
"-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=5",
"-o", "ServerAliveCountMax=2",
)
// In containerized environments, disable strict host key checking
if _, container := os.LookupEnv("CONTAINER"); container {
args = append(args,
@@ -403,6 +420,14 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string {
"-o", "LogLevel=ERROR",
)
}
// Enable SSH connection multiplexing for faster connections
// Use a control socket based on server ID for connection reuse
controlPath := fmt.Sprintf("/tmp/ssh_control_%s_%s", sc.server.ID, strings.ReplaceAll(sc.server.Host, ".", "_"))
args = append(args,
"-o", "ControlMaster=auto",
"-o", fmt.Sprintf("ControlPath=%s", controlPath),
"-o", "ControlPersist=300", // Keep connection alive for 5 minutes
)
if sc.server.SSHKeyPath != "" {
args = append(args, "-i", sc.server.SSHKeyPath)
}
@@ -418,76 +443,182 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string {
return args
}
// GetAllJails implements Connector.
func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
// Read jail.local (DEFAULT only) and jail.d files remotely
var allJails []JailInfo
// listRemoteFiles lists files in a remote directory matching a pattern.
// Uses Python to list files, which works better with FACL permissions than find/ls.
func (sc *SSHConnector) listRemoteFiles(ctx context.Context, directory, pattern string) ([]string, error) {
// Use Python to list files - works better with FACL permissions
script := fmt.Sprintf(`python3 -c "
import os
import sys
directory = %q
pattern = %q
try:
if os.path.isdir(directory):
files = os.listdir(directory)
for f in files:
if f.endswith(pattern) and not f.startswith('.'):
full_path = os.path.join(directory, f)
if os.path.isfile(full_path):
print(full_path)
except Exception as e:
sys.stderr.write(f'Error listing files: {e}\\n')
sys.exit(1)
"`, directory, pattern)
// 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)
// Filter out DEFAULT section - we only want actual jails
for _, jail := range jails {
if jail.JailName != "DEFAULT" {
allJails = append(allJails, jail)
}
out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", script})
if err != nil {
return nil, fmt.Errorf("failed to list files in %s: %w", directory, err)
}
var files []string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line != "" {
files = append(files, line)
}
}
// 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 == "" {
return files, nil
}
// readRemoteFile reads the content of a remote file via SSH.
func (sc *SSHConnector) readRemoteFile(ctx context.Context, filePath string) (string, error) {
content, err := sc.runRemoteCommand(ctx, []string{"cat", filePath})
if err != nil {
return "", fmt.Errorf("failed to read remote file %s: %w", filePath, err)
}
return content, nil
}
// writeRemoteFile writes content to a remote file via SSH using a heredoc.
func (sc *SSHConnector) writeRemoteFile(ctx context.Context, filePath, content string) error {
// Escape single quotes for safe use in a single-quoted heredoc
escaped := strings.ReplaceAll(content, "'", "'\"'\"'")
// Use heredoc to write file content
script := fmt.Sprintf(`cat > %s <<'REMOTEEOF'
%s
REMOTEEOF
`, filePath, escaped)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script})
if err != nil {
return fmt.Errorf("failed to write remote file %s: %w", filePath, err)
}
return nil
}
// ensureRemoteLocalFile ensures that a .local file exists on the remote system.
// If .local doesn't exist, it copies from .conf if available, or creates an empty file.
func (sc *SSHConnector) ensureRemoteLocalFile(ctx context.Context, basePath, name string) error {
localPath := fmt.Sprintf("%s/%s.local", basePath, name)
confPath := fmt.Sprintf("%s/%s.conf", basePath, name)
// Check if .local exists, if not, copy from .conf or create empty file
script := fmt.Sprintf(`
if [ ! -f "%s" ]; then
if [ -f "%s" ]; then
cp "%s" "%s"
else
# Create empty .local file if neither exists
touch "%s"
fi
fi
`, localPath, confPath, confPath, localPath, localPath)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script})
if err != nil {
return fmt.Errorf("failed to ensure remote .local file %s: %w", localPath, err)
}
return nil
}
// getFail2banPath detects the fail2ban configuration path on the remote system.
// Returns /config/fail2ban for linuxserver images, or /etc/fail2ban for standard installations.
func (sc *SSHConnector) getFail2banPath(ctx context.Context) string {
// Check if /config/fail2ban exists (linuxserver image)
checkScript := `if [ -d "/config/fail2ban" ]; then echo "/config/fail2ban"; elif [ -d "/etc/fail2ban" ]; then echo "/etc/fail2ban"; else echo "/etc/fail2ban"; fi`
out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkScript})
if err == nil {
path := strings.TrimSpace(out)
if path != "" {
return path
}
}
// Default to /etc/fail2ban
return "/etc/fail2ban"
}
// GetAllJails implements Connector.
// Discovers all jails from filesystem (mirrors local connector behavior).
func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
fail2banPath := sc.getFail2banPath(ctx)
jailDPath := filepath.Join(fail2banPath, "jail.d")
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
// List all .local files first
localFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".local")
if err != nil {
config.DebugLog("Failed to list .local files in jail.d on server %s: %v", sc.server.Name, err)
// Continue with .conf files
} else {
// Process .local files
for _, filePath := range localFiles {
filename := filepath.Base(filePath)
baseName := strings.TrimSuffix(filename, ".local")
if baseName == "" || processedFiles[baseName] {
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)
processedFiles[baseName] = true
// Read and parse the file
content, err := sc.readRemoteFile(ctx, filePath)
if err != nil {
config.DebugLog("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err)
continue
}
content, err := sc.runRemoteCommand(ctx, []string{"cat", file})
if err == nil {
jails := parseJailConfigContent(content)
for _, jail := range jails {
// Skip jails with empty names
if jail.JailName != "" {
allJails = append(allJails, jail)
processedJails[jail.JailName] = true
}
jails := parseJailConfigContent(content)
for _, jail := range jails {
if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[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 == "" {
// List all .conf files that don't have corresponding .local files
confFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".conf")
if err != nil {
config.DebugLog("Failed to list .conf files in jail.d on server %s: %v", sc.server.Name, err)
} else {
// Process .conf files
for _, filePath := range confFiles {
filename := filepath.Base(filePath)
baseName := strings.TrimSuffix(filename, ".conf")
if baseName == "" || processedFiles[baseName] {
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)
processedFiles[baseName] = true
// Read and parse the file
content, err := sc.readRemoteFile(ctx, filePath)
if err != nil {
config.DebugLog("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err)
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...)
jails := parseJailConfigContent(content)
for _, jail := range jails {
if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] {
allJails = append(allJails, jail)
processedJails[jail.JailName] = true
}
}
}
@@ -498,8 +629,11 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
// UpdateJailEnabledStates implements Connector.
func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
fail2banPath := sc.getFail2banPath(ctx)
jailDPath := filepath.Join(fail2banPath, "jail.d")
// Ensure jail.d directory exists
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", "/etc/fail2ban/jail.d"})
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath})
if err != nil {
return fmt.Errorf("failed to create jail.d directory: %w", err)
}
@@ -513,8 +647,8 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map
continue
}
localPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jailName)
confPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jailName)
localPath := filepath.Join(jailDPath, jailName+".local")
confPath := filepath.Join(jailDPath, jailName+".conf")
// Ensure .local file exists (copy from .conf if needed)
ensureScript := fmt.Sprintf(`
@@ -591,48 +725,72 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map
}
// GetFilters implements Connector.
// Discovers all filters from filesystem (mirrors local connector behavior).
func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) {
// Use find to list filter files
list, err := sc.runRemoteCommand(ctx, []string{"find", "/etc/fail2ban/filter.d", "-maxdepth", "1", "-type", "f"})
if err != nil {
return nil, fmt.Errorf("failed to list filters: %w", err)
fail2banPath := sc.getFail2banPath(ctx)
filterDPath := filepath.Join(fail2banPath, "filter.d")
filterMap := make(map[string]bool) // Track unique filter names
processedFiles := make(map[string]bool) // Track base names to avoid duplicates
// Helper function to check if file should be excluded
shouldExclude := func(filename string) bool {
if strings.HasSuffix(filename, ".bak") ||
strings.HasSuffix(filename, "~") ||
strings.HasSuffix(filename, ".old") ||
strings.HasSuffix(filename, ".rpmnew") ||
strings.HasSuffix(filename, ".rpmsave") ||
strings.Contains(filename, "README") {
return true
}
return false
}
// Filter for .conf files and extract names in Go
var filters []string
seen := make(map[string]bool) // Avoid duplicates
for _, line := range strings.Split(list, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Only process .conf files - be strict about the extension
if !strings.HasSuffix(line, ".conf") {
continue
}
// Exclude backup files and other non-filter files
if strings.HasSuffix(line, ".conf.bak") ||
strings.HasSuffix(line, ".conf~") ||
strings.HasSuffix(line, ".conf.old") ||
strings.HasSuffix(line, ".conf.rpmnew") ||
strings.HasSuffix(line, ".conf.rpmsave") ||
strings.Contains(line, "README") {
continue
}
parts := strings.Split(line, "/")
if len(parts) > 0 {
filename := parts[len(parts)-1]
// Double-check it ends with .conf
if !strings.HasSuffix(filename, ".conf") {
// First pass: collect all .local files (these take precedence)
localFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".local")
if err != nil {
config.DebugLog("Failed to list .local filters on server %s: %v", sc.server.Name, err)
} else {
for _, filePath := range localFiles {
filename := filepath.Base(filePath)
if shouldExclude(filename) {
continue
}
name := strings.TrimSuffix(filename, ".conf")
if name != "" && !seen[name] {
seen[name] = true
filters = append(filters, name)
baseName := strings.TrimSuffix(filename, ".local")
if baseName == "" || processedFiles[baseName] {
continue
}
processedFiles[baseName] = true
filterMap[baseName] = true
}
}
// Second pass: collect .conf files that don't have corresponding .local files
confFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".conf")
if err != nil {
config.DebugLog("Failed to list .conf filters on server %s: %v", sc.server.Name, err)
} else {
for _, filePath := range confFiles {
filename := filepath.Base(filePath)
if shouldExclude(filename) {
continue
}
baseName := strings.TrimSuffix(filename, ".conf")
if baseName == "" || processedFiles[baseName] {
continue
}
processedFiles[baseName] = true
filterMap[baseName] = true
}
}
// Convert map to sorted slice
var filters []string
for name := range filterMap {
filters = append(filters, name)
}
sort.Strings(filters)
return filters, nil
}
@@ -719,29 +877,30 @@ fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true
}
// GetJailConfig implements Connector.
func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, error) {
func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
// Validate jail name
jail = strings.TrimSpace(jail)
if jail == "" {
return "", fmt.Errorf("jail name cannot be empty")
return "", "", fmt.Errorf("jail name cannot be empty")
}
fail2banPath := sc.getFail2banPath(ctx)
// 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)
localPath := filepath.Join(fail2banPath, "jail.d", jail+".local")
confPath := filepath.Join(fail2banPath, "jail.d", jail+".conf")
out, err := sc.runRemoteCommand(ctx, []string{"cat", localPath})
content, err := sc.readRemoteFile(ctx, localPath)
if err == nil {
return out, nil
return content, localPath, nil
}
// Fallback to .conf
out, err = sc.runRemoteCommand(ctx, []string{"cat", confPath})
content, err = sc.readRemoteFile(ctx, confPath)
if err != nil {
// If neither exists, return empty jail section
return fmt.Sprintf("[%s]\n", jail), nil
// If neither exists, return empty jail section with .local path (will be created on save)
return fmt.Sprintf("[%s]\n", jail), localPath, nil
}
return out, nil
return content, confPath, nil
}
// SetJailConfig implements Connector.
@@ -752,34 +911,27 @@ func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string)
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)
fail2banPath := sc.getFail2banPath(ctx)
jailDPath := filepath.Join(fail2banPath, "jail.d")
// Ensure jail.d directory exists
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", "/etc/fail2ban/jail.d"})
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath})
if err != nil {
return fmt.Errorf("failed to create jail.d directory: %w", err)
}
// 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 {
if err := sc.ensureRemoteLocalFile(ctx, jailDPath, jail); 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
localPath := filepath.Join(jailDPath, jail+".local")
if err := sc.writeRemoteFile(ctx, localPath, content); err != nil {
return fmt.Errorf("failed to write jail config: %w", err)
}
return nil
}
// TestLogpath implements Connector.
@@ -1231,19 +1383,20 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err
checkScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailLocalPath)
out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkScript})
if err != nil || strings.TrimSpace(out) != "exists" {
config.DebugLog("No jails to migrate from jail.local on server %s (file does not exist)", sc.server.Name)
return nil // Nothing to migrate
}
// Read jail.local content
content, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath})
if err != nil {
return fmt.Errorf("failed to read jail.local: %w", err)
return fmt.Errorf("failed to read jail.local on server %s: %w", sc.server.Name, err)
}
// Parse content locally to extract non-commented sections
sections, defaultContent, err := parseJailSectionsUncommented(content)
if err != nil {
return fmt.Errorf("failed to parse jail.local: %w", err)
return fmt.Errorf("failed to parse jail.local on server %s: %w", sc.server.Name, err)
}
// If no non-commented, non-DEFAULT jails found, nothing to migrate
@@ -1256,14 +1409,14 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err
backupPath := jailLocalPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix())
backupScript := fmt.Sprintf("cp %s %s", jailLocalPath, backupPath)
if _, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", backupScript}); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
return fmt.Errorf("failed to create backup on server %s: %w", sc.server.Name, err)
}
config.DebugLog("Created backup of jail.local at %s on remote system", backupPath)
config.DebugLog("Created backup of jail.local at %s on server %s", backupPath, sc.server.Name)
// Ensure jail.d directory exists
ensureDirScript := fmt.Sprintf("mkdir -p %s", jailDPath)
if _, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", ensureDirScript}); err != nil {
return fmt.Errorf("failed to create jail.d directory: %w", err)
return fmt.Errorf("failed to create jail.d directory on server %s: %w", sc.server.Name, err)
}
// Write each jail to its own .local file
@@ -1279,7 +1432,7 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err
checkFileScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailFilePath)
fileOut, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkFileScript})
if err == nil && strings.TrimSpace(fileOut) == "exists" {
config.DebugLog("Skipping migration for jail %s: .local file already exists", jailName)
config.DebugLog("Skipping migration for jail %s on server %s: .local file already exists", jailName, sc.server.Name)
continue
}
@@ -1293,7 +1446,7 @@ JAILEOF
if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeScript}); err != nil {
return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err)
}
config.DebugLog("Migrated jail %s to %s on remote system", jailName, jailFilePath)
config.DebugLog("Migrated jail %s to %s on server %s", jailName, jailFilePath, sc.server.Name)
migratedCount++
}
@@ -1309,7 +1462,107 @@ LOCALEOF
if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeLocalScript}); err != nil {
return fmt.Errorf("failed to rewrite jail.local: %w", err)
}
config.DebugLog("Migration completed on remote system: moved %d jails to jail.d/", migratedCount)
config.DebugLog("Migration completed on server %s: moved %d jails to jail.d/", sc.server.Name, migratedCount)
}
return nil
}
// CreateJail implements Connector.
func (sc *SSHConnector) CreateJail(ctx context.Context, jailName, content string) error {
// Validate jail name
if err := ValidateJailName(jailName); err != nil {
return err
}
fail2banPath := sc.getFail2banPath(ctx)
jailDPath := filepath.Join(fail2banPath, "jail.d")
// Ensure jail.d directory exists
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath})
if 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
localPath := filepath.Join(jailDPath, jailName+".local")
if err := sc.writeRemoteFile(ctx, localPath, content); err != nil {
return fmt.Errorf("failed to create jail file: %w", err)
}
return nil
}
// DeleteJail implements Connector.
func (sc *SSHConnector) DeleteJail(ctx context.Context, jailName string) error {
// Validate jail name
if err := ValidateJailName(jailName); err != nil {
return err
}
fail2banPath := sc.getFail2banPath(ctx)
localPath := filepath.Join(fail2banPath, "jail.d", jailName+".local")
confPath := filepath.Join(fail2banPath, "jail.d", jailName+".conf")
// Delete both .local and .conf files if they exist (rm -f doesn't error if file doesn't exist)
// Use a single command to delete both files
_, err := sc.runRemoteCommand(ctx, []string{"rm", "-f", localPath, confPath})
if err != nil {
return fmt.Errorf("failed to delete jail files %s or %s: %w", localPath, confPath, err)
}
return nil
}
// CreateFilter implements Connector.
func (sc *SSHConnector) CreateFilter(ctx context.Context, filterName, content string) error {
// Validate filter name
if err := ValidateFilterName(filterName); err != nil {
return err
}
fail2banPath := sc.getFail2banPath(ctx)
filterDPath := filepath.Join(fail2banPath, "filter.d")
// Ensure filter.d directory exists
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", filterDPath})
if err != nil {
return fmt.Errorf("failed to create filter.d directory: %w", err)
}
// Write the file
localPath := filepath.Join(filterDPath, filterName+".local")
if err := sc.writeRemoteFile(ctx, localPath, content); err != nil {
return fmt.Errorf("failed to create filter file: %w", err)
}
return nil
}
// DeleteFilter implements Connector.
func (sc *SSHConnector) DeleteFilter(ctx context.Context, filterName string) error {
// Validate filter name
if err := ValidateFilterName(filterName); err != nil {
return err
}
fail2banPath := sc.getFail2banPath(ctx)
localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local")
confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf")
// Delete both .local and .conf files if they exist (rm -f doesn't error if file doesn't exist)
// Use a single command to delete both files
_, err := sc.runRemoteCommand(ctx, []string{"rm", "-f", localPath, confPath})
if err != nil {
return fmt.Errorf("failed to delete filter files %s or %s: %w", localPath, confPath, err)
}
return nil

View File

@@ -22,6 +22,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -29,10 +30,11 @@ import (
)
// GetFilterConfig returns the filter configuration using the default connector.
func GetFilterConfig(jail string) (string, error) {
// Returns (config, filePath, error)
func GetFilterConfig(jail string) (string, string, error) {
conn, err := GetManager().DefaultConnector()
if err != nil {
return "", err
return "", "", err
}
return conn.GetFilterConfig(context.Background(), jail)
}
@@ -47,8 +49,7 @@ func SetFilterConfig(jail, newContent string) error {
}
// 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).
// If .local doesn't exist, it copies from .conf if available, or creates an empty file.
func ensureFilterLocalFile(filterName string) error {
// Validate filter name - must not be empty
filterName = strings.TrimSpace(filterName)
@@ -80,16 +81,22 @@ func ensureFilterLocalFile(filterName string) error {
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)
// Neither exists, create empty .local file
config.DebugLog("Neither .local nor .conf exists for filter %s, creating empty .local file", filterName)
if err := os.WriteFile(localPath, []byte(""), 0644); err != nil {
return fmt.Errorf("failed to create empty filter .local file %s: %w", localPath, err)
}
config.DebugLog("Successfully created empty filter .local file: %s", localPath)
return nil
}
// readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf.
func readFilterConfigWithFallback(filterName string) (string, error) {
// Returns (content, filePath, error)
func readFilterConfigWithFallback(filterName string) (string, string, error) {
// Validate filter name - must not be empty
filterName = strings.TrimSpace(filterName)
if filterName == "" {
return "", fmt.Errorf("filter name cannot be empty")
return "", "", fmt.Errorf("filter name cannot be empty")
}
filterDPath := "/etc/fail2ban/filter.d"
@@ -99,22 +106,23 @@ func readFilterConfigWithFallback(filterName string) (string, error) {
// Try .local first
if content, err := os.ReadFile(localPath); err == nil {
config.DebugLog("Reading filter config from .local: %s", localPath)
return string(content), nil
return string(content), localPath, 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
return string(content), confPath, nil
}
// Neither exists, return error
return "", fmt.Errorf("filter config not found: neither %s nor %s exists", localPath, confPath)
// Neither exists, return error with .local path (will be created on save)
return "", localPath, 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) {
// Returns (content, filePath, error)
func GetFilterConfigLocal(jail string) (string, string, error) {
return readFilterConfigWithFallback(jail)
}
@@ -134,42 +142,205 @@ func SetFilterConfigLocal(jail, newContent string) error {
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)
// ValidateFilterName validates a filter name format.
// Returns an error if the name is invalid (empty, contains invalid characters, or is reserved).
func ValidateFilterName(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("filter name cannot be empty")
}
filterMap := make(map[string]bool)
// Check for invalid characters (only alphanumeric, dash, underscore allowed)
invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
if invalidChars.MatchString(name) {
return fmt.Errorf("filter name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", name)
}
return nil
}
// ListFilterFiles lists all filter files in the specified directory.
// Returns full paths to .local and .conf files.
func ListFilterFiles(directory string) ([]string, error) {
var files []string
entries, err := os.ReadDir(directory)
if err != nil {
return nil, fmt.Errorf("failed to read filter directory %s: %w", directory, err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Skip hidden files and invalid names
if strings.HasPrefix(name, ".") {
continue
}
// Only include .local and .conf files
if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") {
fullPath := filepath.Join(directory, name)
files = append(files, fullPath)
}
}
return files, nil
}
// DiscoverFiltersFromFiles discovers all filters from the filesystem.
// Reads from /etc/fail2ban/filter.d/ directory, preferring .local files over .conf files.
// Returns unique filter names.
func DiscoverFiltersFromFiles() ([]string, error) {
filterDPath := "/etc/fail2ban/filter.d"
// Check if directory exists
if _, err := os.Stat(filterDPath); os.IsNotExist(err) {
// Directory doesn't exist, return empty list
return []string{}, nil
}
// List all filter files
files, err := ListFilterFiles(filterDPath)
if err != nil {
return nil, err
}
filterMap := make(map[string]bool) // Track unique filter names
processedFiles := make(map[string]bool) // Track base names to avoid duplicates
// 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
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
filterMap[baseName] = 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")
if !filterMap[name] {
filterMap[name] = true
}
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
filterMap[baseName] = true
}
// Convert map to sorted slice
var filters []string
for name := range filterMap {
filters = append(filters, name)
}
sort.Strings(filters)
return filters, nil
}
// CreateFilter creates a new filter in filter.d/{name}.local.
// If the filter already exists, it will be overwritten.
func CreateFilter(filterName, content string) error {
if err := ValidateFilterName(filterName); err != nil {
return err
}
filterDPath := "/etc/fail2ban/filter.d"
localPath := filepath.Join(filterDPath, filterName+".local")
// Ensure directory exists
if err := os.MkdirAll(filterDPath, 0755); err != nil {
return fmt.Errorf("failed to create filter.d directory: %w", err)
}
// Write the file
if err := os.WriteFile(localPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to create filter file %s: %w", localPath, err)
}
config.DebugLog("Created filter file: %s", localPath)
return nil
}
// DeleteFilter deletes a filter's .local and .conf files from filter.d/ if they exist.
// Both files are deleted to ensure complete removal of the filter configuration.
func DeleteFilter(filterName string) error {
if err := ValidateFilterName(filterName); err != nil {
return err
}
filterDPath := "/etc/fail2ban/filter.d"
localPath := filepath.Join(filterDPath, filterName+".local")
confPath := filepath.Join(filterDPath, filterName+".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 filter file %s: %w", localPath, err)
} else {
deletedFiles = append(deletedFiles, localPath)
config.DebugLog("Deleted filter 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 filter file %s: %w", confPath, err)
} else {
deletedFiles = append(deletedFiles, confPath)
config.DebugLog("Deleted filter 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("filter file %s or %s does not exist", localPath, confPath)
}
// Return the last error if any occurred
if lastErr != nil {
return lastErr
}
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)
// This is the canonical implementation - now uses DiscoverFiltersFromFiles()
func GetFiltersLocal() ([]string, error) {
return DiscoverFiltersFromFiles()
}
func normalizeLogLines(logLines []string) []string {
var cleaned []string
for _, line := range logLines {

View File

@@ -64,11 +64,12 @@ func ensureJailLocalFile(jailName string) error {
}
// readJailConfigWithFallback reads jail config from .local first, then falls back to .conf.
func readJailConfigWithFallback(jailName string) (string, error) {
// Returns (content, filePath, error)
func readJailConfigWithFallback(jailName string) (string, string, error) {
// Validate jail name - must not be empty
jailName = strings.TrimSpace(jailName)
if jailName == "" {
return "", fmt.Errorf("jail name cannot be empty")
return "", "", fmt.Errorf("jail name cannot be empty")
}
jailDPath := "/etc/fail2ban/jail.d"
@@ -78,22 +79,254 @@ func readJailConfigWithFallback(jailName string) (string, error) {
// Try .local first
if content, err := os.ReadFile(localPath); err == nil {
config.DebugLog("Reading jail config from .local: %s", localPath)
return string(content), nil
return string(content), localPath, 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
return string(content), confPath, nil
}
// Neither exists, return empty section
// Neither exists, return empty section with .local path (will be created on save)
config.DebugLog("Neither .local nor .conf exists for jail %s, returning empty section", jailName)
return fmt.Sprintf("[%s]\n", jailName), nil
return fmt.Sprintf("[%s]\n", jailName), localPath, nil
}
// 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
}
name := entry.Name()
// Skip hidden files and invalid names
if strings.HasPrefix(name, ".") {
continue
}
// Only include .local and .conf files
if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") {
fullPath := filepath.Join(directory, name)
files = append(files, fullPath)
}
}
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) {
jailDPath := "/etc/fail2ban/jail.d"
// 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
}
}
}
// 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
}
}
}
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() {
@@ -102,70 +335,12 @@ func GetAllJails() ([]JailInfo, error) {
}
})
var jails []JailInfo
// Parse only DEFAULT section from jail.local (skip other jails)
localPath := "/etc/fail2ban/jail.local"
if _, err := os.Stat(localPath); err == nil {
defaultJails, err := parseJailConfigFileOnlyDefault(localPath)
if err == nil {
jails = append(jails, defaultJails...)
}
// Discover jails from filesystem
jails, err := DiscoverJailsFromFiles()
if err != nil {
return nil, fmt.Errorf("failed to discover jails from files: %w", err)
}
// 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()) == ".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 {
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...)
}
}
}
}
}
}
return jails, nil
}
@@ -697,44 +872,23 @@ func MigrateJailsFromJailLocal() error {
return nil
}
// 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}.local
// Falls back to .conf if .local doesn't exist.
func GetJailConfig(jailName string) (string, error) {
func GetJailConfig(jailName string) (string, string, error) {
// Validate jail name
jailName = strings.TrimSpace(jailName)
if jailName == "" {
return "", fmt.Errorf("jail name cannot be empty")
return "", "", fmt.Errorf("jail name cannot be empty")
}
config.DebugLog("GetJailConfig called for jail: %s", jailName)
content, err := readJailConfigWithFallback(jailName)
content, filePath, 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)
return "", "", fmt.Errorf("failed to read jail config for %s: %w", jailName, err)
}
config.DebugLog("Jail config read successfully, length: %d", len(content))
return content, nil
config.DebugLog("Jail config read successfully, length: %d, file: %s", len(content), filePath)
return content, filePath, nil
}
// SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.local

View File

@@ -18,7 +18,7 @@ type Connector interface {
UnbanIP(ctx context.Context, jail, ip string) error
Reload(ctx context.Context) error
Restart(ctx context.Context) error
GetFilterConfig(ctx context.Context, jail string) (string, error)
GetFilterConfig(ctx context.Context, jail string) (string, string, error) // Returns (config, filePath, error)
SetFilterConfig(ctx context.Context, jail, content string) error
FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error)
@@ -31,7 +31,7 @@ type Connector interface {
TestFilter(ctx context.Context, filterName string, logLines []string) (output string, filterPath string, err error)
// Jail configuration operations
GetJailConfig(ctx context.Context, jail string) (string, error)
GetJailConfig(ctx context.Context, jail string) (string, string, error) // Returns (config, filePath, error)
SetJailConfig(ctx context.Context, jail, content string) error
TestLogpath(ctx context.Context, logpath string) ([]string, error)
TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error)
@@ -41,6 +41,12 @@ type Connector interface {
// Jail local structure management
EnsureJailLocalStructure(ctx context.Context) error
// Jail and filter creation/deletion
CreateJail(ctx context.Context, jailName, content string) error
DeleteJail(ctx context.Context, jailName string) error
CreateFilter(ctx context.Context, filterName, content string) error
DeleteFilter(ctx context.Context, filterName string) error
}
// Manager orchestrates all connectors for configured Fail2ban servers.
@@ -173,12 +179,11 @@ func newConnectorForServer(server config.Fail2banServer) (Connector, error) {
// This ensures any legacy jails in jail.local are migrated to jail.d/*.local
// before ensureJailLocalStructure() overwrites jail.local
if err := MigrateJailsFromJailLocal(); err != nil {
config.DebugLog("Warning: migration check failed (may be normal if no jails to migrate): %v", err)
// Don't fail - continue with ensuring structure
return nil, fmt.Errorf("failed to initialise local fail2ban connector for %s: %w", server.Name, err)
}
if err := config.EnsureLocalFail2banAction(server); err != nil {
fmt.Printf("warning: failed to ensure local fail2ban action: %v\n", err)
return nil, fmt.Errorf("failed to ensure local fail2ban action for %s: %w", server.Name, err)
}
return NewLocalConnector(server), nil
case "ssh":

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "IP sperren",
"settings.advanced.test_unblock": "IP entfernen",
"settings.save": "Speichern",
"modal.filter_config": "Filter-Konfiguration:",
"modal.filter_config_edit": "Filter bearbeiten",
"modal.filter_config": "Filter / Jail-Konfiguration:",
"modal.filter_config_edit": "Filter / Jail bearbeiten",
"modal.cancel": "Abbrechen",
"modal.save": "Speichern",
"modal.close": "Schließen",

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "IP sperre",
"settings.advanced.test_unblock": "IP freigäh",
"settings.save": "Speicherä",
"modal.filter_config": "Filter-Konfiguration:",
"modal.filter_config_edit": "Filter bearbeite",
"modal.filter_config": "Filter / Jail-Konfiguration:",
"modal.filter_config_edit": "Filter / Jail bearbeite",
"modal.cancel": "Abbräche",
"modal.save": "Speicherä",
"modal.close": "Wider Schliesse",

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "Block IP",
"settings.advanced.test_unblock": "Remove IP",
"settings.save": "Save",
"modal.filter_config": "Filter Config:",
"modal.filter_config_edit": "Edit Filter",
"modal.filter_config": "Filter / Jail Configuration:",
"modal.filter_config_edit": "Edit Filter / Jail",
"modal.cancel": "Cancel",
"modal.save": "Save",
"modal.close": "Close",

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "Bloquear IP",
"settings.advanced.test_unblock": "Eliminar IP",
"settings.save": "Guardar",
"modal.filter_config": "Configuración del filtro:",
"modal.filter_config_edit": "Editar filtro",
"modal.filter_config": "Configuración del filtro / Jail:",
"modal.filter_config_edit": "Editar filtro / Jail",
"modal.cancel": "Cancelar",
"modal.save": "Guardar",
"modal.close": "Cerrar",

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "Bloquer lIP",
"settings.advanced.test_unblock": "Retirer lIP",
"settings.save": "Enregistrer",
"modal.filter_config": "Configuration du filtre:",
"modal.filter_config_edit": "Modifier le filtre",
"modal.filter_config": "Configuration du filtre / Jail:",
"modal.filter_config_edit": "Modifier le filtre / Jail",
"modal.cancel": "Annuler",
"modal.save": "Enregistrer",
"modal.close": "Fermer",

View File

@@ -196,8 +196,8 @@
"settings.advanced.test_block": "Blocca IP",
"settings.advanced.test_unblock": "Rimuovi IP",
"settings.save": "Salva",
"modal.filter_config": "Configurazione del filtro:",
"modal.filter_config_edit": "Modifica filtro",
"modal.filter_config": "Configurazione del filtro / Jail:",
"modal.filter_config_edit": "Modifica filtro / Jail",
"modal.cancel": "Annulla",
"modal.save": "Salva",
"modal.close": "Chiudi",