#!/usr/bin/env bash # Passive connection-attempt observer for one client address. # # openfut-observe.sh on # 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 " 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" } # "portpackets", catch-all reported as "other". 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