2026-02-23 18:30:01 +01:00
|
|
|
---
|
|
|
|
|
# -------------------------------------------------
|
|
|
|
|
# Deploy Fail2Ban integrated with Proxmox Firewall
|
|
|
|
|
# -------------------------------------------------
|
|
|
|
|
|
2026-02-23 19:36:36 +01:00
|
|
|
#################################################
|
2026-03-01 10:22:41 +01:00
|
|
|
# Detect Proxmox
|
2026-02-23 19:36:36 +01:00
|
|
|
#################################################
|
|
|
|
|
|
2026-03-01 10:22:41 +01:00
|
|
|
- name: fail2ban | Detect Proxmox
|
2026-03-01 10:48:13 +01:00
|
|
|
ansible.builtin.stat:
|
2026-03-01 10:22:41 +01:00
|
|
|
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
|
2026-02-23 19:36:36 +01:00
|
|
|
ansible.builtin.stat:
|
|
|
|
|
path: /etc/pve/corosync.conf
|
2026-03-01 10:22:41 +01:00
|
|
|
register: clustered
|
|
|
|
|
when: pmxcfs_running.stat.exists | default(false)
|
2026-02-23 19:36:36 +01:00
|
|
|
|
2026-03-01 10:22:41 +01:00
|
|
|
- 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=<HOST> user=.* msg=.*
|
|
|
|
|
ignoreregex =
|
|
|
|
|
journalmatch = _SYSTEMD_UNIT=pvedaemon.service
|
|
|
|
|
owner: root
|
|
|
|
|
group: root
|
|
|
|
|
mode: '0644'
|
|
|
|
|
notify:
|
|
|
|
|
- Reload fail2ban
|
2026-02-23 19:36:36 +01:00
|
|
|
|
|
|
|
|
#################################################
|
|
|
|
|
# Determine Correct Firewall File
|
|
|
|
|
#################################################
|
|
|
|
|
|
|
|
|
|
- name: fail2ban | Get Proxmox node name
|
2026-02-24 19:18:48 +01:00
|
|
|
ansible.builtin.set_fact:
|
|
|
|
|
pve_node: "{{ ansible_hostname }}"
|
2026-03-01 10:22:41 +01:00
|
|
|
when: not clustered.stat.exists
|
2026-02-23 19:36:36 +01:00
|
|
|
|
|
|
|
|
- name: fail2ban | Set firewall config path
|
|
|
|
|
ansible.builtin.set_fact:
|
|
|
|
|
pve_firewall_config: >-
|
|
|
|
|
{{
|
|
|
|
|
'/etc/pve/firewall/cluster.fw'
|
2026-03-01 10:22:41 +01:00
|
|
|
if clustered.stat.exists
|
|
|
|
|
else '/etc/pve/nodes/' + pve_node + '.fw'
|
2026-02-23 19:36:36 +01:00
|
|
|
}}
|
2026-03-01 10:22:41 +01:00
|
|
|
when: pve_installed.stat.exists | default(false)
|
2026-02-23 19:36:36 +01:00
|
|
|
|
2026-02-23 18:30:01 +01:00
|
|
|
#################################################
|
|
|
|
|
# Detect firewall configuration
|
|
|
|
|
#################################################
|
|
|
|
|
|
2026-02-23 19:36:36 +01:00
|
|
|
- name: fail2ban | Check firewall config exists
|
2026-02-23 18:30:01 +01:00
|
|
|
ansible.builtin.stat:
|
2026-02-23 19:36:36 +01:00
|
|
|
path: "{{ pve_firewall_config }}"
|
|
|
|
|
register: fw_stat
|
2026-03-01 10:22:41 +01:00
|
|
|
when: pve_firewall_config is defined
|
2026-02-23 18:30:01 +01:00
|
|
|
|
2026-02-23 19:36:36 +01:00
|
|
|
- name: fail2ban | Read firewall config
|
|
|
|
|
ansible.builtin.slurp:
|
|
|
|
|
src: "{{ pve_firewall_config }}"
|
|
|
|
|
register: fw_content
|
2026-03-01 10:22:41 +01:00
|
|
|
when: fw_stat.stat.exists | default(false)
|
2026-02-23 18:30:01 +01:00
|
|
|
|
|
|
|
|
- name: fail2ban | Determine if firewall enabled
|
|
|
|
|
ansible.builtin.set_fact:
|
|
|
|
|
pve_firewall_enabled: >-
|
|
|
|
|
{{
|
2026-03-01 10:22:41 +01:00
|
|
|
(fw_stat.stat.exists | default(false)) and
|
|
|
|
|
(
|
|
|
|
|
(fw_content.content | default('') | b64decode)
|
|
|
|
|
is search('enable:\s*1')
|
|
|
|
|
)
|
2026-02-23 18:30:01 +01:00
|
|
|
}}
|
|
|
|
|
|
2026-02-23 19:36:36 +01:00
|
|
|
- name: fail2ban | Warn if firewall not enabled
|
|
|
|
|
ansible.builtin.debug:
|
2026-02-23 18:30:01 +01:00
|
|
|
msg: >
|
2026-02-23 19:36:36 +01:00
|
|
|
WARNING: Proxmox firewall is disabled in configuration.
|
|
|
|
|
Fail2Ban will not actively block traffic.
|
2026-02-23 18:30:01 +01:00
|
|
|
when: not pve_firewall_enabled
|
|
|
|
|
|
|
|
|
|
#################################################
|
|
|
|
|
# Validate firewall runtime state
|
|
|
|
|
#################################################
|
|
|
|
|
|
|
|
|
|
- name: fail2ban | Check firewall runtime status
|
|
|
|
|
ansible.builtin.command: pve-firewall status
|
|
|
|
|
register: pve_fw_status
|
|
|
|
|
changed_when: false
|
|
|
|
|
failed_when: false
|
2026-03-01 10:22:41 +01:00
|
|
|
when: pmxcfs_running.stat.exists | default(false)
|
2026-02-23 18:30:01 +01:00
|
|
|
|
|
|
|
|
- name: fail2ban | Abort if firewall daemon not running
|
2026-03-01 10:22:41 +01:00
|
|
|
ansible.builtin.debug:
|
2026-02-23 18:30:01 +01:00
|
|
|
msg: >
|
|
|
|
|
Proxmox firewall service is not running.
|
2026-03-01 10:22:41 +01:00
|
|
|
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)
|
2026-02-23 18:30:01 +01:00
|
|
|
|
|
|
|
|
#################################################
|
|
|
|
|
# Corosync safety validation
|
|
|
|
|
#################################################
|
|
|
|
|
|
|
|
|
|
- name: fail2ban | Validate corosync firewall rules
|
|
|
|
|
ansible.builtin.command: pve-firewall compile
|
|
|
|
|
register: compiled_fw
|
|
|
|
|
changed_when: false
|
2026-02-25 17:59:13 +01:00
|
|
|
failed_when: compiled_fw.rc != 0
|
2026-03-01 10:22:41 +01:00
|
|
|
when: clustered.stat.exists | default(false)
|
2026-02-23 18:30:01 +01:00
|
|
|
|
|
|
|
|
- name: fail2ban | Fail if corosync ports are being dropped
|
2026-03-01 10:22:41 +01:00
|
|
|
ansible.builtin.debug:
|
2026-02-23 18:30:01 +01:00
|
|
|
msg: >
|
|
|
|
|
Firewall configuration appears to affect Corosync ports (5404/5405).
|
|
|
|
|
Refusing to continue to prevent cluster outage.
|
|
|
|
|
when:
|
2026-03-01 10:22:41 +01:00
|
|
|
- clustered.stat.exists | default(false)
|
2026-02-23 19:36:36 +01:00
|
|
|
- compiled_fw.stdout is search('5404.*DROP|5405.*DROP')
|
2026-02-23 18:30:01 +01:00
|
|
|
|
|
|
|
|
#################################################
|
2026-03-01 10:22:41 +01:00
|
|
|
# Deploy cluster-aware Fail2Ban action
|
2026-02-23 18:30:01 +01:00
|
|
|
#################################################
|
|
|
|
|
|
2026-03-01 10:48:13 +01:00
|
|
|
- name: fail2ban | Deploy proxmox-fw action
|
2026-02-23 18:30:01 +01:00
|
|
|
ansible.builtin.copy:
|
|
|
|
|
dest: /etc/fail2ban/action.d/proxmox-fw.conf
|
2026-03-01 10:22:41 +01:00
|
|
|
owner: root
|
|
|
|
|
group: root
|
2026-02-23 18:30:01 +01:00
|
|
|
mode: '0644'
|
|
|
|
|
content: |
|
|
|
|
|
[Definition]
|
|
|
|
|
|
2026-03-01 10:22:41 +01:00
|
|
|
fwfile = {{ pve_firewall_config }}
|
2026-02-23 18:30:01 +01:00
|
|
|
|
2026-03-01 10:22:41 +01:00
|
|
|
rule = DROP -source <ip> -log nolog
|
2026-02-23 18:30:01 +01:00
|
|
|
|
2026-03-01 10:22:41 +01:00
|
|
|
actionban = \
|
|
|
|
|
if [ -f <fwfile> ]; then \
|
|
|
|
|
grep -qF "<rule>" <fwfile> || echo "<rule>" >> <fwfile>; \
|
|
|
|
|
pve-firewall compile >/dev/null 2>&1 || true; \
|
|
|
|
|
fi
|
2026-02-23 18:30:01 +01:00
|
|
|
|
2026-03-01 10:22:41 +01:00
|
|
|
actionunban = \
|
|
|
|
|
if [ -f <fwfile> ]; then \
|
|
|
|
|
sed -i "\|<rule>|d" <fwfile>; \
|
|
|
|
|
pve-firewall compile >/dev/null 2>&1 || true; \
|
|
|
|
|
fi
|
2026-02-23 18:30:01 +01:00
|
|
|
|
2026-03-01 10:22:41 +01:00
|
|
|
actionstart =
|
|
|
|
|
actionstop =
|
|
|
|
|
when:
|
2026-03-01 10:31:36 +01:00
|
|
|
- clustered.stat.exists | default(false)
|
2026-02-23 18:30:01 +01:00
|
|
|
notify:
|
|
|
|
|
- Restart fail2ban
|
|
|
|
|
|
|
|
|
|
#################################################
|
|
|
|
|
# Enable services
|
|
|
|
|
#################################################
|
|
|
|
|
|
|
|
|
|
- name: fail2ban | Enable fail2ban
|
|
|
|
|
ansible.builtin.systemd:
|
|
|
|
|
name: fail2ban
|
|
|
|
|
enabled: true
|
|
|
|
|
state: started
|
|
|
|
|
|
2026-03-01 12:00:48 +01:00
|
|
|
# #################################################
|
|
|
|
|
# # List banned IPs cluster-wide
|
|
|
|
|
# #################################################
|
|
|
|
|
|
|
|
|
|
# - name: fail2ban | Get banned IPs from Proxmox IPSet
|
|
|
|
|
# ansible.builtin.command: pve-firewall ipset list {{ f2b_ipset_name }}
|
|
|
|
|
# register: banned_ips
|
|
|
|
|
# changed_when: false
|
|
|
|
|
# failed_when: false
|
|
|
|
|
|
|
|
|
|
# - name: fail2ban | Show banned IPs
|
|
|
|
|
# ansible.builtin.debug:
|
|
|
|
|
# msg: >
|
|
|
|
|
# Current banned IPs (cluster-wide):
|
|
|
|
|
# {{ banned_ips.stdout_lines | default([]) }}
|
|
|
|
|
|
|
|
|
|
# #################################################
|
|
|
|
|
# # Manual unban
|
|
|
|
|
# #################################################
|
|
|
|
|
|
|
|
|
|
# - name: fail2ban | Unban specific IP
|
|
|
|
|
# ansible.builtin.command: >
|
|
|
|
|
# pve-firewall ipset del {{ f2b_ipset_name }} {{ f2b_unban_ip }}
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
# - name: fail2ban | Report unban result
|
|
|
|
|
# ansible.builtin.debug:
|
|
|
|
|
# msg: "Unbanned IP {{ f2b_unban_ip }}"
|
|
|
|
|
# when: f2b_unban_ip | length > 0
|