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

5.1 KiB

title domain category tags status created updated
Firewall Hardening with firewalld on Fedora Fleet selfhosting security
firewall
firewalld
iptables
fedora
ansible
security
hardening
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 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:

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