#!/usr/bin/env bash
set -euo pipefail

readonly CFPP_NETWORK_ENV=${CFPP_NETWORK_ENV_FILE:-/etc/cf-pp-gateway/network.env}
readonly CFPP_PRE_CHAIN=CFPPGW_PRE
readonly CFPP_OUT_CHAIN=CFPPGW_OUT
readonly CFPP_NAT_CHAIN=CFPPGW_NAT
readonly CFPP_INPUT_CHAIN=CFPPGW_IN
readonly CFPP_TRUSTED_V4_SET=cfppgw_cf4
readonly CFPP_TRUSTED_V4_NEXT=cfppgw_cf4_next
readonly CFPP_DESTINATION_V4_SET=cfppgw_dst4
readonly CFPP_DESTINATION_V4_NEXT=cfppgw_dst4_next
readonly CFPP_COMMENT_PREFIX=cf-pp-gateway

fail() {
  echo "cf-pp-gateway network: $*" >&2
  exit 1
}

[[ -r "$CFPP_NETWORK_ENV" ]] || fail "missing $CFPP_NETWORK_ENV"
# shellcheck source=/dev/null
source "$CFPP_NETWORK_ENV"

require_uint() {
  local name=$1
  local value=${!name:-}
  [[ "$value" =~ ^[0-9]+$ ]] || fail "$name must be an unsigned integer"
}

