From e23557b4fbb37180b5fe8ea1125b5901bc74c901 Mon Sep 17 00:00:00 2001 From: William Gill Date: Fri, 12 Jun 2026 17:06:25 -0500 Subject: [PATCH] feat(firewall): add deny-by-default host firewall (harden-firewall.sh) Add a reusable iptables baseline that hardens hosts with ICMP + SSH defaults and lets deployments register the ports they need. INPUT is deny-by-default (loopback, established, ICMP, SSH on the configured port, plus registered ports); OUTPUT stays open and FORWARD is left untouched so Docker container networking is unaffected. Persistence is native -- no boot hook. Rules are saved and restored by the distro's own package (iptables/ip6tables on Alpine, iptables-persistent on Debian, iptables-services on Alma) via the new oslib helpers install_iptables / fw_save_cmd / fw_enable_restore. The saved ruleset carries the INPUT->sshguard jump, so brute-force protection survives reboot without the old sshguard-iptables hook. A self-contained /usr/local/sbin/firewall-apply rebuilds INPUT from declarative drop-ins under /etc/firewall/ports.d and runs the native save, so deployments add a port without needing the repo present: printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/mystack.rule /usr/local/sbin/firewall-apply - SSH port read live from sshd_config (custom bastion ports just work); FW_SSH_SOURCE restricts the source CIDR; FW_ALLOW_PING gates echo - harden-ssh.sh / harden-jumphost.sh install it when ENABLE_FIREWALL=1 (default) and skip the sshguard-only hook; ENABLE_FIREWALL=0 keeps it - cloud-init base.yml / jumphost.yml forward the toggle - the four stack deploy.sh open_web_ports() register 80/443 via the firewall (ufw/firewalld kept as fallback); Docker-published ports bypass INPUT, so this is belt-and-braces and self-documenting - README + cloud-init/README document the mechanism, Docker caveat, and the `disable` recovery path --- README.md | 38 +++- cloud-init/README.md | 6 +- cloud-init/base.yml | 8 +- cloud-init/jumphost.yml | 2 + deployments/beszel/deploy.sh | 13 +- deployments/headscale/deploy.sh | 13 +- deployments/pocket-id/deploy.sh | 14 +- deployments/webfinger/deploy.sh | 13 +- scripts/harden-firewall.sh | 379 ++++++++++++++++++++++++++++++++ scripts/harden-jumphost.sh | 22 +- scripts/harden-ssh.sh | 25 ++- scripts/oslib.sh | 53 +++++ 12 files changed, 564 insertions(+), 22 deletions(-) create mode 100644 scripts/harden-firewall.sh diff --git a/README.md b/README.md index 55e3e5f..ad21e56 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,11 @@ deployments// # 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`. | | [`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`. | -| [`oslib.sh`](scripts/oslib.sh) | OS-abstraction layer (detection, package manager, init system, SFTP path, hostname, boot hooks, sshguard, login notifier). Sourced, not run. **One file holds every distro difference.** | +| [`oslib.sh`](scripts/oslib.sh) | OS-abstraction layer (detection, package manager, init system, SFTP path, hostname, boot hooks, native firewall persistence, sshguard, login notifier). Sourced, not run. **One file holds every distro difference.** | | [`lib.sh`](scripts/lib.sh) | Launcher helpers (`ensure_gum`, `load_globals`, `resolve_ssh_keys`); sources `oslib.sh`. Not run directly. | ### `deployments/` — per stack @@ -148,6 +149,41 @@ path, hostname, boot hooks, and the sshguard log source/backend. **simplex** remains **Alpine-targeted** — it depends on `awall` and Tor hidden 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). + +- **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=`; 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. +- **Scripted additions** — deployments drop a rule file and re-apply: + ```sh + printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/mystack.rule + /usr/local/sbin/firewall-apply + ``` + or interactively: `harden-firewall.sh allow 443/tcp 51820/udp` / + `allow web` / `deny 51820/udp` / `list`. +- **Docker caveat** — containers published with `-p` (e.g. Caddy's 80/443) + 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`. + ## SSH login notifications The harden scripts can install a `pam_exec` hook diff --git a/cloud-init/README.md b/cloud-init/README.md index cb51ba7..864d53d 100644 --- a/cloud-init/README.md +++ b/cloud-init/README.md @@ -7,8 +7,8 @@ before invoking the scripts. | Template | What it does | |----------|--------------| -| [`base.yml`](base.yml) | Hostname (per the schema) + shared MOTD + seed root keys from `globals/` + SSH hardening (`harden-ssh.sh`). | -| [`jumphost.yml`](jumphost.yml) | Same base, but bastion hardening (`harden-jumphost.sh`) with an `ssh-admins`/`ssh-jumpers` split and a ProxyJump whitelist. | +| [`base.yml`](base.yml) | Hostname (per the schema) + shared MOTD + seed root keys from `globals/` + SSH hardening (`harden-ssh.sh`) + deny-by-default host firewall (`harden-firewall.sh`, `ENABLE_FIREWALL=1`). | +| [`jumphost.yml`](jumphost.yml) | Same base, but bastion hardening (`harden-jumphost.sh`) with an `ssh-admins`/`ssh-jumpers` split and a ProxyJump whitelist; same host firewall. | These are for the **host itself**. To stand up a Docker stack on a host, use the per-deployment `cloud-init.yml` under `deployments//` instead @@ -20,7 +20,7 @@ use the per-deployment `cloud-init.yml` under `deployments//` instead and the other values at the top of the `runcmd` block. 2. Paste it as the instance's **user-data** when creating the VM. 3. On first boot the host names itself, installs the MOTD, seeds admin keys - from `globals/`, and hardens SSH. + from `globals/`, hardens SSH, and brings up the deny-by-default firewall. Hostnames follow [`../globals/Network Domain Name Schema.md`](../globals/Network%20Domain%20Name%20Schema.md); our VMs skip the region code and use `srvno.de` as the base. diff --git a/cloud-init/base.yml b/cloud-init/base.yml index 1da1133..dd9738e 100644 --- a/cloud-init/base.yml +++ b/cloud-init/base.yml @@ -30,6 +30,8 @@ runcmd: SSH_PORT=22 ALLOWED_IP= # optional: whitelist your client IP in sshguard AUTO_UPDATE=1 # schedule daily unattended updates (0 to skip) + ENABLE_FIREWALL=1 # deny-by-default host firewall (0 to skip) + OPEN_PORTS="" # extra inbound ports, e.g. "80/tcp 443/tcp" # ================== # Prerequisites (OS-agnostic). @@ -50,5 +52,7 @@ runcmd: && resolve_ssh_keys >> /root/.ssh/authorized_keys || true sort -u /root/.ssh/authorized_keys -o /root/.ssh/authorized_keys 2>/dev/null || true - # SSH hardening (key-only, PQ KEX, sshguard). - SSH_PORT="$SSH_PORT" ALLOWED_IP="$ALLOWED_IP" FORCE=1 bash scripts/harden-ssh.sh + # SSH hardening (key-only, PQ KEX, sshguard) + deny-by-default host firewall. + SSH_PORT="$SSH_PORT" ALLOWED_IP="$ALLOWED_IP" \ + ENABLE_FIREWALL="$ENABLE_FIREWALL" OPEN_PORTS="$OPEN_PORTS" \ + FORCE=1 bash scripts/harden-ssh.sh diff --git a/cloud-init/jumphost.yml b/cloud-init/jumphost.yml index 74bf41c..b547242 100644 --- a/cloud-init/jumphost.yml +++ b/cloud-init/jumphost.yml @@ -25,6 +25,7 @@ runcmd: DATACENTER="Globally Everywhere" SSH_PORT=22 ALLOWED_IP= # optional: whitelist your client IP + ENABLE_FIREWALL=1 # deny-by-default host firewall (0 to skip) JUMP_TARGETS="10.0.0.5:22 10.0.0.6:22" # hosts jumpers may ProxyJump to # Optional login notifications (pam_exec -> ntfy). Leave NTFY_URL empty to # skip. NTFY_REGION defaults to the region segment of this host's FQDN. @@ -55,5 +56,6 @@ runcmd: # Bastion hardening (admins shell + jumpers ProxyJump whitelist + optional # login notifications). SSH_PORT="$SSH_PORT" ALLOWED_IP="$ALLOWED_IP" JUMP_TARGETS="$JUMP_TARGETS" \ + ENABLE_FIREWALL="$ENABLE_FIREWALL" \ NTFY_URL="$NTFY_URL" NTFY_TOKEN="$NTFY_TOKEN" NTFY_EMAIL="$NTFY_EMAIL" NTFY_REGION="$NTFY_REGION" \ FORCE=1 bash scripts/harden-jumphost.sh diff --git a/deployments/beszel/deploy.sh b/deployments/beszel/deploy.sh index 9a33f34..ce631e2 100644 --- a/deployments/beszel/deploy.sh +++ b/deployments/beszel/deploy.sh @@ -83,7 +83,18 @@ install_docker() { } open_web_ports() { - if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then + # Register 80/443 for this stack. Prefer the host firewall (harden-firewall.sh) + # when present: drop in a rule file and re-apply. Else fall back to ufw/ + # firewalld if active (no-op when neither is). + # + # NOTE: Caddy publishes 80/443 via Docker, which reaches the host through the + # nat/FORWARD chains and BYPASSES the INPUT firewall -- so this is harmless + # belt-and-braces for any host-bound bind and self-documents the stack ports. + if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then + log "Registering 80,443/tcp with host firewall..." + printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/beszel.rule + /usr/local/sbin/firewall-apply + elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then log "ufw active -- allowing 80,443/tcp..." ufw allow 80/tcp >/dev/null; ufw allow 443/tcp >/dev/null elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then diff --git a/deployments/headscale/deploy.sh b/deployments/headscale/deploy.sh index f4ea1f4..4568248 100644 --- a/deployments/headscale/deploy.sh +++ b/deployments/headscale/deploy.sh @@ -101,7 +101,18 @@ install_docker() { } open_web_ports() { - if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then + # Register 80/443 for this stack. Prefer the host firewall (harden-firewall.sh) + # when present: drop in a rule file and re-apply. Else fall back to ufw/ + # firewalld if active (no-op when neither is). + # + # NOTE: Caddy publishes 80/443 via Docker, which reaches the host through the + # nat/FORWARD chains and BYPASSES the INPUT firewall -- so this is harmless + # belt-and-braces for any host-bound bind and self-documents the stack ports. + if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then + log "Registering 80,443/tcp with host firewall..." + printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/headscale.rule + /usr/local/sbin/firewall-apply + elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then log "ufw active -- allowing 80,443/tcp..." ufw allow 80/tcp >/dev/null; ufw allow 443/tcp >/dev/null elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then diff --git a/deployments/pocket-id/deploy.sh b/deployments/pocket-id/deploy.sh index 77a902c..f674032 100644 --- a/deployments/pocket-id/deploy.sh +++ b/deployments/pocket-id/deploy.sh @@ -92,8 +92,18 @@ install_docker() { } open_web_ports() { - # Open 80/443 on whichever host firewall is active (no-op otherwise). - if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then + # Register 80/443 for this stack. Prefer the host firewall (harden-firewall.sh) + # when present: drop in a rule file and re-apply. Else fall back to ufw/ + # firewalld if active (no-op when neither is). + # + # NOTE: Caddy publishes 80/443 via Docker, which reaches the host through the + # nat/FORWARD chains and BYPASSES the INPUT firewall -- so this is harmless + # belt-and-braces for any host-bound bind and self-documents the stack ports. + if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then + log "Registering 80,443/tcp with host firewall..." + printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/pocket-id.rule + /usr/local/sbin/firewall-apply + elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then log "ufw active -- allowing 80,443/tcp..." ufw allow 80/tcp >/dev/null; ufw allow 443/tcp >/dev/null elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then diff --git a/deployments/webfinger/deploy.sh b/deployments/webfinger/deploy.sh index 8603a2c..550259a 100644 --- a/deployments/webfinger/deploy.sh +++ b/deployments/webfinger/deploy.sh @@ -89,7 +89,18 @@ install_docker() { } open_web_ports() { - if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then + # Register 80/443 for this stack. Prefer the host firewall (harden-firewall.sh) + # when present: drop in a rule file and re-apply. Else fall back to ufw/ + # firewalld if active (no-op when neither is). + # + # NOTE: Caddy publishes 80/443 via Docker, which reaches the host through the + # nat/FORWARD chains and BYPASSES the INPUT firewall -- so this is harmless + # belt-and-braces for any host-bound bind and self-documents the stack ports. + if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then + log "Registering 80,443/tcp with host firewall..." + printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/webfinger.rule + /usr/local/sbin/firewall-apply + elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then log "ufw active -- allowing 80,443/tcp..." ufw allow 80/tcp >/dev/null; ufw allow 443/tcp >/dev/null elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then diff --git a/scripts/harden-firewall.sh b/scripts/harden-firewall.sh new file mode 100644 index 0000000..d09106b --- /dev/null +++ b/scripts/harden-firewall.sh @@ -0,0 +1,379 @@ +#!/usr/bin/env bash +# +# 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. +# +# All distro-specific operations go through scripts/oslib.sh. The OS-specific +# surface here is exactly three things, all in oslib: which packages provide +# iptables + native persistence (install_iptables), how to persist the live +# ruleset (fw_save_cmd), and which service restores it at boot (fw_enable_restore). +# +# Persistence is NATIVE -- there is no boot hook. The installer builds the rules +# live, saves them via the distro's own iptables/iptables-persistent/ +# iptables-services package, and enables that package's restore service. The +# saved ruleset carries the INPUT->sshguard jump (and the empty sshguard chain), +# so brute-force protection survives reboot without any custom hook. +# +# A small on-host CLI, /usr/local/sbin/firewall-apply, rebuilds INPUT from the +# declarative drop-ins under /etc/firewall and then runs the native save. It is +# generated here (with the save command baked in) so deployments can register a +# port and re-apply without this repo present: +# +# printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/mystack.rule +# /usr/local/sbin/firewall-apply +# +# Note on Docker: containers published with `-p` reach the host through the nat +# PREROUTING + FORWARD chains, which BYPASS INPUT. This firewall therefore can +# neither block nor needs to open Docker-published ports (e.g. Caddy's 80/443) -- +# the INPUT rules here protect host-bound services only. +# +# Usage: +# bash harden-firewall.sh # install + apply (deny-by-default) +# SSH_PORT=2222 bash harden-firewall.sh # bastion port +# OPEN_PORTS="80/tcp 443/tcp" bash harden-firewall.sh # open extra ports at install +# FW_SSH_SOURCE=10.0.0.0/8 bash harden-firewall.sh # restrict SSH to a source CIDR +# FW_ALLOW_PING=0 bash harden-firewall.sh # drop ICMP echo (ping) +# +# bash harden-firewall.sh allow 443/tcp 51820/udp # register + apply +# bash harden-firewall.sh allow web # preset: 80/tcp + 443/tcp +# bash harden-firewall.sh allow 2222/tcp@10.0.0.0/8 # port limited to a source +# bash harden-firewall.sh deny 51820/udp # unregister + apply +# bash harden-firewall.sh list # show drop-ins + live INPUT +# bash harden-firewall.sh disable # recovery: flush + INPUT ACCEPT + +set -euo pipefail + +# Load the OS abstraction layer (sits next to this script). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/oslib.sh +. "$SCRIPT_DIR/oslib.sh" + +# ============================================================================ +# CONFIG +# ============================================================================ +: "${FW_ALLOW_PING:=1}" # 1 = allow ICMP echo-request (ping); 0 = drop it +: "${FW_SSH_SOURCE:=}" # optional CIDR: restrict SSH to this source (default any) +: "${OPEN_PORTS:=}" # space/comma list of specs to open at install +: "${FORCE:=0}" + +FW_DIR=/etc/firewall +PORTS_DIR="$FW_DIR/ports.d" +CONF="$FW_DIR/firewall.conf" +APPLY=/usr/local/sbin/firewall-apply + +# log()/warn()/die() come from oslib (_log/_warn/_die); alias for readability. +log() { _log "$@"; } +warn() { _warn "$@"; } +die() { _die "$@"; } + +[[ $EUID -eq 0 ]] || die "Run as root." +os_detect + +# SSH port: explicit env wins; otherwise read it live from sshd_config; else 22. +# (The generated engine re-derives this on every run so a later port change is +# picked up; SSH_PORT here is the install-time fallback baked into the conf.) +if [[ -z "${SSH_PORT:-}" ]]; then + SSH_PORT="$(awk '/^[Pp]ort / {print $2; exit}' /etc/ssh/sshd_config 2>/dev/null || true)" + [[ -n "$SSH_PORT" ]] || SSH_PORT=22 +fi + +# ============================================================================ +# Spec parsing: PORT[/PROTO][@CIDR] and named presets -> "PORT/PROTO[ CIDR]" lines +# ============================================================================ +_preset_lines() { # echo drop-in lines for a preset name, else return 1 + case "$1" in + web) printf '80/tcp\n443/tcp\n' ;; + http) printf '80/tcp\n' ;; + https) printf '443/tcp\n' ;; + *) return 1 ;; + esac +} + +_normalize_spec() { # PORT[/PROTO][@CIDR] -> a single "PORT/PROTO[ CIDR]" line + local spec="$1" cidr="" port proto + case "$spec" in *@*) cidr="${spec##*@}"; spec="${spec%@*}" ;; esac + port="${spec%%/*}" + proto=tcp + case "$spec" in */*) proto="${spec##*/}" ;; esac + case "$proto" in tcp|udp) : ;; *) die "Invalid protocol '$proto' in '$1' (tcp|udp)." ;; esac + case "$port" in ''|*[!0-9:]*) die "Invalid port '$port' in '$1' (digits, or a:b range)." ;; esac + if [[ -n "$cidr" ]]; then printf '%s/%s %s\n' "$port" "$proto" "$cidr" + else printf '%s/%s\n' "$port" "$proto"; fi +} + +_expand_args() { # mix of presets + specs -> newline-separated drop-in lines + local a + for a in "$@"; do + _preset_lines "$a" 2>/dev/null || _normalize_spec "$a" + done +} + +# ============================================================================ +# firewall.conf -- the declarative inputs the engine reads on every run +# ============================================================================ +write_conf() { + install -d -m 0755 "$FW_DIR" "$PORTS_DIR" + cat > "$CONF" < a declarative seed drop-in (rewritten each install). +seed_open_ports() { + [[ -n "$OPEN_PORTS" ]] || return 0 + install -d -m 0755 "$PORTS_DIR" + # shellcheck disable=SC2086 # word-split the space/comma list intentionally + _expand_args ${OPEN_PORTS//,/ } | awk 'NF' | sort -u > "$PORTS_DIR/seed.rule" + log "Seeded ports.d/seed.rule from OPEN_PORTS: ${OPEN_PORTS}" +} + +# ============================================================================ +# Generate /usr/local/sbin/firewall-apply (the on-demand rebuild+save engine). +# The native save command is resolved here and baked in, so the helper is fully +# self-contained on the host (no repo / no oslib needed to re-apply). +# ============================================================================ +write_apply() { + local save_cmd; save_cmd="$(fw_save_cmd)" + cat > "$APPLY" <<'ENGINE' +#!/bin/sh +# Managed by harden-firewall.sh -- do not edit by hand (regenerated on install). +# +# Rebuilds the INPUT chain from /etc/firewall (declarative) and persists it via +# the distro's native iptables save. This is NOT a boot service: boot-time +# restore is handled by the native iptables/iptables-persistent/iptables-services +# unit that harden-firewall.sh enabled. Run this after changing /etc/firewall. +set -eu + +FW_DIR=/etc/firewall +PORTS_DIR="$FW_DIR/ports.d" +CONF="$FW_DIR/firewall.conf" + +# Declarative inputs (overridden by firewall.conf when present). +FW_SSH_PORT=22 +FW_ALLOW_PING=1 +FW_SSH_SOURCE= +# shellcheck disable=SC1090 +[ -r "$CONF" ] && . "$CONF" + +# SSH port: prefer the live sshd_config value so a port change is picked up; +# fall back to the conf value, then 22. +SSH_PORT="$(awk '/^[Pp]ort / {print $2; exit}' /etc/ssh/sshd_config 2>/dev/null || true)" +[ -n "${SSH_PORT:-}" ] || SSH_PORT="${FW_SSH_PORT:-22}" + +ssh_src="" +[ -n "${FW_SSH_SOURCE:-}" ] && ssh_src="-s $FW_SSH_SOURCE" + +# Rebuild one address family. $1 = binary, $2 = its ICMP protocol keyword. +# Order matters: open the policy, flush, add every ACCEPT, THEN drop -- so there +# is never a window where INPUT is DROP without the SSH/established rules, and +# the ESTABLISHED accept keeps the current SSH session alive across the rebuild. +apply_family() { + ipt="$1"; icmp="$2" + command -v "$ipt" >/dev/null 2>&1 || return 0 + + # Ensure the sshguard chain exists; NEVER flush it -- the sshguard daemon + # owns its contents (the live block list). + "$ipt" -N sshguard 2>/dev/null || true + + "$ipt" -P INPUT ACCEPT + "$ipt" -F INPUT + "$ipt" -A INPUT -i lo -j ACCEPT + "$ipt" -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + "$ipt" -A INPUT -m conntrack --ctstate INVALID -j DROP + + # ICMP. IPv6 needs NDP/MLD (and PMTUD) or the link breaks, so accept all + # ipv6-icmp. For IPv4 accept the essential diagnostics and, optionally, ping. + if [ "$icmp" = "ipv6-icmp" ]; then + "$ipt" -A INPUT -p ipv6-icmp -j ACCEPT + else + "$ipt" -A INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT + "$ipt" -A INPUT -p icmp --icmp-type time-exceeded -j ACCEPT + "$ipt" -A INPUT -p icmp --icmp-type parameter-problem -j ACCEPT + if [ "${FW_ALLOW_PING:-1}" = "1" ]; then + "$ipt" -A INPUT -p icmp --icmp-type echo-request \ + -m limit --limit 5/second --limit-burst 10 -j ACCEPT + fi + fi + + # SSH: hand NEW connections to sshguard first (it drops blocked sources), + # then accept. Both honour the optional source restriction. + # shellcheck disable=SC2086 + "$ipt" -A INPUT -p tcp --dport "$SSH_PORT" $ssh_src -j sshguard + # shellcheck disable=SC2086 + "$ipt" -A INPUT -p tcp --dport "$SSH_PORT" $ssh_src -m conntrack --ctstate NEW -j ACCEPT + + # Registered drop-in ports: "/ [source-cidr]" per line. + if [ -d "$PORTS_DIR" ]; then + for f in "$PORTS_DIR"/*.rule; do + [ -e "$f" ] || continue + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in ''|\#*) continue ;; esac + spec=$(printf '%s' "$line" | awk '{print $1}') + src=$(printf '%s' "$line" | awk '{print $2}') + port=${spec%%/*} + proto=${spec##*/} + [ "$proto" = "$spec" ] && proto=tcp + case "$proto" in tcp|udp) : ;; *) continue ;; esac + case "$port" in ''|*[!0-9:]*) continue ;; esac + srcarg="" + [ -n "$src" ] && srcarg="-s $src" + # shellcheck disable=SC2086 + "$ipt" -A INPUT -p "$proto" --dport "$port" $srcarg -j ACCEPT + done < "$f" + done + fi + + "$ipt" -P INPUT DROP +} + +apply_family iptables icmp +apply_family ip6tables ipv6-icmp +ENGINE + + # Append the native persist step (resolved for this distro, runs last). + { + echo '' + echo '# Persist the live ruleset so it survives reboot (native, per-distro).' + echo "$save_cmd" + } >> "$APPLY" + chmod 0755 "$APPLY" +} + +run_apply() { + [[ -x "$APPLY" ]] || die "Firewall engine missing ($APPLY). Run: bash $0 apply" + "$APPLY" +} + +ensure_installed() { + [[ -x "$APPLY" ]] || die "Firewall not installed. Run: bash $0 apply" + install -d -m 0755 "$PORTS_DIR" +} + +# ============================================================================ +# Subcommands +# ============================================================================ +cmd_apply() { + log "Detected OS: ${OS_ID} (family ${OS_FAMILY}, init ${INIT_SYSTEM})" + + # 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). + 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)." + fi + + log "Installing iptables + native persistence package..." + install_iptables + + write_conf + seed_open_ports + write_apply + fw_enable_restore + + log "Building and applying firewall rules (deny-by-default INPUT)..." + run_apply + + log "Firewall active. Allowed: loopback, established, ICMP$([[ $FW_ALLOW_PING == 1 ]] || echo ' (no ping)'), SSH/${SSH_PORT}${FW_SSH_SOURCE:+ from $FW_SSH_SOURCE}, + registered ports." + log "Add ports: ${APPLY##*/}-managed -> 'bash $0 allow ' Recover: bash $0 disable" +} + +cmd_allow() { + [[ "$#" -ge 1 ]] || die "usage: allow ..." + ensure_installed + local lines file="$PORTS_DIR/manual.rule" + lines="$(_expand_args "$@")" + touch "$file" + local l + while IFS= read -r l; do + [[ -n "$l" ]] || continue + grep -qxF "$l" "$file" 2>/dev/null || printf '%s\n' "$l" >> "$file" + done <<< "$lines" + log "Registered: $(printf '%s' "$lines" | tr '\n' ' ')" + run_apply + log "Applied." +} + +cmd_deny() { + [[ "$#" -ge 1 ]] || die "usage: deny ..." + ensure_installed + local lines l file tmp + lines="$(_expand_args "$@")" + while IFS= read -r l; do + [[ -n "$l" ]] || continue + for file in "$PORTS_DIR"/*.rule; do + [[ -e "$file" ]] || continue + tmp="$(mktemp)" + grep -vxF "$l" "$file" > "$tmp" 2>/dev/null || true + mv "$tmp" "$file" + done + done <<< "$lines" + # Drop any drop-in file we emptied. + for file in "$PORTS_DIR"/*.rule; do + [[ -e "$file" ]] || continue + [[ -s "$file" ]] || rm -f "$file" + done + log "Unregistered: $(printf '%s' "$lines" | tr '\n' ' ')" + run_apply + log "Applied." +} + +cmd_list() { + echo "Registered ports ($PORTS_DIR):" + if ls "$PORTS_DIR"/*.rule >/dev/null 2>&1; then + for f in "$PORTS_DIR"/*.rule; do + printf ' [%s]\n' "$(basename "$f")" + sed 's/^/ /' "$f" + done + else + echo " (none)" + fi + echo + echo "Live INPUT chain (iptables):" + iptables -S INPUT 2>/dev/null | sed 's/^/ /' || echo " (unavailable)" +} + +# 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() { + local ipt + for ipt in iptables ip6tables; do + command -v "$ipt" >/dev/null 2>&1 || continue + "$ipt" -P INPUT ACCEPT || true + "$ipt" -F INPUT || true + done + eval "$(fw_save_cmd)" + warn "Firewall disabled: INPUT policy ACCEPT, rules flushed and saved." + warn "Re-enable with: bash $0 apply" +} + +usage() { + sed -n '2,50p' "$0" | sed 's/^#\{0,1\} \{0,1\}//' +} + +# ============================================================================ +# Dispatch +# ============================================================================ +cmd="${1:-apply}" +case "$cmd" in + apply) cmd_apply ;; + allow) shift; cmd_allow "$@" ;; + deny|remove) shift; cmd_deny "$@" ;; + list|status) cmd_list ;; + disable) cmd_disable ;; + -h|--help|help) usage ;; + *) die "Unknown command '$cmd'. Use: apply | allow | deny | list | disable." ;; +esac diff --git a/scripts/harden-jumphost.sh b/scripts/harden-jumphost.sh index d329a6f..bdb80bc 100644 --- a/scripts/harden-jumphost.sh +++ b/scripts/harden-jumphost.sh @@ -297,8 +297,21 @@ PID_FILE=/run/sshguard.pid WHITELIST_FILE=${WHITELIST} EOF -HOOK=$(mktemp) -cat > "$HOOK" <<'EOF' +# INPUT -> sshguard jump. When the host firewall (harden-firewall.sh) is enabled +# it owns the whole INPUT chain -- including this jump -- and persists it via the +# distro's native iptables package, so we install the firewall and skip the +# standalone boot hook. Otherwise fall back to the minimal init-agnostic boot +# hook that just (re)inserts the jump at every boot. +: "${ENABLE_FIREWALL:=1}" +if [[ "$ENABLE_FIREWALL" == "1" && -f "$SCRIPT_DIR/harden-firewall.sh" ]]; then + log "Installing host firewall (deny-by-default INPUT; carries the sshguard jump)..." + SSH_PORT="$SSH_PORT" OPEN_PORTS="${OPEN_PORTS:-}" \ + FW_SSH_SOURCE="${FW_SSH_SOURCE:-}" FW_ALLOW_PING="${FW_ALLOW_PING:-1}" \ + FORCE=1 bash "$SCRIPT_DIR/harden-firewall.sh" apply \ + || warn "harden-firewall.sh failed; INPUT left unfiltered. Re-run it manually." +else + HOOK=$(mktemp) + cat > "$HOOK" <<'EOF' #!/bin/sh SSH_PORT=$(awk '/^Port / {print $2; exit}' /etc/ssh/sshd_config) SSH_PORT=${SSH_PORT:-22} @@ -309,8 +322,9 @@ for ipt in iptables ip6tables; do || $ipt -I INPUT -p tcp --dport "$SSH_PORT" -j sshguard done EOF -install_boot_hook sshguard-iptables "$HOOK" -rm -f "$HOOK" + install_boot_hook sshguard-iptables "$HOOK" + rm -f "$HOOK" +fi svc_enable_start sshguard || warn "Could not start sshguard; check sshguard.conf on this distro." diff --git a/scripts/harden-ssh.sh b/scripts/harden-ssh.sh index 8100a02..1393063 100644 --- a/scripts/harden-ssh.sh +++ b/scripts/harden-ssh.sh @@ -275,11 +275,21 @@ PID_FILE=/run/sshguard.pid WHITELIST_FILE=${WHITELIST} EOF -# Boot hook: ensure the sshguard chain exists and INPUT jumps to it for the -# SSH port. oslib installs this as an OpenRC local.d script or a systemd -# oneshot unit, depending on the init system. -HOOK=$(mktemp) -cat > "$HOOK" <<'EOF' +# INPUT -> sshguard jump. When the host firewall (harden-firewall.sh) is enabled +# it owns the whole INPUT chain -- including this jump -- and persists it via the +# distro's native iptables package, so we install the firewall and skip the +# standalone boot hook. Otherwise fall back to the minimal init-agnostic boot +# hook that just (re)inserts the jump at every boot. +: "${ENABLE_FIREWALL:=1}" +if [[ "$ENABLE_FIREWALL" == "1" && -f "$SCRIPT_DIR/harden-firewall.sh" ]]; then + log "Installing host firewall (deny-by-default INPUT; carries the sshguard jump)..." + SSH_PORT="$SSH_PORT" OPEN_PORTS="${OPEN_PORTS:-}" \ + FW_SSH_SOURCE="${FW_SSH_SOURCE:-}" FW_ALLOW_PING="${FW_ALLOW_PING:-1}" \ + FORCE=1 bash "$SCRIPT_DIR/harden-firewall.sh" apply \ + || warn "harden-firewall.sh failed; INPUT left unfiltered. Re-run it manually." +else + HOOK=$(mktemp) + cat > "$HOOK" <<'EOF' #!/bin/sh # Ensure sshguard chain exists and INPUT jumps to it for the SSH port. SSH_PORT=$(awk '/^Port / {print $2; exit}' /etc/ssh/sshd_config) @@ -291,8 +301,9 @@ for ipt in iptables ip6tables; do || $ipt -I INPUT -p tcp --dport "$SSH_PORT" -j sshguard done EOF -install_boot_hook sshguard-iptables "$HOOK" -rm -f "$HOOK" + install_boot_hook sshguard-iptables "$HOOK" + rm -f "$HOOK" +fi svc_enable_start sshguard || warn "Could not start sshguard; check 'sshguard.conf' on this distro." diff --git a/scripts/oslib.sh b/scripts/oslib.sh index e534941..86f0ffa 100644 --- a/scripts/oslib.sh +++ b/scripts/oslib.sh @@ -414,6 +414,59 @@ install_bruteforce_protection() { esac } +# ============================================================================ +# Host firewall -- native iptables persistence (NO boot hook). +# ============================================================================ +# Each family ships a package that saves the live ruleset to disk and restores +# it at boot. harden-firewall.sh builds the INPUT rules live, then persists via +# fw_save_cmd and enables boot restore via fw_enable_restore -- so the firewall +# survives reboot without any custom boot hook. +# +# family persistence package save target(s) restore svc +# alpine iptables ip6tables /etc/iptables/rules{,6}-save iptables, ip6tables +# debian iptables-persistent /etc/iptables/rules.v{4,6} netfilter-persistent +# rhel iptables-services /etc/sysconfig/{,ip6}tables iptables, ip6tables + +# Install iptables + the family's native persistence package. +install_iptables() { + _require_detected + case "$OS_FAMILY" in + alpine) pkg_install iptables ip6tables ;; + debian) + # iptables-persistent's postinst asks (debconf) whether to save the + # CURRENT rules on install. Preseed "no" so it never blocks on a + # prompt; harden-firewall.sh saves explicitly once its rules are up. + echo 'iptables-persistent iptables-persistent/autosave_v4 boolean false' | debconf-set-selections 2>/dev/null || true + echo 'iptables-persistent iptables-persistent/autosave_v6 boolean false' | debconf-set-selections 2>/dev/null || true + pkg_install iptables iptables-persistent ;; + rhel) pkg_install iptables-services ;; + esac +} + +# Echo the family's native "persist the live ruleset to disk" command. This is +# baked verbatim into the generated /usr/local/sbin/firewall-apply so the saved +# rules survive reboot even on a host that never sees this repo again. +fw_save_cmd() { + _require_detected + case "$OS_FAMILY" in + alpine) echo 'rc-service iptables save >/dev/null 2>&1 || true; rc-service ip6tables save >/dev/null 2>&1 || true' ;; + debian) echo 'netfilter-persistent save >/dev/null 2>&1 || true' ;; + rhel) echo 'iptables-save > /etc/sysconfig/iptables 2>/dev/null || true; ip6tables-save > /etc/sysconfig/ip6tables 2>/dev/null || true' ;; + esac +} + +# Enable the family's native boot-time restore service(s). Rules are already +# live when this runs, so we only need them re-applied on the NEXT boot -- +# enable, don't start. +fw_enable_restore() { + _require_detected + case "$OS_FAMILY" in + alpine) svc_enable iptables; svc_enable ip6tables ;; + debian) svc_enable netfilter-persistent ;; + rhel) svc_enable iptables; svc_enable ip6tables ;; + esac +} + # ============================================================================ # gum (Charm TUI) -- multi-OS installer. Best-effort; callers that can fall # back to CLI prompts should not treat failure as fatal.