#!/usr/bin/env bash
#
# THE SSRF BOUNDARY. Read this before deciding it is optional.
#
# The worker spawns `nextjs-seo-prerender` against a URL a stranger typed into a
# public form. `lib/public-target.ts` refuses the obvious attempts, but it says
# so itself: it cannot be the boundary. The binary follows redirects, so
# `https://evil.test/` may answer 302 to `http://169.254.169.254/…` and nothing
# we resolved ever saw that address; and a DNS record with a one-second TTL that
# alternates between a public address and 127.0.0.1 defeats any check-then-use
# by construction.
#
# What actually holds is a packet filter the process cannot talk its way past.
# On a systemd deployment that would be `IPAddressDeny=` on the unit. In Docker
# the equivalent lives in the `DOCKER-USER` chain, which the Docker daemon
# consults *before* its own FORWARD rules and never rewrites — so rules put here
# survive `docker compose up`, container restarts and image rebuilds, which
# rules in FORWARD do not.
#
# DOCKER-USER only sees packets the host *routes*, which is not everything the
# worker can send. A packet addressed to one of the host's own addresses —
# including its public one, which a redirect can name — is delivered locally and
# meets INPUT instead, never FORWARD. So this script writes in both chains: the
# private ranges are dropped on the way out, and everything from the compose
# bridge to the host itself is dropped on the way in. Without the second rule
# `302 -> http://<the host's public IP>:3000/` reaches the Dokploy panel, and any
# service the host binds on 0.0.0.0, from a container that is supposed to be able
# to reach the public internet and nothing of ours.
#
# Effect: the worker's network may reach the public internet and nothing else.
# It cannot reach the host, the other containers' subnets, the cloud metadata
# endpoint, or anything else on the private side of the router.
#
#   sudo ./egress-rules.sh
#
# Postgres is not affected, but not for the reason one would like. Traffic
# between two containers on the same bridge is supposedly switched rather than
# forwarded — except that Docker turns on `br_netfilter` by default, which is
# how `icc=false` works at all, and with it bridged traffic does walk FORWARD.
# The compose subnets live in 172.16/12, which the rules below drop. So the
# database survives because of an explicit same-bridge RETURN above those drops,
# not because the kernel was not looking.
#
# Its price, stated plainly: the worker can still reach Postgres and the
# dashboard, because they share a network. A redirect to one of them is not
# blocked here. Nothing on that bridge answers to an unauthenticated stranger.
#
# PERSISTENCE. iptables rules die with the host. Pick one:
#
#   systemd is the one to pick. `netfilter-persistent save` freezes the bridge
#   name into the saved rules, and `docker compose down && up` gives the network
#   a new id: the rules come back at boot naming an interface that no longer
#   exists, match nothing, and still show up in `iptables -L`. A barrier that
#   looks present and holds nothing is worse than none.
#
#   systemd anywhere:  install this script at /usr/local/sbin/egress-rules.sh and
#                      add a unit that runs it after docker.service:
#
#     # /etc/systemd/system/egress-rules.service
#     [Unit]
#     Description=Deny the audit worker the private internet
#     After=docker.service
#     Requires=docker.service
#     [Service]
#     Type=oneshot
#     RemainAfterExit=yes
#     ExecStart=/usr/local/sbin/egress-rules.sh
#     [Install]
#     WantedBy=multi-user.target
#
#     sudo systemctl enable --now egress-rules
#
# Verify after any deploy, both chains — a run that found no bridge writes the
# first and skips the second:
#   sudo iptables -L DOCKER-USER -n --line-numbers
#   sudo iptables -L INPUT -n --line-numbers | grep seoinsight-egress
# and from inside the worker, the metadata endpoint and the host in turn:
#   docker compose exec worker sh -c \
#     'wget -T3 -qO- http://169.254.169.254/ || echo "metadata blocked"'
#   docker compose exec worker sh -c \
#     'wget -T3 -qO- http://$(ip route | awk "/default/ {print \$3}"):3000/ \
#      || echo "host blocked"'

set -euo pipefail

if [[ ${EUID} -ne 0 ]]; then
  echo "egress-rules.sh: needs root — the DOCKER-USER chain is a kernel table." >&2
  exit 1
fi

# The compose network. `docker network inspect` names it after the project
# directory; override with WORKER_NET=... if you renamed the project.
WORKER_NET="${WORKER_NET:-seoinsight-dashboard_default}"

# Every range that is not the public internet. 169.254.0.0/16 is the one that
# matters most: it holds the cloud metadata endpoint, the address that turns an
# SSRF into stolen instance credentials.
PRIVATE_V4=(
  10.0.0.0/8
  172.16.0.0/12
  192.168.0.0/16
  127.0.0.0/8
  169.254.0.0/16
  100.64.0.0/10
  0.0.0.0/8
  224.0.0.0/4
  240.0.0.0/4
)
PRIVATE_V6=(
  fc00::/7
  ::1/128
  fe80::/10
)

