#!/usr/bin/env bash
# Generated by abox {{ abox_version }} for project "{{ project }}" — DO NOT EDIT.
# Rendered from agentbox.yaml; the authoritative copy lives outside the workspace
# so the agent cannot rewrite the rules that constrain it.
#
# Model: default-deny egress. Only these leave the container:
#   - loopback (which is also how DNS reaches Docker's embedded resolver)
#   - established/related return traffic
#   - tcp/{{ gateway_port }} to the profile gateway
#   - tcp/{{ egress_ports|join(",") }} to addresses resolved from the allowlist
# Everything else hits a counted DROP whose counters abox harvests at teardown.

set -euo pipefail

LOG_DIR=/var/log/abox
MARKER="${LOG_DIR}/firewall-ok"
DNS_LOG="${LOG_DIR}/dns.log"
FW_LOG="${LOG_DIR}/firewall.log"
ALLOW_SET=abox-allow
EGRESS_PORTS="{{ egress_ports|join(",") }}"
GATEWAY_HOST="{{ gateway_host }}"
GATEWAY_PORT="{{ gateway_port }}"
REFRESH_INTERVAL="{{ refresh_interval }}"
UPSTREAM_DNS="{{ upstream_dns }}"

ALLOW_DOMAINS=(
{%- for domain in egress %}
  "{{ domain }}"
{%- endfor %}
)

mkdir -p "${LOG_DIR}"
rm -f "${MARKER}"
exec > >(tee -a "${FW_LOG}") 2>&1
echo "== abox firewall $(date -u +%Y-%m-%dT%H:%M:%SZ) =="

if [[ "$(id -u)" -ne 0 ]]; then
  echo "FATAL: must run as root — abox execs this through the Docker socket" >&2
  exit 1
fi

have() { command -v "$1" >/dev/null 2>&1; }

if ! have iptables; then
  echo "FATAL: iptables missing — the image did not provision the firewall tooling" >&2
  exit 1
fi

# ---------------------------------------------------------------- dnsmasq --
# dnsmasq is the container's only resolver so that every name the agent looks up
# is recorded, including the ones the firewall then refuses to route.
setup_dns() {
  if ! have dnsmasq; then
    echo "WARN: dnsmasq missing — DNS queries will not be logged" >&2
    return 1
  fi
  install -d -m 0755 /etc/dnsmasq.d
  {
    echo "# generated by abox"
    echo "port=53"
    echo "listen-address=127.0.0.1"
    echo "bind-interfaces"
    echo "no-resolv"
    echo "no-hosts"
    echo "log-queries"
    echo "log-facility=${DNS_LOG}"
    echo "cache-size=0"
{%- if scoped_dns %}
    # Forward ONLY names the allowlist covers; everything else gets NXDOMAIN.
    # Without this, dnsmasq resolves arbitrary names, which is a low-bandwidth
    # exfiltration channel that survives default-deny egress: the query itself
    # carries the data. Queries are still logged either way, so the review
    # queue keeps seeing what the agent asked for.
    echo "server=/${GATEWAY_HOST}/${UPSTREAM_DNS}"
{%- for domain in egress %}
    echo "server=/{{ domain }}/${UPSTREAM_DNS}"
{%- endfor %}
    echo "address=/#/"
{%- else %}
    echo "server=${UPSTREAM_DNS}"
{%- endif %}
  } >/etc/dnsmasq.conf
  : >"${DNS_LOG}"
  chmod 0644 "${DNS_LOG}"
  pkill -x dnsmasq >/dev/null 2>&1 || true
  dnsmasq --conf-file=/etc/dnsmasq.conf
  # Point the container at it. /etc/resolv.conf is bind-mounted by Docker, so
  # rewrite in place rather than replacing the inode.
  printf 'nameserver 127.0.0.1\noptions ndots:0\n' >/etc/resolv.conf
  echo "dns: dnsmasq up, upstream ${UPSTREAM_DNS}, queries -> ${DNS_LOG}"
}

resolver_for_lookup() {
  # Resolve through the upstream directly: the allowlist must be resolvable even
  # if dnsmasq failed to start.
  echo "${UPSTREAM_DNS}"
}

resolve_host() {
  local host="$1" server out=""
  server="$(resolver_for_lookup)"
  if have getent; then
    out="$(getent ahostsv4 "${host}" 2>/dev/null | awk '{print $1}' | sort -u)"
  fi
  if [[ -z "${out}" ]] && have dig; then
    out="$(dig +short +time=3 +tries=2 A "${host}" "@${server}" 2>/dev/null |
      grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | sort -u)"
  fi
  [[ -n "${out}" ]] || return 1
  printf '%s\n' "${out}"
}