require_uint CFPP_MARK
require_uint CFPP_ROUTE_TABLE
require_uint CFPP_RULE_PRIORITY
require_uint CFPP_ROUTE_LOCALNET_PREVIOUS
require_uint CFPP_INGRESS_PORT
(( CFPP_MARK > 0 && CFPP_MARK <= 2147483647 )) || fail "CFPP_MARK is out of range"
(( CFPP_ROUTE_TABLE > 0 && CFPP_ROUTE_TABLE <= 2147483647 )) || fail "CFPP_ROUTE_TABLE is out of range"
(( CFPP_RULE_PRIORITY > 0 && CFPP_RULE_PRIORITY <= 32765 )) || fail "CFPP_RULE_PRIORITY is out of range"
(( CFPP_INGRESS_PORT > 0 && CFPP_INGRESS_PORT <= 65535 )) || fail "CFPP_INGRESS_PORT is out of range"
(( CFPP_ROUTE_LOCALNET_PREVIOUS == 0 || CFPP_ROUTE_LOCALNET_PREVIOUS == 1 )) || fail "CFPP_ROUTE_LOCALNET_PREVIOUS must be 0 or 1"
[[ ${CFPP_ROUTE_LOCALNET_INTERFACE:-} =~ ^[A-Za-z0-9_.:-]+$ ]] || fail "CFPP_ROUTE_LOCALNET_INTERFACE must be an explicit interface"
[[ ${CFPP_INGRESS_INTERFACE:-} =~ ^[A-Za-z0-9_.:-]+$ ]] || fail "CFPP_INGRESS_INTERFACE must be an explicit interface"
[[ ${CFPP_INGRESS_ADDRESS:-} =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "CFPP_INGRESS_ADDRESS must be an explicit IPv4 address"
[[ ${CFPP_CAPTURE_PORTS:-} =~ ^[0-9,:]+$ ]] || fail "CFPP_CAPTURE_PORTS must use iptables multiport syntax"
[[ ${CFPP_TRUSTED_CIDRS_FILE:-} == /* && -r ${CFPP_TRUSTED_CIDRS_FILE:-} ]] || fail "CFPP_TRUSTED_CIDRS_FILE must be an absolute readable file"
[[ ${CFPP_DESTINATION_ADDRESSES_FILE:-} == /* && -r ${CFPP_DESTINATION_ADDRESSES_FILE:-} ]] || fail "CFPP_DESTINATION_ADDRESSES_FILE must be an absolute readable file"
[[ -d "/proc/sys/net/ipv4/conf/$CFPP_ROUTE_LOCALNET_INTERFACE" ]] || fail "interface $CFPP_ROUTE_LOCALNET_INTERFACE does not exist"
[[ -d "/proc/sys/net/ipv4/conf/$CFPP_INGRESS_INTERFACE" ]] || fail "ingress interface $CFPP_INGRESS_INTERFACE does not exist"

readonly CFPP_MARK_HEX=$(printf '0x%x' "$CFPP_MARK")
readonly CFPP_MARK_MASK="$CFPP_MARK/0xffffffff"
readonly CFPP_ROUTE_LOCALNET_PATH="/proc/sys/net/ipv4/conf/$CFPP_ROUTE_LOCALNET_INTERFACE/route_localnet"
readonly CFPP_INGRESS_CIDR="$CFPP_INGRESS_ADDRESS/32"
# One shared host listener; trusted packets from each selected interface DNAT
# to it, preserving their original destination for SO_ORIGINAL_DST.
read -r -a CFPP_INGRESS_DEVICES <<<"${CFPP_INGRESS_INTERFACES:-$CFPP_INGRESS_INTERFACE}"
(( ${#CFPP_INGRESS_DEVICES[@]} > 0 )) || fail "no ingress interfaces selected"
for interface in "${CFPP_INGRESS_DEVICES[@]}"; do
  [[ $interface =~ ^[A-Za-z0-9_.:-]+$ && -d /proc/sys/net/ipv4/conf/$interface ]] || fail "invalid ingress interface: $interface"
done
readonly CFPP_NETWORK_LOCK_FILE=${CFPP_NETWORK_LOCK_FILE:-/run/lock/cfpp-network.lock}


rule_is_installed() {
  ip -4 rule show | grep -Eq "^${CFPP_RULE_PRIORITY}:[[:space:]]+from[[:space:]]+all[[:space:]]+fwmark[[:space:]]+${CFPP_MARK_HEX}(/0xffffffff)?[[:space:]]+lookup[[:space:]]+${CFPP_ROUTE_TABLE}[[:space:]]*$"
}

route_is_installed() {
  ip -4 route show table "$CFPP_ROUTE_TABLE" 2>/dev/null | grep -Eq '^local (default|0\.0\.0\.0/0) dev lo( |$)'
}

# Connected Docker bridge routes must outrank the catch-all local route after
# Docker OUTPUT DNAT. Protocol 242 identifies only routes managed here.
readonly CFPP_DOCKER_ROUTE_PROTO=242
readonly CFPP_DOCKER_ROUTES_FILE=${CFPP_DOCKER_ROUTES_FILE:-/etc/cf-pp-gateway/docker-bridge-routes.ipv4}

docker_local() {
  local socket=${CFPP_DOCKER_SOCKET:-/var/run/docker.sock}
  [[ $socket == /* && $socket =~ ^/[A-Za-z0-9_./-]+$ ]] || fail "Docker socket must be a local absolute path"
  DOCKER_HOST= DOCKER_CONTEXT= docker --host "unix://$socket" "$@"
}

docker_bridge_routes() {
  local ids id info name bridge full_id driver bridges="" routes
  if command -v docker >/dev/null 2>&1; then
    ids=$(docker_local network ls --filter driver=bridge --format '{{.ID}}') || fail "cannot query Docker networks"
    for id in $ids; do
      [[ $id =~ ^[a-f0-9]+$ ]] || fail "invalid Docker network ID"
      info=$(docker_local network inspect --format '{{.Name}}|{{with index .Options "com.docker.network.bridge.name"}}{{.}}{{end}}|{{.Id}}|{{.Driver}}' "$id") || fail "cannot inspect Docker network $id"
      IFS='|' read -r name bridge full_id driver <<<"$info"
      [[ $driver == bridge && $full_id =~ ^[a-f0-9]{12,64}$ ]] || fail "Docker network changed during discovery"
      if [[ -z $bridge ]]; then
        if [[ $name == bridge ]]; then bridge=docker0; else bridge="br-${full_id:0:12}"; fi
      fi
      [[ $bridge =~ ^[A-Za-z0-9_.:-]+$ ]] || fail "invalid Docker bridge name"
      bridges+="$bridge "
    done
  else
    # Without the Docker CLI, only standard bridge names can be discovered.
    bridges=""
  fi
  routes=$(ip -o -4 route show table main proto kernel scope link) || fail "cannot read connected IPv4 routes"
  awk -v devices="$bridges" -v has_docker="$(command -v docker >/dev/null 2>&1 && echo yes || echo no)" '
    BEGIN {n=split(devices,a," ");for(i=1;i<=n;i++) allowed[a[i]]=1}
    {for(i=1;i<=NF;i++) if($i=="dev") {
      dev=$(i+1)
      if ((has_docker=="yes" && allowed[dev]) || (has_docker=="no" && (dev=="docker0" || dev ~ /^br-[a-f0-9]+$/))) print $1,dev
    }}' <<<"$routes" | sort -u
}

preflight_docker_routes() {
  local desired=$1 prefix interface current
  # Public/overlapping container subnets can steal marked visitor reply routes.
  python3 - "$desired" <<'PYNET'
import ipaddress,sys
networks=[]
allowed=[ipaddress.ip_network(x) for x in ('10.0.0.0/8','172.16.0.0/12','192.168.0.0/16')]
for row in sys.argv[1].splitlines():
    if not row.strip(): continue
    prefix,interface=row.split()
    network=ipaddress.ip_network(prefix)
    if not any(network.subnet_of(a) for a in allowed):
        raise SystemExit('unsupported Docker subnet outside RFC1918: '+prefix)
    if any(network.overlaps(n) for n in networks):
        raise SystemExit('overlapping Docker subnets are unsupported: '+prefix)
    networks.append(network)
PYNET
  while read -r prefix interface; do
    [[ -n $prefix ]] || continue
    [[ $prefix =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ && $interface =~ ^[A-Za-z0-9_.:-]+$ ]] || fail "invalid Docker route"
    current=$(ip -o -4 route show table "$CFPP_ROUTE_TABLE" exact "$prefix" 2>/dev/null || true)
    if [[ -n $current ]] && grep -Ev "(^| )proto $CFPP_DOCKER_ROUTE_PROTO( |$)" <<<"$current" | grep -q .; then
      fail "refusing to replace foreign route for $prefix in table $CFPP_ROUTE_TABLE"
    fi
  done <<<"$desired"
}

remove_docker_routes() {
  local prefix interface
  while read -r prefix interface; do
    [[ -n $prefix ]] || continue
    ip -4 route del table "$CFPP_ROUTE_TABLE" "$prefix" dev "$interface" proto "$CFPP_DOCKER_ROUTE_PROTO"
  done < <(ip -o -4 route show table "$CFPP_ROUTE_TABLE" proto "$CFPP_DOCKER_ROUTE_PROTO" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $1,$(i+1)}')
}

sync_docker_routes() {
  local desired prefix interface
  desired=${1-$(docker_bridge_routes)}
  preflight_docker_routes "$desired"
  # Update first, so reapplying an unchanged network does not interrupt flows.
  while read -r prefix interface; do
    [[ -n $prefix ]] || continue
    ip -4 route replace table "$CFPP_ROUTE_TABLE" "$prefix" dev "$interface" scope link proto "$CFPP_DOCKER_ROUTE_PROTO"
  done <<<"$desired"
  while read -r prefix interface; do
    [[ -n $prefix ]] || continue
    if ! grep -Fxq "$prefix $interface" <<<"$desired"; then
      ip -4 route del table "$CFPP_ROUTE_TABLE" "$prefix" dev "$interface" proto "$CFPP_DOCKER_ROUTE_PROTO"
    fi
  done < <(ip -o -4 route show table "$CFPP_ROUTE_TABLE" proto "$CFPP_DOCKER_ROUTE_PROTO" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $1,$(i+1)}')
}

save_docker_routes() {
  local temporary
  temporary=$(mktemp "${CFPP_DOCKER_ROUTES_FILE}.XXXXXX") || fail "cannot stage Docker route inventory"
  if ! printf '%s\n' "$1" >"$temporary" || ! chmod 0644 "$temporary" || ! mv -f -- "$temporary" "$CFPP_DOCKER_ROUTES_FILE"; then
    rm -f -- "$temporary"
    fail "cannot save Docker route inventory"
  fi
}

check_docker_routes() {
  local prefix interface actual expected connected
  [[ -r $CFPP_DOCKER_ROUTES_FILE ]] || fail "Docker route inventory missing; root must reapply network rules"
  expected=$(sed '/^[[:space:]]*$/d' "$CFPP_DOCKER_ROUTES_FILE" | sort -u)
  actual=$(ip -o -4 route show table "$CFPP_ROUTE_TABLE" proto "$CFPP_DOCKER_ROUTE_PROTO" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $1,$(i+1)}' | sort -u)
  [[ "$actual" == "$expected" ]] || fail "managed Docker routes differ from recorded inventory"
  connected=$(ip -o -4 route show table main proto kernel scope link | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $1,$(i+1)}')
  while read -r prefix interface; do
    [[ -n $prefix ]] || continue
    grep -Fxq "$prefix $interface" <<<"$connected" || fail "recorded Docker bridge route disappeared; root must reapply rules"
  done <<<"$expected"
  # Never query the Docker socket here: the service user is not a Docker admin.
}

interface_has_address() {
  ip -o -4 address show dev "$1" | awk '{split($4,address,"/"); print address[1]}' | grep -Fxq "$2"
}

network_lock() {
  (( EUID == 0 )) || fail "root required to apply network rules"
  command -v flock >/dev/null || fail "flock is required"
  [[ ! -L $CFPP_NETWORK_LOCK_FILE ]] || fail "unsafe network lock"
  exec 9>"$CFPP_NETWORK_LOCK_FILE"
  flock -w 15 9 || fail "another CFPP network operation is running"
}

preflight_policy_table() {
  local rules table rows line
  rules=$(ip -4 rule show | awk -v p="${CFPP_RULE_PRIORITY}:" '$1==p {print}')
  rows=$(grep -c . <<<"$rules" || true)
  (( rows <= 1 )) || fail "duplicate rules at reserved CFPP priority"
  if [[ -n $rules ]] && ! rule_is_installed; then fail "foreign policy rule at CFPP priority"; fi
  table=$(ip -o -4 route show table "$CFPP_ROUTE_TABLE" 2>/dev/null || true)
  while IFS= read -r line; do
    [[ -n $line ]] || continue
    if [[ $line =~ ^local[[:space:]]+(default|0\.0\.0\.0/0)[[:space:]]+dev[[:space:]]+lo([[:space:]]|$) ]]; then continue; fi
    grep -Eq "(^| )proto $CFPP_DOCKER_ROUTE_PROTO( |$)" <<<"$line" || fail "foreign route in reserved CFPP table"
  done <<<"$table"
}

# iptables -S quotes comments when required. Only a comment in our exact
# namespace claims a rule; matching the project name elsewhere is insufficient.
foreign_chain_rules() {
  awk -v prefix="$CFPP_COMMENT_PREFIX:" '
    $1 == "-A" {
      owned=0
      for (i=1;i<NF;i++) if ($i=="--comment") {
        value=$(i+1)
        gsub(/^"|"$/, "", value)
        if (index(value,prefix)==1 && value ~ /^[A-Za-z0-9_.:-]+$/) owned=1
      }
      if (!owned) print
    }'
}

preflight_owned_chain() {
  local table=$1 chain=$2 existing foreign
  if existing=$(iptables -t "$table" -S "$chain" 2>/dev/null); then
    foreign=$(foreign_chain_rules <<<"$existing")
    [[ -z $foreign ]] || fail "chain $chain contains foreign rules"
  fi
}
