Make FIFA17 roster hostname configurable

This commit is contained in:
funman300
2026-08-21 02:35:27 +00:00
parent 92520de6c3
commit ab62440dbf
7 changed files with 135 additions and 64 deletions
@@ -1,8 +1,7 @@
# Copy to .env in this directory. Required for remote deployment.
#
# OPENFUT_ADVERTISE — the address of THIS host as seen from the game machine
# (105). The responders advertise it to the client for every next hop (Blaze,
# roster, UTAS, POW). Compose refuses to start without it.
# OPENFUT_ADVERTISE — the IP address of THIS host as seen from the game machine
# (105). Responders advertise it for Blaze, UTAS, telemetry, and QoS.
OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
# OPENFUT_BIND — address the listeners bind inside the container.
@@ -10,6 +9,11 @@ OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
# uses the loopback default baked into the responders when unset.
OPENFUT_BIND=0.0.0.0
# FIFA17's roster verifier accepts dNSName SANs but ignores iPAddress SANs.
# Advertise the certificate's DNS identity, then resolve that one hostname to
# OPENFUT_ADVERTISE on the client without changing the URL or certificate.
OPENFUT_ROSTER_HOST=winter15.gosredirector.ea.com:8081
# OPENFUT_SERVERS — which Python responders Docker runs (space/comma separated).
# Default (unset) = the server-side set: "blaze roster utas pow".
#
+7 -11
View File
@@ -37,18 +37,14 @@ RUN set -eu; \
COPY data/ /app/data/
# Redirector/roster TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
# cert-verify is patched client-side, so a self-signed cert is fine — but the
# client dials the roster and redirector BY IP, and that path still checks the
# SAN against the dialed address (it is NOT covered by the two patched gates), so
# a cert without a matching IP SAN is rejected with fatal certificate_unknown
# (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md). The advertised LAN IP is a RUNTIME value,
# unknown here, so this bakes only a loopback-IP baseline and the entrypoint
# reissues with IP:$OPENFUT_ADVERTISE at start.
# Redirector/roster TLS certificate. FIFA17's roster verifier compares only
# dNSName SAN entries, so deployment advertises winter15.gosredirector.ea.com
# through OPENFUT_ROSTER_HOST and resolves that hostname on the client. The
# entrypoint validates this stable certificate; it never reissues it for an IP
# SAN that the verifier ignores.
#
# openssl therefore has to remain in the image for the entrypoint, not be dropped
# with the apt lists. The pair is git-ignored (*.pem/*.key); regenerate if absent
# so a fresh checkout builds without extra steps.
# OpenSSL remains in the image both to create the git-ignored keypair on a fresh
# checkout and to validate the configured DNS identity at startup.
RUN apt-get update && apt-get install -y --no-install-recommends openssl && \
rm -rf /var/lib/apt/lists/*
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
@@ -3,9 +3,9 @@
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
# docker compose up -d --build
#
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the address
# the servers hand the client (105) for every next hop (Blaze, roster, UTAS,
# POW) and is required — there is no silent loopback fallback in remote mode.
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the server IP
# handed out for Blaze, UTAS, telemetry, and QoS; OPENFUT_ROSTER_HOST is the
# certificate DNS identity handed out for roster HTTPS.
#
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
# autopatch.py running locally; see client_arm.sh and the FIFARUNBOOK.
@@ -25,6 +25,9 @@ services:
# Address advertised to the client for the next hop. MUST be this host's
# LAN IP as seen from the game machine (105). Required (see .env.example).
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 203.0.113.10}"
# FIFA17 roster TLS matches only certificate dNSName SANs. The client must
# resolve this hostname to OPENFUT_ADVERTISE.
OPENFUT_ROSTER_HOST: "${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
# POW content advertises port 8080 by default, which collides with the
# openfut-core publish on this host. Remap it to 8085 on the host and
# advertise the remapped endpoint.
+17 -23
View File
@@ -8,45 +8,39 @@
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
# run on the box the game runs on.
#
# Address behaviour is driven by two env vars (see each responder):
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
# OPENFUT_ADVERTISE address handed to the client for the next hop
# (the server's LAN IP, e.g. 203.0.113.10)
# Address behaviour is driven by three env vars (see each responder):
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
# OPENFUT_ADVERTISE IP address handed out for Blaze, UTAS, telemetry, and QoS
# OPENFUT_ROSTER_HOST certificate DNS host:port handed out for roster HTTPS
# ============================================================================
set -uo pipefail
cd "$(dirname "$(readlink -f "$0")")/tools"
BIND="${OPENFUT_BIND:-0.0.0.0}"
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 203.0.113.10)}"
ROSTER_HOST="${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
export OPENFUT_BIND="$BIND"
export OPENFUT_ADVERTISE="$ADV"
export OPENFUT_ROSTER_HOST="$ROSTER_HOST"
# POW keys advertised by blaze must also point at the server, not loopback.
export POW_HOST="${POW_HOST:-$ADV:8094}"
export POW_CONTENT_HOST="${POW_CONTENT_HOST:-$ADV:8080}"
export POW_ADDR="${POW_ADDR:-$BIND:8094}"
export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
echo "[openfut] bind=$BIND advertise=$ADV"
echo "[openfut] bind=$BIND advertise=$ADV roster=$ROSTER_HOST"
# The TLS cert every responder serves must carry the ADVERTISED IP in its SAN.
# The client dials the roster (:8081) and redirector by that IP, and that path
# validates the cert's SAN against the dialed address — it is NOT covered by the
# two client-side ProtoSSL gates autopatch patches, so a cert lacking IP:$ADV is
# rejected with fatal certificate_unknown and the FUT hub fails with "An error
# occurred downloading the FUT Squad Update" (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md).
# The advertised IP is unknown at image-build time, so reconcile it here: reissue
# only when the current cert does not already carry it, so a restart reuses the
# same cert (no per-start fingerprint churn) and this self-heals if $ADV changes.
CERT=redir_cert.pem KEY=redir_key.pem
if ! openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | grep -qF "IP Address:$ADV"; then
echo "[openfut] reissuing TLS cert with SAN IP:$ADV (was missing it)"
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
-subj "/CN=winter15.gosredirector.ea.com" \
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:$ADV,IP:127.0.0.1" \
>/dev/null 2>&1 \
&& echo "[openfut] cert SAN now: $(openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | tail -1 | tr -s ' ')" \
|| { echo "[openfut] FATAL: could not reissue TLS cert" >&2; exit 1; }
# FIFA17's roster verifier compares only dNSName SAN entries. It ignores a valid
# iPAddress SAN when the advertised URL contains an IP literal, so certificate
# regeneration cannot fix that URL. Keep the certificate stable and fail startup
# if the configured roster hostname is not already one of its DNS identities.
CERT=redir_cert.pem
ROSTER_NAME="${ROSTER_HOST%%:*}"
if ! openssl x509 -in "$CERT" -noout -checkhost "$ROSTER_NAME" >/dev/null 2>&1; then
echo "[openfut] FATAL: TLS cert does not cover roster hostname $ROSTER_NAME" >&2
exit 1
fi
echo "[openfut] roster certificate matches $ROSTER_NAME; fingerprint: $(openssl x509 -in "$CERT" -noout -fingerprint -sha256)"
# name script extra-env
declare -a SERVERS=(
+12 -11
View File
@@ -147,14 +147,13 @@ def refresh_account_identity():
# ================================================================== 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.
# Client/server split support (OpenFUT dev-container): bind and advertise default
# to loopback so the original all-on-localhost flow is byte-identical.
# OPENFUT_BIND — address the listeners bind (0.0.0.0 in a container).
# OPENFUT_ADVERTISE — address handed back for Blaze, UTAS, telemetry, QoS,
# and (unless overridden) the roster service.
# OPENFUT_ROSTER_HOST — optional roster host:port advertised in HTTPS URLs.
# Use a certificate dNSName and resolve it on the client.
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")
@@ -563,9 +562,11 @@ OSDK_TICKER = []
# never gets advance/back -> the silent FUT loading-screen hang. The store is the
# MERGED '_all' section (getSection @0x14719e050), so any fetched CFID works; this
# 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 = "%s:8081" % _ADVERTISE
# Serve HTTPS (EA's production value is https; the DirtySDK download manager may
# reject http). FIFA17's roster verifier accepts dNSName SANs but ignores
# iPAddress SANs, so an IP-literal URL fails with certificate_unknown. A remote
# deployment can advertise a certificate DNS name without changing other hosts.
ROSTER_HOST = os.environ.get("OPENFUT_ROSTER_HOST") or "%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),
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Standalone contract test for Blaze roster-host advertisement."""
import importlib
import os
import sys
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
ADVERTISE = "192.0.2.10"
DNS_HOST = "winter15.gosredirector.ea.com:8081"
def assert_roster_config(blaze, host):
config = dict(blaze.OSDK_ROSTER)
assert blaze.ROSTER_HOST == host
assert config["ROSTERUPDATE_URL"] == (
f"https://{host}/fifa17/fut/rosterupdate.xml"
)
assert config["ROSTER_URL"] == f"https://{host}/fifa17/roster/"
assert config["ROSTER_VER"] == "0"
assert config["ROSTER_CSUM"] == ""
def main():
old_advertise = os.environ.get("OPENFUT_ADVERTISE")
old_roster_host = os.environ.get("OPENFUT_ROSTER_HOST")
try:
os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
os.environ.pop("OPENFUT_ROSTER_HOST", None)
import blaze_responder_v3b as blaze
blaze = importlib.reload(blaze)
assert_roster_config(blaze, f"{ADVERTISE}:8081")
os.environ["OPENFUT_ROSTER_HOST"] = DNS_HOST
blaze = importlib.reload(blaze)
assert_roster_config(blaze, DNS_HOST)
os.environ["OPENFUT_ROSTER_HOST"] = ""
blaze = importlib.reload(blaze)
assert_roster_config(blaze, f"{ADVERTISE}:8081")
finally:
if old_advertise is None:
os.environ.pop("OPENFUT_ADVERTISE", None)
else:
os.environ["OPENFUT_ADVERTISE"] = old_advertise
if old_roster_host is None:
os.environ.pop("OPENFUT_ROSTER_HOST", None)
else:
os.environ["OPENFUT_ROSTER_HOST"] = old_roster_host
print("PASS: roster host defaults, override, and URLs")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+25 -13
View File
@@ -12,10 +12,10 @@ ONE entry point. It starts, in order:
and then prints the three `openfut.cfg` lines the operator must put on the client.
Tear the whole thing down with `scripts/sold-staging-down.py`.
WHY the client only needs Blaze ports: FIFA 17 learns the UTAS base URL from Blaze
(`blaze_responder_v3b.py`'s `UTAS_BASE`, where the `8099` is HARDCODED). This script
copies the responder into the staging dir and rewrites that literal to the staging
UTAS port, so pointing the client at staging Blaze is sufficient to move UTAS too.
WHY the client only needs Blaze ports: FIFA 17 learns both the UTAS base URL and
the roster URL from Blaze. This script copies the responder into the staging dir,
rewrites the hardcoded UTAS port, and passes the independently configurable roster
host through `OPENFUT_ROSTER_HOST`.
ISOLATION, enforced not assumed:
* production ports 8099 8199 18080 8443 42127 42130 42131 4216 8080 8081 8094 are
@@ -33,10 +33,11 @@ ISOLATION, enforced not assumed:
edits `openfut.cfg` by hand, using the block this script prints.
Read-only reuse of production: staging Blaze advertises the production roster
(10.10.0.120:8081) and POW content/API hosts (10.10.0.120:8085 / :8094) verbatim, the
same values production Blaze advertises. Those services hold NO economy state (roster
XML and POW content are static), staging never connects to them itself -- it only
hands the client the same strings -- and they are therefore shared deliberately.
service through a certificate dNSName (`winter15.gosredirector.ea.com:8081` by
default) and advertises the production POW content/API hosts
(`10.10.0.120:8085` / `:8094`) verbatim. These services hold NO economy state.
The client must resolve the roster hostname to this server without changing the
advertised URL.
python3 scripts/sold-staging-up.py --variant highest
python3 scripts/sold-staging-up.py --variant buyNow --coins-processed 1 \
@@ -98,6 +99,7 @@ ADVERTISE = "10.10.0.120"
BIND = "0.0.0.0"
POW_CONTENT_HOST = "10.10.0.120:8085"
POW_HOST = "10.10.0.120:8094"
DEFAULT_ROSTER_HOST = "winter15.gosredirector.ea.com:8081"
BLAZE_SRC = os.path.join(REPO, "fifa17-recon", "tools", "blaze_responder_v3b.py")
BLAZE_ASSETS = ("redir_cert.pem", "redir_key.pem")
@@ -729,11 +731,12 @@ def start_host(lay: Layout, variant: str, coins_processed: str, count_mode: str)
return Launched("staging-utas-host", proc, lay.host_log, f"{BIND}:{HOST_PORT}")
def start_blaze(lay: Layout) -> Launched:
def start_blaze(lay: Layout, roster_host: str) -> Launched:
env = dict(
os.environ,
OPENFUT_ADVERTISE=ADVERTISE,
OPENFUT_BIND=BIND,
OPENFUT_ROSTER_HOST=roster_host,
# Read-only reuse of the production auxiliary services: static content, no
# economy state, and staging never connects to them -- it only advertises
# the same strings production Blaze advertises.
@@ -766,7 +769,8 @@ def start_blaze(lay: Layout) -> Launched:
)
ok(
f"staging blaze ready: redirector {BLAZE_REDIR_PORT}, main {BLAZE_MAIN_PORT}, "
f"nucleus {BLAZE_NUCLEUS_PORT} (pid {proc.pid}); logged {want!r}"
f"nucleus {BLAZE_NUCLEUS_PORT} (pid {proc.pid}); roster {roster_host}; "
f"logged {want!r}"
)
return Launched(
"staging-blaze", proc, lay.blaze_log,
@@ -850,7 +854,7 @@ def cfg_block() -> list[str]:
def print_summary(lay: Layout, variant: str, coins_processed: str, count_mode: str,
records: list[dict]) -> None:
roster_host: str, records: list[dict]) -> None:
banner("STAGING STACK IS UP")
rows = [
("staging Core", f"127.0.0.1:{CORE_PORT}", "loopback only; client never talks to it"),
@@ -888,6 +892,7 @@ def print_summary(lay: Layout, variant: str, coins_processed: str, count_mode: s
print(f" experiment variant : {variant}")
print(f" coinsProcessed : {coins_processed}")
print(f" count mode : {count_mode}")
print(f" roster host : {roster_host}")
print(f" banner : {host_banner_line(lay)}")
print(f" seeded state : {db_summary(lay)}")
print(f" disposable item to sell : {DISPOSABLE_ITEM} ({DISPOSABLE_CARD})")
@@ -931,6 +936,11 @@ def main() -> int:
ap.add_argument("--count-mode", choices=["active", "active_plus_sold"],
default="active",
help="what /tradePile/counts.count reports (default: active)")
ap.add_argument(
"--roster-host",
default=os.environ.get("OPENFUT_ROSTER_HOST", DEFAULT_ROSTER_HOST),
help=f"host:port advertised for roster HTTPS (default: {DEFAULT_ROSTER_HOST})",
)
ap.add_argument("--dir", default=os.environ.get("OPENFUT_SOLD_STAGING_DIR",
DEFAULT_STAGING_DIR),
help=f"staging directory (default: {DEFAULT_STAGING_DIR})")
@@ -968,7 +978,7 @@ def main() -> int:
args.count_mode))
banner("STAGING BLAZE")
started.append(start_blaze(lay))
started.append(start_blaze(lay, args.roster_host))
records = [item.record() for item in started]
for rec in records:
@@ -986,6 +996,7 @@ def main() -> int:
"variant": args.variant,
"coins_processed": args.coins_processed,
"count_mode": args.count_mode,
"roster_host": args.roster_host,
"ports": {
"core": CORE_PORT,
"utas_host": HOST_PORT,
@@ -1012,7 +1023,8 @@ def main() -> int:
banner("VERIFY ISOLATION")
verify(lay, args.variant)
print_summary(lay, args.variant, args.coins_processed, args.count_mode, records)
print_summary(lay, args.variant, args.coins_processed, args.count_mode,
args.roster_host, records)
return 0
except Fatal as exc:
print(f"\nFATAL: {exc}", file=sys.stderr)