roster: redirect the FIFA 17 roster dial at the socket

FIFA 17's ProtoSSL verifies the roster certificate by dNSName only, so an
IP-addressed roster host is refused even with the IP in the SANs. The hostname
therefore has to survive into SNI while the connection lands on our server.

The connect/WSAConnect/ConnectEx detour already intercepted the dial; it just
did not rewrite it, because 8081 was absent from the EA port table. Adding
ea_ports::FIFA17_ROSTER plus an OpenFutPorts.roster destination makes the
existing, proven redirect handle it with no new hook surface, and removes the
need for any client-side DNS change.

to_cfg_string writes roster_port ONLY when it differs from the default: the
parser rejects unknown keys, so emitting it unconditionally would make an
already-deployed older hook reject the whole config and install no redirect at
all -- breaking the game instead of degrading.
This commit is contained in:
funman300
2026-08-23 01:58:11 +00:00
parent 5294f589ad
commit 9ba88c79fc
2 changed files with 80 additions and 2 deletions
+79 -2
View File
@@ -40,6 +40,21 @@ pub mod ea_ports {
pub const FIFA17_BLAZE_REDIRECTOR: u16 = 42230;
/// EA Blaze main server source port.
pub const BLAZE_MAIN: u16 = 42127;
/// The roster / "FUT Squad Update" port.
///
/// Unlike the others this number is OURS: the client only dials it because
/// our Blaze hands it `ROSTERUPDATE_URL = https://<roster-host>:8081/...`.
/// It is still a *signature* in exactly the same sense, because the IP the
/// client dials is whatever the roster hostname resolved to — in practice
/// EA's live `159.153.51.20` record — and we rewrite that to the configured
/// server while leaving the hostname (and therefore SNI) untouched.
///
/// Keeping the hostname is the whole point: FIFA 17's ProtoSSL verifies the
/// roster certificate by **dNSName only**, so redirecting at the socket
/// preserves certificate validity in a way an IP-addressed URL cannot. This
/// is what removes the need for a client hosts entry, an NRPT rule, or an
/// external DNS responder.
pub const FIFA17_ROSTER: u16 = 8081;
}
/// Default OpenFUT *destination* ports, derived from the current OpenFUT server
@@ -64,6 +79,11 @@ pub mod default_ports {
/// which serves `/fut/packs/loc/storepackdescriptions.en_us.xml`. Verified
/// live: that path returns 200 with a 180-byte XLIFF document.
pub const FUT_CONTENT: u16 = 8085;
/// OpenFUT roster / "FUT Squad Update" listener. Same number as the
/// [`ea_ports::FIFA17_ROSTER`] signature because we advertise that port
/// ourselves; it is a separate constant so a deployment can move the roster
/// service without changing what the client dials.
pub const ROSTER: u16 = 8081;
}
/// OpenFUT destination ports. Each field is where an intercepted EA source port
@@ -80,6 +100,8 @@ pub struct OpenFutPorts {
/// FUT web-file content server. Not a redirect destination — see
/// [`default_ports::FUT_CONTENT`].
pub fut_content: u16,
/// Destination for roster traffic ([`ea_ports::FIFA17_ROSTER`]).
pub roster: u16,
}
impl Default for OpenFutPorts {
@@ -89,6 +111,7 @@ impl Default for OpenFutPorts {
blaze_redirector: default_ports::BLAZE_REDIRECTOR,
blaze_main: default_ports::BLAZE_MAIN,
fut_content: default_ports::FUT_CONTENT,
roster: default_ports::ROSTER,
}
}
}
@@ -104,6 +127,7 @@ impl OpenFutPorts {
Some(self.blaze_redirector)
}
ea_ports::BLAZE_MAIN => Some(self.blaze_main),
ea_ports::FIFA17_ROSTER => Some(self.roster),
_ => None,
}
}
@@ -260,6 +284,7 @@ impl ServerConfig {
"blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?,
"blaze_main_port" => ports.blaze_main = parse_port(value)?,
"fut_content_port" => ports.fut_content = parse_port(value)?,
"roster_port" => ports.roster = parse_port(value)?,
other => {
return Err(ConfigError::MalformedConfig(format!(
"line {}: unknown key '{other}'",
@@ -274,15 +299,26 @@ impl ServerConfig {
}
/// Serialize to the structured `openfut.cfg` format.
///
/// `roster_port` is emitted ONLY when it differs from the default. The
/// parser rejects unknown keys, so a config written by a newer launcher and
/// read by an older hook would fail to parse and install NO redirect at all
/// — breaking the game rather than degrading. Withholding the default keeps
/// the common case byte-identical to what every deployed hook already
/// accepts, while still round-tripping a deliberately changed port.
pub fn to_cfg_string(&self) -> String {
format!(
let mut out = format!(
"host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\nfut_content_port={}\n",
self.host,
self.ports.https,
self.ports.blaze_redirector,
self.ports.blaze_main,
self.ports.fut_content
)
);
if self.ports.roster != default_ports::ROSTER {
out.push_str(&format!("roster_port={}\n", self.ports.roster));
}
out
}
/// Base URL the FUT web-file (CDN) prefix is built from, e.g.
@@ -485,6 +521,7 @@ mod tests {
blaze_redirector: 10041,
blaze_main: 42127,
fut_content: 8085,
roster: default_ports::ROSTER,
},
};
let s = c.to_cfg_string();
@@ -555,6 +592,46 @@ mod tests {
assert_eq!(p.map_source_port(12345), None);
}
/// The roster dial is the whole point of the FIFA17_ROSTER signature: the
/// client resolves `winter15.gosredirector.ea.com` to EA's live record and
/// dials THAT ip on 8081, so the socket layer is the only place we can send
/// it to ourselves without touching the client's DNS.
#[test]
fn roster_port_is_redirected_to_the_configured_server() {
let server = ServerConfig::parse("host=10.10.0.120\n")
.unwrap()
.resolve()
.unwrap();
let redirect = server
.redirect_for_ea_port(sin_port_nbo(ea_ports::FIFA17_ROSTER))
.expect("roster dial must be recognised");
assert_eq!(redirect.redirect_ip, Ipv4Addr::new(10, 10, 0, 120));
// Port is preserved: we advertise 8081 and serve 8081.
assert_eq!(redirect.port_nbo, sin_port_nbo(8081));
}
#[test]
fn roster_destination_port_is_configurable() {
let c = ServerConfig::parse("host=10.10.0.120\nroster_port=9443\n").unwrap();
assert_eq!(c.ports.roster, 9443);
assert_eq!(c.ports.map_source_port(ea_ports::FIFA17_ROSTER), Some(9443));
}
/// A default roster port must NOT appear in the written config: the parser
/// rejects unknown keys, so emitting it unconditionally would make every
/// already-deployed hook reject the whole file and install no redirect.
#[test]
fn default_roster_port_is_not_emitted_but_a_custom_one_round_trips() {
let default_cfg = ServerConfig::parse("host=10.10.0.120\n").unwrap();
assert!(!default_cfg.to_cfg_string().contains("roster_port"));
let mut custom = default_cfg.clone();
custom.ports.roster = 9443;
let reparsed = ServerConfig::parse(&custom.to_cfg_string()).unwrap();
assert_eq!(reparsed.ports.roster, 9443);
assert_eq!(reparsed, custom);
}
#[test]
fn resolve_literal_ipv4_no_dns() {
let c = ServerConfig::parse("host=127.0.0.1\n").unwrap();
+1
View File
@@ -326,6 +326,7 @@ impl LauncherConfig {
blaze_redirector: self.openfut_blaze_redirector_port,
blaze_main: self.openfut_blaze_main_port,
fut_content: openfut_common::default_ports::FUT_CONTENT,
roster: openfut_common::default_ports::ROSTER,
},
}
}