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.
579 lines
23 KiB
Python
579 lines
23 KiB
Python
#!/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")
|