70a64e3709
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
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()
|