Merge pull request 'style 💎: Fix table formatting and add code block for list_banned task' (#46) from dev into main
All checks were successful
ansible-lint / Ansible Lint (push) Successful in 12s
Gitleaks Scan / gitleaks (push) Successful in 5s
Markdown Lint / markdown-lint (push) Successful in 6s

Reviewed-on: #46
This commit was merged in pull request #46.
This commit is contained in:
2026-03-01 10:55:12 +01:00
4 changed files with 297 additions and 176 deletions

View File

@@ -49,10 +49,10 @@ vm_dirty_background_ratio: 5
vm_swappiness: "{{ proxmox_swapiness }}" vm_swappiness: "{{ proxmox_swapiness }}"
# Fail2ban settings # Fail2ban settings
f2b_bantime: 1800 # 30 minutes f2b_bantime: 600 # 10 minutes
f2b_findtime: 600 f2b_findtime: 1200 # 20 minutes
f2b_maxretry: 5 f2b_maxretry: 5
f2b_recidive_bantime: 86400 # 24 hours f2b_recidive_bantime: 3600 # 1 hours
f2b_recidive_findtime: 86400 # 24 hours f2b_recidive_findtime: 86400 # 24 hours
f2b_recidive_maxretry: 3 f2b_recidive_maxretry: 3
f2b_ipset_name: f2b-blacklist f2b_ipset_name: f2b-blacklist

View File

@@ -32,10 +32,18 @@
ansible.builtin.systemd: ansible.builtin.systemd:
daemon_reload: true daemon_reload: true
- name: Restart fail2ban - name: Reload fail2ban
ansible.builtin.systemd: ansible.builtin.systemd:
name: fail2ban name: fail2ban
state: reloaded state: reloaded
enabled: true
- name: Restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted
enabled: true
- name: Reload pve firewall - name: Reload pve firewall
ansible.builtin.command: pve-firewall reload ansible.builtin.command: pve-firewall reload

110
meta/fail2ban.md Normal file
View File

@@ -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

View File

@@ -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: ansible.builtin.stat:
path: /etc/pve/corosync.conf path: /etc/pve/corosync.conf
register: cluster_status register: clustered
when: pmxcfs_running.stat.exists | default(false)
- name: fail2ban | Set cluster fact - name: fail2ban | Warn if corosync.conf is missing
ansible.builtin.set_fact: ansible.builtin.debug:
pve_clustered: "{{ cluster_status.stat.exists }}" 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=<HOST> user=.* msg=.*
ignoreregex =
journalmatch = _SYSTEMD_UNIT=pvedaemon.service
owner: root
group: root
mode: '0644'
notify:
- Reload fail2ban
################################################# #################################################
# Determine Correct Firewall File # Determine Correct Firewall File
@@ -23,15 +145,17 @@
- name: fail2ban | Get Proxmox node name - name: fail2ban | Get Proxmox node name
ansible.builtin.set_fact: ansible.builtin.set_fact:
pve_node: "{{ ansible_hostname }}" pve_node: "{{ ansible_hostname }}"
when: not clustered.stat.exists
- name: fail2ban | Set firewall config path - name: fail2ban | Set firewall config path
ansible.builtin.set_fact: ansible.builtin.set_fact:
pve_firewall_config: >- pve_firewall_config: >-
{{ {{
'/etc/pve/firewall/cluster.fw' '/etc/pve/firewall/cluster.fw'
if pve_clustered if clustered.stat.exists
else '/etc/pve/firewall/' + pve_node + '.fw' else '/etc/pve/nodes/' + pve_node + '.fw'
}} }}
when: pve_installed.stat.exists | default(false)
################################################# #################################################
# Detect firewall configuration # Detect firewall configuration
@@ -41,19 +165,23 @@
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ pve_firewall_config }}" path: "{{ pve_firewall_config }}"
register: fw_stat register: fw_stat
when: pve_firewall_config is defined
- name: fail2ban | Read firewall config - name: fail2ban | Read firewall config
ansible.builtin.slurp: ansible.builtin.slurp:
src: "{{ pve_firewall_config }}" src: "{{ pve_firewall_config }}"
register: fw_content register: fw_content
when: fw_stat.stat.exists when: fw_stat.stat.exists | default(false)
- name: fail2ban | Determine if firewall enabled - name: fail2ban | Determine if firewall enabled
ansible.builtin.set_fact: ansible.builtin.set_fact:
pve_firewall_enabled: >- pve_firewall_enabled: >-
{{ {{
fw_stat.stat.exists and (fw_stat.stat.exists | default(false)) and
(fw_content.content | b64decode) is search('enable:\s*1') (
(fw_content.content | default('') | b64decode)
is search('enable:\s*1')
)
}} }}
- name: fail2ban | Warn if firewall not enabled - name: fail2ban | Warn if firewall not enabled
@@ -72,13 +200,18 @@
register: pve_fw_status register: pve_fw_status
changed_when: false changed_when: false
failed_when: false failed_when: false
when: pmxcfs_running.stat.exists | default(false)
- name: fail2ban | Abort if firewall daemon not running - name: fail2ban | Abort if firewall daemon not running
ansible.builtin.fail: ansible.builtin.debug:
msg: > msg: >
Proxmox firewall service is not running. Proxmox firewall service is not running.
Run: systemctl enable --now pve-firewall You can run: systemctl enable --now pve-firewall
when: pve_fw_status.rc != 0 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 # Corosync safety validation
@@ -88,181 +221,51 @@
ansible.builtin.command: pve-firewall compile ansible.builtin.command: pve-firewall compile
register: compiled_fw register: compiled_fw
changed_when: false changed_when: false
failed_when: fw_compile_check.rc != 0 failed_when: compiled_fw.rc != 0
when: cluster_status.stat.exists when: clustered.stat.exists | default(false)
- name: fail2ban | Fail if corosync ports are being dropped - name: fail2ban | Fail if corosync ports are being dropped
ansible.builtin.fail: ansible.builtin.debug:
msg: > msg: >
Firewall configuration appears to affect Corosync ports (5404/5405). Firewall configuration appears to affect Corosync ports (5404/5405).
Refusing to continue to prevent cluster outage. Refusing to continue to prevent cluster outage.
when: when:
- cluster_status.stat.exists - clustered.stat.exists | default(false)
- compiled_fw.stdout is search('5404.*DROP|5405.*DROP') - compiled_fw.stdout is search('5404.*DROP|5405.*DROP')
################################################# #################################################
# Install Fail2Ban # Deploy cluster-aware Fail2Ban action
################################################# #################################################
- name: fail2ban | Install fail2ban - name: fail2ban | Deploy proxmox-fw action
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
ansible.builtin.copy: ansible.builtin.copy:
dest: /etc/fail2ban/action.d/proxmox-fw.conf dest: /etc/fail2ban/action.d/proxmox-fw.conf
owner: root
group: root
mode: '0644' mode: '0644'
content: | content: |
[Definition] [Definition]
fwfile = {{ pve_firewall_config }}
rule = DROP -source <ip> -log nolog
actionban = \
if [ -f <fwfile> ]; then \
grep -qF "<rule>" <fwfile> || echo "<rule>" >> <fwfile>; \
pve-firewall compile >/dev/null 2>&1 || true; \
fi
actionunban = \
if [ -f <fwfile> ]; then \
sed -i "\|<rule>|d" <fwfile>; \
pve-firewall compile >/dev/null 2>&1 || true; \
fi
actionstart = actionstart =
actionstop = actionstop =
actioncheck = when:
actionban = /usr/sbin/pve-firewall ipset add {{ f2b_ipset_name }} <ip> - clustered.stat.exists | default(false)
actionunban = /usr/sbin/pve-firewall ipset del {{ f2b_ipset_name }} <ip>
#################################################
# 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=<HOST>
pam_unix\(sshd:auth\): authentication failure;.*rhost=<HOST>
winbind.*authentication for user.*from <HOST> failed
ignoreregex =
notify: notify:
- Restart fail2ban - Restart fail2ban
@@ -299,7 +302,7 @@
- name: fail2ban | Unban specific IP - name: fail2ban | Unban specific IP
ansible.builtin.command: > ansible.builtin.command: >
pve-firewall ipset del {{ f2b_ipset_name }} {{ f2b_unban_ip }} 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 register: unban_result
changed_when: "'removed' in unban_result.stdout or unban_result.rc == 0" changed_when: "'removed' in unban_result.stdout or unban_result.rc == 0"
failed_when: false failed_when: false