Updates status from root-caused to fixed, and records that option 1 (IP SAN) was taken across the three cert generators, with the verification and the operator-gated production rebuild that remains.
6.7 KiB
FIFA 17 "FUT Squad Update" download failure — root cause
Status: FIXED in the cert generators (commit fbc0da2) — the SAN now carries the
advertised IP. Verified without the client (a verifying TLS client rejects the old
DNS-only cert by IP and accepts the new one; the entrypoint reconcile is idempotent).
Live confirmation needs the production container rebuilt with OPENFUT_ADVERTISE set,
which is operator-gated. See "Fix options (applied)" below.
Symptom (client): entering the FUT hub shows
An error occurred downloading the FUT Squad Update. Please try again.
Recovered from the live client's memory with scripts/client-error-string.py, not from a
log. Nothing server-side reports an error: every UTAS route answers 200 and the Blaze
session stays healthy, answering PINGs while the client sits on the dialog.
What it is
"Squad Update" here is the roster update, not the player's lineup. The Blaze responder
advertises it as the FUT loading gate (blaze_responder_v3b.py):
ROSTER_HOST = "%s:8081" % _ADVERTISE
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST)
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST)
The client fetches that URL over TLS, by IP, from roster_server.py.
Root cause
The client completes TCP, sends a ClientHello, receives the certificate, and then aborts the handshake itself. Captured and decoded from the wire:
CLIENT: ClientHello client_version = TLS1.2
offers 8 suites: RSA_AES_256_GCM_SHA384, RSA_AES_128_GCM_SHA256,
RSA_AES_256_CBC_SHA256, RSA_AES_128_CBC_SHA256, RSA_AES_256_CBC_SHA,
RSA_AES_128_CBC_SHA, RSA_RC4_128_SHA, RSA_RC4_128_MD5
SERVER: ServerHello TLS1.2 -> Certificate (909 B) -> ServerHelloDone
CLIENT: Alert level=fatal desc=certificate_unknown
Protocol and cipher selection are compatible — TLS1.2 with an RSA suite is offered and chosen. The client rejects the certificate:
subject/issuer = CN = winter15.gosredirector.ea.com (self-signed, CA:TRUE)
SAN = DNS:winter15.gosredirector.ea.com, DNS:*.gosredirector.ea.com, DNS:*.ea.com
notBefore = Aug 13 01:12:30 2026
sha256 = FF:66:69:48:E2:02:AB:F4:A4:40:78:B1:8C:E1:F9:65:96:32:BB:FF:1F:3D:CC:BC:07:54:02:11:8F:4D:12:3F
Two facts matter:
- The SAN carries DNS names only, no
iPAddressentry, while the advertised URL is an IP literal (https://10.10.0.120:8081/...). A DNS-only SAN cannot match an IP dial. - The certificate was regenerated on Aug 13.
roster_server.pyloads the sameredir_cert.pem/redir_key.pemas the Blaze redirector, so the redirector rejects identically — visible asREDIR REJECTED ... TLS/SSL connection has been closed (EOF)in the Blaze log. That is tolerated only because the client then reaches Blaze main over plain TCP; the roster fetch has no such fallback, so it is where the failure surfaces.
roster_server.py still carries the comment "ProtoSSL cert-verify is patched (autopatch),
so our self-signed cert is accepted." That assumption no longer holds for this path.
autopatch demonstrably patched both gates in the failing process — the log shows
pid 56298: PATCHED cert gates and the live bytes read back as the patched patterns
(909090909090 at 0x146132548, 31c0c3 at 0x1461361b0) — and the client still sent
certificate_unknown. So those two gates do not govern this validation; either another
check exists on the roster path, or it validates before reaching them.
Ruled out, with evidence
| Hypothesis | Evidence against |
|---|---|
| Missing/mistyped UTAS field | 0 structural differences across 14 hub routes vs production (scripts/hub-diff-prod-staging.py) |
| Player squad shape | matches production field-for-field, including squadType, custom, chemistry, manager |
| Squad save | PUT/GET round-trips exactly, every field and slot |
| Cert gates unpatched | autopatch log + live byte read both confirm patched |
| Roster server broken | serves 200, application/xml over TLS1.2 AES256-GCM-SHA384 |
| Advertised host wrong | production and staging Blaze envs are identical for OPENFUT_ADVERTISE, POW_*; the scripts are byte-identical |
| TLS version mismatch | client offers TLS1.2 and the server selects it |
| Blaze session | healthy, PING/PING_REPLY throughout |
Probing gotcha
A default modern Python TLS context gets SSLV3_ALERT_HANDSHAKE_FAILURE against this
server and looks like a server fault. It is not — the server requires legacy-compatible
cipher selection. Probe with ALL:@SECLEVEL=0, and note it rejects TLS1.0/1.1 outright
(TLSV1_ALERT_PROTOCOL_VERSION), accepting only TLS1.2.
Why production appears unaffected
Unresolved, and worth stating plainly rather than guessing. Production advertises the same URL, backed by the same server and certificate, so the same rejection should occur. The operator reports production is clean. The likely explanation is that a production client does not re-fetch the roster (already satisfied), while a session against a different Blaze forces the fetch — but that has not been measured. The known-good hook log used as a control (Aug 12 19:13) predates the Aug 13 certificate regeneration, so it is not evidence about the current certificate.
Fix options — option 1 applied (fbc0da2)
- Reissue the certificate with an
iPAddressSAN — APPLIED. The three cert generators now put the advertised IP in the SAN: the docker entrypoint reconciles it at startup fromOPENFUT_ADVERTISE(runtime value, unknown at build time; reissued only when missing, so restarts don't churn the fingerprint), the Dockerfile keepsopensslin the runtime image and bakes a loopback-IP baseline, andopenfut-fut.shdefaults the SAN IP to the host's primary LAN IP. Smallest change, benefits the redirector too. Noteopenfut-tlsonly loads the cert — it does not generate it — so the generation fix lives in these three scripts regardless of whether the roster is served by Python or the Rustopenfut-roster-host. - Serve the roster over plain HTTP — not taken. Sidesteps TLS entirely but only acceptable off production, and option 1 fixes it properly everywhere.
- Find the real validation site on the roster path and extend the patch set — not needed once the SAN matches; kept on record as the most-faithful alternative.
Option 1 was chosen because it is testable without the client: a verifying TLS client
checking the cert by IP fails on the old cert and passes on the new one. That test now
passes (scripts-style probe run at fix time); the remaining step is the operator
rebuilding the production container so the reissued cert is actually served.