--- # ------------------------------------------------- # Deploy Fail2Ban integrated with Proxmox Firewall # ------------------------------------------------- ################################################# # Detect Proxmox ################################################# - 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: clustered when: pmxcfs_running.stat.exists | default(false) - 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 ################################################# # 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 ################################################# - 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 clustered.stat.exists else '/etc/pve/nodes/' + pve_node + '.fw' }} when: pve_installed.stat.exists | default(false) - name: fail2ban | show firewall config path ansible.builtin.debug: msg: > WARNING: Proxmox firewall config path is: {{ pve_firewall_config}} when: pve_firewall_config is defined ################################################# # Detect firewall configuration ################################################# - name: fail2ban | Check firewall config exists 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 | default(false) - name: fail2ban | debug config contents ansible.builtin.debug: msg: > {{ fw_content }} when: not pve_firewall_enabled - 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$', multiline=True) }} - name: fail2ban | Warn if firewall not enabled ansible.builtin.debug: msg: > WARNING: Proxmox firewall is disabled in configuration. Fail2Ban will not actively block traffic. 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 when: pmxcfs_running.stat.exists | default(false) - name: fail2ban | Abort if firewall daemon not running ansible.builtin.debug: msg: > Proxmox firewall service is not running. 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 ################################################# - name: fail2ban | Validate corosync firewall rules ansible.builtin.command: pve-firewall compile register: compiled_fw changed_when: false failed_when: compiled_fw.rc != 0 when: clustered.stat.exists | default(false) - name: fail2ban | Fail if corosync ports are being dropped ansible.builtin.debug: msg: > Firewall configuration appears to affect Corosync ports (5404/5405). Refusing to continue to prevent cluster outage. when: - clustered.stat.exists | default(false) - compiled_fw.stdout is search('5404.*DROP|5405.*DROP') ################################################# # Deploy cluster-aware Fail2Ban 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 = when: - clustered.stat.exists | default(false) notify: - Restart fail2ban ################################################# # Enable services ################################################# - name: fail2ban | Enable fail2ban ansible.builtin.systemd: name: fail2ban enabled: true state: started # ################################################# # # 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