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

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
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