mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
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:
@@ -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"`
|
||||
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"`
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,19 +21,21 @@ 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)
|
||||
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 = 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:
|
||||
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:
|
||||
@@ -60,6 +62,9 @@ if not already:
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// Read jail.local (DEFAULT only) and jail.d files remotely
|
||||
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
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
// 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 != "" {
|
||||
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...)
|
||||
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"})
|
||||
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
|
||||
}
|
||||
|
||||
// First pass: collect all .local files (these take precedence)
|
||||
localFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".local")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list filters: %w", err)
|
||||
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
|
||||
}
|
||||
// Filter for .conf files and extract names in Go
|
||||
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
|
||||
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") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(filename, ".conf")
|
||||
if name != "" && !seen[name] {
|
||||
seen[name] = true
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -196,8 +196,8 @@
|
||||
"settings.advanced.test_block": "Bloquer l’IP",
|
||||
"settings.advanced.test_unblock": "Retirer l’IP",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1116,66 +1116,51 @@ func GetJailFilterConfigHandler(c *gin.Context) {
|
||||
config.DebugLog("Connector resolved: %s", conn.Server().Name)
|
||||
|
||||
var filterCfg string
|
||||
var filterFilePath string
|
||||
var jailCfg string
|
||||
var jailCfgLoaded bool
|
||||
var jailFilePath string
|
||||
var filterErr error
|
||||
|
||||
// First, try to load filter config using jail name
|
||||
config.DebugLog("Loading filter config for jail: %s", jail)
|
||||
filterCfg, filterErr = conn.GetFilterConfig(c.Request.Context(), jail)
|
||||
if filterErr != nil {
|
||||
config.DebugLog("Failed to load filter config with jail name, trying to find filter from jail config: %v", filterErr)
|
||||
|
||||
// Load jail config first to check for custom filter directive
|
||||
var jailErr error
|
||||
jailCfg, jailErr = conn.GetJailConfig(c.Request.Context(), jail)
|
||||
if jailErr != nil {
|
||||
config.DebugLog("Failed to load jail config: %v", jailErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load filter config: " + filterErr.Error() + ". Also failed to load jail config: " + jailErr.Error()})
|
||||
return
|
||||
}
|
||||
jailCfgLoaded = true
|
||||
config.DebugLog("Jail config loaded, length: %d", len(jailCfg))
|
||||
|
||||
// Extract filter name from jail config
|
||||
filterName := fail2ban.ExtractFilterFromJailConfig(jailCfg)
|
||||
if filterName == "" {
|
||||
config.DebugLog("No filter directive found in jail config")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load filter config: " + filterErr.Error() + ". No filter directive found in jail config."})
|
||||
return
|
||||
}
|
||||
|
||||
config.DebugLog("Found filter directive in jail config: %s, trying to load that filter", filterName)
|
||||
// Try loading the filter specified in jail config
|
||||
filterCfg, filterErr = conn.GetFilterConfig(c.Request.Context(), filterName)
|
||||
if filterErr != nil {
|
||||
config.DebugLog("Failed to load filter config for %s: %v", filterName, filterErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to load filter config. Tried '%s' (jail name) and '%s' (from jail config), both failed. Last error: %v", jail, filterName, filterErr),
|
||||
})
|
||||
return
|
||||
}
|
||||
config.DebugLog("Successfully loaded filter config for %s (from jail config directive)", filterName)
|
||||
}
|
||||
config.DebugLog("Filter config loaded, length: %d", len(filterCfg))
|
||||
|
||||
// Load jail config if not already loaded
|
||||
if !jailCfgLoaded {
|
||||
// Always load jail config first to determine which filter to load
|
||||
config.DebugLog("Loading jail config for jail: %s", jail)
|
||||
var jailErr error
|
||||
jailCfg, jailErr = conn.GetJailConfig(c.Request.Context(), jail)
|
||||
jailCfg, jailFilePath, jailErr = conn.GetJailConfig(c.Request.Context(), jail)
|
||||
if jailErr != nil {
|
||||
config.DebugLog("Failed to load jail config: %v", jailErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + jailErr.Error()})
|
||||
return
|
||||
}
|
||||
config.DebugLog("Jail config loaded, length: %d", len(jailCfg))
|
||||
config.DebugLog("Jail config loaded, length: %d, file: %s", len(jailCfg), jailFilePath)
|
||||
|
||||
// Extract filter name from jail config, or use jail name as fallback
|
||||
filterName := fail2ban.ExtractFilterFromJailConfig(jailCfg)
|
||||
if filterName == "" {
|
||||
// No filter directive found, use jail name as filter name (default behavior)
|
||||
filterName = jail
|
||||
config.DebugLog("No filter directive found in jail config, using jail name as filter name: %s", filterName)
|
||||
} else {
|
||||
config.DebugLog("Found filter directive in jail config: %s", filterName)
|
||||
}
|
||||
|
||||
// Load filter config using the determined filter name
|
||||
config.DebugLog("Loading filter config for filter: %s", filterName)
|
||||
filterCfg, filterFilePath, filterErr = conn.GetFilterConfig(c.Request.Context(), filterName)
|
||||
if filterErr != nil {
|
||||
config.DebugLog("Failed to load filter config for %s: %v", filterName, filterErr)
|
||||
// Don't fail completely - allow editing even if filter doesn't exist yet
|
||||
config.DebugLog("Continuing without filter config (filter may not exist yet)")
|
||||
filterCfg = ""
|
||||
filterFilePath = ""
|
||||
} else {
|
||||
config.DebugLog("Filter config loaded, length: %d, file: %s", len(filterCfg), filterFilePath)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"jail": jail,
|
||||
"filter": filterCfg,
|
||||
"filterFilePath": filterFilePath,
|
||||
"jailConfig": jailCfg,
|
||||
"jailFilePath": jailFilePath,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1219,15 +1204,47 @@ func SetJailFilterConfigHandler(c *gin.Context) {
|
||||
config.DebugLog("Jail preview (first 100 chars): %s", req.Jail[:min(100, len(req.Jail))])
|
||||
}
|
||||
|
||||
// Save filter config
|
||||
// Save filter config - use original filter name, not the one from the new jail config
|
||||
if req.Filter != "" {
|
||||
config.DebugLog("Saving filter config for jail: %s", jail)
|
||||
if err := conn.SetFilterConfig(c.Request.Context(), jail, req.Filter); err != nil {
|
||||
// Load the original jail config to determine which filter was originally loaded
|
||||
originalJailCfg, _, err := conn.GetJailConfig(c.Request.Context(), jail)
|
||||
if err != nil {
|
||||
config.DebugLog("Failed to load original jail config to determine filter name: %v", err)
|
||||
// Fallback: extract from new jail config
|
||||
originalJailCfg = req.Jail
|
||||
}
|
||||
|
||||
// Extract the ORIGINAL filter name (the one that was loaded when the modal opened)
|
||||
originalFilterName := fail2ban.ExtractFilterFromJailConfig(originalJailCfg)
|
||||
if originalFilterName == "" {
|
||||
// No filter directive found in original config, use jail name as filter name (default behavior)
|
||||
originalFilterName = jail
|
||||
config.DebugLog("No filter directive found in original jail config, using jail name as filter name: %s", originalFilterName)
|
||||
} else {
|
||||
config.DebugLog("Found original filter directive in jail config: %s", originalFilterName)
|
||||
}
|
||||
|
||||
// Extract the NEW filter name from the updated jail config
|
||||
newFilterName := fail2ban.ExtractFilterFromJailConfig(req.Jail)
|
||||
if newFilterName == "" {
|
||||
newFilterName = jail
|
||||
}
|
||||
|
||||
// If the filter name changed, save to the ORIGINAL filter name (not the new one)
|
||||
// This prevents overwriting a different filter with the old filter's content
|
||||
if originalFilterName != newFilterName {
|
||||
config.DebugLog("Filter name changed from %s to %s, saving filter to original name: %s", originalFilterName, newFilterName, originalFilterName)
|
||||
} else {
|
||||
config.DebugLog("Filter name unchanged: %s", originalFilterName)
|
||||
}
|
||||
|
||||
config.DebugLog("Saving filter config for filter: %s", originalFilterName)
|
||||
if err := conn.SetFilterConfig(c.Request.Context(), originalFilterName, req.Filter); err != nil {
|
||||
config.DebugLog("Failed to save filter config: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save filter config: " + err.Error()})
|
||||
return
|
||||
}
|
||||
config.DebugLog("Filter config saved successfully")
|
||||
config.DebugLog("Filter config saved successfully to filter: %s", originalFilterName)
|
||||
} else {
|
||||
config.DebugLog("No filter config provided, skipping")
|
||||
}
|
||||
@@ -1307,7 +1324,7 @@ func TestLogpathHandler(c *gin.Context) {
|
||||
config.DebugLog("Using logpath from request body: %s", originalLogpath)
|
||||
} else {
|
||||
// Fall back to reading from saved jail config
|
||||
jailCfg, err := conn.GetJailConfig(c.Request.Context(), jail)
|
||||
jailCfg, _, err := conn.GetJailConfig(c.Request.Context(), jail)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + err.Error()})
|
||||
return
|
||||
@@ -1679,6 +1696,78 @@ func UpdateJailManagementHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Jail settings updated and fail2ban reloaded successfully"})
|
||||
}
|
||||
|
||||
// CreateJailHandler creates a new jail.
|
||||
func CreateJailHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
config.DebugLog("CreateJailHandler called (handlers.go)")
|
||||
|
||||
conn, err := resolveConnector(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
JailName string `json:"jailName" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate jail name
|
||||
if err := fail2ban.ValidateJailName(req.JailName); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// If no content provided, create minimal jail config
|
||||
if req.Content == "" {
|
||||
req.Content = fmt.Sprintf("[%s]\nenabled = false\n", req.JailName)
|
||||
}
|
||||
|
||||
// Create the jail
|
||||
if err := conn.CreateJail(c.Request.Context(), req.JailName, req.Content); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create jail: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' created successfully", req.JailName)})
|
||||
}
|
||||
|
||||
// DeleteJailHandler deletes a jail.
|
||||
func DeleteJailHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
config.DebugLog("DeleteJailHandler called (handlers.go)")
|
||||
|
||||
conn, err := resolveConnector(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
jailName := c.Param("jail")
|
||||
if jailName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Jail name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate jail name
|
||||
if err := fail2ban.ValidateJailName(jailName); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the jail
|
||||
if err := conn.DeleteJail(c.Request.Context(), jailName); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete jail: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' deleted successfully", jailName)})
|
||||
}
|
||||
|
||||
// GetSettingsHandler returns the entire AppSettings struct as JSON
|
||||
func GetSettingsHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
@@ -1871,6 +1960,78 @@ func TestFilterHandler(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// CreateFilterHandler creates a new filter.
|
||||
func CreateFilterHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
config.DebugLog("CreateFilterHandler called (handlers.go)")
|
||||
|
||||
conn, err := resolveConnector(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
FilterName string `json:"filterName" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate filter name
|
||||
if err := fail2ban.ValidateFilterName(req.FilterName); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// If no content provided, create empty filter
|
||||
if req.Content == "" {
|
||||
req.Content = fmt.Sprintf("# Filter: %s\n", req.FilterName)
|
||||
}
|
||||
|
||||
// Create the filter
|
||||
if err := conn.CreateFilter(c.Request.Context(), req.FilterName, req.Content); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create filter: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' created successfully", req.FilterName)})
|
||||
}
|
||||
|
||||
// DeleteFilterHandler deletes a filter.
|
||||
func DeleteFilterHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
config.DebugLog("DeleteFilterHandler called (handlers.go)")
|
||||
|
||||
conn, err := resolveConnector(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filterName := c.Param("filter")
|
||||
if filterName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Filter name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate filter name
|
||||
if err := fail2ban.ValidateFilterName(filterName); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the filter
|
||||
if err := conn.DeleteFilter(c.Request.Context(), filterName); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete filter: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' deleted successfully", filterName)})
|
||||
}
|
||||
|
||||
// ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON
|
||||
func ApplyFail2banSettings(jailLocalPath string) error {
|
||||
config.DebugLog("----------------------------")
|
||||
|
||||
@@ -41,6 +41,8 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) {
|
||||
// Routes for jail management
|
||||
api.GET("/jails/manage", ManageJailsHandler)
|
||||
api.POST("/jails/manage", UpdateJailManagementHandler)
|
||||
api.POST("/jails", CreateJailHandler)
|
||||
api.DELETE("/jails/:jail", DeleteJailHandler)
|
||||
|
||||
// Settings endpoints
|
||||
api.GET("/settings", GetSettingsHandler)
|
||||
@@ -60,9 +62,8 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) {
|
||||
// Filter debugger endpoints
|
||||
api.GET("/filters", ListFiltersHandler)
|
||||
api.POST("/filters/test", TestFilterHandler)
|
||||
|
||||
// TODO: create or generate new filters
|
||||
// api.POST("/filters/generate", GenerateFilterHandler)
|
||||
api.POST("/filters", CreateFilterHandler)
|
||||
api.DELETE("/filters/:filter", DeleteFilterHandler)
|
||||
|
||||
// Restart endpoint
|
||||
api.POST("/fail2ban/restart", RestartFail2banHandler)
|
||||
|
||||
@@ -317,3 +317,106 @@ mark {
|
||||
#advancedMikrotikFields, #advancedPfSenseFields {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Additional Tailwind color classes for buttons */
|
||||
.bg-red-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-red-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-red-600:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-purple-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(147 51 234 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-purple-700 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(126 34 206 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-purple-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(126 34 206 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
/* Ensure Font Awesome icons are visible */
|
||||
.fas, .far, .fab, .fal, .fad {
|
||||
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands", "Font Awesome 6 Pro";
|
||||
font-weight: 900;
|
||||
display: inline-block;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-rendering: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fas::before {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* Button icon spacing */
|
||||
button .fas, button .far, button .fab {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
button .fas:only-child, button .far:only-child, button .fab:only-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Additional utility classes that might be missing */
|
||||
/* Support for top-1/2 and -translate-y-1/2 (with escaped slash) */
|
||||
.top-1\/2 {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.-translate-y-1\/2 {
|
||||
--tw-translate-y: -50%;
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
/* Alternative class name without escape (for compatibility) */
|
||||
.top-1-2 {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.-translate-y-1-2 {
|
||||
--tw-translate-y: -50%;
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Ensure buttons with icons display properly */
|
||||
button.inline-flex, .inline-flex {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button .fas, button .far, button .fab {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Ensure delete button is visible */
|
||||
button.bg-red-500, button.bg-red-600 {
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.bg-red-500:hover, button.bg-red-600:hover {
|
||||
background-color: rgb(220 38 38);
|
||||
}
|
||||
@@ -24,11 +24,13 @@ function loadFilters() {
|
||||
}
|
||||
}
|
||||
select.innerHTML = '';
|
||||
const deleteBtn = document.getElementById('deleteFilterBtn');
|
||||
if (!data.filters || data.filters.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'No Filters Found';
|
||||
select.appendChild(opt);
|
||||
if (deleteBtn) deleteBtn.disabled = true;
|
||||
} else {
|
||||
data.filters.forEach(f => {
|
||||
const opt = document.createElement('option');
|
||||
@@ -36,6 +38,14 @@ function loadFilters() {
|
||||
opt.textContent = f;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
// Add change listener if not already added
|
||||
if (!select.hasAttribute('data-listener-added')) {
|
||||
select.setAttribute('data-listener-added', 'true');
|
||||
select.addEventListener('change', function() {
|
||||
if (deleteBtn) deleteBtn.disabled = !select.value;
|
||||
});
|
||||
}
|
||||
if (deleteBtn) deleteBtn.disabled = !select.value;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -122,11 +132,115 @@ function showFilterSection() {
|
||||
document.getElementById('logLinesTextarea').value = '';
|
||||
testResultsEl.innerHTML = '';
|
||||
testResultsEl.classList.add('hidden');
|
||||
document.getElementById('deleteFilterBtn').disabled = true;
|
||||
return;
|
||||
}
|
||||
loadFilters();
|
||||
testResultsEl.innerHTML = '';
|
||||
testResultsEl.classList.add('hidden');
|
||||
document.getElementById('logLinesTextarea').value = '';
|
||||
// Add change listener to enable/disable delete button
|
||||
const filterSelect = document.getElementById('filterSelect');
|
||||
const deleteBtn = document.getElementById('deleteFilterBtn');
|
||||
filterSelect.addEventListener('change', function() {
|
||||
deleteBtn.disabled = !filterSelect.value;
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateFilterModal() {
|
||||
document.getElementById('newFilterName').value = '';
|
||||
document.getElementById('newFilterContent').value = '';
|
||||
openModal('createFilterModal');
|
||||
}
|
||||
|
||||
function createFilter() {
|
||||
const filterName = document.getElementById('newFilterName').value.trim();
|
||||
const content = document.getElementById('newFilterContent').value.trim();
|
||||
|
||||
if (!filterName) {
|
||||
showToast('Filter name is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
fetch(withServerParam('/api/filters'), {
|
||||
method: 'POST',
|
||||
headers: serverHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
filterName: filterName,
|
||||
content: content
|
||||
})
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
return res.json().then(function(data) {
|
||||
throw new Error(data.error || 'Server returned ' + res.status);
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
showToast('Error creating filter: ' + data.error, 'error');
|
||||
return;
|
||||
}
|
||||
closeModal('createFilterModal');
|
||||
showToast(data.message || 'Filter created successfully', 'success');
|
||||
// Reload filters
|
||||
loadFilters();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error creating filter:', err);
|
||||
showToast('Error creating filter: ' + (err.message || err), 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
showLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFilter() {
|
||||
const filterName = document.getElementById('filterSelect').value;
|
||||
if (!filterName) {
|
||||
showToast('Please select a filter to delete', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Are you sure you want to delete the filter "' + escapeHtml(filterName) + '"? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
fetch(withServerParam('/api/filters/' + encodeURIComponent(filterName)), {
|
||||
method: 'DELETE',
|
||||
headers: serverHeaders()
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
return res.json().then(function(data) {
|
||||
throw new Error(data.error || 'Server returned ' + res.status);
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
showToast('Error deleting filter: ' + data.error, 'error');
|
||||
return;
|
||||
}
|
||||
showToast(data.message || 'Filter deleted successfully', 'success');
|
||||
// Reload filters
|
||||
loadFilters();
|
||||
// Clear test results
|
||||
document.getElementById('testResults').innerHTML = '';
|
||||
document.getElementById('testResults').classList.add('hidden');
|
||||
document.getElementById('logLinesTextarea').value = '';
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error deleting filter:', err);
|
||||
showToast('Error deleting filter: ' + (err.message || err), 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
showLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,22 @@ function openJailConfigModal(jailName) {
|
||||
filterTextArea.value = data.filter || '';
|
||||
jailTextArea.value = data.jailConfig || '';
|
||||
|
||||
// Display file paths if available
|
||||
var filterFilePathEl = document.getElementById('filterFilePath');
|
||||
var jailFilePathEl = document.getElementById('jailFilePath');
|
||||
if (filterFilePathEl && data.filterFilePath) {
|
||||
filterFilePathEl.textContent = data.filterFilePath;
|
||||
filterFilePathEl.style.display = 'block';
|
||||
} else if (filterFilePathEl) {
|
||||
filterFilePathEl.style.display = 'none';
|
||||
}
|
||||
if (jailFilePathEl && data.jailFilePath) {
|
||||
jailFilePathEl.textContent = data.jailFilePath;
|
||||
jailFilePathEl.style.display = 'block';
|
||||
} else if (jailFilePathEl) {
|
||||
jailFilePathEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Check if logpath is set in jail config and show test button
|
||||
updateLogpathButtonVisibility();
|
||||
|
||||
@@ -267,9 +283,17 @@ function openManageJailsModal() {
|
||||
+ ' onclick="openJailConfigModal(\'' + jsEscapedJailName + '\')"'
|
||||
+ ' class="text-xs px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors whitespace-nowrap"'
|
||||
+ ' data-i18n="modal.filter_config_edit"'
|
||||
+ ' title="' + escapeHtml(t('modal.filter_config_edit', 'Edit Filter')) + '"'
|
||||
+ ' title="' + escapeHtml(t('modal.filter_config_edit', 'Edit Filter / Jail')) + '"'
|
||||
+ ' >'
|
||||
+ escapeHtml(t('modal.filter_config_edit', 'Edit Filter'))
|
||||
+ escapeHtml(t('modal.filter_config_edit', 'Edit Filter / Jail'))
|
||||
+ ' </button>'
|
||||
+ ' <button'
|
||||
+ ' type="button"'
|
||||
+ ' onclick="deleteJail(\'' + jsEscapedJailName + '\')"'
|
||||
+ ' class="text-xs px-3 py-1.5 bg-red-500 text-white rounded hover:bg-red-600 transition-colors whitespace-nowrap"'
|
||||
+ ' title="' + escapeHtml(t('modal.delete_jail', 'Delete Jail')) + '"'
|
||||
+ ' >'
|
||||
+ ' <i class="fas fa-trash"></i>'
|
||||
+ ' </button>'
|
||||
+ ' <label class="inline-flex relative items-center cursor-pointer">'
|
||||
+ ' <input'
|
||||
@@ -428,3 +452,152 @@ function saveManageJailsSingle(checkbox) {
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateJailModal() {
|
||||
document.getElementById('newJailName').value = '';
|
||||
document.getElementById('newJailContent').value = '';
|
||||
const filterSelect = document.getElementById('newJailFilter');
|
||||
if (filterSelect) {
|
||||
filterSelect.value = '';
|
||||
}
|
||||
|
||||
// Load filters into dropdown
|
||||
showLoading(true);
|
||||
fetch(withServerParam('/api/filters'), {
|
||||
headers: serverHeaders()
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (filterSelect) {
|
||||
filterSelect.innerHTML = '<option value="">-- Select a filter --</option>';
|
||||
if (data.filters && data.filters.length > 0) {
|
||||
data.filters.forEach(filter => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = filter;
|
||||
opt.textContent = filter;
|
||||
filterSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
openModal('createJailModal');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading filters:', err);
|
||||
openModal('createJailModal');
|
||||
})
|
||||
.finally(() => showLoading(false));
|
||||
}
|
||||
|
||||
function updateJailConfigFromFilter() {
|
||||
const filterSelect = document.getElementById('newJailFilter');
|
||||
const jailNameInput = document.getElementById('newJailName');
|
||||
const contentTextarea = document.getElementById('newJailContent');
|
||||
|
||||
if (!filterSelect || !contentTextarea) return;
|
||||
|
||||
const selectedFilter = filterSelect.value;
|
||||
|
||||
if (!selectedFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-fill jail name if empty
|
||||
if (jailNameInput && !jailNameInput.value.trim()) {
|
||||
jailNameInput.value = selectedFilter;
|
||||
}
|
||||
|
||||
// Auto-populate jail config
|
||||
const jailName = (jailNameInput && jailNameInput.value.trim()) || selectedFilter;
|
||||
const config = `[${jailName}]
|
||||
enabled = false
|
||||
filter = ${selectedFilter}
|
||||
logpath = /var/log/auth.log
|
||||
maxretry = 5
|
||||
bantime = 3600
|
||||
findtime = 600`;
|
||||
|
||||
contentTextarea.value = config;
|
||||
}
|
||||
|
||||
function createJail() {
|
||||
const jailName = document.getElementById('newJailName').value.trim();
|
||||
const content = document.getElementById('newJailContent').value.trim();
|
||||
|
||||
if (!jailName) {
|
||||
showToast('Jail name is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
fetch(withServerParam('/api/jails'), {
|
||||
method: 'POST',
|
||||
headers: serverHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
jailName: jailName,
|
||||
content: content
|
||||
})
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
return res.json().then(function(data) {
|
||||
throw new Error(data.error || 'Server returned ' + res.status);
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
showToast('Error creating jail: ' + data.error, 'error');
|
||||
return;
|
||||
}
|
||||
closeModal('createJailModal');
|
||||
showToast(data.message || 'Jail created successfully', 'success');
|
||||
// Reload the manage jails modal
|
||||
openManageJailsModal();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error creating jail:', err);
|
||||
showToast('Error creating jail: ' + (err.message || err), 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
showLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteJail(jailName) {
|
||||
if (!confirm('Are you sure you want to delete the jail "' + escapeHtml(jailName) + '"? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
fetch(withServerParam('/api/jails/' + encodeURIComponent(jailName)), {
|
||||
method: 'DELETE',
|
||||
headers: serverHeaders()
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
return res.json().then(function(data) {
|
||||
throw new Error(data.error || 'Server returned ' + res.status);
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
showToast('Error deleting jail: ' + data.error, 'error');
|
||||
return;
|
||||
}
|
||||
showToast(data.message || 'Jail deleted successfully', 'success');
|
||||
// Reload the manage jails modal
|
||||
openManageJailsModal();
|
||||
// Refresh dashboard
|
||||
refreshData({ silent: true });
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error deleting jail:', err);
|
||||
showToast('Error deleting jail: ' + (err.message || err), 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
showLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -157,10 +157,16 @@
|
||||
<div id="filterNotice" class="hidden mb-4 text-sm text-yellow-700 bg-yellow-100 border border-yellow-200 rounded px-4 py-3"></div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<!-- Dropdown of available jail/filters -->
|
||||
<div class="mb-4">
|
||||
<div class="mb-4 flex justify-between items-end">
|
||||
<div class="flex-1 mr-4">
|
||||
<label for="filterSelect" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="filter_debug.select_filter">Select a Filter</label>
|
||||
<select id="filterSelect" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"></select>
|
||||
<div class="flex gap-2">
|
||||
<select id="filterSelect" class="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"></select>
|
||||
<button type="button" onclick="deleteFilter()" id="deleteFilterBtn" class="px-3 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" disabled title="Delete selected filter">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Textarea for log lines to test -->
|
||||
@@ -811,7 +817,10 @@
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- Filter Configuration -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.filter_config_label">Filter Configuration</label>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.filter_config_label">Filter Configuration</label>
|
||||
<span id="filterFilePath" class="text-xs text-gray-500 font-mono" style="display: none;"></span>
|
||||
</div>
|
||||
<div class="relative" style="position: relative;">
|
||||
<textarea id="filterConfigTextarea"
|
||||
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-96 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto"
|
||||
@@ -841,7 +850,10 @@
|
||||
|
||||
<!-- Jail Configuration -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_config_label">Jail Configuration</label>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.jail_config_label">Jail Configuration</label>
|
||||
<span id="jailFilePath" class="text-xs text-gray-500 font-mono" style="display: none;"></span>
|
||||
</div>
|
||||
<div class="relative" style="position: relative;">
|
||||
<textarea id="jailConfigTextarea"
|
||||
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto"
|
||||
@@ -904,6 +916,16 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-4 flex justify-end gap-2">
|
||||
<button type="button" onclick="openCreateFilterModal()" class="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-md hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span data-i18n="modal.create_filter">Create New Filter</span>
|
||||
</button>
|
||||
<button type="button" onclick="openCreateJailModal()" class="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-md hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span data-i18n="modal.create_jail">Create New Jail</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Dynamically filled list of jails with toggle switches -->
|
||||
<div id="jailsList" class="divide-y divide-gray-200"></div>
|
||||
</div>
|
||||
@@ -917,6 +939,93 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Jail Modal -->
|
||||
<div id="createJailModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||
|
||||
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all" style="max-width: 600px;">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900" data-i18n="modal.create_jail_title">Create New Jail</h3>
|
||||
<button type="button" onclick="closeModal('createJailModal')" class="text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600" aria-label="Close">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-4">
|
||||
<label for="newJailName" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_name">Jail Name</label>
|
||||
<input type="text" id="newJailName" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g., sshd" />
|
||||
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.jail_name_hint">Only alphanumeric characters, dashes, and underscores are allowed.</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="newJailFilter" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_filter">Filter (optional)</label>
|
||||
<select id="newJailFilter" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="updateJailConfigFromFilter()">
|
||||
<option value="">-- Select a filter --</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.jail_filter_hint">Selecting a filter will auto-populate the jail configuration.</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="newJailContent" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_config">Jail Configuration</label>
|
||||
<textarea id="newJailContent" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40 font-mono text-sm" placeholder="[jailname] enabled = false port = ssh filter = sshd logpath = /var/log/auth.log"></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.jail_config_hint">Jail configuration will be auto-populated when you select a filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button type="button" onclick="createJail()" class="mt-3 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" data-i18n="modal.create">Create</button>
|
||||
<button type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('createJailModal')" data-i18n="modal.cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Filter Modal -->
|
||||
<div id="createFilterModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||
|
||||
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all" style="max-width: 600px;">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900" data-i18n="modal.create_filter_title">Create New Filter</h3>
|
||||
<button type="button" onclick="closeModal('createFilterModal')" class="text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600" aria-label="Close">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-4">
|
||||
<label for="newFilterName" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.filter_name">Filter Name</label>
|
||||
<input type="text" id="newFilterName" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g., myfilter" />
|
||||
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.filter_name_hint">Only alphanumeric characters, dashes, and underscores are allowed.</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="newFilterContent" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.filter_config">Filter Configuration (optional)</label>
|
||||
<textarea id="newFilterContent" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40 font-mono text-sm" placeholder="# Filter: myfilter [Definition] failregex = ^.*Failed login.*$"></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.filter_config_hint">If left empty, an empty filter file will be created.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button type="button" onclick="createFilter()" class="mt-3 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" data-i18n="modal.create">Create</button>
|
||||
<button type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('createFilterModal')" data-i18n="modal.cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Manager Modal -->
|
||||
<div id="serverManagerModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||
|
||||
Reference in New Issue
Block a user