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:
@@ -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
|
||||
|
||||
|
||||
+128
-13
@@ -2,11 +2,17 @@
|
||||
#
|
||||
# harden-firewall.sh
|
||||
#
|
||||
# Deny-by-default host firewall (iptables) for Alpine, Debian, and Alma Linux.
|
||||
# Hardens the INPUT chain to: loopback, established/related, ICMP, SSH (on the
|
||||
# configured port), and any explicitly-registered ports -- everything else is
|
||||
# dropped. OUTPUT stays open (egress allow); FORWARD is left untouched so Docker
|
||||
# container networking is unaffected.
|
||||
# Deny-by-default host firewall for Alpine, Debian, and Alma Linux.
|
||||
#
|
||||
# Backend per family (override with FW_BACKEND=iptables|firewalld):
|
||||
# alpine/debian iptables -- hardens the INPUT chain to loopback,
|
||||
# established/related, ICMP, SSH (configured port) + registered
|
||||
# ports; everything else dropped. Persisted natively, no boot hook.
|
||||
# rhel (Alma) firewalld -- the native RHEL firewall. The default zone is
|
||||
# deny-by-default; we open SSH + registered ports and let
|
||||
# sshguard block via the sshguard-firewalld backend.
|
||||
# OUTPUT/egress stays open; FORWARD is left untouched so Docker networking is
|
||||
# unaffected. The allow/deny/list/disable subcommands work on both backends.
|
||||
#
|
||||
# All distro-specific operations go through scripts/oslib.sh. The OS-specific
|
||||
# surface here is exactly three things, all in oslib: which packages provide
|
||||
@@ -82,6 +88,14 @@ if [[ -z "${SSH_PORT:-}" ]]; then
|
||||
[[ -n "$SSH_PORT" ]] || SSH_PORT=22
|
||||
fi
|
||||
|
||||
# Firewall backend: firewalld on RHEL/Alma (native, and the sshguard-firewalld
|
||||
# backend blocks there), iptables everywhere else. Override with
|
||||
# FW_BACKEND=iptables|firewalld.
|
||||
: "${FW_BACKEND:=}"
|
||||
if [[ -z "$FW_BACKEND" ]]; then
|
||||
[[ "$OS_FAMILY" == rhel ]] && FW_BACKEND=firewalld || FW_BACKEND=iptables
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Spec parsing: PORT[/PROTO][@CIDR] and named presets -> "PORT/PROTO[ CIDR]" lines
|
||||
# ============================================================================
|
||||
@@ -259,21 +273,118 @@ ensure_installed() {
|
||||
install -d -m 0755 "$PORTS_DIR"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# firewalld backend (RHEL/Alma) -- same CLI as the iptables engine, driven via
|
||||
# firewall-cmd. firewalld zones are deny-by-default for unsolicited inbound and
|
||||
# persist natively (--permanent), and sshguard blocks via the sshguard-firewalld
|
||||
# backend -- so there is no INPUT->sshguard jump, no native save, no boot hook.
|
||||
# The default zone is the managed surface.
|
||||
# ============================================================================
|
||||
_fw_zone() { firewall-cmd --get-default-zone 2>/dev/null || echo public; }
|
||||
|
||||
# Add/remove one "PORT/PROTO" spec on the zone (permanent). A range a:b becomes
|
||||
# a-b; a source CIDR turns the rule into a rich rule.
|
||||
_fw_spec() { # <add|remove> <zone> <port/proto> [cidr]
|
||||
local op="$1" z="$2" spec="$3" cidr="${4:-}" port proto flag
|
||||
port="${spec%%/*}"; proto="${spec##*/}"; [[ "$proto" == "$spec" ]] && proto=tcp
|
||||
port="${port//:/-}"
|
||||
[[ "$op" == add ]] && flag=--add || flag=--remove
|
||||
if [[ -n "$cidr" ]]; then
|
||||
firewall-cmd -q --permanent --zone="$z" \
|
||||
"${flag}-rich-rule=rule family=\"ipv4\" source address=\"$cidr\" port port=\"$port\" protocol=\"$proto\" accept" 2>/dev/null || true
|
||||
else
|
||||
firewall-cmd -q --permanent --zone="$z" "${flag}-port=$port/$proto" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Apply each "PORT/PROTO [CIDR]" spec line from stdin to the zone.
|
||||
_fw_apply_specs() { # <add|remove> <zone> (spec lines on stdin)
|
||||
local op="$1" z="$2" line spec cidr
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] || continue
|
||||
spec="$(awk '{print $1}' <<< "$line")"; cidr="$(awk '{print $2}' <<< "$line")"
|
||||
_fw_spec "$op" "$z" "$spec" "$cidr"
|
||||
done
|
||||
}
|
||||
|
||||
apply_firewalld() {
|
||||
log "Detected OS: ${OS_ID} (family ${OS_FAMILY}) -- firewall backend: firewalld"
|
||||
install_firewalld
|
||||
local z; z="$(_fw_zone)"
|
||||
log "Hardening firewalld zone '$z' (deny-by-default; SSH/${SSH_PORT}${FW_SSH_SOURCE:+ from $FW_SSH_SOURCE}$([[ $FW_ALLOW_PING == 1 ]] || echo '; no ping'))..."
|
||||
|
||||
# We own SSH via an explicit port/rich-rule, so drop the stock services that
|
||||
# would otherwise leave 22 (and cockpit's 9090) open on the zone.
|
||||
local s
|
||||
for s in ssh cockpit; do firewall-cmd -q --permanent --zone="$z" --remove-service="$s" 2>/dev/null || true; done
|
||||
|
||||
# SSH (source-restricted -> rich rule; otherwise a plain port).
|
||||
_fw_spec add "$z" "$SSH_PORT/tcp" "$FW_SSH_SOURCE"
|
||||
|
||||
# Extra ports from OPEN_PORTS (same spec grammar as the iptables path).
|
||||
if [[ -n "$OPEN_PORTS" ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
_fw_apply_specs add "$z" <<< "$(_expand_args ${OPEN_PORTS//,/ })"
|
||||
fi
|
||||
|
||||
# Ping: firewalld permits echo-request by default; block it only when asked.
|
||||
if [[ "$FW_ALLOW_PING" == "1" ]]; then
|
||||
firewall-cmd -q --permanent --zone="$z" --remove-icmp-block=echo-request 2>/dev/null || true
|
||||
else
|
||||
firewall-cmd -q --permanent --zone="$z" --add-icmp-block=echo-request 2>/dev/null || true
|
||||
fi
|
||||
|
||||
firewall-cmd -q --reload
|
||||
log "firewalld active on zone '$z'. Add ports: 'bash $0 allow <port/proto>' Recover: 'bash $0 disable'"
|
||||
}
|
||||
|
||||
allow_firewalld() {
|
||||
local z lines; z="$(_fw_zone)"; lines="$(_expand_args "$@")"
|
||||
_fw_apply_specs add "$z" <<< "$lines"
|
||||
firewall-cmd -q --reload
|
||||
log "Allowed in firewalld zone '$z': $(printf '%s' "$lines" | tr '\n' ' ')"
|
||||
}
|
||||
|
||||
deny_firewalld() {
|
||||
local z lines; z="$(_fw_zone)"; lines="$(_expand_args "$@")"
|
||||
_fw_apply_specs remove "$z" <<< "$lines"
|
||||
firewall-cmd -q --reload
|
||||
log "Removed from firewalld zone '$z': $(printf '%s' "$lines" | tr '\n' ' ')"
|
||||
}
|
||||
|
||||
list_firewalld() {
|
||||
local z; z="$(_fw_zone)"
|
||||
echo "firewalld default zone: $z"
|
||||
firewall-cmd --list-all --zone="$z" 2>/dev/null | sed 's/^/ /' || echo " (firewalld not running)"
|
||||
}
|
||||
|
||||
# Recovery: re-open SSH so a misconfig can't lock you out. Leaves firewalld
|
||||
# running (sshguard-firewalld keeps working). To turn the firewall off entirely:
|
||||
# systemctl disable --now firewalld.
|
||||
disable_firewalld() {
|
||||
local z; z="$(_fw_zone)"
|
||||
firewall-cmd -q --permanent --zone="$z" --add-service=ssh 2>/dev/null || true
|
||||
firewall-cmd -q --permanent --zone="$z" --add-port="${SSH_PORT}/tcp" 2>/dev/null || true
|
||||
firewall-cmd -q --reload || true
|
||||
warn "Re-opened SSH (service ssh + ${SSH_PORT}/tcp) on firewalld zone '$z'; firewalld left running."
|
||||
warn "Re-harden: 'bash $0 apply' | turn firewalld off: 'systemctl disable --now firewalld'"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Subcommands
|
||||
# ============================================================================
|
||||
cmd_apply() {
|
||||
log "Detected OS: ${OS_ID} (family ${OS_FAMILY}, init ${INIT_SYSTEM})"
|
||||
if [[ "$FW_BACKEND" == firewalld ]]; then apply_firewalld; return; fi
|
||||
|
||||
# RHEL/Alma ships firewalld; it and iptables-services fight over nftables.
|
||||
# Decoupled from FORCE on purpose: harden-ssh passes FORCE=1 to skip its SSH
|
||||
# prompt, and we must NOT let that bulldoze an active firewalld. Bail clearly
|
||||
# instead (harden-ssh treats this as a warning and leaves firewalld in place).
|
||||
log "Detected OS: ${OS_ID} (family ${OS_FAMILY}, init ${INIT_SYSTEM}) -- firewall backend: iptables"
|
||||
|
||||
# FW_BACKEND=iptables was forced on a RHEL/Alma host where firewalld is the
|
||||
# native firewall; the two fight over nftables. Bail clearly unless overridden.
|
||||
if [[ "$OS_FAMILY" == rhel ]] && command -v firewall-cmd >/dev/null 2>&1 \
|
||||
&& firewall-cmd --state >/dev/null 2>&1; then
|
||||
warn "firewalld is active. It conflicts with this iptables firewall."
|
||||
warn "Disable it first: systemctl disable --now firewalld"
|
||||
[[ "${FW_IGNORE_FIREWALLD:-0}" == "1" ]] || die "Refusing to proceed while firewalld is active (set FW_IGNORE_FIREWALLD=1 to override)."
|
||||
warn "firewalld is active but FW_BACKEND=iptables was forced; they conflict."
|
||||
warn "Prefer the firewalld backend (unset FW_BACKEND), or: systemctl disable --now firewalld"
|
||||
[[ "${FW_IGNORE_FIREWALLD:-0}" == "1" ]] || die "Refusing iptables while firewalld is active (set FW_IGNORE_FIREWALLD=1 to override)."
|
||||
fi
|
||||
|
||||
log "Installing iptables + native persistence package..."
|
||||
@@ -293,6 +404,7 @@ cmd_apply() {
|
||||
|
||||
cmd_allow() {
|
||||
[[ "$#" -ge 1 ]] || die "usage: allow <port[/proto][@cidr]|web|http|https>..."
|
||||
[[ "$FW_BACKEND" == firewalld ]] && { allow_firewalld "$@"; return; }
|
||||
ensure_installed
|
||||
local lines file="$PORTS_DIR/manual.rule"
|
||||
lines="$(_expand_args "$@")"
|
||||
@@ -309,6 +421,7 @@ cmd_allow() {
|
||||
|
||||
cmd_deny() {
|
||||
[[ "$#" -ge 1 ]] || die "usage: deny <port[/proto][@cidr]|web|http|https>..."
|
||||
[[ "$FW_BACKEND" == firewalld ]] && { deny_firewalld "$@"; return; }
|
||||
ensure_installed
|
||||
local lines l file tmp
|
||||
lines="$(_expand_args "$@")"
|
||||
@@ -332,6 +445,7 @@ cmd_deny() {
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
[[ "$FW_BACKEND" == firewalld ]] && { list_firewalld; return; }
|
||||
echo "Registered ports ($PORTS_DIR):"
|
||||
if ls "$PORTS_DIR"/*.rule >/dev/null 2>&1; then
|
||||
for f in "$PORTS_DIR"/*.rule; do
|
||||
@@ -349,6 +463,7 @@ cmd_list() {
|
||||
# Recovery escape hatch: open the policy and flush our rules, then persist that
|
||||
# open state so a reboot does not re-DROP. The sshguard chain is left intact.
|
||||
cmd_disable() {
|
||||
[[ "$FW_BACKEND" == firewalld ]] && { disable_firewalld; return; }
|
||||
local ipt
|
||||
for ipt in iptables ip6tables; do
|
||||
command -v "$ipt" >/dev/null 2>&1 || continue
|
||||
|
||||
+26
-1
@@ -481,6 +481,17 @@ fw_enable_restore() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Install + enable firewalld (the native RHEL/Alma firewall). harden-firewall.sh
|
||||
# uses this on the rhel family instead of raw iptables; sshguard then blocks via
|
||||
# the sshguard-firewalld backend (no INPUT->sshguard jump, no boot hook).
|
||||
install_firewalld() {
|
||||
_require_detected
|
||||
command -v firewall-cmd >/dev/null 2>&1 || pkg_install firewalld
|
||||
svc_enable firewalld
|
||||
# Must be running before we push --permanent rules and --reload.
|
||||
firewall-cmd --state >/dev/null 2>&1 || svc_start firewalld || true
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# gum (Charm TUI) -- multi-OS installer. Best-effort; callers that can fall
|
||||
# back to CLI prompts should not treat failure as fatal.
|
||||
@@ -585,9 +596,23 @@ CONF
|
||||
return 1
|
||||
}
|
||||
|
||||
# Locate the sshguard iptables backend binary (path varies by packaging).
|
||||
# Locate the sshguard firewall-backend binary (path varies by packaging).
|
||||
# On RHEL/Alma we run firewalld, so prefer sshguard's firewalld backend
|
||||
# (from the sshguard-firewalld package) -- blocks land in firewalld and there
|
||||
# is no iptables INPUT->sshguard jump to maintain.
|
||||
sshguard_backend() {
|
||||
_require_detected
|
||||
local c
|
||||
if [[ "${OS_FAMILY:-}" == rhel ]]; then
|
||||
for c in /usr/libexec/sshguard/sshg-fw-firewalld \
|
||||
/usr/lib/sshguard/sshg-fw-firewalld; do
|
||||
[[ -x "$c" ]] && { echo "$c"; return; }
|
||||
done
|
||||
# firewalld backend expected but not found yet; name it anyway so
|
||||
# sshguard.conf points at the right binary once the package is in.
|
||||
echo /usr/libexec/sshguard/sshg-fw-firewalld
|
||||
return
|
||||
fi
|
||||
for c in /usr/libexec/sshguard/sshg-fw-iptables \
|
||||
/usr/libexec/sshg-fw-iptables \
|
||||
/usr/lib/sshguard/sshg-fw-iptables \
|
||||
|
||||
Reference in New Issue
Block a user