← 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=""
agent_lo_log=""
# Loopback handling. Default: a blanket pass.
# A blanket `-o lo -j RETURN` is the obvious rule and it is what this script used
# to emit, but it quietly defeats the whole point: a restricted uid could still
# reach MariaDB, an app API or another daemon's RPC over 127.0.0.1, which is the
# lateral movement the chain exists to stop. Verified reachable on a real box,
# 2026-08-24. When there is a node uid to restrict, the pass is narrowed for THAT
# uid.
#
# Read that last sentence literally, because it is the limit of what this does.
# The pass is narrowed for the node uid and for nobody else. Every other account
# on the box, including the agent accounts below, keeps an unrestricted loopback
# leg and can still dial any 127.0.0.1 service. Closing that for an agent account
# needs its real destination list first, which is what the GW-LO log rule further
# down is for. Do not read the scoped rule as "loopback is closed".
lo_rules="-A HARDEN-EGRESS -o lo -j RETURN"
# 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)
# Loopback for everyone EXCEPT bitcoind; bitcoind gets only its own RPC port,
# because bitcoin-cli runs as this uid and would otherwise stop working.
# Replies to inbound RPC are covered by the ESTABLISHED rule that follows.
lo_rules="-A HARDEN-EGRESS -o lo -m owner ! --uid-owner ${btc_uid} -j RETURN
-A HARDEN-EGRESS -o lo -m owner --uid-owner ${btc_uid} -p tcp --dport 8332 -j RETURN"
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")
# Measure the loopback leg. Non-terminating, so it changes no packet's fate:
# it only names the 127.0.0.1 destinations this account actually dials. That
# list is the missing input for closing the leg, and it cannot be written from
# today's `ss -tlnp` output because some loopback listeners bind a port out of
# the ephemeral range and move on every restart. Log the DESTINATION port, the
# one belonging to the listener, and ignore the source port.
# ctstate NEW keeps it to one line per connection instead of one per packet.
# The limit is a disk airbag against a runaway loop, not a sampling choice;
# raise it if a real destination list is being truncated.
agent_lo_log="${agent_lo_log}
-A HARDEN-EGRESS -o lo -m owner --uid-owner ${a_uid} -m conntrack --ctstate NEW -m limit --limit 60/minute --limit-burst 200 -j LOG --log-prefix \"GW-LO \" --log-level 4"
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]
# NOTE: the jump into OUTPUT is deliberately NOT here. It is installed by
# /etc/ufw/after.init, written further down this script.
#
# WHY: '-A OUTPUT -j HARDEN-EGRESS' appends the jump LAST, and ufw's own
# 'ufw-track-output' sits earlier in OUTPUT and ACCEPTs every new TCP/UDP
# connection, which ends traversal. The chain is then never reached. On the box
# this was found, it had blocked 0 packets in its entire lifetime while looking
# perfectly correct in 'iptables -L'. The rule must be INSERTED at the top, and
# an iptables-restore file cannot insert-without-duplicating across reloads.
# WHAT THIS CHAIN DOES NOT DO. It has no terminating rule. It runs off the end,
# and running off the end of a user-defined chain means RETURN, so anything not
# explicitly REJECTed above continues into ufw and is accepted. That is on
# purpose here (see the note above about not making egress default-deny), but it
# has a consequence worth stating plainly: every '-j RETURN' below is
# documentation of an intent, not an enforced permission, and every '-j LOG' is
# measurement, not enforcement. The only accounts actually constrained are the
# ones that end in a catch-all REJECT. Check which those are with
# 'iptables -L HARDEN-EGRESS -n -v' and read the counters, not the comments.
${agent_lo_log}
# Never break already-established flows, or loopback for unrestricted users.
${lo_rules}
-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"
# Install the OUTPUT jump via ufw's after.init hook. This must exist BEFORE
# the reload below, or the chain is loaded but never reached.
# after.init is called by ufw_start (ufw-init-functions), and TWICE per
# `ufw reload`, so it has to be idempotent: delete every existing jump, then
# insert one at the top. Note ufw ships this file NON-executable, so it has
# never run on a stock box; the chmod is what arms it.
cat > /etc/ufw/after.init <<'AFTERINIT'
#!/bin/sh
# Installs the HARDEN-EGRESS jump at the TOP of OUTPUT.
# See harden-2-firewall.sh for why this cannot live in after.rules.
# `set -e` plus any failing command here takes the WHOLE firewall down, so
# every command is guarded and the script always exits 0.
set -e
IPT=/usr/sbin/iptables
drop_jumps() {
i=0
while [ $i -lt 10 ] && $IPT -C OUTPUT -j HARDEN-EGRESS 2>/dev/null; do
$IPT -D OUTPUT -j HARDEN-EGRESS 2>/dev/null || break
i=$((i+1))
done
}
case "$1" in
start) drop_jumps || true
if $IPT -L HARDEN-EGRESS -n >/dev/null 2>&1; then
$IPT -I OUTPUT 1 -j HARDEN-EGRESS 2>/dev/null || true
fi ;;
stop) drop_jumps || true ;;
flush-all) drop_jumps || true ;;
status) $IPT -L HARDEN-EGRESS -n -v --line-numbers 2>/dev/null || true ;;
*) echo "Usage: after.init {start|stop|flush-all|status}" ;;
esac
exit 0
AFTERINIT
chown root:root /etc/ufw/after.init
chmod 0750 /etc/ufw/after.init
did "installed /etc/ufw/after.init (inserts the jump at the top of OUTPUT)"
run "reload ufw (loads HARDEN-EGRESS)" ufw reload >/dev/null
# Prove the chain is REACHED, not merely present. This is the check whose
# absence hid the bug: `iptables -L` showed a perfect chain that no packet
# ever entered. Position matters more than content here.
if iptables -L OUTPUT -n --line-numbers 2>/dev/null \
| awk '$2 == "HARDEN-EGRESS" {print $1; found=1} END{exit !found}' \
| grep -qx 1; then
did "HARDEN-EGRESS is at OUTPUT position 1 (it will actually fire)"
else
err "HARDEN-EGRESS is NOT first in OUTPUT. It will not fire. Investigate."
fi
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