diff --git a/README.md b/README.md index 6885fd3..7022506 100644 --- a/README.md +++ b/README.md @@ -141,14 +141,34 @@ podman run -d \ - **Host**: IP address or hostname - **Port**: SSH port (default: 22) - **SSH User**: Username for SSH connection - - **SSH Key**: Select an SSH key from your `~/.ssh/` directory + - **SSH Key**: Select an SSH key from your `~/.ssh/` directory (or `/config/.ssh/` when running in a container) 5. Click **Test Connection** to verify before saving 6. The UI will automatically deploy the custom action for ban notifications **Requirements for SSH connections:** - SSH key-based authentication (passwordless login) -- Passwordless sudo access to `fail2ban-client` and Fail2ban configuration files - Network connectivity from UI host to remote server +- The SSH user must have: + - Sudo access to run `systemctl restart fail2ban` and `/usr/bin/fail2ban-client` (configured via sudoers) + - File system ACLs on `/etc/fail2ban` for read/write access to configuration files (no sudo needed for file operations) + +**Recommended SSH setup (using service account with ACLs):** +```bash +# Create service account +sudo useradd -r -s /bin/bash sa_fail2ban + +# Configure sudoers for fail2ban-client and systemctl restart +sudo visudo -f /etc/sudoers.d/fail2ban-ui +# Add: +sa_fail2ban ALL=(ALL) NOPASSWD: /usr/bin/fail2ban-client * +sa_fail2ban ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart fail2ban + +# Set ACLs for /etc/fail2ban directory +sudo setfacl -Rm u:sa_fail2ban:rwX /etc/fail2ban +sudo setfacl -dRm u:sa_fail2ban:rwX /etc/fail2ban +``` + +This setup provides fine-grained permissions without requiring full passwordless sudo access. #### **API Agent Server** (Future) - API agent support is planned for future releases @@ -158,7 +178,10 @@ podman run -d \ ## **🔒 Security Considerations** -- Fail2Ban-UI requires **root privileges** or **passwordless sudo** to interact with Fail2Ban sockets. +- Fail2Ban-UI requires **root privileges** or **passwordless sudo** to interact with Fail2Ban sockets (for local connections). +- For SSH connections, use a **dedicated service account** with: + - **Limited sudo access** (only for `fail2ban-client` and `systemctl restart fail2ban`) + - **File system ACLs** on `/etc/fail2ban` for configuration file access (more secure than full sudo) - **Restrict access** using **firewall rules** or a **reverse proxy** with authentication. - Ensure that Fail2Ban logs/configs **aren't exposed publicly**. - For SSH connections, use **SSH key-based authentication** and restrict SSH access. @@ -200,10 +223,15 @@ semodule -i fail2ban-container-client.pp ```bash ssh -i ~/.ssh/your_key user@remote-host ``` -- Ensure passwordless sudo is configured: - ```bash - sudo -l - ``` +- Ensure the SSH user has proper permissions: + - Check sudo access for `fail2ban-client` and `systemctl restart fail2ban`: + ```bash + sudo -l -U sa_fail2ban + ``` + - Verify ACLs on `/etc/fail2ban`: + ```bash + getfacl /etc/fail2ban + ``` - Check debug mode in settings for detailed error messages - Verify the SSH user has access to `/var/run/fail2ban/fail2ban.sock` diff --git a/deployment/container/README.md b/deployment/container/README.md index f901db9..0e590ad 100644 --- a/deployment/container/README.md +++ b/deployment/container/README.md @@ -107,9 +107,13 @@ podman exec -it fail2ban-ui ps aux ### SSH Connection Issues - Verify SSH key authentication works from the host -- Ensure passwordless sudo is configured on the remote server +- Ensure the SSH user has proper permissions on the remote server: + - Sudo access for `fail2ban-client` and `systemctl restart fail2ban` (configured via sudoers) + - File system ACLs on `/etc/fail2ban` for configuration file access + - See the main README for recommended setup with service account and ACLs - Check debug mode in settings for detailed error messages - The container needs network access to remote SSH servers +- SSH keys should be placed in `/config/.ssh` directory inside the container ## Contact & Support For issues, contributions, or feature requests, visit our GitHub repository: diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index 57babbe..e12d255 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -178,7 +178,7 @@ func (sc *SSHConnector) Restart(ctx context.Context) error { func (sc *SSHConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) { path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail) - out, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", path}) + out, err := sc.runRemoteCommand(ctx, []string{"cat", path}) if err != nil { return "", fmt.Errorf("failed to read remote filter config: %w", err) } @@ -187,7 +187,7 @@ func (sc *SSHConnector) GetFilterConfig(ctx context.Context, jail string) (strin func (sc *SSHConnector) SetFilterConfig(ctx context.Context, jail, content string) error { path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail) - cmd := fmt.Sprintf("cat <<'EOF' | sudo tee %s >/dev/null\n%s\nEOF", path, content) + cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", path, content) _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) return err } @@ -204,7 +204,7 @@ func (sc *SSHConnector) ensureAction(ctx context.Context) error { script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload) // Base64 encode the entire script to avoid shell escaping issues scriptB64 := base64.StdEncoding.EncodeToString([]byte(script)) - cmd := fmt.Sprintf("echo %s | base64 -d | sudo bash", scriptB64) + cmd := fmt.Sprintf("echo %s | base64 -d | bash", scriptB64) _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) return err } @@ -297,14 +297,14 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { var allJails []JailInfo // Parse jail.local - jailLocalContent, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", "/etc/fail2ban/jail.local"}) + jailLocalContent, err := sc.runRemoteCommand(ctx, []string{"cat", "/etc/fail2ban/jail.local"}) if err == nil { jails := parseJailConfigContent(jailLocalContent) allJails = append(allJails, jails...) } // Parse jail.d directory - jailDCmd := "sudo find /etc/fail2ban/jail.d -maxdepth 1 -name '*.conf' -type f" + jailDCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.conf' -type f" jailDList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDCmd}) if err == nil && jailDList != "" { for _, file := range strings.Split(jailDList, "\n") { @@ -312,7 +312,7 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { if file == "" { continue } - content, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", file}) + content, err := sc.runRemoteCommand(ctx, []string{"cat", file}) if err == nil { jails := parseJailConfigContent(content) allJails = append(allJails, jails...) @@ -326,7 +326,7 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { // UpdateJailEnabledStates implements Connector. func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { // Read current jail.local - content, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", "/etc/fail2ban/jail.local"}) + content, err := sc.runRemoteCommand(ctx, []string{"cat", "/etc/fail2ban/jail.local"}) if err != nil { return fmt.Errorf("failed to read jail.local: %w", err) } @@ -354,16 +354,15 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map // Write back newContent := strings.Join(outputLines, "\n") - cmd := fmt.Sprintf("cat <<'EOF' | sudo tee /etc/fail2ban/jail.local >/dev/null\n%s\nEOF", newContent) + cmd := fmt.Sprintf("cat <<'EOF' | tee /etc/fail2ban/jail.local >/dev/null\n%s\nEOF", newContent) _, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) return err } // GetFilters implements Connector. func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { - // Use find with sudo - execute sudo separately to avoid shell issues - // First try with sudo, if that fails, the error will be clear - list, err := sc.runRemoteCommand(ctx, []string{"sudo", "find", "/etc/fail2ban/filter.d", "-maxdepth", "1", "-type", "f"}) + // Use find to list filter files + list, err := sc.runRemoteCommand(ctx, []string{"find", "/etc/fail2ban/filter.d", "-maxdepth", "1", "-type", "f"}) if err != nil { return nil, fmt.Errorf("failed to list filters: %w", err) } @@ -431,11 +430,10 @@ func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLi continue } // Use fail2ban-regex: log line as string, filter file path - // Use sudo -s to run a shell that executes the piped command escapedLine := strconv.Quote(logLine) escapedPath := strconv.Quote(filterPath) cmd := fmt.Sprintf("echo %s | fail2ban-regex - %s", escapedLine, escapedPath) - out, err := sc.runRemoteCommand(ctx, []string{"sudo", "sh", "-c", cmd}) + out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", cmd}) // fail2ban-regex returns success (exit 0) if the line matches // Look for "Lines: 1 lines, 0 ignored, 1 matched" or similar success indicators if err == nil {