fifa17-recon: futlog.py -- client-filtered log reader, and a correction it caught

Implements the standing requirement recorded in priority-2026-08 S5: the User-Agent
filter is now the DEFAULT in the log tooling, not an option. The real client sends
ProtoHttp; our own probes send curl/* or Python-urllib/*. Reading the log unfiltered
gave this project a materially wrong picture of itself (/clubUser and /user/list had 93
and 180 hits, none from the game).

The old futlog.py was a one-off with a hardcoded path and no notion of who made the
request. Replaced with a real tool: timeline or summary, body/response display, path
regex, time window, status filter, and an --unmapped view that lists the endpoints the
client wants and we catch-all. --all and --probes exist for when you deliberately want
our own traffic.

Over the full 3044-request history: 486 requests came from the game.

IT IMMEDIATELY CAUGHT ME OVERSTATING SOMETHING. Yesterday's commit called the PUT /item
request shape "captured for the first time (the client had never successfully reached
this path)". False. There are NINE client PUT /item requests in the log, eight of them
during the failed attempts, every one carrying swap and tradeId:

  08:45:11 08:51:13 08:55:21 08:58:19 09:10:52 09:15:26 09:22:31 09:37:12 | 11:15:48

The request was on the wire and in the log the whole time. What was new was reading it.
Same class of error as the truncated decompile in S16: evidence already collected and
not looked at. REBUILD_RESEARCH S17 corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
funman300
2026-08-04 11:28:42 -07:00
parent 5b139864ee
commit e5bfe58fc8
2 changed files with 220 additions and 56 deletions
+18 -6
View File
@@ -679,17 +679,29 @@ wrong, and §16 explains exactly which truncated decompile produced it.
Both defaults are flipped in `utas_server.py`: `FUT_MOVE_BODY=ack`, and Both defaults are flipped in `utas_server.py`: `FUT_MOVE_BODY=ack`, and
`FUT_PACK_AUTOCLUB` now defaults **off**. `FUT_PACK_AUTOCLUB` now defaults **off**.
### New intel: the request shape ### The request shape
Captured for the first time (the client had never successfully reached this path):
```json ```json
{"itemData":[{"id":100000125,"pile":"club","swap":0,"tradeId":0}, ...]} {"itemData":[{"id":100000125,"pile":"club","swap":0,"tradeId":0}, ...]}
``` ```
`swap` and `tradeId` accompany `id` and `pile`. We ignore both and the move `swap` and `tradeId` accompany `id` and `pile`. We ignore both and the move succeeded,
succeeded, so neither is load-bearing for a pending-pile-to-club move. `TODO/CONFIRM` so neither is load-bearing for a pending-pile-to-club move. `TODO/CONFIRM` what `swap`
what `swap` means for a squad-slot exchange, where it plausibly is. means for a squad-slot exchange, where it plausibly is.
**Correction to the first version of this section**, which called this shape "captured
for the first time" because "the client had never successfully reached this path". That
is wrong. `futlog.py` over the full history shows **nine** client `PUT /item` requests,
eight of them before today, all carrying `swap` and `tradeId`:
```
08:45:11 08:51:13 08:55:21 08:58:19 09:10:52 09:15:26 09:22:31 09:37:12 11:15:48
the seven failed attempts the fix
```
The request was on the wire and in the log the entire time. What was new today was not
the capture, it was reading it. That is the same class of error as the truncated
decompile in §16: evidence already collected, not looked at.
### Process note, and it is not a small one ### Process note, and it is not a small one
+202 -50
View File
@@ -1,55 +1,207 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Summarise /tmp/utas_server.log since the last MARKER line: one row per request """Read the UTAS request log, FILTERED TO THE REAL CLIENT BY DEFAULT.
(verb, path, status), plus the squad/massinfo detail lines. Keeps the transcript
small instead of dumping the raw log."""
import re, sys, collections
LOG = "/tmp/utas_server.log" futlog.py what the game asked for, one line each
raw = open(LOG, errors="replace").read().splitlines() futlog.py -v ... with request bodies and responses
# start after the last marker (or the last server banner) futlog.py --all include our own curl/python probes
start = 0 futlog.py --probes only our probes
for i, l in enumerate(raw): futlog.py -s summary: request counts per endpoint
if "=== MARKER" in l or "=== utas_server" in l: futlog.py --unmapped only requests that fell through to the catch-all
start = i futlog.py -p item -v only paths matching a regex
lines = raw[start:] futlog.py --since 11:15 only from that clock time onward
REQ = re.compile(r"^\[[\d:]+\] (GET|PUT|POST|DELETE|HEAD|PATCH) (\S+)") WHY THE FILTER IS THE DEFAULT, and why every future analysis tool here should do the
RESP = re.compile(r"^\[[\d:]+\] -> (\d+) (.*)") same. The log records User-Agent. The real client sends ProtoHttp; this project's own
rows, counts, pending = [], collections.Counter(), None probes send curl/* or Python-urllib/*. Reading the log unfiltered gave the project a
notes = [] materially wrong picture of itself: /clubUser and /user/list had 93 and 180 recorded
for l in lines: hits and NOT ONE came from the game, while endpoints assumed to be exercised turned out
m = REQ.match(l) to be exercised only by us. Any claim of the form "the client asks for X" made before
if m: this distinction existed is unsupported until re-checked with the filter on.
pending = (m.group(1), m.group(2).split("?")[0], m.group(2))
continue
m = RESP.match(l)
if m and pending:
rows.append((pending[0], pending[1], m.group(1), len(m.group(2))))
counts[(pending[0], pending[1])] += 1
pending = None
continue
if "UNMAPPED" in l or "SQUAD:" in l or "STORE:" in l or "MARKET:" in l or "ITEM:" in l:
notes.append(l.strip())
print(f"{len(rows)} requests since marker\n") The corollary matters just as much: do NOT probe the live server while the client is
print("verb path n") running. It pollutes the evidence you are collecting. Probe a scratch instance on
for (v, p), n in counts.most_common(): another port instead.
print(f"{v:6} {p:48} {n}")
squad = [r for r in rows if "/squad" in r[1]] The log defaults to utas_server.py's own LOG path and can be pointed elsewhere with
print(f"\n--- squad family ({len(squad)}) ---") FUT_LOG or a positional argument, because the harness has been started both ways.
for r in squad: """
print(" ", r[0], r[1], "->", r[2], f"({r[3]}b)") import argparse
# the live URL is /squad/<id> (e.g. /squad/0), not a bare /squad -- match on the import collections
# segment, not endswith, or the headline result reads as a false negative. import os
print("\nPUT /squad seen:", any(r[0] == "PUT" and "/squad" in r[1] for r in rows)) import re
mi = [r for r in rows if "userMassInfo" in r[1]] import sys
print("userMassInfo requests:", len(mi), [r[2] for r in mi])
if notes: LOG = os.environ.get("FUT_LOG", "/tmp/utas_server.log")
print("\n--- notes ---") FALLBACKS = ("/tmp/utas_server.log", "/tmp/utas.log")
for n in notes[-25:]:
print(" ", n) # The game. Everything else in this log is us.
last = rows[-6:] CLIENT_UA = "ProtoHttp"
print("\n--- last 6 requests (where it stopped) ---")
for r in last: REQ_RE = re.compile(r"^\[(\d\d:\d\d:\d\d)\] (GET|POST|PUT|DELETE|HEAD|PATCH) (\S+)")
print(" ", r[0], r[1], "->", r[2]) RES_RE = re.compile(r"^\[\d\d:\d\d:\d\d\]\s+-> (\d{3}) ?(.*)$")
HDR_RE = re.compile(r"^\[\d\d:\d\d:\d\d\]\s+([A-Za-z-]+): (.*)$")
BODY_RE = re.compile(r"^\[\d\d:\d\d:\d\d\]\s+body: (.*)$")
NOTE_RE = re.compile(r"^\[\d\d:\d\d:\d\d\]\s+([A-Z]{3,}): (.*)$")
class Req(object):
__slots__ = ("t", "method", "path", "ua", "body", "status", "resp", "notes", "unmapped")
def __init__(self, t, method, path):
self.t, self.method, self.path = t, method, path
self.ua = ""
self.body = ""
self.status = ""
self.resp = ""
self.notes = []
self.unmapped = False
@property
def is_client(self):
return CLIENT_UA in self.ua
@property
def base(self):
"""Path without the query string, for grouping."""
return self.path.split("?", 1)[0]
def resolve(path):
if path or os.path.exists(LOG):
return path or LOG
for f in FALLBACKS:
if os.path.exists(f):
return f
return LOG
def parse(path):
"""Log lines to Req objects. Tolerates a truncated final entry."""
out = []
cur = None
try:
fh = open(path, errors="replace")
except IOError as e:
sys.exit("cannot read %s: %s" % (path, e))
with fh:
for line in fh:
line = line.rstrip("\n")
m = REQ_RE.match(line)
if m:
cur = Req(*m.groups())
out.append(cur)
continue
if cur is None:
continue
m = RES_RE.match(line)
if m:
cur.status, cur.resp = m.group(1), m.group(2)
continue
m = BODY_RE.match(line)
if m:
cur.body = m.group(1)
continue
if "UNMAPPED" in line:
cur.unmapped = True
continue
m = HDR_RE.match(line)
if m and m.group(1).lower() == "user-agent":
cur.ua = m.group(2)
continue
m = NOTE_RE.match(line)
if m:
cur.notes.append(line.split("] ", 1)[1].strip())
return out
def select(reqs, a):
"""Apply the filters. Client-only unless told otherwise."""
if a.probes:
reqs = [r for r in reqs if not r.is_client]
elif not a.all:
reqs = [r for r in reqs if r.is_client]
if a.since:
reqs = [r for r in reqs if r.t >= a.since]
if a.until:
reqs = [r for r in reqs if r.t <= a.until]
if a.path:
rx = re.compile(a.path)
reqs = [r for r in reqs if rx.search(r.path)]
if a.unmapped:
reqs = [r for r in reqs if r.unmapped]
if a.status:
reqs = [r for r in reqs if r.status == a.status]
return reqs
def cut(s, n):
return s if len(s) <= n else s[: n - 1] + "\u2026"
def main():
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0],
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("logfile", nargs="?", default="")
g = p.add_mutually_exclusive_group()
g.add_argument("--all", action="store_true", help="include our own probes (default: client only)")
g.add_argument("--probes", action="store_true", help="show ONLY our probes")
p.add_argument("-v", "--verbose", action="store_true", help="show request bodies and responses")
p.add_argument("-s", "--summary", action="store_true", help="counts per endpoint instead of a timeline")
p.add_argument("-p", "--path", help="regex the path must match")
p.add_argument("--unmapped", action="store_true", help="only catch-all fallthroughs")
p.add_argument("--status", help="only this HTTP status")
p.add_argument("--since", help="HH:MM or HH:MM:SS lower bound")
p.add_argument("--until", help="HH:MM or HH:MM:SS upper bound")
p.add_argument("-w", "--width", type=int, default=150, help="truncate bodies to this width")
a = p.parse_args()
for attr in ("since", "until"):
v = getattr(a, attr)
if v and len(v) == 5:
setattr(a, attr, v + (":00" if attr == "since" else ":59"))
logfile = resolve(a.logfile)
everything = parse(logfile)
reqs = select(everything, a)
n_client = sum(1 for r in everything if r.is_client)
scope = "our probes" if a.probes else ("client + probes" if a.all else "client only")
print("%s: %d requests total, %d from the game (%s), showing %d [%s]"
% (logfile, len(everything), n_client, CLIENT_UA, len(reqs), scope))
if not reqs:
if not a.all and not a.probes and n_client == 0 and everything:
print("\nNothing from the game in this log. Every request here is ours.")
print("If you expected client traffic, the client never reached the server:")
print("check that it got past auth, and that the server was up the whole time.")
return
if a.summary:
by = collections.Counter(r.base for r in reqs)
unmapped = collections.Counter(r.base for r in reqs if r.unmapped)
print()
for path, n in by.most_common():
flag = " UNMAPPED" if unmapped.get(path) else ""
print(" %5d %s%s" % (n, path, flag))
if unmapped:
print("\n%d request(s) fell through to the catch-all. Those are endpoints the"
% sum(unmapped.values()))
print("client wants and we do not serve, and the binary's URL template table")
print("does not list them: four such suffix endpoints have been found this way.")
return
print()
for r in reqs:
mark = " !!UNMAPPED" if r.unmapped else ""
print(" %s %-6s %-3s %s%s" % (r.t, r.method, r.status or "?", cut(r.path, a.width), mark))
if a.verbose:
if r.body:
print(" req: %s" % cut(r.body, a.width))
for n in r.notes:
print(" log: %s" % cut(n, a.width))
if r.resp:
print(" res: %s" % cut(r.resp, a.width))
if __name__ == "__main__":
main()