# ----------------------------------------------------------------- ipset ----
USE_IPSET=0
if have ipset && ipset create "${ALLOW_SET}" hash:ip family inet -exist >/dev/null 2>&1; then
  USE_IPSET=1
  ipset flush "${ALLOW_SET}"
  echo "allowlist: using ipset ${ALLOW_SET}"
else
  echo "allowlist: ipset unavailable, falling back to per-address rules"
fi

allow_addr() {
  local addr="$1"
  if [[ "${USE_IPSET}" -eq 1 ]]; then
    ipset add "${ALLOW_SET}" "${addr}" -exist
  else
    iptables -C ABOX-ALLOW -d "${addr}" -p tcp -m multiport --dports "${EGRESS_PORTS}" \
      -j ACCEPT 2>/dev/null ||
      iptables -A ABOX-ALLOW -d "${addr}" -p tcp -m multiport --dports "${EGRESS_PORTS}" -j ACCEPT
  fi
}

# --------------------------------------------------------------- iptables ---
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
iptables -F
iptables -X ABOX-ALLOW 2>/dev/null || true
iptables -N ABOX-ALLOW

# Loopback first: this also carries DNS to Docker's embedded resolver.
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

setup_dns || true

# The gateway is addressed by container name; resolve it on the bridge.
GATEWAY_IPS="$(resolve_host "${GATEWAY_HOST}" || true)"
if [[ -z "${GATEWAY_IPS}" ]]; then
  echo "FATAL: cannot resolve gateway host ${GATEWAY_HOST} — is it on this network?" >&2
  exit 2
fi
while read -r ip; do
  [[ -n "${ip}" ]] || continue
  iptables -A OUTPUT -d "${ip}" -p tcp --dport "${GATEWAY_PORT}" -j ACCEPT
  echo "gateway: allow ${ip}:${GATEWAY_PORT}"
done <<<"${GATEWAY_IPS}"

resolved_total=0
{%- if not proxy_enabled %}
for domain in "${ALLOW_DOMAINS[@]}"; do
  addrs="$(resolve_host "${domain}" || true)"
  if [[ -z "${addrs}" ]]; then
    echo "WARN: could not resolve ${domain} — it will be blocked until re-resolved" >&2
    continue
  fi
  count=0
  while read -r ip; do
    [[ -n "${ip}" ]] || continue
    allow_addr "${ip}"
    count=$((count + 1))
  done <<<"${addrs}"
  resolved_total=$((resolved_total + count))
  echo "allow: ${domain} -> ${count} address(es)"
done
{%- else %}
echo "allowlist: enforced by SNI at the proxy, not by address"
{%- endif %}

{%- if proxy_enabled %}
# ------------------------------------------------------------- SNI proxy ---
# Address allowlisting is deliberately NOT used here. It cannot distinguish two
# domains sharing an IP, so every outbound 443 connection is redirected to the
# egress proxy, which decides by the server name in the TLS ClientHello. The
# only address this container may reach directly is the proxy itself.
PROXY_HOST="{{ proxy_host }}"
PROXY_PORT="{{ proxy_port }}"
PROXY_IPS="$(resolve_host "${PROXY_HOST}" || true)"
if [[ -z "${PROXY_IPS}" ]]; then
  echo "FATAL: egress proxy ${PROXY_HOST} is not resolvable — refusing to run" >&2
  echo "       (without it there is no egress path at all)" >&2
  exit 4
fi
PROXY_IP="$(echo "${PROXY_IPS}" | head -1)"
iptables -t nat -A OUTPUT -p tcp --dport 443 ! -d "${PROXY_IP}" \
  -j DNAT --to-destination "${PROXY_IP}:${PROXY_PORT}"
iptables -A OUTPUT -d "${PROXY_IP}" -p tcp --dport "${PROXY_PORT}" -j ACCEPT
echo "egress: all 443 -> ${PROXY_HOST} (${PROXY_IP}:${PROXY_PORT}), filtered by SNI"
{%- else %}
if [[ "${USE_IPSET}" -eq 1 ]]; then
  iptables -A OUTPUT -m set --match-set "${ALLOW_SET}" dst \
    -p tcp -m multiport --dports "${EGRESS_PORTS}" -j ACCEPT
else
  iptables -A OUTPUT -j ABOX-ALLOW
fi
{%- endif %}

