#!/usr/bin/env bash
# Passive connection-attempt observer for one client address.
#
#   openfut-observe.sh on  <CLIENT_IP>
#   openfut-observe.sh off
#   openfut-observe.sh status
#   openfut-observe.sh mark            record the current counters as a baseline
#   openfut-observe.sh delta           attempts since the last mark
#
# WHY THIS EXISTS
#
# A live gate can fail in two very different ways that look identical in every
# server log: the client tried to connect and could not, or the client never
# tried at all. No server log can separate those, because both produce silence.
#
# Worked example, and the reason this exists: two redirector gates failed with
# "An error occurred downloading the FUT Squad Update" while the roster server
# logged nothing at all. "Nothing" was consistent with a broken roster service,
# a wrong roster URL, and a client that never asked — three very different bugs.
#
# HOW
#
# This box has no tcpdump, no conntrack, and no readable kernel log (iptables
# LOG rules match but their output goes nowhere — verified, not assumed). What
# does work is iptables PACKET COUNTERS, so the observation is built from those:
# a dedicated raw-table chain, one counting rule per interesting port, each with
# no target so it counts and falls through.
#
# Only SYNs are counted, so one line per connection attempt, and no payload is
# recorded — this cannot see message contents even in principle.
#
# SAFETY
#
# A counting rule has no target: it cannot drop, rewrite or delay a packet. All
# state lives in one custom chain, so `off` is "unhook, flush, delete" and its
# verification re-reads the table rather than trusting the delete's exit code.
# No rule value contains a space, which is what made an earlier LOG-based
# version impossible to delete by reconstructed spec.
set -uo pipefail

TAG=openfut-observe
CHAIN=OPENFUT_OBS
TABLE=raw
STATE="${OPENFUT_OBSERVE_STATE:-${TMPDIR:-/tmp}/openfut-observe.mark}"

# Ports worth separating. The final catch-all counts EVERY attempt, so it is a
# TOTAL and not a remainder: attempts to an untracked port show up as the gap
# between TOTAL and the sum of the named ports, rather than vanishing.
PORTS=(42127 42227 42130 8081 8099 8080 8094 9988 8999 4216 80 443 17502)

die() { echo "observe: $*" >&2; exit 1; }
ipt() { sudo iptables -t "$TABLE" "$@"; }
chain_exists() { ipt -S "$CHAIN" >/dev/null 2>&1; }
hooks() { sudo iptables-save -t "$TABLE" 2>/dev/null | grep -cF -- "--comment $TAG"; }

cmd_on() {
  local ip="${1:-}"
  [[ -n "$ip" ]] || die "usage: openfut-observe.sh on <CLIENT_IP>"
  chain_exists && die "already on — run 'off' first"

  ipt -N "$CHAIN" || die "could not create $CHAIN"
  local p
  for p in "${PORTS[@]}"; do
    ipt -A "$CHAIN" -p tcp --dport "$p" || { cmd_off >/dev/null; die "rule for $p failed"; }
  done
  ipt -A "$CHAIN" -p tcp || { cmd_off >/dev/null; die "catch-all rule failed"; }

  # --syn is SYN without ACK: one match per connection ATTEMPT, retries included.
  ipt -I PREROUTING -s "$ip" -p tcp --syn -m comment --comment "$TAG" -j "$CHAIN" \
    || { cmd_off >/dev/null; die "could not hook $CHAIN into PREROUTING"; }

  [[ "$(hooks)" == "1" ]] || { cmd_off >/dev/null; die "hook not installed"; }
  rm -f "$STATE"
  echo "observing $ip: ${#PORTS[@]} ports + catch-all, hooked into $TABLE/PREROUTING"
}

cmd_off() {
  local removed=0
  # Unhook by parsed fields as argv elements, then flush and delete. No value
  # here contains a space, so this round trip is safe.
  while read -r ip; do
    [[ -n "$ip" ]] || continue
    ipt -D PREROUTING -s "$ip" -p tcp --syn -m comment --comment "$TAG" -j "$CHAIN" \
      2>/dev/null && removed=$((removed + 1))
  done < <(sudo iptables-save -t "$TABLE" 2>/dev/null \
             | grep -F -- "--comment $TAG" \
             | sed -nE 's/.* -s ([0-9.]+)(\/32)? .*/\1/p')

  chain_exists && { ipt -F "$CHAIN"; ipt -X "$CHAIN"; }

  local left_hooks left_chain
  left_hooks="$(hooks)"; chain_exists && left_chain=yes || left_chain=no
  if [[ "$left_hooks" != "0" || "$left_chain" != "no" ]]; then
    echo "observe: REFUSING to report success — hooks=$left_hooks chain=$left_chain" >&2
    return 1
  fi
  rm -f "$STATE"
  echo "observing off: removed $removed hook(s) and the chain, verified none remain"
}

# "port<TAB>packets". The final catch-all is reported as TOTAL, not "other":
# every packet reaching the chain counts there, including ones already counted
# by a named-port rule above it.
counters() {
  ipt -L "$CHAIN" -v -n -x 2>/dev/null | awk '
    /dpt:/ { for(i=1;i<=NF;i++) if($i ~ /^dpt:/){ sub(/dpt:/,"",$i); print $i "\t" $1 } ; next }
    /^ *[0-9]+ +[0-9]+ +/ && !/dpt:/ && NR>2 { print "TOTAL\t" $1 }'
}

cmd_status() {
  chain_exists || { echo "INACTIVE (no observe chain)"; return 0; }
  echo "ACTIVE, hooked for: $(sudo iptables-save -t "$TABLE" | grep -F -- "--comment $TAG" \
        | sed -nE 's/.* -s ([0-9.]+)(\/32)? .*/\1/p' | tr '\n' ' ')"
  counters | awk -F'\t' '$2>0 {printf "  %-8s %s attempt(s)\n", $1, $2}'
  counters | awk -F'\t' '$2>0' | grep -q . || echo "  (no connection attempts yet)"
}

cmd_mark() {
  chain_exists || die "not observing"
  counters > "$STATE" || die "could not write $STATE"
  echo "baseline recorded ($(wc -l <"$STATE") counters)"
}

cmd_delta() {
  chain_exists || die "not observing"
  [[ -f "$STATE" ]] || die "no baseline — run 'mark' first"
  join -t$'\t' -a2 -e 0 -o '0,1.2,2.2' <(sort "$STATE") <(counters | sort) \
    | awk -F'\t' '{ d=$3-$2; if (d>0) printf "  %-8s %s attempt(s)\n", $1, d }' \
    | sort -k2 -rn
  echo "  ---"
  join -t$'\t' -a2 -e 0 -o '0,1.2,2.2' <(sort "$STATE") <(counters | sort) \
    | awk -F'\t' '{ if ($3-$2 > 0) n++ } END { print "  ports contacted since mark: " n+0 }'
}

case "${1:-}" in
  on)     shift; cmd_on "$@" ;;
  off)    cmd_off ;;
  status) cmd_status ;;
  mark)   cmd_mark ;;
  delta)  cmd_delta ;;
  *)      sed -n '2,9p' "$0" | sed 's/^# \?//'; exit 2 ;;
esac
