← All four scripts

harden-2-firewall.sh

ufw ingress plus per-uid egress control. Can lock you out, so it arms an automatic rollback before it changes anything.

Read this, do not pipe it. These scripts run as root. Copy the source into a file on your own machine, read it, and run it yourself. Nobody should curl a root shell script straight off a website, including this one.

#!/usr/bin/env bash # harden-2-firewall.sh — ufw ingress + per-uid egress control. # # ⚠️ THIS CAN LOCK YOU OUT. It arms an automatic rollback first: if you lose # access, `ufw disable` fires after ROLLBACK_MIN minutes and you are back in. # # sudo DRY_RUN=1 ./harden-2-firewall.sh # read it first # sudo ./harden-2-firewall.sh # # BEFORE RUNNING: # 1. Confirm your provider's web/VNC console works. That is your real escape # hatch, and the only one that survives a firewall mistake. # 2. Keep this session open. Verification happens in a SECOND terminal. # 3. If TAILSCALE_ONLY_SSH=1, verify `tailscale status` is healthy first. cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 # shellcheck source=lib.sh source ./lib.sh need_root ROLLBACK_MIN="${ROLLBACK_MIN:-10}" TAILSCALE_ONLY_SSH="${TAILSCALE_ONLY_SSH:-0}" # 0 = keep 22 open to the world TS_NET="100.64.0.0/10" # Tailscale CGNAT range STRICT_WP_EGRESS="${STRICT_WP_EGRESS:-0}" # 1 = deny, 0 = log only # Accounts that run agents or agent-adjacent code, space separated. Set this to # your own service accounts; empty means the egress rules below are skipped. # Egress is LOG-ONLY by default on purpose: an agent's real destination list is # not knowable up front, and a guessed allowlist either breaks it or is too # permissive to be worth having. Run log-only for a week, read the AGENT-EGRESS # lines out of the journal, then set STRICT_AGENT_EGRESS=1. AGENT_USERS="${AGENT_USERS:-}" STRICT_AGENT_EGRESS="${STRICT_AGENT_EGRESS:-0}" # 1 = deny, 0 = log only head1 "Pre-flight" ensure_pkg ufw command -v iptables >/dev/null || { err "iptables missing"; exit 1; } if [ "$TAILSCALE_ONLY_SSH" = "1" ]; then if ! command -v tailscale >/dev/null 2>&1; then err "TAILSCALE_ONLY_SSH=1 but tailscale is not installed. Refusing."; exit 1 fi if ! tailscale status >/dev/null 2>&1; then err "TAILSCALE_ONLY_SSH=1 but 'tailscale status' fails. Refusing."; exit 1 fi ts_ip=$(tailscale ip -4 2>/dev/null | head -1) [ -n "$ts_ip" ] || { err "no Tailscale IPv4. Refusing."; exit 1; } ok "Tailscale up at $ts_ip" warn "SSH will be restricted to $TS_NET. Rollback armed for ${ROLLBACK_MIN} min." else info "SSH stays open to the world (safe default). Re-run with" info " TAILSCALE_ONLY_SSH=1 once Tailscale is verified." fi # The IPv6 trap: IPV6=no means ufw does not manage ip6tables AT ALL. That is only # safe if IPv6 is also down at the kernel. Refuse the dangerous combination. v6_disabled=$(sysctl -n net.ipv6.conf.all.disable_ipv6 2>/dev/null || echo 0) if [ "$v6_disabled" = "1" ]; then ensure_kv /etc/default/ufw IPV6 no ok "IPv6 down at the kernel; ufw IPV6=no is consistent" else ensure_kv /etc/default/ufw IPV6 yes warn "IPv6 is UP, so ufw must manage it. IPV6=yes set." warn " Every rule below is applied to v4 AND v6 by ufw." fi head1 "Arming rollback" if [ "$DRY_RUN" != "1" ]; then rollback_arm fw-harden-rollback "$ROLLBACK_MIN" /usr/sbin/ufw --force disable else printf '%s[dry-run]%s would arm rollback\n' "$YLW" "$RST" fi head1 "Ingress rules" run "default deny incoming" ufw --force default deny incoming >/dev/null run "default allow outgoing" ufw --force default allow outgoing >/dev/null run "default deny routed" ufw --force default deny routed >/dev/null # Public web. The only two ports the internet may reach. run "allow 80/tcp" ufw allow 80/tcp >/dev/null run "allow 443/tcp" ufw allow 443/tcp >/dev/null if [ "$TAILSCALE_ONLY_SSH" = "1" ]; then run "allow 22 from Tailscale" ufw allow from "$TS_NET" to any port 22 proto tcp >/dev/null run "allow 80/443 from Tailscale" ufw allow from "$TS_NET" to any port 443 proto tcp >/dev/null ufw delete allow 22/tcp >/dev/null 2>&1 || true ufw delete allow OpenSSH >/dev/null 2>&1 || true did "removed public SSH" else run "allow 22/tcp (public, temporary)" ufw allow 22/tcp >/dev/null fi run "enable ufw" ufw --force enable >/dev/null head1 "Egress control" # Deliberately NOT a global default-deny egress: that breaks package updates and # every third-party API, and fails in ways that are hard to spot. Instead: # targeted per-uid rules plus two blanket blocks that are safe here. # # Everything lives in ufw's own after.rules and the whole block is REGENERATED on # every run. Two reasons: # - ufw is then the single thing restoring rules at boot. Raw `iptables -A` # rules need iptables-persistent, and two rule managers restoring at boot is # how you get duplicated or stale chains. # - regenerating means uid rules stay in sync as users are added or removed. # # ⚠️ THE BLOCK BELOW MUST STAY PURE ASCII. When ufw changes a default policy it # rewrites after.rules line by line through an ascii codec (see # backend_iptables.py set_default_policy). One box-drawing character or em dash # in here makes `ufw default deny incoming` die with UnicodeEncodeError on every # run after the first. EGRESS_RULES=/etc/ufw/after.rules BEGIN_MARK='# --- HARDEN-EGRESS (harden-2-firewall.sh) ---' END_MARK='# --- end HARDEN-EGRESS ---' # Match loosely on delete, so blocks written by older versions of this script are # cleaned up too: earlier releases used box-drawing characters and a differently # named chain. Leaving one behind would keep its rules live via a stale # `-A OUTPUT -j <old chain>` while you read the new block and assume it is all # that applies. Both patterns therefore match any *-EGRESS block this script # has ever written. BEGIN_PAT='EGRESS (harden-2-firewall.sh)' END_PAT='end .*EGRESS' # Per-uid rules are built first so they land INSIDE the persistent block. uid_rules="" # bitcoind: DNS seeds + the p2p port, nothing else. Safe and well understood. if id bitcoin >/dev/null 2>&1; then btc_uid=$(id -u bitcoin) uid_rules="${uid_rules} # bitcoind: DNS seeds + the p2p port, nothing else. # TRAP: anything else running as this uid that needs the internet fails SILENTLY. -A HARDEN-EGRESS -m owner --uid-owner ${btc_uid} -p udp --dport 53 -j RETURN -A HARDEN-EGRESS -m owner --uid-owner ${btc_uid} -p tcp --dport 53 -j RETURN -A HARDEN-EGRESS -m owner --uid-owner ${btc_uid} -p tcp --dport 8333 -j RETURN -A HARDEN-EGRESS -m owner --uid-owner ${btc_uid} -j REJECT --reject-with icmp-port-unreachable " info "bitcoin uid ${btc_uid} restricted to DNS + 8333" else ok "no 'bitcoin' user yet, skipping node egress rules" fi # Agent accounts. See AGENT_USERS above for why this is log-only by default. # The threat: an injected agent that can reach any host can exfiltrate anything it # can read. Constraining egress is the cheapest way to remove that leg, but only # once the real destination list is known rather than guessed. for u in $AGENT_USERS; do id "$u" >/dev/null 2>&1 || continue a_uid=$(id -u "$u") uid_rules="${uid_rules} # agent: ${u} -A HARDEN-EGRESS -m owner --uid-owner ${a_uid} -p udp --dport 53 -j RETURN -A HARDEN-EGRESS -m owner --uid-owner ${a_uid} -p tcp --dport 53 -j RETURN -A HARDEN-EGRESS -m owner --uid-owner ${a_uid} -p tcp --dport 443 -j RETURN " if [ "$STRICT_AGENT_EGRESS" = "1" ]; then uid_rules="${uid_rules}-A HARDEN-EGRESS -m owner --uid-owner ${a_uid} -j REJECT --reject-with icmp-port-unreachable " info "agent $u egress restricted to DNS + 443" else uid_rules="${uid_rules}-A HARDEN-EGRESS -m owner --uid-owner ${a_uid} -j LOG --log-prefix \"AGENT-EGRESS \" " info "agent $u egress LOGGED (set STRICT_AGENT_EGRESS=1 to enforce)" fi done # WordPress pool users. Default LOG-ONLY, because a hard deny on 443 breaks # WooCommerce payment-gateway callbacks and SMTP-plugin order emails. Watch the # logs first, then flip STRICT_WP_EGRESS=1 once you know the real destinations. for u in $(awk -F: '$1 ~ /^wp-/ {print $1}' /etc/passwd 2>/dev/null); do wp_uid=$(id -u "$u") uid_rules="${uid_rules} # ${u} -A HARDEN-EGRESS -m owner --uid-owner ${wp_uid} -p udp --dport 53 -j RETURN -A HARDEN-EGRESS -m owner --uid-owner ${wp_uid} -p tcp --dport 443 -j RETURN " if [ "$STRICT_WP_EGRESS" = "1" ]; then uid_rules="${uid_rules}-A HARDEN-EGRESS -m owner --uid-owner ${wp_uid} -j REJECT --reject-with icmp-port-unreachable " info "$u egress restricted to DNS + 443" else uid_rules="${uid_rules}-A HARDEN-EGRESS -m owner --uid-owner ${wp_uid} -j LOG --log-prefix \"WP-EGRESS \" " info "$u egress LOGGED (set STRICT_WP_EGRESS=1 to enforce)" fi done egress_block="${BEGIN_MARK} # Outbound restrictions. Ordered: allow what is needed, then reject. # REGENERATED by harden-2-firewall.sh. Edit the script, not this block. # ASCII ONLY. ufw rewrites this file through an ascii codec. *filter :HARDEN-EGRESS - [0:0] -A OUTPUT -j HARDEN-EGRESS # Never break loopback or already-established flows. -A HARDEN-EGRESS -o lo -j RETURN -A HARDEN-EGRESS -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN # Block outbound SMTP from everything. A compromised web app becoming a spam # relay is the single most common post-exploitation outcome. If your mail leaves # over a vendor HTTPS API rather than local SMTP, as most now does, this costs # nothing. Drop this rule if anything on the box genuinely sends over port 25. -A HARDEN-EGRESS -p tcp --dport 25 -j REJECT --reject-with icmp-port-unreachable # Block DNS-over-TLS so agents cannot bypass the logging resolver (DNS is the # exfil channel people forget to close). -A HARDEN-EGRESS -p tcp --dport 853 -j REJECT --reject-with icmp-port-unreachable -A HARDEN-EGRESS -p udp --dport 853 -j REJECT --reject-with icmp-port-unreachable ${uid_rules} COMMIT ${END_MARK}" if [ "$DRY_RUN" = "1" ]; then printf '%s[dry-run]%s would regenerate HARDEN-EGRESS in %s (%s bytes)\n' \ "$YLW" "$RST" "$EGRESS_RULES" "${#egress_block}" else [ -f "${EGRESS_RULES}.bak" ] || cp -a "$EGRESS_RULES" "${EGRESS_RULES}.bak" cp -a "$EGRESS_RULES" "${EGRESS_RULES}.prev" sed -i "\|${BEGIN_PAT}|,\|${END_PAT}|d" "$EGRESS_RULES" printf '\n%s\n' "$egress_block" >> "$EGRESS_RULES" if LC_ALL=C grep -qP '[^\x00-\x7F]' "$EGRESS_RULES"; then err "after.rules contains non-ASCII; ufw would break. Restoring." cp -a "${EGRESS_RULES}.prev" "$EGRESS_RULES"; exit 1 fi # Validate before ufw loads it. A malformed after.rules takes the firewall # down with it, which on a tailnet-only box means no way back in over SSH. if iptables-restore --test --noflush < "$EGRESS_RULES"; then did "regenerated HARDEN-EGRESS in after.rules" run "reload ufw (loads HARDEN-EGRESS)" ufw reload >/dev/null else err "after.rules failed validation — restoring previous, NOT reloading" cp -a "${EGRESS_RULES}.prev" "$EGRESS_RULES" exit 1 fi rm -f "${EGRESS_RULES}.prev" fi head1 "Verify" ufw status verbose 2>/dev/null | sed 's/^/ /' if [ "$DRY_RUN" = "1" ]; then summary; exit 0; fi if confirm_still_connected "Open a NEW ssh session to this box. It must succeed."; then rollback_disarm fw-harden-rollback ok "firewall active and confirmed (rules persist via ufw's after.rules)" else warn "NOT confirmed. Doing nothing — rollback fires in <=${ROLLBACK_MIN} min." warn " To undo right now from the VNC console: ufw --force disable" fi summary cat <<EOF Next: ${BLU}./harden-3-ssh.sh${RST} (also has a rollback timer). Once Tailscale is verified, re-run this with TAILSCALE_ONLY_SSH=1 to close public SSH. EOF