# Counted terminal drop. abox reads these counters at teardown to show what the
# agent tried to reach and could not.
iptables -A OUTPUT -m comment --comment "abox-final-drop" -j DROP 2>/dev/null ||
  iptables -A OUTPUT -j DROP

echo "firewall: default-deny active, ${resolved_total} allowlisted address(es)"

# ---------------------------------------------------------------- refresh ---
# CDN addresses rotate; without this a long run loses access to a domain the
# operator explicitly allowed. Best-effort, never fatal.
if [[ "${REFRESH_INTERVAL}" -gt 0 ]]{% if proxy_enabled %} && false{% endif %}; then
  cat >/usr/local/bin/abox-fw-refresh <<'REFRESH'
#!/usr/bin/env bash
set -uo pipefail
source /etc/abox/firewall.env
while sleep "${REFRESH_INTERVAL}"; do
  for domain in ${ALLOW_DOMAINS_FLAT}; do
    while read -r ip; do
      [[ -n "${ip}" ]] || continue
      if command -v ipset >/dev/null 2>&1 && ipset list -n | grep -qx "${ALLOW_SET}"; then
        ipset add "${ALLOW_SET}" "${ip}" -exist
      else
        iptables -C ABOX-ALLOW -d "${ip}" -p tcp -m multiport --dports "${EGRESS_PORTS}" \
          -j ACCEPT 2>/dev/null ||
          iptables -A ABOX-ALLOW -d "${ip}" -p tcp -m multiport --dports "${EGRESS_PORTS}" \
            -j ACCEPT
      fi
    done < <(getent ahostsv4 "${domain}" 2>/dev/null | awk '{print $1}' | sort -u)
  done
done
REFRESH
  install -d -m 0755 /etc/abox
  {
    echo "ALLOW_SET=${ALLOW_SET}"
    echo "EGRESS_PORTS=${EGRESS_PORTS}"
    echo "REFRESH_INTERVAL=${REFRESH_INTERVAL}"
    printf 'ALLOW_DOMAINS_FLAT="%s"\n' "${ALLOW_DOMAINS[*]}"
  } >/etc/abox/firewall.env
  chmod 0755 /usr/local/bin/abox-fw-refresh
  pkill -f abox-fw-refresh >/dev/null 2>&1 || true
  setsid /usr/local/bin/abox-fw-refresh >>"${LOG_DIR}/fw-refresh.log" 2>&1 &
  echo "refresh: re-resolving allowlist every ${REFRESH_INTERVAL}s"
fi

# ------------------------------------------------------------ self-test -----
# A firewall that silently failed open is worse than none, because the manifest
# claims one and `abox run` will grant bypassPermissions on the strength of it.
# Assert the invariants rather than assuming the commands above took effect.
fail=0
assert() {
  local what="$1"
  shift
  if "$@" >/dev/null 2>&1; then
    echo "selftest: ok — ${what}"
  else
    echo "selftest: FAIL — ${what}" >&2
    fail=1
  fi
}

policy_is_drop() {
  iptables -S OUTPUT | head -1 | grep -qx -- '-P OUTPUT DROP'
}
final_drop_present() {
  iptables -S OUTPUT | grep -q -- '-j DROP'
}
gateway_rule_present() {
  iptables -S OUTPUT | grep -q -- "--dport ${GATEWAY_PORT} -j ACCEPT"
}
{%- if proxy_enabled %}
proxy_redirect_present() {
  iptables -t nat -S OUTPUT | grep -q -- "--dport 443 .*DNAT"
}
{%- endif %}

assert "OUTPUT policy is DROP" policy_is_drop
assert "terminal DROP rule installed" final_drop_present
assert "gateway reachable on tcp/${GATEWAY_PORT}" gateway_rule_present
{%- if proxy_enabled %}
assert "443 redirected to the SNI proxy" proxy_redirect_present
{%- endif %}

if getent hosts "{{ selftest_allowed }}" >/dev/null 2>&1; then
  echo "selftest: ok — DNS resolution works"
else
  echo "selftest: WARN — DNS resolution for {{ selftest_allowed }} failed" >&2
fi

chmod -R a+rX "${LOG_DIR}" 2>/dev/null || true
if [[ "${fail}" -eq 0 ]]; then
  {
    echo "ok"
    date -u +%Y-%m-%dT%H:%M:%SZ
    echo "domains={{ egress | length }}"
    echo "addresses=${resolved_total}"
    echo "ipset=${USE_IPSET}"
  } >"${MARKER}"
  chmod 0444 "${MARKER}"
  echo "== abox firewall ready =="
else
  echo "== abox firewall FAILED ==" >&2
  exit 3
fi
