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>
92 lines
4.1 KiB
Bash
Executable File
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."
|