fifa17-python: commit working FUT backend deployment (client/server split)
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.
This commit is contained in:
@@ -0,0 +1,578 @@
|
||||
#!/usr/bin/env python3
|
||||
# FROZEN: not started by openfut-fut.sh (see its SERVERS array); identity is NOT
|
||||
# sourced from fut_account.py here. The live pair is lsx_responder_v2.py +
|
||||
# blaze_responder_v3b.py -- edit those. Kept for reference/bisecting only.
|
||||
"""FIFA17 Blaze redirector + SESSION SERVER (v2).
|
||||
|
||||
Two listeners:
|
||||
|
||||
* TLS on 42127 -- the redirector. Answers POST /redirector/getServerInstance
|
||||
with a <serverinstanceinfo> pointing the client at 127.0.0.1:BLAZE_PORT
|
||||
(secure=0). UNCHANGED from blaze_responder.py -- it already works.
|
||||
|
||||
* Plain TCP on 42130 -- the Blaze session server. Properly frames Fire2,
|
||||
decodes the Heat2 TDF body, logs everything, and ANSWERS:
|
||||
Util(0x0009)/preAuth(0x0007) -> PreAuthResponse (the current gate)
|
||||
Util(0x0009)/ping(0x0002) -> PingResponse {STIM, TIME}
|
||||
msgType PING(4) -> PING_REPLY(5), empty body
|
||||
Everything else is logged in full and (optionally) answered with an empty
|
||||
REPLY so the client is never left hanging. See the TODO block near
|
||||
dispatch() for the next RPCs on the path.
|
||||
|
||||
CLEAN ROOM. Schema comes from (a) FIFA17.exe's own in-process TDF reflection
|
||||
metadata that we walked in live memory, (b) our own captured preAuth REQUEST,
|
||||
and (c) independent third-party clean-room BlazeSDK-15.x reimplementations used
|
||||
only to cross-check structure. No EA/FIFA leaked source was consulted.
|
||||
|
||||
Run: python3 blaze_responder_v2.py (binds 42127 + 42130)
|
||||
Log: /tmp/blaze_responder.log
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import heat2 # noqa: E402
|
||||
from heat2 import INT, STRING, STRUCT, LIST, MAP, encode_tdf, decode_tdf # noqa: E402
|
||||
|
||||
# ------------------------------------------------------------------ config
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
REDIR_PORT = 42127
|
||||
BLAZE_PORT = 42130
|
||||
BLAZE_IP_STR = "127.0.0.1"
|
||||
BLAZE_IP_U32 = (127 << 24) | 1 # 2130706433
|
||||
LOG = "/tmp/blaze_responder.log"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CERT = os.path.join(HERE, "redir_cert.pem")
|
||||
KEY = os.path.join(HERE, "redir_key.pem")
|
||||
|
||||
# If True, any RPC we do not implement still gets an empty REPLY frame so the
|
||||
# client's request does not time out. Flip to False to see which RPC the
|
||||
# client is actually blocking on.
|
||||
REPLY_EMPTY_TO_UNKNOWN = True
|
||||
|
||||
# Dump every frame we receive to /tmp/blaze_rx_<comp>_<cmd>_<n>.bin
|
||||
DUMP_FRAMES = True
|
||||
|
||||
_log_lock = threading.Lock()
|
||||
|
||||
|
||||
def log(m: str) -> None:
|
||||
line = "[%s] %s" % (time.strftime("%H:%M:%S"), m)
|
||||
with _log_lock:
|
||||
print(line, flush=True)
|
||||
with open(LOG, "a") as fh:
|
||||
fh.write(line + "\n")
|
||||
|
||||
|
||||
def hexdump(b: bytes, limit: int = 512) -> str:
|
||||
out = []
|
||||
for i in range(0, min(len(b), limit), 16):
|
||||
chunk = b[i:i + 16]
|
||||
txt = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk)
|
||||
out.append(" %04x: %-47s %s"
|
||||
% (i, binascii.hexlify(chunk, " ").decode(), txt))
|
||||
if len(b) > limit:
|
||||
out.append(" ... (%d more bytes)" % (len(b) - limit))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Fire2
|
||||
#
|
||||
# CORRECTED 16-byte big-endian header (heat2.build_fire2_frame /
|
||||
# heat2.parse_fire2_frame encode the OLD, WRONG layout -- do not use them):
|
||||
#
|
||||
# [0:4] u32 payload length
|
||||
# [4:6] u16 metadata length
|
||||
# [6:8] u16 component
|
||||
# [8:10] u16 command
|
||||
# [10:13] u24 msgNum <- 3 bytes; what we once read as "msgType"
|
||||
# [13] u8 (msgType << 5) | (userIndex & 0x1F)
|
||||
# [14] u8 options
|
||||
# [15] u8 reserved
|
||||
# wire = header(16) || metadata || payload
|
||||
#
|
||||
# There is NO error field in the Fire2 header (that is Fire v1's 12-byte frame).
|
||||
|
||||
FIRE2_HDR = 16
|
||||
|
||||
MESSAGE, REPLY, NOTIFICATION, ERROR_REPLY, PING, PING_REPLY = range(6)
|
||||
MSGTYPE_NAME = {0: "MESSAGE", 1: "REPLY", 2: "NOTIFICATION",
|
||||
3: "ERROR_REPLY", 4: "PING", 5: "PING_REPLY"}
|
||||
|
||||
COMP_AUTH = 0x0001
|
||||
COMP_GAMEMANAGER = 0x0004
|
||||
COMP_REDIRECTOR = 0x0005
|
||||
COMP_STATS = 0x0007
|
||||
COMP_UTIL = 0x0009
|
||||
COMP_MESSAGING = 0x000F
|
||||
COMP_ASSOCLISTS = 0x0019
|
||||
COMP_GAMEREPORTING = 0x001C
|
||||
COMP_USERSESSIONS = 0x7802
|
||||
|
||||
CMD_FETCHCLIENTCONFIG = 0x0001
|
||||
CMD_PING = 0x0002
|
||||
CMD_PREAUTH = 0x0007
|
||||
CMD_POSTAUTH = 0x0008
|
||||
CMD_SETCLIENTSTATE = 0x001C
|
||||
|
||||
# Util command table recovered from the binary's getCommandName switch.
|
||||
UTIL_CMDS = {
|
||||
0x01: "fetchClientConfig", 0x02: "ping", 0x03: "setClientData",
|
||||
0x04: "localizeStrings", 0x05: "getTelemetryServer", 0x06: "getTickerServer",
|
||||
0x07: "preAuth", 0x08: "postAuth", 0x0A: "userSettingsLoad",
|
||||
0x0B: "userSettingsSave", 0x0C: "userSettingsLoadAll",
|
||||
0x0E: "userSettingsDelete", 0x0F: "userSettingsLoadAllForUser",
|
||||
0x14: "filterForProfanity", 0x15: "fetchQosConfig",
|
||||
0x16: "setClientMetrics", 0x17: "setConnectionState",
|
||||
0x19: "getUserOptions", 0x1A: "setUserOptions", 0x1B: "suspendUserPing",
|
||||
0x1C: "setClientState",
|
||||
}
|
||||
COMP_NAMES = {
|
||||
COMP_AUTH: "Authentication", COMP_GAMEMANAGER: "GameManager",
|
||||
COMP_REDIRECTOR: "Redirector", COMP_STATS: "Stats", COMP_UTIL: "Util",
|
||||
COMP_MESSAGING: "Messaging", COMP_ASSOCLISTS: "AssociationLists",
|
||||
COMP_GAMEREPORTING: "GameReporting", COMP_USERSESSIONS: "UserSessions",
|
||||
}
|
||||
|
||||
|
||||
def rpc_name(component: int, command: int) -> str:
|
||||
comp = COMP_NAMES.get(component, "Component:0x%04x" % component)
|
||||
if component == COMP_UTIL:
|
||||
cmd = UTIL_CMDS.get(command, "cmd:0x%04x" % command)
|
||||
else:
|
||||
cmd = "cmd:0x%04x" % command
|
||||
return "%s::%s" % (comp, cmd)
|
||||
|
||||
|
||||
def fire2(component: int, command: int, msg_num: int, msg_type: int,
|
||||
payload: bytes = b"", metadata: bytes = b"",
|
||||
user_index: int = 0, options: int = 0) -> bytes:
|
||||
h = bytearray(16)
|
||||
struct.pack_into(">I", h, 0, len(payload))
|
||||
struct.pack_into(">H", h, 4, len(metadata))
|
||||
struct.pack_into(">H", h, 6, component & 0xFFFF)
|
||||
struct.pack_into(">H", h, 8, command & 0xFFFF)
|
||||
h[10] = (msg_num >> 16) & 0xFF
|
||||
h[11] = (msg_num >> 8) & 0xFF
|
||||
h[12] = msg_num & 0xFF
|
||||
h[13] = ((msg_type & 0x07) << 5) | (user_index & 0x1F)
|
||||
h[14] = options & 0xFF
|
||||
h[15] = 0
|
||||
return bytes(h) + metadata + payload
|
||||
|
||||
|
||||
def parse_fire2_header(buf: bytes) -> dict:
|
||||
return dict(
|
||||
payload_len=struct.unpack_from(">I", buf, 0)[0],
|
||||
metadata_len=struct.unpack_from(">H", buf, 4)[0],
|
||||
component=struct.unpack_from(">H", buf, 6)[0],
|
||||
command=struct.unpack_from(">H", buf, 8)[0],
|
||||
msg_num=(buf[10] << 16) | (buf[11] << 8) | buf[12],
|
||||
msg_type=(buf[13] >> 5) & 0x07,
|
||||
user_index=buf[13] & 0x1F,
|
||||
options=buf[14],
|
||||
reserved=buf[15],
|
||||
)
|
||||
|
||||
|
||||
def reply_to(hdr: dict, payload: bytes = b"", msg_type: int = REPLY) -> bytes:
|
||||
"""A Blaze reply echoes component/command/msgNum/userIndex verbatim and
|
||||
only overwrites the msgType bits."""
|
||||
return fire2(hdr["component"], hdr["command"], hdr["msg_num"], msg_type,
|
||||
payload, user_index=hdr["user_index"])
|
||||
|
||||
|
||||
# ------------------------------------------------------- PreAuthResponse
|
||||
#
|
||||
# Reconciled schema: reflection descriptor VA 0x144875600 (14 members) INTERSECT
|
||||
# the independent clean-room emulators. Members are emitted in ascending
|
||||
# packed-tag order; heat2.encode_tdf enforces that automatically.
|
||||
|
||||
# EA numeric title id. NOT reverse engineered -- onPreAuthResponse only
|
||||
# memcpy's ASRC/ESRC/RSRC, so any value is accepted here; Authentication
|
||||
# (component 1) may care later.
|
||||
TITLE_ID = "309111"
|
||||
|
||||
# Nucleus client id. Plausible convention, not RE'd.
|
||||
CLIENT_ID = "FIFA17-PC-SERVER-BLAZE"
|
||||
|
||||
# Persona namespace. Client caps this field at 32 bytes.
|
||||
PERSONA_NAMESPACE = "cem_ea_id"
|
||||
|
||||
PLATFORM = "pc"
|
||||
SERVER_VERSION = "Blaze 15.1.1.3.0 (OpenFUT)\n" # EA's real value ends in \n
|
||||
|
||||
# Component ids recovered from each component's own notification dispatcher in
|
||||
# FIFA17.exe. This is the client's view of "which components exist server
|
||||
# side"; later components look themselves up in this list.
|
||||
COMPONENT_IDS = [
|
||||
COMP_AUTH, # 1 Authentication
|
||||
COMP_GAMEMANAGER, # 4 GameManager
|
||||
COMP_REDIRECTOR, # 5 Redirector
|
||||
COMP_STATS, # 7 Stats
|
||||
COMP_UTIL, # 9 Util
|
||||
COMP_MESSAGING, # 15 Messaging
|
||||
COMP_ASSOCLISTS, # 25 AssociationLists
|
||||
COMP_GAMEREPORTING, # 28 GameReporting
|
||||
COMP_USERSESSIONS, # 30722 UserSessions
|
||||
]
|
||||
|
||||
# The request carried FCCR{CFID="BlazeSDK"}, i.e. an embedded fetchClientConfig
|
||||
# for the "BlazeSDK" section -- so CONF.CONF is that section. These five keys
|
||||
# are the ones ConnectionManager::onPreAuthResponse actually reads (verified by
|
||||
# disassembly); every one has a fallback, so nothing here is strictly required.
|
||||
# Time values are MICROSECONDS: the client divides by 1000 to get ms.
|
||||
BLAZESDK_CONFIG = [
|
||||
("connIdleTimeout", "90000000"), # 90 s
|
||||
("defaultRequestTimeout", "30000000"), # 30 s
|
||||
("enableQosBandwidthTest", "false"), # exact string "false" clears bit 1
|
||||
("enableQosFirewallTest", "false"), # exact string "false" clears bit 0
|
||||
("pingPeriod", "20000000"), # 20 s (default would be 15000 ms)
|
||||
]
|
||||
|
||||
# TODO(auth gate): the BlazeSDK section also carries the Nucleus endpoints
|
||||
# nucleusConnect / nucleusConnectTrusted / nucleusPortal / nucleusProxy.
|
||||
# Pointing those at a local HTTPS shim is our lever for offline login.
|
||||
# Not emitted yet -- unread at preAuth, and wrong values may send the client
|
||||
# at a real EA host during Authentication::login.
|
||||
|
||||
|
||||
def qos_config() -> "OrderedDict":
|
||||
"""Blaze::QosConfigInfo -- 4 members per reflection (there is NO SVID in
|
||||
FIFA17's descriptor, unlike Mirror's Edge Catalyst)."""
|
||||
return OrderedDict([
|
||||
("BWPS", (STRUCT, OrderedDict([ # Blaze::QosPingSiteInfo
|
||||
("PSA", (STRING, "127.0.0.1")), # address
|
||||
("PSP", (INT, 17502)), # port
|
||||
]))),
|
||||
("LNP", (INT, 10)), # numLatencyProbes
|
||||
("LTPS", (MAP, (STRING, STRUCT, []))), # pingSiteInfoByAliasMap: EMPTY
|
||||
("TIME", (INT, 5000000)), # timeout, microseconds
|
||||
])
|
||||
|
||||
|
||||
def preauth_response_fields(service_name: str = "fifa-2017-pc") -> "OrderedDict":
|
||||
return OrderedDict([
|
||||
("ASRC", (STRING, TITLE_ID)), # authenticationSource
|
||||
("CIDS", (LIST, (INT, COMPONENT_IDS))), # componentIds
|
||||
("CLID", (STRING, CLIENT_ID)), # clientId
|
||||
("CONF", (STRUCT, OrderedDict([ # Util::FetchConfigResponse
|
||||
("CONF", (MAP, (STRING, STRING, list(BLAZESDK_CONFIG)))),
|
||||
]))),
|
||||
("ESRC", (STRING, TITLE_ID)), # entitlementSource
|
||||
("INST", (STRING, service_name)), # serviceName -- echo CDAT.SVCN
|
||||
("MAID", (INT, 0)), # machineId
|
||||
("MINR", (INT, 0)), # underageSupported = false
|
||||
("NASP", (STRING, PERSONA_NAMESPACE)), # personaNamespace
|
||||
("PILD", (STRING, "")), # legalDocGameIdentifier
|
||||
("PLAT", (STRING, PLATFORM)), # platform
|
||||
("QOSS", (STRUCT, qos_config())), # qosSettings
|
||||
("RSRC", (STRING, TITLE_ID)), # registrationSource
|
||||
("SVER", (STRING, SERVER_VERSION)), # serverVersion
|
||||
])
|
||||
|
||||
|
||||
def ping_response_fields() -> "OrderedDict":
|
||||
"""Blaze 15.1.1.1.0+ reads STIM, 15.1.1.0.x reads TIME. FIFA17 reports
|
||||
BSDK 15.1.1.3.0, so STIM is the live one -- but unknown tags are ignored,
|
||||
so emit both and stay version-proof. (Tag order STIM < TIME is handled by
|
||||
heat2's ascending-tag sort.)"""
|
||||
now = int(time.time())
|
||||
return OrderedDict([("STIM", (INT, now)), ("TIME", (INT, now))])
|
||||
|
||||
|
||||
def extract_service_name(fields) -> str:
|
||||
"""PreAuthRequest.CDAT.SVCN -- echo it back as INST."""
|
||||
try:
|
||||
cdat = fields.get("CDAT")
|
||||
if cdat and cdat[0] == STRUCT:
|
||||
svcn = cdat[1].get("SVCN")
|
||||
if svcn and svcn[0] == STRING and svcn[1]:
|
||||
return svcn[1]
|
||||
except Exception:
|
||||
pass
|
||||
return "fifa-2017-pc"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ dispatch
|
||||
|
||||
def dispatch(hdr: dict, fields, raw_payload: bytes):
|
||||
"""-> bytes to send back, or None to stay silent."""
|
||||
comp, cmd, mtype = hdr["component"], hdr["command"], hdr["msg_type"]
|
||||
|
||||
# Transport-level PING frame (msgType 4) -- answer with PING_REPLY (5).
|
||||
if mtype == PING:
|
||||
log(" -> transport PING, answering PING_REPLY (empty)")
|
||||
return reply_to(hdr, b"", msg_type=PING_REPLY)
|
||||
|
||||
if mtype not in (MESSAGE, PING):
|
||||
log(" -> msgType %s is not a request; not answering"
|
||||
% MSGTYPE_NAME.get(mtype, mtype))
|
||||
return None
|
||||
|
||||
if comp == COMP_UTIL and cmd == CMD_PREAUTH:
|
||||
svcn = extract_service_name(fields) if fields is not None else "fifa-2017-pc"
|
||||
resp = preauth_response_fields(service_name=svcn)
|
||||
payload = encode_tdf(resp)
|
||||
log(" -> PreAuthResponse (INST=%r, %d payload bytes):\n%s"
|
||||
% (svcn, len(payload), heat2.dump(resp)))
|
||||
return reply_to(hdr, payload)
|
||||
|
||||
if comp == COMP_UTIL and cmd == CMD_PING:
|
||||
resp = ping_response_fields()
|
||||
log(" -> PingResponse %s" % dict((k, v[1]) for k, v in resp.items()))
|
||||
return reply_to(hdr, encode_tdf(resp))
|
||||
|
||||
# ---------------------------------------------------------------- TODO
|
||||
# Expected next RPCs on the FIFA17 login path (in order):
|
||||
#
|
||||
# 1. Util::fetchClientConfig (9/1) with FCCR/CFID="IdentityParams"
|
||||
# -> FetchConfigResponse{CONF: map<str,str>} carrying `display` and
|
||||
# `redirect_uri`; this drives the Nucleus web login overlay.
|
||||
# 2. Authentication::login (1/0x0A) with AUTH=<nucleus auth code>
|
||||
# -> plus server NOTIFICATION 0x7802/8 UserAuthenticated.
|
||||
# 3. Util::postAuth (9/8)
|
||||
# -> PostAuthResponse{TELE, TICK, UROP}; plus notifications
|
||||
# 0x7802/5 and 0x7802/1|2 (UserExtendedData).
|
||||
# 4. Util::setClientState (9/0x1C), Authentication::getAuthToken (1/0x24),
|
||||
# AssociationLists::getLists (25/6), UserSessions::updateNetworkInfo
|
||||
# (0x7802/0x14).
|
||||
#
|
||||
# Notifications are msgType=2 with msgNum=0 and are pushed unsolicited.
|
||||
# Error replies are msgType=3 but the ERROR-CODE placement is UNRESOLVED
|
||||
# (three clean-room sources disagree: header[14:16] vs metadata ERRC vs
|
||||
# payload CNTX/ERRC) -- do not emit one until it is verified.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
if REPLY_EMPTY_TO_UNKNOWN:
|
||||
log(" -> UNIMPLEMENTED %s; sending EMPTY REPLY so the client does not "
|
||||
"hang (all fields fall back to client-side defaults)"
|
||||
% rpc_name(comp, cmd))
|
||||
return reply_to(hdr, b"")
|
||||
|
||||
log(" -> UNIMPLEMENTED %s; staying silent" % rpc_name(comp, cmd))
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- blaze server
|
||||
|
||||
def recv_exactly(sock: socket.socket, n: int, buf: bytearray) -> bool:
|
||||
"""Fill `buf` to at least n bytes. False on clean EOF / short close."""
|
||||
while len(buf) < n:
|
||||
try:
|
||||
chunk = sock.recv(65536)
|
||||
except socket.timeout:
|
||||
return False
|
||||
if not chunk:
|
||||
return False
|
||||
buf += chunk
|
||||
return True
|
||||
|
||||
|
||||
_frame_counter = [0]
|
||||
|
||||
|
||||
def blaze_handle(raw: socket.socket, addr) -> None:
|
||||
log("*** BLAZE CONNECT from %s ***" % (addr,))
|
||||
buf = bytearray()
|
||||
raw.settimeout(300)
|
||||
try:
|
||||
while True:
|
||||
if not recv_exactly(raw, FIRE2_HDR, buf):
|
||||
break
|
||||
hdr = parse_fire2_header(bytes(buf[:FIRE2_HDR]))
|
||||
total = FIRE2_HDR + hdr["metadata_len"] + hdr["payload_len"]
|
||||
if hdr["payload_len"] > 4 * 1024 * 1024:
|
||||
log("BLAZE %s: absurd payload_len %d, dropping connection\n%s"
|
||||
% (addr, hdr["payload_len"], hexdump(bytes(buf[:64]))))
|
||||
break
|
||||
if not recv_exactly(raw, total, buf):
|
||||
log("BLAZE %s: EOF mid-frame (want %d, have %d)"
|
||||
% (addr, total, len(buf)))
|
||||
break
|
||||
|
||||
frame = bytes(buf[:total])
|
||||
del buf[:total]
|
||||
metadata = frame[FIRE2_HDR:FIRE2_HDR + hdr["metadata_len"]]
|
||||
payload = frame[FIRE2_HDR + hdr["metadata_len"]:]
|
||||
|
||||
_frame_counter[0] += 1
|
||||
n = _frame_counter[0]
|
||||
log("RX #%d %s msgType=%s msgNum=%d userIdx=%d opts=0x%02x "
|
||||
"meta=%dB payload=%dB"
|
||||
% (n, rpc_name(hdr["component"], hdr["command"]),
|
||||
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:
|
||||
try:
|
||||
fn = "/tmp/blaze_rx_%04x_%04x_%d.bin" % (
|
||||
hdr["component"], hdr["command"], n)
|
||||
with open(fn, "wb") as fh:
|
||||
fh.write(frame)
|
||||
log("RX #%d saved -> %s" % (n, fn))
|
||||
except Exception as e:
|
||||
log("RX #%d save failed: %s" % (n, e))
|
||||
|
||||
fields = None
|
||||
if payload:
|
||||
try:
|
||||
fields = decode_tdf(payload)
|
||||
log("RX #%d TDF:\n%s" % (n, heat2.dump(fields)))
|
||||
except Exception as e:
|
||||
log("RX #%d TDF DECODE FAILED: %s" % (n, e))
|
||||
else:
|
||||
log("RX #%d TDF: (empty payload)" % n)
|
||||
|
||||
try:
|
||||
out = dispatch(hdr, fields, payload)
|
||||
except Exception as e:
|
||||
log("RX #%d DISPATCH ERROR: %r" % (n, e))
|
||||
out = None
|
||||
|
||||
if out:
|
||||
raw.sendall(out)
|
||||
ohdr = parse_fire2_header(out)
|
||||
log("TX #%d %s msgType=%s msgNum=%d %dB total (%d payload)"
|
||||
% (n, rpc_name(ohdr["component"], ohdr["command"]),
|
||||
MSGTYPE_NAME.get(ohdr["msg_type"], ohdr["msg_type"]),
|
||||
ohdr["msg_num"], len(out), ohdr["payload_len"]))
|
||||
log("TX #%d HEX:\n%s" % (n, hexdump(out, limit=1024)))
|
||||
except ConnectionResetError:
|
||||
log("BLAZE %s: connection reset by client" % (addr,))
|
||||
except Exception as e:
|
||||
log("BLAZE %s ERR: %r" % (addr, e))
|
||||
finally:
|
||||
try:
|
||||
raw.close()
|
||||
except Exception:
|
||||
pass
|
||||
log("BLAZE %s: closed" % (addr,))
|
||||
|
||||
|
||||
# --------------------------------------------------------- redirector (TLS)
|
||||
|
||||
def build_redirect_response() -> bytes:
|
||||
# Confirmed schema (clean-room, MEC Catalyst): ServerInstanceInfo.address is
|
||||
# a ServerAddress union -> Heat2 XML union = <address member="N"><valu>...
|
||||
# member="0" = ipAddress variant {hostname, ip(uint32 decimal), port(uint16)}
|
||||
body = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<serverinstanceinfo>\n'
|
||||
'\t<address member="0">\n'
|
||||
'\t\t<valu>\n'
|
||||
f'\t\t\t<hostname>{BLAZE_IP_STR}</hostname>\n'
|
||||
f'\t\t\t<ip>{BLAZE_IP_U32}</ip>\n'
|
||||
f'\t\t\t<port>{BLAZE_PORT}</port>\n'
|
||||
'\t\t</valu>\n'
|
||||
'\t</address>\n'
|
||||
'\t<secure>0</secure>\n'
|
||||
'\t<trialservicename></trialservicename>\n'
|
||||
'\t<defaultdnsaddress>0</defaultdnsaddress>\n'
|
||||
'</serverinstanceinfo>\n'
|
||||
)
|
||||
b = body.encode()
|
||||
hdr = ("HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n"
|
||||
f"Content-Length: {len(b)}\r\nConnection: close\r\n\r\n").encode()
|
||||
return hdr + b
|
||||
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(CERT, KEY)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1
|
||||
ctx.set_ciphers("ALL:@SECLEVEL=0")
|
||||
|
||||
|
||||
def redir_handle(raw: socket.socket, addr) -> None:
|
||||
try:
|
||||
tls = ctx.wrap_socket(raw, server_side=True)
|
||||
except ssl.SSLError as e:
|
||||
log("REDIR REJECTED %s: %s" % (addr, e))
|
||||
raw.close()
|
||||
return
|
||||
log("REDIR TLS-OK %s cipher=%s" % (addr, tls.cipher()[0]))
|
||||
try:
|
||||
tls.settimeout(8)
|
||||
req = b""
|
||||
while b"\r\n\r\n" not in req:
|
||||
c = tls.recv(4096)
|
||||
if not c:
|
||||
break
|
||||
req += c
|
||||
if b"content-length:" in req.lower():
|
||||
head, _, rest = req.partition(b"\r\n\r\n")
|
||||
cl = int([l.split(b":")[1] for l in head.split(b"\r\n")
|
||||
if l.lower().startswith(b"content-length")][0])
|
||||
while len(rest) < cl:
|
||||
c = tls.recv(4096)
|
||||
if not c:
|
||||
break
|
||||
rest += c
|
||||
req = head + b"\r\n\r\n" + rest
|
||||
line0 = req.split(b"\r\n", 1)[0].decode(errors="replace")
|
||||
log("REDIR REQ %s: %s" % (addr, line0))
|
||||
resp = build_redirect_response()
|
||||
tls.sendall(resp)
|
||||
log("REDIR SENT %s %dB serverinstanceinfo -> %s:%d"
|
||||
% (addr, len(resp), BLAZE_IP_STR, BLAZE_PORT))
|
||||
time.sleep(0.3)
|
||||
tls.close()
|
||||
except Exception as e:
|
||||
log("REDIR ERR %s: %s" % (addr, e))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ serve
|
||||
|
||||
def serve(port: int, handler, name: str) -> None:
|
||||
s = socket.socket()
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind((HOST, port))
|
||||
s.listen(16)
|
||||
log("%s listening on %s:%d" % (name, HOST, port))
|
||||
while True:
|
||||
c, a = s.accept()
|
||||
threading.Thread(target=handler, args=(c, a), daemon=True).start()
|
||||
|
||||
|
||||
def _selftest() -> None:
|
||||
"""Sanity: build the preAuth reply and round-trip it through the decoder."""
|
||||
fields = preauth_response_fields()
|
||||
payload = encode_tdf(fields)
|
||||
frame = fire2(COMP_UTIL, CMD_PREAUTH, 0, REPLY, payload)
|
||||
h = parse_fire2_header(frame)
|
||||
assert h["component"] == COMP_UTIL and h["command"] == CMD_PREAUTH
|
||||
assert h["msg_type"] == REPLY and h["payload_len"] == len(payload)
|
||||
assert frame[13] == 0x20, frame[13]
|
||||
back = decode_tdf(frame[16:])
|
||||
assert list(back.keys()) == ["ASRC", "CIDS", "CLID", "CONF", "ESRC", "INST",
|
||||
"MAID", "MINR", "NASP", "PILD", "PLAT", "QOSS",
|
||||
"RSRC", "SVER"], list(back.keys())
|
||||
assert encode_tdf(back) == payload
|
||||
print("selftest OK: preAuth reply = %d bytes (%d payload)"
|
||||
% (len(frame), len(payload)))
|
||||
print("header:", frame[:16].hex(" "))
|
||||
print(heat2.dump(fields))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--selftest" in sys.argv:
|
||||
_selftest()
|
||||
raise SystemExit(0)
|
||||
log("=== RESPONDER v2 START (redir %d / blaze %d) ===" % (REDIR_PORT, BLAZE_PORT))
|
||||
threading.Thread(target=serve, args=(BLAZE_PORT, blaze_handle, "BLAZE"),
|
||||
daemon=True).start()
|
||||
serve(REDIR_PORT, redir_handle, "REDIR")
|
||||
Reference in New Issue
Block a user