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
This commit is contained in:
173
02-selfhosting/security/firewalld-fleet-hardening.md
Normal file
173
02-selfhosting/security/firewalld-fleet-hardening.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
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)
|
||||
Reference in New Issue
Block a user