--- title: Firewall Hardening with firewalld on Fedora Fleet domain: selfhosting category: security tags: - firewall - firewalld - iptables - fedora - ansible - security - hardening status: published created: 2026-04-18 updated: 2026-04-18T11:13 --- # Firewall Hardening with firewalld on Fedora Fleet ## Overview Fedora and RHEL-based hosts use `firewalld` as the default firewall manager, backed by `nftables`. Over time, firewall rules accumulate stale entries — decommissioned services, old IP allowances, leftover port forwards — that widen the attack surface silently. This article covers the audit-and-harden pattern for Fedora fleet hosts using Ansible. > For Ubuntu/Debian hosts, see [ufw-firewall-management](ufw-firewall-management.md). ## The Problem with Accumulated Rules Rules added manually or by service installers (`firewall-cmd --add-port=...`) don't get cleaned up when services are removed. Common sources of stale rules: - Monitoring agents (Zabbix, old Netdata exporters) - Media servers moved to another host (Jellyfin, Plex) - Development ports left open during testing - IP-specific allowances for home IPs that have since changed These stale rules are invisible in day-to-day operation but show up during audits as unnecessary exposure. ## Auditing Current Rules ```bash # Show all active rules (nftables, what firewalld actually uses) nft list ruleset # Show firewalld zones and services firewall-cmd --list-all-zones # Show permanent config (what survives reboot) firewall-cmd --permanent --list-all ``` Cross-reference open ports against running services: ```bash # What's actually listening? ss -tlnp # Match against firewall rules — anything open that has no listener is stale ``` ## Ansible Hardening Approach Rather than patching rules incrementally, the cleanest approach is to **flush and rebuild**: remove all non-essential rules and explicitly whitelist only what the host legitimately serves. This avoids drift and makes the resulting ruleset self-documenting. The Ansible playbook uses `ansible.posix.firewalld` to manage rules declaratively and a flush task to clear the slate before applying the desired state. ### Pattern: Flush → Rebuild ```yaml - name: Remove stale firewalld rules ansible.posix.firewalld: port: "{{ item }}" permanent: true state: disabled loop: - 8096/tcp # Jellyfin — decommissioned - 10050/tcp # Zabbix agent — removed - 10051/tcp # Zabbix server — removed ignore_errors: true # OK if rule doesn't exist - name: Apply minimal whitelist ansible.posix.firewalld: port: "{{ item }}" permanent: true state: enabled loop: "{{ allowed_ports }}" notify: Reload firewalld ``` Define `allowed_ports` per host in `host_vars/`: ```yaml # host_vars/majorlab/firewall.yml allowed_ports: - 80/tcp # Caddy HTTP - 443/tcp # Caddy HTTPS - 22/tcp # SSH (public) - 2222/tcp # SSH (alt) - 3478/tcp # Nextcloud Talk TURN ``` ### Tailscale SSH: Restrict to ts-input Zone For hosts where SSH should only be accessible via Tailscale, move the SSH rule from the public zone to the `ts-input` interface: ```yaml - name: Remove SSH from public zone ansible.posix.firewalld: zone: public service: ssh permanent: true state: disabled - name: Allow SSH on Tailscale interface only ansible.posix.firewalld: zone: trusted interface: tailscale0 permanent: true state: enabled notify: Reload firewalld ``` > **Note:** The Tailscale interface is `tailscale0` unless customized. Confirm with `ip link show` before applying. ## Per-Host Hardening Reference Different host roles need different rule sets. These are the minimal whitelists for common MajorsHouse host types: | Host Role | Open Ports | Notes | |-----------|-----------|-------| | Reverse proxy (Caddy) | 80, 443, 22/2222 | No app ports exposed — Caddy proxies internally | | Storage/media (Plex) | 32400 (public), 22 (Tailscale-only) | Plex needs public; SSH Tailscale-only | | Bot/Discord host | 25 (Postfix), 25000 (webUI), 6514 (syslog-TLS) | No inbound SSH needed if Tailscale-only | | Mail server | 25, 587, 993, 443, 22 | Standard mail ports | ## Default Policy Set the default zone policy to `DROP` (not `REJECT`) to make the host non-discoverable: ```bash firewall-cmd --set-default-zone=drop --permanent firewall-cmd --reload ``` `DROP` silently discards packets; `REJECT` sends an ICMP unreachable back, confirming the host exists. ## Verifying After Apply ```bash # Confirm active rules match intent firewall-cmd --list-all # Spot-check a port that should be closed nmap -p 10050 # Expected: filtered (not open, not closed) # Confirm a port that should be open nmap -p 443 # Expected: open ``` ## Ansible Handler ```yaml handlers: - name: Reload firewalld ansible.builtin.service: name: firewalld state: reloaded ``` ## See Also - [ufw-firewall-management](ufw-firewall-management.md) — Ubuntu/Debian equivalent - [ssh-hardening-ansible-fleet](ssh-hardening-ansible-fleet.md) - [linux-server-hardening-checklist](linux-server-hardening-checklist.md)