From fc55de19fa2f2e800999efd4afb85937abbb397a Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 18 Aug 2026 16:37:19 +0000 Subject: [PATCH] test(fifa17-tls): reproducible isolated confirmation of the roster-cert fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the REAL roster_server.py under `sudo unshare -n` (so port 8081 is free and production is never touched) and validates its certificate BY THE DIALED IP, proving the before/after the SAN fix (fbc0da2) targets: * OLD cert (DNS-only, production's current shape) -> a by-IP-verifying client is rejected with "IP address mismatch, certificate is not valid for '127.0.0.1'" — the certificate_unknown class the FIFA client hit. * NEW cert (fixed generator, DNS + IP SANs) -> verifies through the actual roster server and returns the roster XML (200, application/xml). roster-cert-verify.py is the client probe (trusts the served self-signed cert as CA, checks it against the dialed IP, then GETs /fifa17/fut/rosterupdate.xml). roster-cert-iso-test.sh drives the real server with each cert and asserts new=pass, old=fail. Two harness bugs were found and fixed while writing it (a shared /tmp log the production run owns, and a subshell pid that left the first server alive so the "old" probe hit a stale server presenting the new cert — the tell was "self-signed" instead of "IP mismatch"), so the final before/after is clean. Complements the in-process check: this exercises the production server code path, not a hand-rolled server. Live production confirmation still needs the container rebuilt with OPENFUT_ADVERTISE set (operator-gated). --- scripts/roster-cert-iso-test.sh | 56 +++++++++++++++++++++++++++++ scripts/roster-cert-verify.py | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100755 scripts/roster-cert-iso-test.sh create mode 100755 scripts/roster-cert-verify.py diff --git a/scripts/roster-cert-iso-test.sh b/scripts/roster-cert-iso-test.sh new file mode 100755 index 0000000..c4377f1 --- /dev/null +++ b/scripts/roster-cert-iso-test.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Isolated live confirmation of the roster-cert fix, using the REAL roster_server.py. +# Runs under `sudo unshare -n` so port 8081 is free and production is never touched. +# +# Proves: with a cert from the FIXED generator (IP SAN), a client that verifies the cert +# BY THE DIALED IP completes the handshake against the real server and gets the roster +# XML; with the OLD DNS-only cert, the same client fails verification (certificate_unknown +# class). That is the exact before/after the production fix targets. +set -u +D=/tmp/roster-iso +rm -rf "$D"; mkdir -p "$D" +cp /home/alex/OpenFUT/fifa17-recon/tools/roster_server.py "$D/" && sed -i "s@/tmp/roster_server.log@$D/roster_server.log@" "$D/roster_server.py" +DIAL=127.0.0.1 +DNS="DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com" + +ip link set lo up 2>/dev/null || { echo " FATAL: cannot bring up lo in namespace"; exit 1; } + +gen() { # $1 = san + openssl req -x509 -newkey rsa:2048 -nodes -keyout "$D/redir_key.pem" -out "$D/redir_cert.pem" \ + -days 3650 -subj "/CN=winter15.gosredirector.ea.com" -addext "subjectAltName=$1" >/dev/null 2>&1 +} + +run_server() { + OPENFUT_BIND=127.0.0.1 python3 "$D/roster_server.py" >"$D/srv.log" 2>&1 & + echo $! > "$D/srv.pid" + for i in $(seq 1 25); do ss -tln 2>/dev/null | grep -q ":8081" && return 0; sleep 0.2; done + echo " FATAL: roster_server never bound 8081"; echo " --- srv.log ---"; sed "s/^/ /" "$D/srv.log"; return 1 +} +stop_server() { kill "$(cat "$D/srv.pid" 2>/dev/null)" 2>/dev/null; for i in $(seq 1 25); do ss -tln 2>/dev/null | grep -q ":8081" || return 0; sleep 0.2; done; echo " WARN: 8081 still bound after stop"; } + +rc_new=9; rc_old=9 + +echo "=== NEW cert (fixed generator: DNS + IP:$DIAL,IP:10.10.0.120) ===" +gen "$DNS,IP:$DIAL,IP:10.10.0.120" +echo " SAN: $(openssl x509 -in "$D/redir_cert.pem" -noout -ext subjectAltName | tail -1 | tr -s ' ')" +if run_server; then + python3 "$(dirname "$0")/roster-cert-verify.py" "$D/redir_cert.pem" "$DIAL" 8081; rc_new=$? + stop_server +fi + +echo "=== OLD cert (DNS-only, the failing production shape) ===" +gen "$DNS" +if run_server; then + python3 "$(dirname "$0")/roster-cert-verify.py" "$D/redir_cert.pem" "$DIAL" 8081; rc_old=$? + stop_server +fi + +echo "=== verdict ===" +echo " new cert exit=$rc_new (want 0 = verified+200)" +echo " old cert exit=$rc_old (want 1 = verify failed)" +if [ "$rc_new" = 0 ] && [ "$rc_old" = 1 ]; then + echo " PASS: the real roster server serves a by-IP-verifiable cert after the fix, not before" +else + echo " FAIL: unexpected outcome" +fi +rm -rf "$D" \ No newline at end of file diff --git a/scripts/roster-cert-verify.py b/scripts/roster-cert-verify.py new file mode 100755 index 0000000..13a21d7 --- /dev/null +++ b/scripts/roster-cert-verify.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Connect to the roster server and VERIFY its cert by the dialed IP, then GET the roster. + +Mirrors the client's failing path: dial the roster by IP over TLS and validate the +presented certificate against that IP. Exit 0 only if the cert verifies AND the roster +XML comes back 200; exit 1 on cert-verify failure (the certificate_unknown class). + +argv: +""" +import socket +import ssl +import sys + +cafile, dial_ip, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) + +ctx = ssl.create_default_context(cafile=cafile) # trust the server's self-signed cert as CA +ctx.check_hostname = True +try: + ctx.minimum_version = ssl.TLSVersion.TLSv1 +except Exception: + pass +try: + ctx.set_ciphers("ALL:@SECLEVEL=0") +except Exception: + pass + +try: + raw = socket.create_connection((dial_ip, port), timeout=8) +except Exception as e: + print(f" CONNECT-FAIL {type(e).__name__}: {e}") + sys.exit(2) + +try: + # server_hostname is the IP the roster is dialed by; ssl matches it against the + # cert's iPAddress SANs — exactly the check the DNS-only cert failed. + s = ctx.wrap_socket(raw, server_hostname=dial_ip) +except ssl.SSLCertVerificationError as e: + print(f" VERIFY-FAIL {e.verify_message or e}") + sys.exit(1) +except Exception as e: + print(f" TLS-FAIL {type(e).__name__}: {e}") + sys.exit(1) + +try: + ver, cipher = s.version(), s.cipher()[0] + req = (f"GET /fifa17/fut/rosterupdate.xml HTTP/1.1\r\nHost: {dial_ip}:{port}\r\n" + "Connection: close\r\n\r\n") + s.sendall(req.encode()) + buf = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + buf += chunk + s.close() + status = buf.split(b"\r\n", 1)[0].decode(errors="replace") + body = buf.split(b"\r\n\r\n", 1)[1] if b"\r\n\r\n" in buf else b"" + print(f" VERIFIED {ver} {cipher}") + print(f" {status} body={len(body)}B head={body[:60]!r}") + sys.exit(0 if status.endswith("200 OK") and body else 3) +except Exception as e: + print(f" GET-FAIL {type(e).__name__}: {e}") + sys.exit(3)