diff --git a/fifa17-recon/docker/fifa17-python/.env.example b/fifa17-recon/docker/fifa17-python/.env.example index 6a65996..929806b 100644 --- a/fifa17-recon/docker/fifa17-python/.env.example +++ b/fifa17-recon/docker/fifa17-python/.env.example @@ -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". # diff --git a/fifa17-recon/docker/fifa17-python/Dockerfile b/fifa17-recon/docker/fifa17-python/Dockerfile index 5218184..4aab5ad 100644 --- a/fifa17-recon/docker/fifa17-python/Dockerfile +++ b/fifa17-recon/docker/fifa17-python/Dockerfile @@ -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 \ diff --git a/fifa17-recon/docker/fifa17-python/docker-compose.yml b/fifa17-recon/docker/fifa17-python/docker-compose.yml index 8194e02..ce8e490 100644 --- a/fifa17-recon/docker/fifa17-python/docker-compose.yml +++ b/fifa17-recon/docker/fifa17-python/docker-compose.yml @@ -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. diff --git a/fifa17-recon/docker/fifa17-python/entrypoint.sh b/fifa17-recon/docker/fifa17-python/entrypoint.sh index 710a487..3f42c53 100644 --- a/fifa17-recon/docker/fifa17-python/entrypoint.sh +++ b/fifa17-recon/docker/fifa17-python/entrypoint.sh @@ -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=( diff --git a/fifa17-recon/tools/blaze_responder_v3b.py b/fifa17-recon/tools/blaze_responder_v3b.py index 65d9f74..bc9e8b9 100644 --- a/fifa17-recon/tools/blaze_responder_v3b.py +++ b/fifa17-recon/tools/blaze_responder_v3b.py @@ -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), diff --git a/fifa17-recon/tools/test_roster_host_config.py b/fifa17-recon/tools/test_roster_host_config.py new file mode 100755 index 0000000..0630acc --- /dev/null +++ b/fifa17-recon/tools/test_roster_host_config.py @@ -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()) diff --git a/scripts/sold-staging-up.py b/scripts/sold-staging-up.py index 143053e..6d98582 100755 --- a/scripts/sold-staging-up.py +++ b/scripts/sold-staging-up.py @@ -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)