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
This commit is contained in:
@@ -92,10 +92,11 @@ 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`. |
|
||||
| [`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=<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.
|
||||
- **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
|
||||
|
||||
@@ -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/<name>/` instead
|
||||
@@ -20,7 +20,7 @@ use the per-deployment `cloud-init.yml` under `deployments/<name>/` 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.
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" <<EOF
|
||||
# Generated by harden-firewall.sh -- $(date -u +%FT%TZ)
|
||||
# Read by ${APPLY} on every run. After editing, re-apply with: ${APPLY##*/}
|
||||
FW_SSH_PORT="${SSH_PORT}"
|
||||
FW_ALLOW_PING="${FW_ALLOW_PING}"
|
||||
FW_SSH_SOURCE="${FW_SSH_SOURCE}"
|
||||
EOF
|
||||
chmod 0644 "$CONF"
|
||||
}
|
||||
|
||||
# OPEN_PORTS env -> 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: "<port>/<proto> [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 <port/proto>' Recover: bash $0 disable"
|
||||
}
|
||||
|
||||
cmd_allow() {
|
||||
[[ "$#" -ge 1 ]] || die "usage: allow <port[/proto][@cidr]|web|http|https>..."
|
||||
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 <port[/proto][@cidr]|web|http|https>..."
|
||||
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
|
||||
@@ -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."
|
||||
|
||||
|
||||
+18
-7
@@ -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."
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user