#!/usr/bin/env bash # # ============================================================================ # TAP — seamless capture launcher for macOS / Linux # ============================================================================ # # Double-click this file. It will: # 1. find your active network card (and let you pick, if there's more # than one), # 2. record ~30 seconds of traffic (asking for your password once — # packet capture needs administrator rights), # 3. open the TAP dashboard in your browser with the reading ALREADY on # screen. No file to choose, nothing uploaded. # # Why any of this: a web browser cannot sniff the wire — not with # JavaScript, not with WebAssembly, on any operating system. So we use the # capture tool that already ships with your OS (tcpdump), then hand the file # to the in-browser engine over a tiny web server that runs only on this # machine (127.0.0.1) and only while this window is open. # # What it can see: on a normal switched port you see THIS computer's traffic # plus broadcast/multicast. To see the whole LAN, capture at the # gateway/router, a mirror (SPAN) port, or the Wi-Fi access point. # # Change the defaults below, or pass them as arguments: # ./tap-capture.command [SECONDS] [SIZE_MB] [INTERFACE] # e.g. ./tap-capture.command 60 250 en1 # ============================================================================ set -u SECS=30 # seconds to capture (try 15 / 30 / 60) MB=100 # hard size ceiling, MB (try 50 / 100 / 250) IFACE="" # leave empty to auto-detect / choose SNAP=512 # bytes kept per packet — headers, not payloads. See the note # at the tcpdump line before changing this. # optional positional overrides [ "${1:-}" != "" ] && SECS="$1" [ "${2:-}" != "" ] && MB="$2" [ "${3:-}" != "" ] && IFACE="$3" case "$SECS" in ''|*[!0-9]*) echo "SECONDS must be a whole number."; exit 1;; esac case "$MB" in ''|*[!0-9]*) echo "SIZE_MB must be a whole number."; exit 1;; esac DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" CAP="$DIR/tap-latest.pcap" # --------------------------------------------------------------- helpers ---- filesize() { stat -f%z "$1" 2>/dev/null || stat -c%s "$1" 2>/dev/null || echo 0; } default_iface() { local d="" d="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')" # macOS [ -z "$d" ] && command -v ip >/dev/null 2>&1 && \ d="$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" # Linux printf '%s' "$d" } # Print "ifaceipv4" for every up interface that has an IPv4 address. list_ifaces() { if command -v ifconfig >/dev/null 2>&1 && ifconfig -l >/dev/null 2>&1; then local i ip for i in $(ifconfig -l 2>/dev/null); do case "$i" in lo*|gif*|stf*|awdl*|llw*|utun*|ap*|bridge*|p2p*) continue;; esac if command -v ipconfig >/dev/null 2>&1; then ip="$(ipconfig getifaddr "$i" 2>/dev/null)"; else ip="$(ifconfig "$i" 2>/dev/null | awk '/inet /{print $2; exit}')"; fi [ -n "$ip" ] && printf '%s\t%s\n' "$i" "$ip" done elif command -v ip >/dev/null 2>&1; then ip -o -4 addr show up 2>/dev/null | awk '$2!="lo"{print $2"\t"$4}' | sed 's#/[0-9]*##' fi } pick_port() { local p for p in $(seq 8790 8820); do if command -v nc >/dev/null 2>&1; then nc -z 127.0.0.1 "$p" >/dev/null 2>&1 || { echo "$p"; return; } else { (exec 3<>"/dev/tcp/127.0.0.1/$p") >/dev/null 2>&1 && exec 3>&-; } || { echo "$p"; return; } fi done echo 8790 } open_url() { if command -v open >/dev/null 2>&1; then open "$1" elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$1" >/dev/null 2>&1 & else echo " Open this in your browser: $1"; fi } # ------------------------------------------------------------- interface ---- if [ -z "$IFACE" ]; then DEF="$(default_iface)" # collect candidates into arrays (portable, no mapfile) NAMES=(); IPS=() while IFS=$'\t' read -r nm ip; do [ -n "$nm" ] && { NAMES+=("$nm"); IPS+=("$ip"); }; done < <(list_ifaces) if [ "${#NAMES[@]}" -eq 0 ]; then IFACE="${DEF:-en0}" elif [ "${#NAMES[@]}" -eq 1 ]; then IFACE="${NAMES[0]}" else echo "Network cards with an address right now:" defidx=1 for k in "${!NAMES[@]}"; do tag=""; [ "${NAMES[$k]}" = "$DEF" ] && { tag=" (default route)"; defidx=$((k+1)); } printf " %d) %-8s %s%s\n" "$((k+1))" "${NAMES[$k]}" "${IPS[$k]}" "$tag" done printf "Pick a card [1-%d] (Enter = %d): " "${#NAMES[@]}" "$defidx" read -r choice || choice="" [ -z "$choice" ] && choice="$defidx" case "$choice" in *[!0-9]*|"") choice="$defidx";; esac [ "$choice" -lt 1 ] && choice="$defidx"; [ "$choice" -gt "${#NAMES[@]}" ] && choice="$defidx" IFACE="${NAMES[$((choice-1))]}" fi fi command -v tcpdump >/dev/null 2>&1 || { echo "tcpdump not found (macOS ships it; on Linux: sudo apt install tcpdump)."; exit 1; } echo "======================================================================" echo " TAP capture" echo "----------------------------------------------------------------------" echo " Card : $IFACE" echo " Duration : ${SECS}s (stops early if it hits the size cap)" echo " Size cap : ${MB} MB" echo " File : $CAP" echo " Nothing is uploaded. The file stays on this machine." echo "----------------------------------------------------------------------" echo " Packet capture needs administrator rights — you'll be asked for your" echo " login password now (this is tcpdump, a built-in tool)." echo "======================================================================" # Prime sudo in the FOREGROUND so the background capture never has to prompt # (a backgrounded sudo cannot ask for a password — that is what suspends it). sudo -v || { echo "Could not get administrator rights — cancelled."; exit 1; } # clean any previous capture so the size watchdog measures only this run sudo rm -f "$CAP" 2>/dev/null; rm -f "$CAP" 2>/dev/null # ---------------------------------------------------------------- capture --- cleanup_capture() { [ -n "${TDPID:-}" ] && sudo kill -INT "$TDPID" 2>/dev/null; } trap 'echo; echo "Stopping…"; cleanup_capture' INT # -s $SNAP, NOT -s 0. # # THIS IS THE LINE THAT MAKES THE PRIVACY CLAIM TRUE. With -s 0 tcpdump writes # every byte on the wire, so tap-latest.pcap held complete HTTP bodies, cookies # and any credential that crossed the link in the clear — while the README and # the page both told the reader the file contained "packet headers … addresses, # ports, sizes, timing". The dashboard's own behaviour was never the problem; # the FILE was, and the file is the thing that sits on disk afterwards. # # 512 bytes keeps everything TAP actually reads: Ethernet and IP and TCP # headers many times over, a whole DNS answer, a DHCP BOOTP frame with its # options (the fixed part alone is 240), a NetBIOS registration, and the server # name in all but the largest TLS ClientHellos. # # THE BYTE FIGURES DO NOT CHANGE. tcpdump still records each packet's true # on-wire length, and the engine sums that rather than what it captured — which # is exactly why it was built that way. Only the payloads stop being written. sudo tcpdump -i "$IFACE" -s "$SNAP" -n -w "$CAP" >/dev/null 2>&1 & TDPID=$! CAPBYTES=$((MB * 1000000)) echo "Recording for up to ${SECS}s…" i=0 while kill -0 "$TDPID" 2>/dev/null; do sleep 1; i=$((i + 1)) sz="$(filesize "$CAP")" if [ "$i" -ge "$SECS" ] || [ "$sz" -ge "$CAPBYTES" ]; then [ "$sz" -ge "$CAPBYTES" ] && echo "Hit the ${MB} MB size cap — stopping." sudo kill -INT "$TDPID" 2>/dev/null; break fi done wait "$TDPID" 2>/dev/null trap - INT # make the root-owned savefile readable by the (user-owned) local server sudo chmod 644 "$CAP" 2>/dev/null # 24 IS AN EMPTY CAPTURE, NOT A MISSING ONE. `-s` is true only for a zero-byte # file, and tcpdump always writes the 24-byte pcap global header — so a capture # on an up-but-idle interface produced "Captured 24 bytes" and opened the # dashboard on an all-zeros reading, instead of printing the "try another card" # help that was written for exactly this case. if [ "$(filesize "$CAP")" -le 24 ]; then echo "No packets crossed $IFACE during the window. Try a different card:" echo " ./tap-capture.command $SECS $MB (list them with: ifconfig)" exit 1 fi echo "Captured $(filesize "$CAP") bytes → $CAP" # ------------------------------------------------------ serve + open page --- # The page reads the capture over a server that listens ONLY on 127.0.0.1 and # ONLY while this window is open. The engine loads the whole file into memory, # so once the reading appears the server is no longer needed. PORT="$(pick_port)" SRV="" # `command -v python3` IS NOT A TEST THAT PYTHON3 RUNS. On a Mac without the # Xcode command line tools, /usr/bin/python3 is a stub that pops the developer # tools dialog and exits 1 — but `command -v` has already succeeded, so the # working ruby fallback was skipped, the failure was swallowed by >/dev/null, # and the script cheerfully printed a URL for a server that was never running. # Run the interpreter, then confirm the process is still alive a moment later. if python3 -c '' >/dev/null 2>&1; then ( cd "$DIR" && exec python3 -m http.server "$PORT" --bind 127.0.0.1 ) 2>/tmp/tap-srv.$$ & SRV=$! elif ruby -e '' >/dev/null 2>&1; then ( cd "$DIR" && exec ruby -run -e httpd . -p "$PORT" -b 127.0.0.1 ) 2>/tmp/tap-srv.$$ & SRV=$! fi if [ -n "$SRV" ]; then sleep 1 if ! kill -0 "$SRV" 2>/dev/null; then echo " The local server could not start on port $PORT:" sed 's/^/ /' "/tmp/tap-srv.$$" 2>/dev/null | tail -3 SRV="" fi fi rm -f "/tmp/tap-srv.$$" 2>/dev/null # [38] AND IT MUST DIE WITH THE SCRIPT. The only kill used to be after the # `read`, so Ctrl-C at the prompt — or a HUP — left python serving the capture # and holding the port for ever; the next run then walked to the next port and # the orphans accumulated. bash sets SIG_IGN for async children, so the child # does not get the Ctrl-C that kills the script. trap 'kill "${SRV:-}" 2>/dev/null' EXIT INT TERM HUP if [ -n "$SRV" ]; then URL="http://127.0.0.1:${PORT}/index.html?load=tap-latest.pcap" echo "----------------------------------------------------------------------" echo " Opening the reading: $URL" echo " (local server, this machine only — press Enter here to close it)" echo "----------------------------------------------------------------------" open_url "$URL" read -r _ || true kill "$SRV" 2>/dev/null else # No local server available — fall back to opening the page for a manual pick. echo "----------------------------------------------------------------------" echo " Neither python3 nor ruby was found for the local server, so open the" echo " dashboard and choose the file yourself:" echo " file: $CAP" echo "----------------------------------------------------------------------" open_url "file://$DIR/index.html" fi echo "Done."