feat(firewall): drive firewalld on Alma/RHEL with full CLI parity

A fresh Alma box has firewalld active, and the iptables-based harden-firewall.sh
refused to run there (caught by harden-ssh's '|| warn', so the host firewall was
silently skipped). Use firewalld natively on the rhel family instead of fighting it.

- harden-firewall.sh: family-aware backend. On rhel, apply/allow/deny/list/disable
  drive firewall-cmd (deny-by-default zone, SSH + registered ports, ping policy,
  source-restricted rich rules); Alpine/Debian keep the iptables engine unchanged.
  FW_BACKEND=iptables|firewalld overrides.
- oslib: install_firewalld(); sshguard_backend() prefers sshg-fw-firewalld on rhel
  so brute-force blocks land in firewalld (no INPUT->sshguard jump needed).
- Deployments already fall through to a firewall-cmd branch when the iptables
  engine is absent, so they need no changes.
- README + script header document the per-family backend.

harden-ssh / harden-jumphost are unchanged -- they call harden-firewall.sh apply
and read sshguard_backend(), so the switch happens underneath them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 17:16:24 -05:00
co-authored by Claude Opus 4.8
parent c3e2e9c52b
commit fe25f35305
3 changed files with 180 additions and 31 deletions
+26 -17
View File
@@ -92,7 +92,7 @@ deployments/<name>/ # one folder per stack
| [`setup-host.sh`](scripts/setup-host.sh) | Set hostname per the naming schema (derives FQDN + Node ID) and render the shared MOTD with auto-computed border spacing. |
| [`harden-ssh.sh`](scripts/harden-ssh.sh) | SSH hardening: post-quantum hybrid KEX, fresh Ed25519 host keys, key-only auth, external SFTP subsystem, sshguard. |
| [`harden-jumphost.sh`](scripts/harden-jumphost.sh) | Bastion hardening on top of `harden-ssh`: `ssh-admins` (shell) vs `ssh-jumpers` (ProxyJump-only) with a PermitOpen allow-list. |
| [`harden-firewall.sh`](scripts/harden-firewall.sh) | Deny-by-default iptables baseline: loopback, established, ICMP, SSH (configurable port) + registered ports. Persisted **natively** (no boot hook). Deployments add ports via `/etc/firewall/ports.d` + `firewall-apply`. Sub-commands: `allow`/`deny`/`list`/`disable`. |
| [`harden-firewall.sh`](scripts/harden-firewall.sh) | Deny-by-default host firewall: **iptables** on Alpine/Debian, **firewalld** on Alma/RHEL (set `FW_BACKEND` to override). Loopback, established, ICMP, SSH (configurable port) + registered ports; persisted natively (no boot hook). Same `allow`/`deny`/`list`/`disable` sub-commands on both. |
| [`sshuser.sh`](scripts/sshuser.sh) | Add/edit/remove SSH users on a hardened jump host (Gum TUI or CLI flags). Installed standalone as `sshuser`. |
| [`ntfy-ssh-login.sh`](scripts/ntfy-ssh-login.sh) | `pam_exec` hook that posts SSH logins to ntfy (user, source IP, key used, best-effort jump target), gated by group. Config: [`ssh-notify.conf.example`](scripts/ssh-notify.conf.example). |
| [`auto-update.sh`](scripts/auto-update.sh) | Daily unattended package updates; reports (doesn't auto-jump) a new Alpine branch; reboot detection; ntfy summary. `install`/`run`/`uninstall`. |
@@ -152,22 +152,29 @@ services with Alpine-specific wiring, so it isn't part of the tri-distro set.
## Host firewall
[`scripts/harden-firewall.sh`](scripts/harden-firewall.sh) installs a
**deny-by-default** iptables baseline: `INPUT` drops everything except loopback,
established/related, ICMP, and SSH on the configured port — plus any ports a
deployment registers. `OUTPUT` stays open and `FORWARD` is left untouched, so
Docker container networking is unaffected. The harden scripts and
`cloud-init/base.yml` / `jumphost.yml` install it automatically
(`ENABLE_FIREWALL=1` by default; set `0` to fall back to the minimal
sshguard-only jump).
**deny-by-default** baseline, with the backend chosen per family (override with
`FW_BACKEND=iptables|firewalld`):
- **Alpine / Debian → iptables.** `INPUT` drops everything except loopback,
established/related, ICMP, and SSH on the configured port — plus any ports a
deployment registers.
- **Alma / RHEL → firewalld** (its native firewall). The default zone is already
deny-by-default; we strip the stock `ssh`/`cockpit` services, open SSH +
registered ports, and let sshguard block via the `sshguard-firewalld` backend
(no `INPUT → sshguard` jump needed).
`OUTPUT`/egress stays open and `FORWARD` is left untouched, so Docker container
networking is unaffected. The harden scripts and `cloud-init/base.yml` /
`jumphost.yml` install it automatically (`ENABLE_FIREWALL=1` by default).
- **Configurable SSH port** — read live from `sshd_config`, so a bastion on
`2222` is firewalled correctly with no extra flags. Restrict the source with
`FW_SSH_SOURCE=<cidr>`; drop ping with `FW_ALLOW_PING=0`.
- **Native persistence, no boot hook** — rules are saved and restored by the
distro's own package: `iptables` + `ip6tables` (Alpine/OpenRC),
`iptables-persistent` (Debian), or `iptables-services` (Alma). The saved
ruleset carries the `INPUT → sshguard` jump, so brute-force protection
survives reboot without a custom hook.
- **Native persistence, no boot hook** — on iptables hosts the ruleset is saved
and restored by the distro's own package: `iptables` + `ip6tables`
(Alpine/OpenRC) or `iptables-persistent` (Debian); the saved ruleset carries
the `INPUT → sshguard` jump. On firewalld hosts every change is `--permanent`,
so it persists across reboot natively and sshguard manages its own blocks.
- **Scripted additions** — deployments drop a rule file and re-apply:
```sh
printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/mystack.rule
@@ -179,10 +186,12 @@ sshguard-only jump).
reach the host through nat/`FORWARD` and **bypass `INPUT`**, so the firewall
neither blocks nor needs to open them; the per-stack rule files are
belt-and-braces for any host-bound bind and self-documentation.
- **Recovery** — `harden-firewall.sh disable` flushes the rules and sets `INPUT`
back to `ACCEPT` (persisted), should a rule ever lock you out. A re-apply never
drops the live SSH session: the established-connection accept is added before
the policy flips to `DROP`.
- **Recovery** — `harden-firewall.sh disable` un-locks you: on iptables it
flushes the rules and sets `INPUT` back to `ACCEPT` (persisted); on firewalld it
re-opens SSH (the `ssh` service + the configured port) and leaves firewalld
running. A re-apply never drops the live SSH session — on iptables the
established-connection accept is added before the policy flips to `DROP`, and
firewalld reloads preserve established connections.
## SSH login notifications