# Resolve the bridge interface for the compose network. Docker names it
# br-<first 12 of the network id>; if the network does not exist yet (first
# deploy), fall back to filtering by source subnet only.
#
# Not finding it is fatal, and used not to be. Rules that cannot name an
# interface apply to every bridge on the host — on a Dokploy box that is every
# other project's traffic, and the systemd unit below would reapply that at each
# boot the network happened to be missing. A rule that cannot be scoped should
# not exist.
if ! NET_ID=$(docker network inspect -f '{{.Id}}' "${WORKER_NET}" 2>/dev/null); then
  echo "egress-rules.sh: network ${WORKER_NET} not found." >&2
  echo "  Bring the stack up once so its bridge exists, then run this again." >&2
  echo "  Renamed the compose project? Pass WORKER_NET=<network>." >&2
  exit 1
fi
BRIDGE="br-${NET_ID:0:12}"
echo "egress-rules.sh: filtering ${WORKER_NET} on ${BRIDGE}"

# Drop our previous rules before adding them back, so re-running after a deploy
# does not stack duplicates.
#
# By line number, read back from the chain. Deleting by specification looks
# tidier and silently does nothing: `-D` demands a rule that matches in every
# detail, and the earlier version omitted the `-i` and `-d` the rules carry — so
# it never matched, the loop ended on its first turn, and each run left twelve
# more rules behind than it found.
flush_ours() {
  local ipt=$1 chain line
  for chain in DOCKER-USER INPUT; do
    while :; do
      # The comment as iptables prints it, delimiters included: INPUT is a
      # shared chain and a substring match would happily delete somebody else's
      # `seoinsight-egress-v2`. `|| true` because awk's `exit` closes the pipe
      # under the writer, and `pipefail` would call that a failure.
      line=$(${ipt} -w -L "${chain}" --line-numbers -n 2>/dev/null |
        awk '/\/\* seoinsight-egress \*\//{ print $1; exit }') || true
      [[ -z ${line} ]] && break
      ${ipt} -w -D "${chain}" "${line}"
    done
  done
}

# -I, not -A, everywhere below: DOCKER-USER ends in a RETURN that hands the
# packet back to Docker's own ACCEPT rules, so an appended rule is never
# reached. Insertion order is therefore reversed on the way out — the RETURNs go
# in last so they sit above the DROPs.

add_rule() {
  local ipt=$1 cidr=$2
  ${ipt} -w -I DOCKER-USER -i "${BRIDGE}" -d "${cidr}" \
    -m comment --comment "seoinsight-egress" -j DROP
}

# The two exemptions that keep the deployment alive.
allow_ours() {
  local ipt=$1
  # This bridge to itself. The dashboard and the worker reach Postgres here, and
  # `br_netfilter` — on by default — sends that traffic through FORWARD, where
  # the 172.16/12 drop above would take the first SYN and the whole stack would
  # lose its database the second this script ran.
  ${ipt} -w -I DOCKER-USER -i "${BRIDGE}" -o "${BRIDGE}" \
    -m comment --comment "seoinsight-egress" -j RETURN
  # Replies to connections opened from outside. A flow whose first packet was
  # dropped never reaches ESTABLISHED, so this gives the worker nothing — but
  # without it a proxy on another bridge gets its SYN through and never sees the
  # answer, which reads as the site being down.
  ${ipt} -w -I DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED \
    -m comment --comment "seoinsight-egress" -j RETURN
}

# The host itself, which DOCKER-USER never sees.
deny_host() {
  local ipt=$1
  # `! --ctstate ESTABLISHED,RELATED` because this rule goes in at position one,
  # ahead of whatever the distribution accepts for established flows: without it
  # the answer to a `curl localhost:3000` from the host dies here — the first
  # thing anyone does after a deploy.
  ${ipt} -w -I INPUT -i "${BRIDGE}" -m conntrack ! --ctstate ESTABLISHED,RELATED \
    -m comment --comment "seoinsight-egress" -j DROP
}

for ipt in iptables ip6tables; do
  command -v "${ipt}" >/dev/null || { echo "egress-rules.sh: ${ipt} missing, skipping." >&2; continue; }

  # The chain exists only once Docker has started at least once.
  ${ipt} -w -N DOCKER-USER 2>/dev/null || true
  flush_ours "${ipt}"

  # One family per pass, so a host without ip6tables loses the v6 rules and
  # keeps every v4 one — rather than dying between the drops and the exemptions.
  if [[ ${ipt} == iptables ]]; then
    for cidr in "${PRIVATE_V4[@]}"; do add_rule "${ipt}" "${cidr}"; done
  else
    for cidr in "${PRIVATE_V6[@]}"; do add_rule "${ipt}" "${cidr}"; done
  fi

  allow_ours "${ipt}"
  deny_host "${ipt}"
done

echo "egress-rules.sh: applied. The worker can reach the public internet, this"
echo "  bridge, and nothing else."
echo "Remember to make this persistent — see the header of this file."
