- ghost-smtp-mailgun-setup: two-system email config (newsletter API + transactional SMTP) - firewalld-fleet-hardening: Fedora fleet firewall audit-and-harden pattern with Ansible - clamav-fleet-deployment: fleet deployment with nice/ionice throttling + quarantine - ansible-check-mode-false-positives: when: not ansible_check_mode guard for verify/assert tasks - ghost-emailanalytics-lag-warning: submitted status, lag counter, fetchMissing skip explained
5.1 KiB
title, domain, category, tags, status, created, updated
| title | domain | category | tags | status | created | updated | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Firewall Hardening with firewalld on Fedora Fleet | selfhosting | security |
|
published | 2026-04-18 | 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.
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
# 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:
# 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
- 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/:
# 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:
- 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
tailscale0unless customized. Confirm withip link showbefore 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:
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
# Confirm active rules match intent
firewall-cmd --list-all
# Spot-check a port that should be closed
nmap -p 10050 <host-ip>
# Expected: filtered (not open, not closed)
# Confirm a port that should be open
nmap -p 443 <host-ip>
# Expected: open
Ansible Handler
handlers:
- name: Reload firewalld
ansible.builtin.service:
name: firewalld
state: reloaded
See Also
- ufw-firewall-management — Ubuntu/Debian equivalent
- ssh-hardening-ansible-fleet
- linux-server-hardening-checklist