5c40b4993f
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.
122 lines
6.7 KiB
Markdown
122 lines
6.7 KiB
Markdown
# 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`):
|
|
|
|
```python
|
|
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:
|
|
|
|
1. **The SAN carries DNS names only, no `iPAddress` entry**, while the advertised URL is an
|
|
IP literal (`https://10.10.0.120:8081/...`). A DNS-only SAN cannot match an IP dial.
|
|
2. **The certificate was regenerated on Aug 13.** `roster_server.py` loads the same
|
|
`redir_cert.pem`/`redir_key.pem` as the Blaze redirector, so the redirector rejects
|
|
identically — visible as `REDIR 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`)
|
|
|
|
1. **Reissue the certificate with an `iPAddress` SAN** — **APPLIED.** The three cert
|
|
generators now put the advertised IP in the SAN: the docker entrypoint reconciles it at
|
|
startup from `OPENFUT_ADVERTISE` (runtime value, unknown at build time; reissued only
|
|
when missing, so restarts don't churn the fingerprint), the Dockerfile keeps `openssl`
|
|
in the runtime image and bakes a loopback-IP baseline, and `openfut-fut.sh` defaults the
|
|
SAN IP to the host's primary LAN IP. Smallest change, benefits the redirector too. Note
|
|
`openfut-tls` only *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
|
|
Rust `openfut-roster-host`.
|
|
2. **Serve the roster over plain HTTP** — not taken. Sidesteps TLS entirely but only
|
|
acceptable off production, and option 1 fixes it properly everywhere.
|
|
3. **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.
|