#!/usr/bin/env bash # Run the Kotlin obfs4 port against real lyrebird, in both directions. # # 🔴 The only check that proves interoperability. Everything else in `:obfs4` # compares against recorded vectors or against itself, and two identically-wrong # ends agree perfectly. # # ⚠️ A wire-format bug shows up as SILENCE, not an error: obfs4 bridges do not # answer handshakes they cannot verify. The Kotlin side uses read timeouts and # reports them as "the bridge refused to answer". # # ⚠️ The Go counterparty lives in the flamenet-elligator2 checkout, which also # holds the Swift port. Both clients are pinned to the SAME reference rather # than to each other — two independent implementations agreeing with lyrebird is # a real statement; agreeing with each other is not. set -euo pipefail cd "$(dirname "$0")/.." REFERENCE="${FN_OBFS4_REFERENCE:-../flamenet-elligator2/Reference}" if [[ ! -d "$REFERENCE" ]]; then echo "Cannot find the Go reference at $REFERENCE." echo "Clone flamenet-elligator2 beside this repo, or set FN_OBFS4_REFERENCE." exit 1 fi WORK="$(mktemp -d)" PARAMS="$WORK/params.json" SERVER_LOG="$WORK/server.log" cleanup() { if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true fi # The bridge log is the other half of every diagnosis: it says whether the # handshake was accepted or refused, which the silent client cannot. if [[ -s "$SERVER_LOG" ]]; then echo "--- bridge log ---"; cat "$SERVER_LOG"; fi rm -rf "$WORK" } trap cleanup EXIT echo "building the lyrebird bridge and client..." (cd "$REFERENCE" && go build -o "$WORK/interopserver" ./interopserver) (cd "$REFERENCE" && go build -o "$WORK/interopclient" ./interopclient) "$WORK/interopserver" -params "$PARAMS" -addr 127.0.0.1:0 >/dev/null 2>"$SERVER_LOG" & SERVER_PID=$! # ⚠️ Poll for the params file rather than sleeping. A fixed sleep is either # flaky or slow, and on a loaded machine it is both. for _ in $(seq 1 100); do [[ -f "$PARAMS" ]] && break kill -0 "$SERVER_PID" 2>/dev/null || { echo "the bridge died on startup"; exit 1; } sleep 0.1 done [[ -f "$PARAMS" ]] || { echo "the bridge never published its parameters"; exit 1; } # ⚠️ sed, not python3 or jq: the CI images for this repo cannot be assumed to # have either, and that assumption has cost red pipelines before. ADDRESS="$(sed -n 's/.*"address"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PARAMS")" [[ -n "$ADDRESS" ]] || { echo "could not read the bridge address from $PARAMS"; exit 1; } echo "bridge up at $ADDRESS" # 🔴 `--rerun-tasks`, because Gradle's up-to-date check does not know the # counterparty changed. Without it a second run skips the task entirely and # prints nothing — the tests do not fail, they simply never happen. Caught by # the name check below, which is the second time that check has earned its keep # in ten minutes. OBFS4_INTEROP_PARAMS="$PARAMS" OBFS4_INTEROP_CLIENT="$WORK/interopclient" \ ./gradlew :obfs4:test --tests '*InteropTest*' --rerun-tasks --console=plain 2>&1 \ | tee "$WORK/test.log" # 🔴 A Gradle filter that matches nothing still exits 0, which would make "no # interop coverage at all" look exactly like success. for name in "a handshake and an echo against a real lyrebird bridge" \ "a bridge ignores a handshake it cannot verify" \ "a real lyrebird client accepts the kotlin bridge"; do # ⚠️ Gradle prints `name() PASSED`, with parentheses. Matching on # "$name PASSED" silently matches nothing — which this check caught on # itself, reporting failure while all three tests had passed. grep -qF "$name() PASSED" "$WORK/test.log" || { echo echo "FAILED: '$name' did not pass. A filter that matches nothing exits 0," echo "so each test is checked by name rather than trusting the exit code." exit 1 } done echo echo "ANDROID INTEROP OK: both directions -- a real lyrebird bridge accepted the" echo "Kotlin client, and a real lyrebird client accepted the Kotlin bridge."