Files
OpenFUT/scripts/test-utas-observe.py
T
funman300 cdea85e214 utas: standalone recording proxy that tees rather than rebuilds
UTAS needs a real request/response corpus before any Rust is written: it
is where protocol shape and FUT state start being coupled, so guessing is
worse here than it was for Blaze. The oracle truncates logged bodies at
~200 chars, and raising that cap would mean editing the behavioural
specification to make it easier to copy -- backwards. A proxy gets the
same evidence and leaves the oracle untouched.

THE DESIGN RULE: TEE, DO NOT REBUILD.

UTAS is plaintext HTTP/1.1 on ThreadingHTTPServer, so keep-alive,
pipelining and chunked transfer are all live. A proxy that parses a
request and re-emits it can corrupt the traffic it exists to observe --
and that corruption would present as a UTAS bug, pointing the
investigation in exactly the wrong direction. So bytes are copied
verbatim in both directions and a second copy goes to disk; transactions
are reconstructed later, offline, from that copy. A parser bug therefore
spoils the record and never the session.

Standalone, NOT in the container, so the same tool can later sit in front
of a Rust UTAS host and replay an identical captured request against both.

Two layers, as with the Blaze captures: raw/*.ofcap is exact bytes at mode
0600 and gitignored; sanitized/transactions.jsonl is the committed
artefact. Bodies are preserved EXACTLY and sanitised second -- only
known-secret headers and JSON keys are replaced, structure is never
reshaped, and every redaction is recorded in the transaction so a reader
knows what was touched.

Captured per transaction: connection id, sequence, relative and wall
time, elapsed ms, method, path, query, HTTP version, headers IN RECEIVED
ORDER as pairs (a dict would drop duplicates and ordering), raw body and
length for both directions, status, and observed keep-alive.

Verified as two independent properties, because they fail differently:
transparency (bytes through the proxy identical to bytes direct, Date
masked, with the mask asserted to have fired) and fidelity (parsed
transactions match what was sent, including a dechunked response and a
300-byte POST body). 5/5 mutations killed, including "record but do not
forward", "drop the last byte of every chunk" and "stop redacting".

scripts/test-utas-observe.py is committed alongside it: a capture tool
nobody can re-verify is not evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:13:06 +00:00

211 lines
8.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Prove the UTAS recorder is byte-transparent and parses what it saw.
Two independent properties, because they fail differently:
TRANSPARENCY what the client receives through the proxy is byte-identical to
what it receives going direct. If this breaks, the tool corrupts
the session it exists to observe -- and the damage would look
like a UTAS bug.
FIDELITY the parsed transactions match what was actually sent. If this
breaks, only the record is wrong, which is why the design
separates the two.
The upstream here is a purpose-built server, not the live oracle: it can be made
to exercise keep-alive, bodies in both directions and chunked encoding on
demand, and no live client is at risk.
"""
import base64
import http.server
import json
import os
import shutil
import socket
import subprocess
import sys
import threading
import time
TOOL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "openfut-utas-observe.py")
SESSION = os.path.join(os.environ.get("TMPDIR", "/tmp"), "openfut-utas-observe-selftest")
fails = []
def check(name, ok, detail=""):
print((" ok " if ok else " FAIL ") + name + ((" " + detail) if detail and not ok else ""))
if not ok:
fails.append(name)
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # keep-alive, like the real UTAS
def log_message(self, *a):
pass
def _read_body(self):
n = int(self.headers.get("Content-Length", 0) or 0)
return self.rfile.read(n) if n else b""
def do_GET(self):
if self.path == "/chunked":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
for part in (b'{"a":1,', b'"b":[2,3],', b'"c":"end"}'):
self.wfile.write(b"%x\r\n" % len(part) + part + b"\r\n")
self.wfile.write(b"0\r\n\r\n")
return
body = json.dumps({"path": self.path}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
got = self._read_body()
body = json.dumps({"echo_len": len(got),
"echo": got.decode("latin1"),
"sid": "SECRET-SESSION-VALUE"}).encode()
self.send_response(201)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
return p
def raw_exchange(port, requests):
"""Send requests on ONE keep-alive connection; return all bytes received."""
s = socket.create_connection(("127.0.0.1", port), timeout=5)
for r in requests:
s.sendall(r)
time.sleep(0.15)
s.settimeout(1.5)
out = b""
try:
while True:
d = s.recv(65536)
if not d:
break
out += d
except socket.timeout:
pass
s.close()
return out
def main():
shutil.rmtree(SESSION, ignore_errors=True)
up_port, proxy_port = free_port(), free_port()
srv = http.server.ThreadingHTTPServer(("127.0.0.1", up_port), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
proxy = subprocess.Popen(
[sys.executable, TOOL, "record",
"--listen", "127.0.0.1:%d" % proxy_port,
"--upstream", "127.0.0.1:%d" % up_port,
"--session", SESSION],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
time.sleep(1.0)
body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode()
reqs = [
b"GET /ut/game/fifa17/userMassInfo HTTP/1.1\r\nHost: t\r\n\r\n",
b"POST /ut/game/fifa17/purchased/items HTTP/1.1\r\nHost: t\r\n"
b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + body,
b"GET /chunked HTTP/1.1\r\nHost: t\r\n\r\n",
b"GET /ut/game/fifa17/hub HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n",
]
print("== transparency ==")
direct = raw_exchange(up_port, reqs)
through = raw_exchange(proxy_port, reqs)
# The two exchanges happen seconds apart, so `Date:` legitimately differs.
# Mask it -- but ONLY it, and assert the mask actually fired, so this cannot
# quietly hide a real difference.
import re as _re
def mask(b):
return _re.sub(rb"Date: [^\r\n]+", b"Date: <M>", b)
dm, tm = mask(direct), mask(through)
check("masking Date actually applied", dm != direct and tm != through)
check("bytes through the proxy are identical to bytes direct (Date masked)",
dm == tm,
"direct=%dB through=%dB firstdiff=%s" % (
len(direct), len(through),
next((i for i in range(min(len(dm), len(tm))) if dm[i] != tm[i]), "len")))
check("nothing but Date differed in the unmasked bytes",
len(direct) == len(through))
check("all four responses arrived", through.count(b"HTTP/1.1 ") == 4,
"saw %d" % through.count(b"HTTP/1.1 "))
check("keep-alive was actually used (one connection, four responses)",
through.count(b"HTTP/1.1 ") == 4)
time.sleep(0.6)
proxy.terminate()
try:
proxy.wait(timeout=5)
except subprocess.TimeoutExpired:
proxy.kill()
print("== capture file ==")
raw = os.path.join(SESSION, "raw", "utas.ofcap")
check("raw capture exists", os.path.exists(raw))
check("raw capture is mode 0600",
oct(os.stat(raw).st_mode & 0o777) == "0o600",
oct(os.stat(raw).st_mode & 0o777))
print("== fidelity ==")
r = subprocess.run([sys.executable, TOOL, "parse", "--session", SESSION],
capture_output=True, text=True)
print(" " + r.stdout.strip().replace("\n", "\n "))
if r.stderr.strip():
print(" stderr: " + r.stderr.strip().replace("\n", "\n "))
txs = [json.loads(l) for l in
open(os.path.join(SESSION, "sanitized", "transactions.jsonl"))]
# Two client connections were made (direct + through); only one went via the proxy.
check("four transactions parsed", len(txs) == 4, "got %d" % len(txs))
if len(txs) == 4:
check("methods and paths in order",
[(t["request"]["method"], t["request"]["path"]) for t in txs] ==
[("GET", "/ut/game/fifa17/userMassInfo"),
("POST", "/ut/game/fifa17/purchased/items"),
("GET", "/chunked"),
("GET", "/ut/game/fifa17/hub")])
check("request body preserved byte-for-byte",
base64.b64decode(txs[1]["request"]["body_b64"]) == body)
check("statuses recorded", [t["response"]["status"] for t in txs] == [200, 201, 200, 200])
chunked = base64.b64decode(txs[2]["response"]["body_b64"])
check("chunked response body dechunked correctly",
chunked == b'{"a":1,"b":[2,3],"c":"end"}', chunked[:60].decode("latin1"))
check("headers kept in received order, as pairs",
txs[0]["response"]["headers"][0][0] == "Server")
check("keep-alive recorded", txs[0]["connection"]["requests_on_this_connection"] == 4)
check("timing recorded", all(t["elapsed_ms"] is not None for t in txs))
# Sanitiser: the secret goes, the rest of the body does not.
echoed = json.loads(base64.b64decode(txs[1]["response"]["body_b64"]))
check("secret JSON value redacted", echoed.get("sid") == "<REDACTED>")
check("non-secret payload preserved exactly", echoed.get("echo_len") == len(body))
check("redaction is recorded, not silent", "redacted" in txs[1])
srv.shutdown()
print()
print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails))
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())