Files
MajorWiki/02-selfhosting/security/firewalld-fleet-hardening.md
majorlinux b40e484aae Add 5 wiki articles from 2026-04-17/18 work
- 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
2026-04-18 11:13:39 -04:00

174 lines
5.1 KiB
Markdown

---
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 <host-ip>
# Expected: filtered (not open, not closed)
# Confirm a port that should be open
nmap -p 443 <host-ip>
# 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)