Files
flamenet-messenger-android/scripts/obfs4-interop.sh
T
Paul HittandClaude Opus 5 a7ce010403 obfs4 for Android, part 6: live interop with real lyrebird, both directions
scripts/obfs4-interop.sh builds a real lyrebird bridge and a real
lyrebird client from the flamenet-elligator2 checkout and runs the Kotlin
port against both. The bridge accepts the Kotlin client's handshake and
echoes data; lyrebird's own client accepts the Kotlin bridge and its
echo. 61 tests.

Both directions are needed, and the falsification shows why: swapping the
framing key halves by role leaves the CLIENT test green -- a client takes
the first half either way -- and fails only the reverse one. Encoding the
epoch hour as binary rather than a decimal string fails both. The
probe-resistance test, which asserts the bridge stays silent for garbage,
passes throughout, as it should.

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, and would hide anything they
share.

The runner's name check earned its keep twice in ten minutes. First it
reported failure while all three tests had passed, because Gradle prints
`name() PASSED` with parentheses and the pattern omitted them. Then, with
that fixed, it caught the tests not running AT ALL: Gradle's up-to-date
check does not know the Go counterparty changed, so a second run skips
the task silently. Hence --rerun-tasks. An exit code alone would have
called both of those green.

The tests never skip quietly: absent configuration is a hard failure
unless OBFS4_INTEROP_OPTOUT=1 is set explicitly. Verified both ways --
the bare run fails, the opted-out run passes 61.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:33:44 -04:00

92 lines
4.1 KiB
Bash
Executable File

#!/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."