Compare commits

..

3 Commits

Author SHA1 Message Date
94bcbbac5b docs 📝: Add Fail2Ban deployment and configuration documentation for Proxmox VE
Some checks failed
ansible-lint / Ansible Lint (push) Failing after 8s
Gitleaks Scan / gitleaks (push) Successful in 5s
Markdown Lint / markdown-lint (push) Failing after 7s
This commit adds a new file `meta/fail2ban.md` containing detailed documentation on how to deploy and configure Fail2Ban on Proxmox VE, including integration with the Proxmox firewall. The documentation aims to provide comprehensive guidance for users looking to enhance their server security by implementing Fail2Ban.
2026-03-01 10:23:11 +01:00
bc91383094 chore 📦: Update Fail2ban settings and add/restart/reload tasks
This commit updates the Fail2ban configuration to reduce bantime and findtime, and adds/fixes restart/reload tasks in handlers/main.yml. These changes aim to improve security and manageability of the fail2ban service.
2026-03-01 10:22:58 +01:00
7c304765a4 refactor ♻️: Refactor fail2ban.yml for Proxmox cluster detection and configuration
This commit refactors the fail2ban.yml file to include support for detecting a Proxmox cluster, ensuring that pmxcfs is mounted, installing Fail2Ban, and configuring appropriate jails. This enhances the security and management of the Proxmox environment by automating the setup and monitoring of failed login attempts.
2026-03-01 10:22:41 +01:00
4 changed files with 281 additions and 175 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

94
meta/fail2ban.md Normal file
View File

@@ -0,0 +1,94 @@
# 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 / Notes |
|----------|-------------|----------------|
| `f2b_bantime` | Default ban time for repeated failures | e.g., `600s` |
| `f2b_findtime` | Time window to check failures | e.g., `1200s`|
| `f2b_maxretry` | Maximum retries before ban | e.g., `5` |
| `f2b_bantime_increment` | Incremental ban time (recidive) | e.g., `true` |
| `f2b_bantime_factor` | Factor for incremental ban | e.g., `2` |
| `f2b_bantime_max` | Maximum ban time | e.g., `7d` |
| `f2b_recidive_bantime` | Ban time for recidive jail | e.g., `3600` |
| `f2b_recidive_findtime` | Findtime for recidive jail | e.g., `86400` |
| `f2b_recidive_maxretry` | Max retry for recidive jail | e.g., `3` |
| `f2b_ipset_name` | Name of Proxmox IPSet used for banned IPs | e.g., `f2b-blacklist` |
| `f2b_unban_ip` | Optional IP to unban manually | Leave undefined if not needed |
> 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,140 @@
# ------------------------------------------------- # -------------------------------------------------
################################################# #################################################
# Detect cluster # Detect Proxmox
################################################# #################################################
- name: fail2ban | Detect Proxmox cluster - name: fail2ban | Detect Proxmox
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
create: true
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 +146,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 +166,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 +201,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
@@ -89,180 +223,50 @@
register: compiled_fw register: compiled_fw
changed_when: false changed_when: false
failed_when: compiled_fw.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-fw | 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
register: fw_compile_check
failed_when: fw_compile_check.rc != 0
# 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 +303,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