fifa17-recon: take running-backend versions of 8 runtime files (direction fix)

The earlier reconcile committed the local working-tree versions of these
files, which are OLDER than the deployed backend. The running container (C)
is byte-identical to docker/fifa17-python/tools (B) and is a strict superset:
it adds profile_path_for/select_account/ensure_security_question (fut_store),
safe_header_for_log/safe_request_path/security_question_route (utas_server),
account_sync_route/_match_call/match_ready_body, plus POW balance fields and
match lifecycle support, with zero unique local functions lost.

Reconciled tree is now a strict superset of B with every shared file
byte-identical; verified via md5 map (0 missing, 0 differing).
This commit is contained in:
funman300
2026-08-10 17:12:27 -07:00
parent 695421cfd4
commit 83539e33ec
8 changed files with 419 additions and 70 deletions
+92 -33
View File
@@ -128,14 +128,52 @@ CLIENT_ID = ACCOUNT.CLIENT_ID
PLATFORM = ACCOUNT.PLATFORM
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n"
# ================================================================== config
HOST = "127.0.0.1"
def refresh_account_identity():
"""Refresh launcher-selected identity before constructing a Blaze session.
The account sync endpoint runs in the separate UTAS process and atomically
replaces the shared active-account file. Blaze snapshots these aliases for
its response builders, so refresh them once at each new TCP session.
"""
global PERSONA_ID, PERSONA_NAME, USER_ID, EXT_ID, EMAIL, ACCOUNT_LOCALE_FALLBACK
ACCOUNT.load(force=True)
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
USER_ID = ACCOUNT.user_id
EXT_ID = ACCOUNT.ext_id
EMAIL = ACCOUNT.email
ACCOUNT_LOCALE_FALLBACK = ACCOUNT.account_locale_int
# ================================================================== config
#
# Client/server split support (OpenFUT dev-container): two env vars, both
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
# 105-local this is 127.0.0.1; on the 120 server it is the
# server's LAN IP so the game dials 120 directly after the
# first (hook/DNAT-redirected) contact.
import os as _os_cfg
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
def _ip_str_to_u32(ip):
"""Dotted-quad -> big-endian u32 (matches the original (127<<24)|1 layout).
Falls back to loopback if the advertise value isn't a bare IPv4 literal."""
try:
a, b, c, d = (int(x) for x in ip.split("."))
return (a << 24) | (b << 16) | (c << 8) | d
except Exception:
return (127 << 24) | 1
HOST = _BIND
REDIR_PORT = 42127
BLAZE_PORT = 42130
NUCLEUS_PORT = 42131
BLAZE_IP_STR = "127.0.0.1"
BLAZE_IP_U32 = (127 << 24) | 1
BLAZE_IP_STR = _ADVERTISE
BLAZE_IP_U32 = _ip_str_to_u32(_ADVERTISE)
LOG = "/tmp/blaze_responder.log"
RXDIR = "/tmp/blaze_rx"
HERE = os.path.dirname(os.path.abspath(__file__))
@@ -157,7 +195,9 @@ REPLY_EMPTY_TO_UNKNOWN = True
# (grid-blaze order) or after (pamplona order). Both are reported to work.
NOTIFY_BEFORE_LOGIN_REPLY = False
DUMP_FRAMES = True
# Raw Fire2 frames and decoded TDF can contain auth/session material. Keep the
# reverse-engineering capture path, but require an explicit opt-in for it.
DUMP_FRAMES = os.environ.get("OPENFUT_BLAZE_DUMP_FRAMES") == "1"
_log_lock = threading.Lock()
@@ -525,7 +565,7 @@ OSDK_TICKER = []
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
ROSTER_HOST = "127.0.0.1:8081"
ROSTER_HOST = "%s:8081" % _ADVERTISE
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
OSDK_ROSTER = [
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
@@ -603,7 +643,7 @@ CLIENT_CONFIGS = {
# /etc/hosts easw.easports.com->127.0.0.1 redirect. MUST be exactly "http://127.0.0.1:8099/"
# (scheme + trailing slash mandatory on the auth path). Do NOT serve FUT_TARGET_PORT
# (bug @0x1801808e8 reads FUT_MAX_HOPS instead) nor FUT/MODULE_BASEURL_* (dead code).
UTAS_BASE = "http://127.0.0.1:8099/"
UTAS_BASE = "http://%s:8099/" % _ADVERTISE
FUT_RS4_MODULES = [
"AUCTIONHOUSE", "CLUB_USER", "CLUB_INFO", "CLUB", "DREAM", "SQUAD",
"DELETE_SQUAD", "LBOPTIONS", "LBDEFAULT", "PAFPRACTICE", "UT", "USER",
@@ -739,10 +779,12 @@ FUT_RS4_CONFIG = (
def client_config_for(cfid: str) -> list:
"""-> sorted [(key, value)]. Unknown CFID -> [] (an EMPTY MAP, which we
still wrap in a present CONF field -- never an empty frame).
FUT_RS4_* base-URL keys ride on EVERY CFID (merged '_all' store; which section
CardsDLL reads is unproven, so serve them everywhere)."""
"""Return sorted config rows for one section.
Unknown CFIDs still receive the shared FUT/content/POW rows because those
consumers read the merged ``_all`` store and the contributing section is
unproven. The response always carries a present CONF field.
"""
# OSDK_POW rides on EVERY CFID for the same reason FUT_RS4_* does: powdll's
# FUN_18005a460 reads FIFA_POW_URL out of the merged '_all' store, and which
# section it happens to read is unproven. Empty list when FUT_POW is unset, so
@@ -774,7 +816,7 @@ def qos_config() -> "OrderedDict":
has NO SVID, unlike Mirror's Edge Catalyst)."""
return OrderedDict([
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
("PSA", (STRING, "127.0.0.1")),
("PSA", (STRING, _ADVERTISE)),
("PSP", (INT, 17502)),
]))),
("LNP", (INT, 10)),
@@ -1100,7 +1142,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
client to have a well-formed config and then fail to connect quietly rather
than resolve a real EA hostname."""
tele = OrderedDict([ # GetTelemetryServerResponse (15)
("ADRS", (STRING, "127.0.0.1")),
("ADRS", (STRING, _ADVERTISE)),
("ANON", (INT, 0)),
("DISA", (STRING, "")),
("EDCT", (INT, 0)),
@@ -1117,7 +1159,7 @@ def post_auth_response_fields(sess: Session) -> "OrderedDict":
("SVNM", (STRING, "telemetry-openfut")),
])
tick = OrderedDict([ # GetTickerServerResponse (3)
("ADRS", (STRING, "127.0.0.1")),
("ADRS", (STRING, _ADVERTISE)),
("PORT", (INT, 8999)),
("SKEY", (STRING, "")),
])
@@ -1261,8 +1303,10 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
log(" -- client locale 0x%08x captured for ALOC" % loc)
resp = preauth_response_fields(service_name=sess.service_name)
payload = encode_tdf(resp)
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
% (sess.service_name, len(payload), heat2.dump(resp)))
log(" -> PreAuthResponse (INST=%r, %d payload bytes)"
% (sess.service_name, len(payload)))
if DUMP_FRAMES:
log(" -> PreAuthResponse TDF:\n%s" % heat2.dump(resp))
return [reply_to(hdr, payload)]
if cmd == CMD_PING:
@@ -1276,8 +1320,9 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
n = len(resp["CONF"][1][2])
log(" -> FetchConfigResponse CFID=%r -> %d key(s)%s"
% (cfid, n, "" if n else " (EMPTY MAP, unknown CFID)"))
for k, v in resp["CONF"][1][2]:
log(" %-32s = %s" % (k, v))
if DUMP_FRAMES:
for k, v in resp["CONF"][1][2]:
log(" %-32s = %s" % (k, v))
return [reply_to(hdr, encode_tdf(resp))]
if cmd == CMD_POSTAUTH:
@@ -1311,12 +1356,13 @@ def dispatch(hdr: dict, fields, raw_payload: bytes, sess: Session) -> list:
sess.auth_code = get_str(fields or {}, "AUTH", "")
sess.logged_in = True
sess.login_time = int(time.time())
log(" == Authentication::login AUTH=%r (accepted WITHOUT Nucleus "
"validation -- forged offline session)" % sess.auth_code)
log(" == Authentication::login AUTH=[REDACTED] "
"(accepted as an offline OpenFUT session)")
resp = login_response_fields(sess)
payload = encode_tdf(resp)
log(" -> LoginResponse (%d bytes):\n%s"
% (len(payload), heat2.dump(resp)))
log(" -> LoginResponse (%d bytes)" % len(payload))
if DUMP_FRAMES:
log(" -> LoginResponse TDF:\n%s" % heat2.dump(resp))
notifs = build_login_notifications(sess, sess.login_time)
out = []
if NOTIFY_BEFORE_LOGIN_REPLY:
@@ -1467,9 +1513,10 @@ _frame_counter = [0]
def blaze_handle(raw: socket.socket, addr) -> None:
refresh_account_identity()
log("*** BLAZE CONNECT from %s ***" % (addr,))
sess = Session()
log(" session key minted: %s" % sess.session_key)
log(" session key minted: [REDACTED]")
buf = bytearray()
raw.settimeout(300)
try:
@@ -1500,10 +1547,10 @@ def blaze_handle(raw: socket.socket, addr) -> None:
MSGTYPE_NAME.get(hdr["msg_type"], hdr["msg_type"]),
hdr["msg_num"], hdr["user_index"], hdr["options"],
hdr["metadata_len"], hdr["payload_len"]))
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
if metadata:
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
if DUMP_FRAMES:
log("RX #%d HEX:\n%s" % (n, hexdump(frame)))
if metadata:
log("RX #%d METADATA:\n%s" % (n, hexdump(metadata)))
try:
os.makedirs(RXDIR, exist_ok=True)
fn = os.path.join(RXDIR, "rx_%04d_%04x_%04x.bin"
@@ -1518,7 +1565,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
if payload:
try:
fields = decode_tdf(payload)
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
if DUMP_FRAMES:
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
except Exception as e:
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
else:
@@ -1539,7 +1587,8 @@ def blaze_handle(raw: socket.socket, addr) -> None:
ohdr["msg_type"]),
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
ohdr["msg_num"], len(out), ohdr["payload_len"]))
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
if DUMP_FRAMES:
log("TX #%d.%d HEX:\n%s" % (n, k, hexdump(out, limit=1024)))
except ConnectionResetError:
log("BLAZE %s: connection reset by client" % (addr,))
except Exception as e:
@@ -1637,6 +1686,10 @@ def redir_handle(raw: socket.socket, addr) -> None:
# client can never reach accounts.ea.com. Note the exact spacing in the JSON:
# the client searches for the literal '"access_token" : "'.
def nucleus_sent_log(addr, size):
return "NUCLEUS SENT %s %dB access_token=[REDACTED]" % (addr, size)
def nucleus_handle(raw: socket.socket, addr) -> None:
try:
raw.settimeout(10)
@@ -1649,9 +1702,9 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
head, _, rest = req.partition(b"\r\n\r\n")
line0 = head.split(b"\r\n", 1)[0].decode(errors="replace") if head else ""
log("NUCLEUS REQ %s: %s" % (addr, line0))
if head:
if head and DUMP_FRAMES:
log("NUCLEUS HEADERS:\n%s" % head.decode(errors="replace"))
if rest:
if rest and DUMP_FRAMES:
log("NUCLEUS BODY: %r" % rest[:512])
token = "OPENFUT_" + "".join(
@@ -1665,7 +1718,7 @@ def nucleus_handle(raw: socket.socket, addr) -> None:
b"Cache-Control: no-store\r\nContent-Length: "
+ str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
raw.sendall(out)
log("NUCLEUS SENT %s %dB access_token=%s" % (addr, len(out), token))
log(nucleus_sent_log(addr, len(out)))
except Exception as e:
log("NUCLEUS ERR %s: %s" % (addr, e))
finally:
@@ -1717,6 +1770,10 @@ def _selftest() -> None:
sess.account_locale = 0x656E5553
now = 1469000000
nucleus_summary = nucleus_sent_log(("127.0.0.1", 1234), 380)
assert "[REDACTED]" in nucleus_summary
assert "OPENFUT_selftest_secret" not in nucleus_summary
# ---- 1. preAuth still round-trips (regression guard vs v2)
pre = preauth_response_fields()
p = _check_roundtrip("PreAuthResponse", pre)
@@ -1740,9 +1797,11 @@ def _selftest() -> None:
assert items == client_config_for(cfid), cfid
print("[ok] fetchClientConfig %-26s %2d keys, %4d payload bytes"
% (cfid, len(items), len(pb)))
assert client_config_for("TOTALLY_UNKNOWN") == [], "unknown CFID must be []"
shared = sorted(FUT_RS4_CONFIG + FUT_CONTENT_CONFIG + OSDK_POW)
assert client_config_for("TOTALLY_UNKNOWN") == shared, \
"unknown CFID must carry only the shared merged-store rows"
assert len(fetch_config_response_fields("TOTALLY_UNKNOWN")) == 1, \
"unknown CFID must still carry a CONF field (empty map, not empty frame)"
"unknown CFID must still carry a CONF field"
# ---- 3. LoginResponse
lr = login_response_fields(sess)