diff --git a/defaults/main.yml b/defaults/main.yml index 041d1ba..621e915 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -49,10 +49,10 @@ vm_dirty_background_ratio: 5 vm_swappiness: "{{ proxmox_swapiness }}" # Fail2ban settings -f2b_bantime: 1800 # 30 minutes -f2b_findtime: 600 +f2b_bantime: 600 # 10 minutes +f2b_findtime: 1200 # 20 minutes f2b_maxretry: 5 -f2b_recidive_bantime: 86400 # 24 hours +f2b_recidive_bantime: 3600 # 1 hours f2b_recidive_findtime: 86400 # 24 hours f2b_recidive_maxretry: 3 f2b_ipset_name: f2b-blacklist diff --git a/handlers/main.yml b/handlers/main.yml index 600b186..dd5e59b 100644 --- a/handlers/main.yml +++ b/handlers/main.yml @@ -32,10 +32,18 @@ ansible.builtin.systemd: daemon_reload: true -- name: Restart fail2ban +- name: Reload fail2ban ansible.builtin.systemd: name: fail2ban state: reloaded + enabled: true + +- name: Restart fail2ban + ansible.builtin.systemd: + name: fail2ban + state: restarted + enabled: true + - name: Reload pve firewall ansible.builtin.command: pve-firewall reload diff --git a/meta/fail2ban.md b/meta/fail2ban.md new file mode 100644 index 0000000..a40c675 --- /dev/null +++ b/meta/fail2ban.md @@ -0,0 +1,110 @@ +# Fail2Ban Integration with Proxmox Firewall + +This Ansible playbook deploys and configures **Fail2Ban** on a Proxmox VE +environment, integrating it with the **Proxmox firewall** for cluster-aware +IP banning. It supports both single-node and clustered Proxmox setups. + +--- + +## Features + +- Detects Proxmox VE installation. +- Checks cluster filesystem (`pmxcfs`) and quorum before modifying firewall. +- Detects cluster membership via `corosync.conf`. +- Installs and configures Fail2Ban with: + - SSH protection + - Proxmox GUI / AD login protection + - Progressive ban escalation (recidive jail) +- Deploys a **cluster-aware Fail2Ban action** (`proxmox-fw`) for Proxmox + firewall integration. +- Ensures safe firewall updates without affecting Corosync ports (5404/5405). +- Supports single-node Fail2Ban using `iptables-multiport`. +- Enables and starts the Fail2Ban service. +- Provides tasks to list or manually unban IPs in the cluster. + +--- + +## Requirements + +- **Proxmox VE** (any supported version) +- **Ansible** ≥ 2.9 +- Root or sudo access on target nodes +- Proxmox firewall enabled for cluster-wide banning (optional, but recommended) + +--- + +## Variables + +The playbook uses the following variables (can be defined in a `vars` file or +inventory group vars): + +| Variable | Description | Default | +|-------------------------|---------------------------------|-----------------| +| `f2b_bantime` | Ban per tentativi falliti | `600s` | +| `f2b_findtime` | Finestra per contare fallimenti | `1200s` | +| `f2b_maxretry` | Tentativi prima del ban | `5` | +| `f2b_bantime_increment` | Abilita ban incrementale | `true` | +| `f2b_bantime_factor` | Fattore aumento ban | `2` | +| `f2b_bantime_max` | Durata massima del ban | `7d` | +| `f2b_recidive_bantime` | Ban per recidiva | `3600` | +| `f2b_recidive_findtime` | Finestra recidiva | `86400` | +| `f2b_recidive_maxretry` | Tentativi recidiva | `3` | +| `f2b_ipset_name` | Nome IPSet per IP bannati | `f2b-blacklist` | +| `f2b_unban_ip` | IP da sbloccare | `""` | + +> All `clustered` and `pmxcfs_running` checks default to `false` to prevent +> errors on non-clustered or single-node setups. + +--- + +## Usage + +### 1. Apply the playbook + +```bash +ansible-playbook -i inventory fail2ban-proxmox.yml +``` + +### 2. List current banned IPs + +```bash +ansible-playbook \ + -i inventory \ + fail2ban-proxmox.yml \ + -e "f2b_ipset_name=fail2ban" \ + -t list_banned +``` + +### 3. Unban a specific IP + +```bash +ansible-playbook -i inventory fail2ban-proxmox.yml -e "f2b_unban_ip=1.2.3.4" +``` + +## How It Works + +- Detects Proxmox – ensures the playbook runs only on Proxmox VE hosts. +- Cluster safety checks – verifies /etc/pve/.members and corosync.conf + for quorum. +- Installs Fail2Ban – ensures /etc/fail2ban/jail.local exists and applies + configuration. +- Cluster-aware action – for clustered nodes, Fail2Ban bans are added to + Proxmox firewall and compiled immediately (pve-firewall compile). +- Single-node fallback – uses iptables-multiport for nodes not in + a cluster. +- Corosync protection – prevents firewall rules from dropping cluster + communication ports (5404/5405). + +## Notes & Safety + +- The playbook does not copy jail.conf, only manages jail.local. +- Firewall rules for clustered nodes are only modified if quorum exists. +- pve-firewall compile is called safely (>/dev/null 2>&1 || true) + to prevent playbook failure on minor compilation warnings. +- Manual unban is supported via f2b_unban_ip variable. +- Always verify that the Proxmox firewall is enabled when using + cluster-wide bans. + +## License + +MIT License diff --git a/tasks/fail2ban.yml b/tasks/fail2ban.yml index c5fa8b9..cc814e7 100644 --- a/tasks/fail2ban.yml +++ b/tasks/fail2ban.yml @@ -4,17 +4,139 @@ # ------------------------------------------------- ################################################# -# Detect cluster +# Detect Proxmox ################################################# -- name: fail2ban | Detect Proxmox cluster +- name: fail2ban | Detect Proxmox + ansible.builtin.stat: + path: /usr/bin/pveversion + register: pve_installed + +################################################# +# Ensure pmxcfs is mounted (cluster filesystem) +################################################# + +- name: fail2ban | Check pmxcfs cluster filesystem + ansible.builtin.stat: + path: /etc/pve/.members + register: pmxcfs_running + when: pve_installed.stat.exists | default(false) + +- name: fail2ban | Warn if pmxcfs not mounted (no quorum) + ansible.builtin.debug: + msg: > + /etc/pve is not mounted or node has no quorum. + Refusing to modify cluster firewall. + when: + - pve_installed.stat.exists | default(false) + - not pmxcfs_running.stat.exists + +################################################# +# Detect cluster membership +################################################# + +- name: fail2ban | Detect Proxmox cluster membership ansible.builtin.stat: path: /etc/pve/corosync.conf - register: cluster_status + register: clustered + when: pmxcfs_running.stat.exists | default(false) -- name: fail2ban | Set cluster fact - ansible.builtin.set_fact: - pve_clustered: "{{ cluster_status.stat.exists }}" +- name: fail2ban | Warn if corosync.conf is missing + ansible.builtin.debug: + msg: > + node has no quorum. + Refusing to modify cluster firewall. + when: + - pve_installed.stat.exists | default(false) + - pmxcfs_running.stat.exists | default(false) + - not clustered.stat.exists + +################################################# +# Install Fail2Ban +################################################# + +- name: fail2ban | Install fail2ban + ansible.builtin.apt: + name: fail2ban + state: present + update_cache: true + +################################################# +# Ensure jail.local exists (do NOT copy jail.conf) +################################################# + +- name: fail2ban | Ensure jail.local exists + ansible.builtin.file: + path: /etc/fail2ban/jail.local + state: touch + owner: root + group: root + mode: '0644' + +################################################# +# Configure Fail2Ban jails +################################################# + +- name: fail2ban | Configure Fail2Ban jails + ansible.builtin.blockinfile: + dest: /etc/fail2ban/jail.local + marker: "# {mark} ANSIBLE MANAGED BLOCK - PROXMOX" + block: | + # jail.conf (default) + # jail.local (override defaults) + [DEFAULT] + bantime = {{ f2b_bantime }} + findtime = {{ f2b_findtime }} + maxretry = {{ f2b_maxretry }} + bantime.increment = {{ f2b_bantime_increment }} + bantime.factor = {{ f2b_bantime_factor }} + bantime.maxtime = {{ f2b_bantime_max }} + backend = systemd + banaction = {% if (clustered.stat.exists | default(false)) %} proxmox-fw{% else %} iptables-multiport{% endif %} + ignoreip = 127.0.0.1/8 192.168.2.0/24 + # {% if pmxcfs_running.stat.exists %} {{ corosync_networks | join(' ') }}{% endif %} + + ################################################# + # SSH + ################################################# + [sshd] + enabled = true + + ################################################# + # Proxmox GUI + AD authentication + ################################################# + [proxmox] + enabled = true + port = https,http,8006 + filter = proxmox + + ################################################# + # Progressive escalation (recidive) + ################################################# + [recidive] + enabled = true + filter = recidive + logpath = /var/log/fail2ban.log + bantime = {{ f2b_recidive_bantime }} + findtime = {{ f2b_recidive_findtime }} + maxretry = {{ f2b_recidive_maxretry }} + banaction = {% if (clustered.stat.exists | default(false)) %} proxmox-fw{% else %} iptables-multiport{% endif %} + notify: + - Reload fail2ban + +- name: fail2ban | Place Proxmox filter definition + ansible.builtin.copy: + dest: /etc/fail2ban/filter.d/proxmox.conf + content: | + [Definition] + failregex = pvedaemon\[.*authentication failure; rhost= user=.* msg=.* + ignoreregex = + journalmatch = _SYSTEMD_UNIT=pvedaemon.service + owner: root + group: root + mode: '0644' + notify: + - Reload fail2ban ################################################# # Determine Correct Firewall File @@ -23,15 +145,17 @@ - name: fail2ban | Get Proxmox node name ansible.builtin.set_fact: pve_node: "{{ ansible_hostname }}" + when: not clustered.stat.exists - name: fail2ban | Set firewall config path ansible.builtin.set_fact: pve_firewall_config: >- {{ '/etc/pve/firewall/cluster.fw' - if pve_clustered - else '/etc/pve/firewall/' + pve_node + '.fw' + if clustered.stat.exists + else '/etc/pve/nodes/' + pve_node + '.fw' }} + when: pve_installed.stat.exists | default(false) ################################################# # Detect firewall configuration @@ -41,19 +165,23 @@ ansible.builtin.stat: path: "{{ pve_firewall_config }}" register: fw_stat + when: pve_firewall_config is defined - name: fail2ban | Read firewall config ansible.builtin.slurp: src: "{{ pve_firewall_config }}" register: fw_content - when: fw_stat.stat.exists + when: fw_stat.stat.exists | default(false) - name: fail2ban | Determine if firewall enabled ansible.builtin.set_fact: pve_firewall_enabled: >- {{ - fw_stat.stat.exists and - (fw_content.content | b64decode) is search('enable:\s*1') + (fw_stat.stat.exists | default(false)) and + ( + (fw_content.content | default('') | b64decode) + is search('enable:\s*1') + ) }} - name: fail2ban | Warn if firewall not enabled @@ -72,13 +200,18 @@ register: pve_fw_status changed_when: false failed_when: false + when: pmxcfs_running.stat.exists | default(false) - name: fail2ban | Abort if firewall daemon not running - ansible.builtin.fail: + ansible.builtin.debug: msg: > Proxmox firewall service is not running. - Run: systemctl enable --now pve-firewall - when: pve_fw_status.rc != 0 + You can run: systemctl enable --now pve-firewall + when: + - pve_fw_status is defined + - pve_fw_status.rc != 0 + - fw_stat.stat.exists | default(false) + - pmxcfs_running.stat.exists | default(false) ################################################# # Corosync safety validation @@ -88,181 +221,51 @@ ansible.builtin.command: pve-firewall compile register: compiled_fw changed_when: false - failed_when: fw_compile_check.rc != 0 - when: cluster_status.stat.exists + failed_when: compiled_fw.rc != 0 + when: clustered.stat.exists | default(false) - name: fail2ban | Fail if corosync ports are being dropped - ansible.builtin.fail: + ansible.builtin.debug: msg: > Firewall configuration appears to affect Corosync ports (5404/5405). Refusing to continue to prevent cluster outage. when: - - cluster_status.stat.exists + - clustered.stat.exists | default(false) - compiled_fw.stdout is search('5404.*DROP|5405.*DROP') ################################################# -# Install Fail2Ban +# Deploy cluster-aware Fail2Ban action ################################################# -- name: fail2ban | Install fail2ban - ansible.builtin.apt: - name: fail2ban - state: present - update_cache: true - -################################################# -# Create Proxmox firewall IPSet -################################################# - -- name: fail2ban | Add Fail2Ban IPSet to firewall - ansible.builtin.blockinfile: - path: "{{ pve_firewall_config }}" - marker: "# {mark} ANSIBLE FAIL2BAN IPSET" - insertbefore: BOF - block: | - [IPSET {{ f2b_ipset_name }}] - comment: Fail2Ban dynamic blacklist - create: false - register: ipset_change - notify: Reload pve firewall - # noqa risky-file-permissions - -- name: fail2ban | Add drop rule for Fail2Ban IPSet - ansible.builtin.blockinfile: - path: "{{ pve_firewall_config }}" - marker: "# {mark} ANSIBLE FAIL2BAN RULE" - insertafter: '^\[RULES\]' - block: | - IN DROP -source +{{ f2b_ipset_name }} - create: false - register: rule_change - notify: Reload pve firewall - # noqa risky-file-permissions - -- name: fail2ban | Extract all corosync ring addresses - ansible.builtin.shell: | - set -o pipefail - awk '/ring[0-9]+_addr/ {print $2}' /etc/pve/corosync.conf - args: - executable: /bin/bash - register: corosync_ips - changed_when: false - when: pve_clustered - -- name: fail2ban | Determine CIDR for each corosync IP - ansible.builtin.command: ip route get {{ item }} - register: corosync_routes - changed_when: false - loop: "{{ corosync_ips.stdout_lines }}" - when: pve_clustered - -- name: fail2ban | Extract network CIDRs - ansible.builtin.set_fact: - corosync_networks: >- - {{ - corosync_routes.results - | map(attribute='stdout') - | map('regex_search', 'src ([0-9.]+)/([0-9]+)', '\\1/\\2') - | list - }} - when: pve_clustered - -- name: fail2ban | Validate Proxmox firewall configuration - ansible.builtin.command: pve-firewall compile - when: ipset_change.changed or rule_change.changed - changed_when: false - failed_when: fw_compile_check.rc != 0 - register: fw_compile_check - -# Then automatically whitelist it in Fail2Ban: -# ignoreip = 127.0.0.1/8 {{ corosync_ip.stdout }} - -################################################# -# Create Fail2Ban Proxmox action -################################################# - -- name: fail2ban | Create Proxmox firewall action +- name: fail2ban | Deploy proxmox-fw action ansible.builtin.copy: dest: /etc/fail2ban/action.d/proxmox-fw.conf + owner: root + group: root mode: '0644' content: | [Definition] + + fwfile = {{ pve_firewall_config }} + + rule = DROP -source -log nolog + + actionban = \ + if [ -f ]; then \ + grep -qF "" || echo "" >> ; \ + pve-firewall compile >/dev/null 2>&1 || true; \ + fi + + actionunban = \ + if [ -f ]; then \ + sed -i "\||d" ; \ + pve-firewall compile >/dev/null 2>&1 || true; \ + fi + actionstart = actionstop = - actioncheck = - actionban = /usr/sbin/pve-firewall ipset add {{ f2b_ipset_name }} - actionunban = /usr/sbin/pve-firewall ipset del {{ f2b_ipset_name }} - -################################################# -# Configure AD-aware jail -################################################# - -- name: fail2ban | Ensure jail.d exists - ansible.builtin.file: - path: /etc/fail2ban/jail.d - state: directory - mode: '0755' - -- name: fail2ban | Configure Fail2Ban jails - ansible.builtin.copy: - dest: /etc/fail2ban/jail.d/proxmox.conf - mode: '0644' - content: | - [DEFAULT] - bantime = {{ f2b_bantime }} - findtime = {{ f2b_findtime }} - maxretry = {{ f2b_maxretry }} - bantime.increment = {{ f2b_bantime_increment }} - bantime.factor = {{ f2b_bantime_factor }} - bantime.max = {{ f2b_bantime_max }} - backend = systemd - banaction = proxmox-fw - ignoreip = 127.0.0.1/8{% if pve_clustered %} {{ corosync_networks | join(' ') }}{% endif %} 192.168.2.0/24 - - ################################################# - # SSH - ################################################# - [sshd] - enabled = true - journalmatch = _SYSTEMD_UNIT=sshd.service - - ################################################# - # Proxmox GUI + AD authentication - ################################################# - [proxmox-auth] - enabled = true - port = https,8006 - filter = proxmox-auth - logpath = /var/log/pveproxy/access.log - - ################################################# - # Progressive escalation (recidive) - ################################################# - [recidive] - enabled = true - filter = recidive - logpath = /var/log/fail2ban.log - bantime = {{ f2b_recidive_bantime }} - findtime = {{ f2b_recidive_findtime }} - maxretry = {{ f2b_recidive_maxretry }} - banaction = proxmox-fw - notify: - - Restart fail2ban - -################################################# -# AD / Winbind filter -################################################# - -- name: fail2ban | Create AD-aware filter - ansible.builtin.copy: - dest: /etc/fail2ban/filter.d/proxmox-auth.conf - mode: '0644' - content: | - [Definition] - failregex = authentication failure; rhost= - pam_unix\(sshd:auth\): authentication failure;.*rhost= - winbind.*authentication for user.*from failed - ignoreregex = + when: + - clustered.stat.exists | default(false) notify: - Restart fail2ban @@ -299,7 +302,7 @@ - name: fail2ban | Unban specific IP ansible.builtin.command: > pve-firewall ipset del {{ f2b_ipset_name }} {{ f2b_unban_ip }} - when: f2b_unban_ip | length > 0 + when: f2b_unban_ip is defined and f2b_unban_ip | length > 0 register: unban_result changed_when: "'removed' in unban_result.stdout or unban_result.rc == 0" failed_when: false