From 286a44461d72d8f593b10cb380283fb8b6662a87 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sun, 23 Aug 2026 01:34:13 +0000 Subject: [PATCH] tools/windows: scoped DNS resolver for the FIFA 17 roster hostname FIFA 17's ProtoSSL verifies the roster certificate by dNSName only, so the client must reach the roster as winter15.gosredirector.ea.com. Retested on Windows 2026-08-23: an IP-addressed roster host is refused even though the certificate carries IP Address:10.10.0.120 as a SAN. Public DNS points that name at EA's dead 159.153.51.20, so the client has to resolve it to us. scoped-dns.py pins exactly that one name and forwards every other query upstream verbatim, so a client pointed at it cannot lose general resolution -- verified against www.microsoft.com, github.com and www.msftconnecttest.com. Paired with a Windows NRPT rule rather than a hosts entry: per-name, auditable via Get-DnsClientNrptRule, and revertible in one command. A hosts edit on this machine had previously taken its whole internet down. --- tools/windows/README.md | 35 +++++++++ tools/windows/scoped-dns.py | 145 ++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100755 tools/windows/scoped-dns.py diff --git a/tools/windows/README.md b/tools/windows/README.md index 21aaf71..85223dc 100644 --- a/tools/windows/README.md +++ b/tools/windows/README.md @@ -85,6 +85,41 @@ Copy-Item 'C:\FIFA 17\version.dll.stale-849k.bak' 'C:\FIFA 17\version.dll' -Forc Always keep a `*.bak` of the live hook before redeploying (the preflight checks that a rollback backup exists and differs from the live DLL). +## Roster / "FUT Squad Update" — the client MUST reach the roster by HOSTNAME + +If FUT fails with **"An error occurred downloading the FUT Squad Update"**, the +client could not fetch `https:///fifa17/fut/rosterupdate.xml`. + +FIFA 17's ProtoSSL verifies that certificate by **dNSName only**. Pointing the +roster at an IP does **not** work even though our certificate carries +`IP Address:10.10.0.120` in its SANs — this was retested on Windows on +2026-08-23 and rejected. Do not retry an IP roster host, and do not reissue the +certificate for an IP SAN. The roster must be reached as +`winter15.gosredirector.ea.com`, which our certificate does carry as a dNSName. + +Public DNS resolves that name to EA's dead `159.153.51.20`, so the client has to +be told to resolve it to us. **Use an NRPT rule, not the hosts file.** A hosts +edit on this machine previously took its entire internet down; NRPT is per-name, +auditable with `Get-DnsClientNrptRule`, and reverts in one command. + +On the server, run the scoped resolver (needs root for UDP 53): + +```bash +sudo python3 tools/windows/scoped-dns.py +``` + +It answers **only** `winter15.gosredirector.ea.com` and forwards every other +query upstream verbatim, so it cannot strand a client that is pointed at it. +Then start Blaze with `OPENFUT_ROSTER_HOST=winter15.gosredirector.ea.com:8081`. + +On the client, as Administrator: + +```powershell +Add-DnsClientNrptRule -Namespace "winter15.gosredirector.ea.com" -NameServers "10.10.0.120" +# revert: +Get-DnsClientNrptRule | Where-Object Namespace -eq "winter15.gosredirector.ea.com" | Remove-DnsClientNrptRule -Force +``` + ## Preflight `openfut-client-preflight.ps1` is **read-only**: it never launches the game, diff --git a/tools/windows/scoped-dns.py b/tools/windows/scoped-dns.py new file mode 100755 index 0000000..fffa0a3 --- /dev/null +++ b/tools/windows/scoped-dns.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""A deliberately tiny DNS responder that answers ONE name and forwards the rest. + +WHY THIS EXISTS. FIFA 17's ProtoSSL verifies the roster certificate by dNSName +only, so the client must reach the roster as `winter15.gosredirector.ea.com` +rather than by IP -- an IP-addressed roster is rejected and the player gets +"An error occurred downloading the FUT Squad Update". The usual fix is a hosts +entry on the client, which on this operator's machine previously took the whole +machine's internet down. + +So instead of editing the client's hosts file, the client points ONE name at +this resolver through a Windows NRPT rule. Everything else on the client keeps +using its normal DNS and is never routed here at all. + +SAFETY PROPERTY THAT MATTERS: even for the queries that do arrive, an unknown +name is FORWARDED upstream verbatim rather than refused or NXDOMAINed. So the +worst case if the NRPT scope is ever widened by accident is added latency, not a +broken resolver -- this cannot repeat the earlier outage. + +Answers A records only; every other qtype for the pinned name is forwarded too, +because inventing an answer for a type we do not model is how a resolver starts +lying. +""" + +import os +import socket +import socketserver +import struct +import sys + +PINNED_NAME = os.environ.get("SCOPED_DNS_NAME", "winter15.gosredirector.ea.com").rstrip(".").lower() +PINNED_ADDR = os.environ.get("SCOPED_DNS_ADDR", "10.10.0.120") +BIND_ADDR = os.environ.get("SCOPED_DNS_BIND", "0.0.0.0") +BIND_PORT = int(os.environ.get("SCOPED_DNS_PORT", "53")) +TTL = 60 + +QTYPE_A = 1 +QCLASS_IN = 1 + + +def upstream_servers() -> list[str]: + """Real resolvers, read from resolv.conf, minus ourselves.""" + out = [] + try: + with open("/etc/resolv.conf") as fh: + for line in fh: + parts = line.split() + if len(parts) >= 2 and parts[0] == "nameserver" and parts[1] != PINNED_ADDR: + out.append(parts[1]) + except OSError: + pass + return out or ["1.1.1.1", "8.8.8.8"] + + +UPSTREAM = upstream_servers() + + +def parse_question(data: bytes): + """Return (qname, qtype, end_offset) or None if the packet is not parseable.""" + if len(data) < 12: + return None + qdcount = struct.unpack("!H", data[4:6])[0] + if qdcount < 1: + return None + labels, off = [], 12 + while off < len(data): + ln = data[off] + if ln == 0: + off += 1 + break + # A pointer in the QUESTION section is malformed; forward and let the + # upstream decide rather than guessing. + if ln & 0xC0: + return None + off += 1 + labels.append(data[off:off + ln].decode("ascii", "replace")) + off += ln + if off + 4 > len(data): + return None + qtype, _qclass = struct.unpack("!HH", data[off:off + 4]) + return ".".join(labels).lower(), qtype, off + 4 + + +def build_answer(query: bytes, qend: int) -> bytes: + """Echo the question and append one A record for the pinned address.""" + txid = query[:2] + # QR=1, RD copied from the query, RA=1. + rd = query[2] & 0x01 + flags = struct.pack("!H", 0x8000 | (rd << 8) | 0x0080) + counts = struct.pack("!HHHH", 1, 1, 0, 0) + question = query[12:qend] + rr = ( + b"\xc0\x0c" # name -> pointer to the question + + struct.pack("!HHIH", QTYPE_A, QCLASS_IN, TTL, 4) + + socket.inet_aton(PINNED_ADDR) + ) + return txid + flags + counts + question + rr + + +def forward(query: bytes) -> bytes | None: + for server in UPSTREAM: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.settimeout(3.0) + s.sendto(query, (server, 53)) + return s.recv(4096) + except OSError: + continue + return None + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + data, sock = self.request + parsed = parse_question(data) + if parsed: + qname, qtype, qend = parsed + if qname == PINNED_NAME and qtype == QTYPE_A: + sock.sendto(build_answer(data, qend), self.client_address) + print(f"ANSWER {qname} A -> {PINNED_ADDR} (from {self.client_address[0]})", flush=True) + return + reply = forward(data) + if reply: + sock.sendto(reply, self.client_address) + if parsed: + print(f"forward {parsed[0]} qtype={parsed[1]} (from {self.client_address[0]})", flush=True) + + +class Server(socketserver.ThreadingUDPServer): + allow_reuse_address = True + daemon_threads = True + + +if __name__ == "__main__": + print( + f"scoped-dns: pinning {PINNED_NAME} -> {PINNED_ADDR}; " + f"forwarding everything else to {', '.join(UPSTREAM)}", + flush=True, + ) + try: + with Server((BIND_ADDR, BIND_PORT), Handler) as srv: + print(f"scoped-dns: LISTENING on {BIND_ADDR}:{BIND_PORT}", flush=True) + srv.serve_forever() + except PermissionError: + sys.exit(f"scoped-dns: cannot bind {BIND_ADDR}:{BIND_PORT} (needs root)")