cdea85e214
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>
211 lines
8.0 KiB
Python
Executable File
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())
|