e5bfe58fc8
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
208 lines
7.8 KiB
Python
208 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Read the UTAS request log, FILTERED TO THE REAL CLIENT BY DEFAULT.
|
|
|
|
futlog.py what the game asked for, one line each
|
|
futlog.py -v ... with request bodies and responses
|
|
futlog.py --all include our own curl/python probes
|
|
futlog.py --probes only our probes
|
|
futlog.py -s summary: request counts per endpoint
|
|
futlog.py --unmapped only requests that fell through to the catch-all
|
|
futlog.py -p item -v only paths matching a regex
|
|
futlog.py --since 11:15 only from that clock time onward
|
|
|
|
WHY THE FILTER IS THE DEFAULT, and why every future analysis tool here should do the
|
|
same. The log records User-Agent. The real client sends ProtoHttp; this project's own
|
|
probes send curl/* or Python-urllib/*. Reading the log unfiltered gave the project a
|
|
materially wrong picture of itself: /clubUser and /user/list had 93 and 180 recorded
|
|
hits and NOT ONE came from the game, while endpoints assumed to be exercised turned out
|
|
to be exercised only by us. Any claim of the form "the client asks for X" made before
|
|
this distinction existed is unsupported until re-checked with the filter on.
|
|
|
|
The corollary matters just as much: do NOT probe the live server while the client is
|
|
running. It pollutes the evidence you are collecting. Probe a scratch instance on
|
|
another port instead.
|
|
|
|
The log defaults to utas_server.py's own LOG path and can be pointed elsewhere with
|
|
FUT_LOG or a positional argument, because the harness has been started both ways.
|
|
"""
|
|
import argparse
|
|
import collections
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
LOG = os.environ.get("FUT_LOG", "/tmp/utas_server.log")
|
|
FALLBACKS = ("/tmp/utas_server.log", "/tmp/utas.log")
|
|
|
|
# The game. Everything else in this log is us.
|
|
CLIENT_UA = "ProtoHttp"
|
|
|
|
REQ_RE = re.compile(r"^\[(\d\d:\d\d:\d\d)\] (GET|POST|PUT|DELETE|HEAD|PATCH) (\S+)")
|
|
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()
|