9ba88c79fc
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.
705 lines
28 KiB
Rust
705 lines
28 KiB
Rust
//! Shared OpenFUT destination configuration.
|
|
//!
|
|
//! This crate is the **single source of truth** for where FIFA's intercepted
|
|
//! EA traffic is redirected. It is consumed by both the launcher (which writes
|
|
//! `openfut.cfg`) and `openfut_hook.dll` (which reads it and rewrites sockets).
|
|
//!
|
|
//! Design rules enforced here:
|
|
//! * There is exactly ONE configured OpenFUT destination (host + ports).
|
|
//! * Missing/invalid configuration is a hard error — it NEVER silently
|
|
//! degrades to `127.0.0.1`/localhost. Loopback is only ever used if the
|
|
//! user explicitly configures it.
|
|
//! * The address/port -> WinSock representation helpers live here and are
|
|
//! unit-tested on the host, so the byte-order logic the socket hooks depend
|
|
//! on is verified without needing WinSock or FIFA.
|
|
//!
|
|
//! ## EA source ports vs OpenFUT destination ports
|
|
//!
|
|
//! FIFA connects to a handful of EA endpoints on fixed ports. Those source
|
|
//! ports are *signatures* used to recognise traffic that must be intercepted —
|
|
//! they are hardcoded on purpose (see [`ea_ports`]). Each recognised EA source
|
|
//! port maps to an OpenFUT *destination* port, which comes from configuration
|
|
//! ([`OpenFutPorts`]). Source port = fixed EA signature; destination port =
|
|
//! configurable OpenFUT service.
|
|
|
|
use std::fmt;
|
|
use std::net::{Ipv4Addr, ToSocketAddrs};
|
|
use std::str::FromStr;
|
|
|
|
/// EA source ports FIFA dials. These are fixed EA endpoint signatures used to
|
|
/// recognise traffic for interception — NOT OpenFUT configuration. Do not make
|
|
/// these configurable; changing them would stop us recognising EA traffic.
|
|
pub mod ea_ports {
|
|
/// EA HTTPS (UTAS / EAWebKit / footapi) — the game dials EA hosts on 443.
|
|
pub const HTTPS: u16 = 443;
|
|
/// EA Blaze redirector (gosredirector) source port.
|
|
pub const BLAZE_REDIRECTOR: u16 = 10041;
|
|
/// FIFA 17's `winter15.gosredirector.ea.com` endpoint source port. This is
|
|
/// an observed, fixed vendor-port signature; it maps to the same configured
|
|
/// OpenFUT redirector destination as the newer 10041 endpoint.
|
|
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
|
|
/// implementation (bridge listens on 8443 for HTTPS; Blaze services keep their
|
|
/// native ports). The launcher may pre-populate these; the user may change them
|
|
/// without recompiling anything.
|
|
pub mod default_ports {
|
|
/// OpenFUT bridge HTTPS listener (EA :443 is redirected here).
|
|
pub const HTTPS: u16 = 8443;
|
|
/// OpenFUT FIFA 17 Blaze redirector listener.
|
|
pub const BLAZE_REDIRECTOR: u16 = 42127;
|
|
/// OpenFUT FIFA 17 Blaze main listener.
|
|
pub const BLAZE_MAIN: u16 = 42130;
|
|
/// OpenFUT FUT web-file (CDN) content server. Unlike the others this is not
|
|
/// an EA redirect target: the client never dials it directly, because its
|
|
/// `RS4::ServerSettings` CDN base arrives EMPTY in the emulator. The hook
|
|
/// supplies the missing `<base>/fut/` prefix, and the base is built from the
|
|
/// configured server host plus this port.
|
|
///
|
|
/// 8085 is the POW content server (`pow_server.py`, kind "content"), which is
|
|
/// the port Blaze already advertises to the client as its content host and
|
|
/// 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
|
|
/// is redirected. Not collapsed into one port: OpenFUT runs distinct services
|
|
/// (bridge HTTPS + two Blaze listeners) that FIFA reaches on distinct ports.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct OpenFutPorts {
|
|
/// Destination for EA :443 traffic (bridge HTTPS).
|
|
pub https: u16,
|
|
/// Destination for EA :10041 traffic (Blaze redirector).
|
|
pub blaze_redirector: u16,
|
|
/// Destination for EA :42127 traffic (Blaze main).
|
|
pub blaze_main: u16,
|
|
/// 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 {
|
|
fn default() -> Self {
|
|
Self {
|
|
https: default_ports::HTTPS,
|
|
blaze_redirector: default_ports::BLAZE_REDIRECTOR,
|
|
blaze_main: default_ports::BLAZE_MAIN,
|
|
fut_content: default_ports::FUT_CONTENT,
|
|
roster: default_ports::ROSTER,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl OpenFutPorts {
|
|
/// Map a recognised EA *source* port to its OpenFUT *destination* port.
|
|
/// Returns `None` for ports we don't intercept (the socket hook then leaves
|
|
/// the connection untouched).
|
|
pub fn map_source_port(&self, ea_source_port: u16) -> Option<u16> {
|
|
match ea_source_port {
|
|
ea_ports::HTTPS => Some(self.https),
|
|
ea_ports::BLAZE_REDIRECTOR | ea_ports::FIFA17_BLAZE_REDIRECTOR => {
|
|
Some(self.blaze_redirector)
|
|
}
|
|
ea_ports::BLAZE_MAIN => Some(self.blaze_main),
|
|
ea_ports::FIFA17_ROSTER => Some(self.roster),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The parsed OpenFUT server configuration: what host to redirect EA traffic to
|
|
/// and which destination ports to use. `host` may be an IPv4 literal or a
|
|
/// hostname; resolution to a concrete [`Ipv4Addr`] happens in [`Self::resolve`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ServerConfig {
|
|
/// User-configured OpenFUT server host (IPv4 literal or hostname). Never
|
|
/// defaulted to loopback — an empty host is a [`ConfigError::ServerMissing`].
|
|
pub host: String,
|
|
pub ports: OpenFutPorts,
|
|
}
|
|
|
|
/// A [`ServerConfig`] whose host has been resolved to a concrete IPv4 address.
|
|
/// This is what the socket hooks consume — all three interception layers share
|
|
/// exactly this resolved destination.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct ResolvedServer {
|
|
/// The concrete IPv4 the EA connection's destination is rewritten to.
|
|
pub redirect_ip: Ipv4Addr,
|
|
pub ports: OpenFutPorts,
|
|
}
|
|
|
|
/// Where one matched EA connection is rewritten to, in the exact WinSock
|
|
/// on-the-wire representation the socket hooks need. Produced by
|
|
/// [`ResolvedServer::redirect_for_ea_port`] so the hook and the launcher's
|
|
/// `openfut.cfg` share one decision by construction.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Redirect {
|
|
/// Rewritten IPv4 for `sockaddr_in.sin_addr` (network byte order in memory).
|
|
pub addr_nbo: u32,
|
|
/// Rewritten port for `sin_port` / `sin6_port` (network byte order).
|
|
pub port_nbo: u16,
|
|
/// The resolved server IPv4, for callers building an IPv6 v4-mapped address.
|
|
pub redirect_ip: Ipv4Addr,
|
|
}
|
|
|
|
impl ResolvedServer {
|
|
/// Decide the redirect for an outbound EA connection whose destination port
|
|
/// is `ea_port_nbo` (network byte order, as read straight from the sockaddr).
|
|
///
|
|
/// Returns `None` when the port is not a recognised OpenFUT route — the hook
|
|
/// then leaves the connection untouched. The original destination IP is
|
|
/// intentionally ignored: matching is by the fixed EA source-port signature
|
|
/// ([`ea_ports`]), so a hardcoded EA IP (e.g. FIFA17's `159.153.51.20`
|
|
/// redirector) and a DNS-resolved one are treated identically and both land
|
|
/// on the configured server — no `/etc/hosts`, DNAT, or portproxy required.
|
|
pub fn redirect_for_ea_port(&self, ea_port_nbo: u16) -> Option<Redirect> {
|
|
let ea_port = u16::from_be(ea_port_nbo);
|
|
let dest_port = self.ports.map_source_port(ea_port)?;
|
|
Some(Redirect {
|
|
addr_nbo: sin_addr_from_ipv4(self.redirect_ip),
|
|
port_nbo: sin_port_nbo(dest_port),
|
|
redirect_ip: self.redirect_ip,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Errors loading/validating OpenFUT server configuration. Every one of these
|
|
/// must BLOCK operation — none of them may fall back to loopback.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ConfigError {
|
|
/// No config file present (e.g. `openfut.cfg` missing).
|
|
ConfigMissing,
|
|
/// Config present but no server host set.
|
|
ServerMissing,
|
|
/// Host could not be parsed/resolved to an IPv4 address.
|
|
InvalidAddress(String),
|
|
/// A port value was not a valid `u16` / was zero.
|
|
InvalidPort(String),
|
|
/// Config bytes were structurally unparseable.
|
|
MalformedConfig(String),
|
|
}
|
|
|
|
impl fmt::Display for ConfigError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
ConfigError::ConfigMissing => {
|
|
write!(f, "No OpenFUT server configured (config file missing)")
|
|
}
|
|
ConfigError::ServerMissing => write!(
|
|
f,
|
|
"No OpenFUT server configured. Enter the hostname or IP address of your OpenFUT server."
|
|
),
|
|
ConfigError::InvalidAddress(a) => write!(f, "Invalid OpenFUT server address: {a}"),
|
|
ConfigError::InvalidPort(p) => write!(f, "Invalid OpenFUT port: {p}"),
|
|
ConfigError::MalformedConfig(m) => write!(f, "Malformed OpenFUT config: {m}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ConfigError {}
|
|
|
|
impl ServerConfig {
|
|
/// Parse `openfut.cfg` contents.
|
|
///
|
|
/// Two accepted formats:
|
|
/// * **Structured** (preferred), one `key=value` per line:
|
|
/// ```text
|
|
/// host=192.168.1.50
|
|
/// https_port=8443
|
|
/// blaze_redirector_port=10041
|
|
/// blaze_main_port=42127
|
|
/// ```
|
|
/// `host` is required; any omitted port uses its default.
|
|
/// * **Legacy**, a single bare line containing just the host
|
|
/// (IPv4 or hostname). Ports default. This keeps old deployments working.
|
|
///
|
|
/// An empty/whitespace-only body is [`ConfigError::ServerMissing`], NOT a
|
|
/// silent loopback fallback.
|
|
pub fn parse(contents: &str) -> Result<Self, ConfigError> {
|
|
let trimmed = contents.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(ConfigError::ServerMissing);
|
|
}
|
|
|
|
// Legacy single-token form: no '=' anywhere and a single line.
|
|
let has_kv = trimmed.lines().any(|l| l.contains('='));
|
|
if !has_kv {
|
|
let host = trimmed.trim();
|
|
if host.is_empty() {
|
|
return Err(ConfigError::ServerMissing);
|
|
}
|
|
return Ok(Self {
|
|
host: host.to_string(),
|
|
ports: OpenFutPorts::default(),
|
|
});
|
|
}
|
|
|
|
let mut host: Option<String> = None;
|
|
let mut ports = OpenFutPorts::default();
|
|
|
|
for (lineno, raw) in trimmed.lines().enumerate() {
|
|
let line = raw.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
let (key, value) = line.split_once('=').ok_or_else(|| {
|
|
ConfigError::MalformedConfig(format!("line {}: expected key=value", lineno + 1))
|
|
})?;
|
|
let key = key.trim();
|
|
let value = value.trim();
|
|
match key {
|
|
"host" | "server" | "redirect_ip" => {
|
|
if value.is_empty() {
|
|
return Err(ConfigError::ServerMissing);
|
|
}
|
|
host = Some(value.to_string());
|
|
}
|
|
"https_port" => ports.https = parse_port(value)?,
|
|
"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}'",
|
|
lineno + 1
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
let host = host.ok_or(ConfigError::ServerMissing)?;
|
|
Ok(Self { host, ports })
|
|
}
|
|
|
|
/// 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 {
|
|
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.
|
|
/// `http://10.10.0.120:8085/fut/`.
|
|
///
|
|
/// The client's `RS4::ServerSettings` CDN base arrives EMPTY in the emulator,
|
|
/// so FUT web-file urls reach the download entry point as bare relative paths
|
|
/// and fail. The hook supplies this prefix. Built from the SAME configured
|
|
/// host as every other redirect, so a lab address is never compiled in.
|
|
pub fn fut_content_base(&self) -> String {
|
|
format!(
|
|
"http://{}:{}/fut/",
|
|
self.host.trim(),
|
|
self.ports.fut_content
|
|
)
|
|
}
|
|
|
|
/// Validate the host is a syntactically acceptable IPv4 literal or hostname.
|
|
/// Does NOT perform DNS (no network) — use [`Self::resolve`] for that.
|
|
pub fn validate(&self) -> Result<(), ConfigError> {
|
|
let host = self.host.trim();
|
|
if host.is_empty() {
|
|
return Err(ConfigError::ServerMissing);
|
|
}
|
|
if Ipv4Addr::from_str(host).is_ok() {
|
|
return Ok(());
|
|
}
|
|
if is_plausible_hostname(host) {
|
|
Ok(())
|
|
} else {
|
|
Err(ConfigError::InvalidAddress(host.to_string()))
|
|
}
|
|
}
|
|
|
|
/// Resolve the configured host to a concrete IPv4 destination.
|
|
///
|
|
/// This is the single shared resolution path all hooks rely on: an IPv4
|
|
/// literal is used directly; a hostname is resolved via the platform
|
|
/// resolver ([`ToSocketAddrs`]). Only IPv4 results are used (WinSock
|
|
/// interception here is IPv4). Never falls back to loopback.
|
|
pub fn resolve(&self) -> Result<ResolvedServer, ConfigError> {
|
|
let host = self.host.trim();
|
|
if host.is_empty() {
|
|
return Err(ConfigError::ServerMissing);
|
|
}
|
|
|
|
// IPv4 literal: no DNS needed.
|
|
if let Ok(ip) = Ipv4Addr::from_str(host) {
|
|
return Ok(ResolvedServer {
|
|
redirect_ip: ip,
|
|
ports: self.ports,
|
|
});
|
|
}
|
|
|
|
// Hostname: resolve via the platform resolver. Port is irrelevant to
|
|
// the lookup; we only need an address. Take the first IPv4 answer.
|
|
let ip = (host, 0u16)
|
|
.to_socket_addrs()
|
|
.map_err(|e| ConfigError::InvalidAddress(format!("{host}: {e}")))?
|
|
.find_map(|sa| match sa.ip() {
|
|
std::net::IpAddr::V4(v4) => Some(v4),
|
|
std::net::IpAddr::V6(_) => None,
|
|
})
|
|
.ok_or_else(|| ConfigError::InvalidAddress(format!("{host}: no IPv4 address found")))?;
|
|
|
|
Ok(ResolvedServer {
|
|
redirect_ip: ip,
|
|
ports: self.ports,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn parse_port(value: &str) -> Result<u16, ConfigError> {
|
|
let n: u16 = value
|
|
.parse()
|
|
.map_err(|_| ConfigError::InvalidPort(value.to_string()))?;
|
|
if n == 0 {
|
|
return Err(ConfigError::InvalidPort(value.to_string()));
|
|
}
|
|
Ok(n)
|
|
}
|
|
|
|
/// Lenient hostname sanity check (RFC-1123-ish). Not a full validator — just
|
|
/// enough to reject obvious garbage while accepting `.local`/`.home` names.
|
|
fn is_plausible_hostname(host: &str) -> bool {
|
|
if host.is_empty() || host.len() > 253 {
|
|
return false;
|
|
}
|
|
host.split('.').all(|label| {
|
|
!label.is_empty()
|
|
&& label.len() <= 63
|
|
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
|
&& !label.starts_with('-')
|
|
&& !label.ends_with('-')
|
|
})
|
|
}
|
|
|
|
// ── WinSock representation helpers ───────────────────────────────────────────
|
|
// These convert a resolved destination into the exact in-memory representation
|
|
// WinSock's `sockaddr_in` expects. Kept here (not in the DLL) so the byte-order
|
|
// contract the socket hooks depend on is unit-tested on the host.
|
|
|
|
/// Value to store in `sockaddr_in.sin_addr.S_un.S_addr`.
|
|
///
|
|
/// WinSock stores the four IPv4 octets in network byte order in memory. Reading
|
|
/// those bytes back as a native-endian `u32` (how the hook's `SockaddrIn.sin_addr`
|
|
/// field is typed) is exactly `from_ne_bytes(octets)`. On little-endian x86_64
|
|
/// this yields e.g. `127.0.0.1 -> 0x0100_007F`, matching the value the original
|
|
/// hooks hardcoded — confirming the conversion is correct.
|
|
pub fn sin_addr_from_ipv4(ip: Ipv4Addr) -> u32 {
|
|
u32::from_ne_bytes(ip.octets())
|
|
}
|
|
|
|
/// Value to store in `sockaddr_in.sin_port` — the port in network byte order.
|
|
pub fn sin_port_nbo(port: u16) -> u16 {
|
|
port.to_be()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn configured_ipv4_survives_parsing() {
|
|
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
|
|
assert_eq!(c.host, "192.168.1.50");
|
|
assert_eq!(c.ports, OpenFutPorts::default());
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_bare_host_line_parses() {
|
|
let c = ServerConfig::parse("10.0.0.7\n").unwrap();
|
|
assert_eq!(c.host, "10.0.0.7");
|
|
assert_eq!(c.ports, OpenFutPorts::default());
|
|
}
|
|
|
|
#[test]
|
|
fn missing_server_does_not_become_loopback() {
|
|
// Empty config is an explicit error, never 127.0.0.1.
|
|
assert_eq!(
|
|
ServerConfig::parse("").unwrap_err(),
|
|
ConfigError::ServerMissing
|
|
);
|
|
assert_eq!(
|
|
ServerConfig::parse(" \n\n").unwrap_err(),
|
|
ConfigError::ServerMissing
|
|
);
|
|
assert_eq!(
|
|
ServerConfig::parse("https_port=8443\n").unwrap_err(),
|
|
ConfigError::ServerMissing
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_server_is_rejected() {
|
|
let c = ServerConfig {
|
|
host: "not a host!!".into(),
|
|
ports: OpenFutPorts::default(),
|
|
};
|
|
assert!(matches!(c.validate(), Err(ConfigError::InvalidAddress(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn configured_port_survives_parsing() {
|
|
let c = ServerConfig::parse(
|
|
"host=srv.home\nhttps_port=9000\nblaze_redirector_port=11000\nblaze_main_port=42000\n",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(c.ports.https, 9000);
|
|
assert_eq!(c.ports.blaze_redirector, 11000);
|
|
assert_eq!(c.ports.blaze_main, 42000);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_port_is_rejected() {
|
|
assert!(matches!(
|
|
ServerConfig::parse("host=x\nhttps_port=0\n"),
|
|
Err(ConfigError::InvalidPort(_))
|
|
));
|
|
assert!(matches!(
|
|
ServerConfig::parse("host=x\nhttps_port=99999\n"),
|
|
Err(ConfigError::InvalidPort(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_key_is_malformed() {
|
|
assert!(matches!(
|
|
ServerConfig::parse("host=x\nbogus=1\n"),
|
|
Err(ConfigError::MalformedConfig(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip_cfg_string() {
|
|
let c = ServerConfig {
|
|
host: "192.168.1.50".into(),
|
|
ports: OpenFutPorts {
|
|
https: 8443,
|
|
blaze_redirector: 10041,
|
|
blaze_main: 42127,
|
|
fut_content: 8085,
|
|
roster: default_ports::ROSTER,
|
|
},
|
|
};
|
|
let s = c.to_cfg_string();
|
|
assert_eq!(ServerConfig::parse(&s).unwrap(), c);
|
|
}
|
|
|
|
/// The FUT web-file prefix follows the CONFIGURED server, so no lab address
|
|
/// is ever compiled into the hook.
|
|
#[test]
|
|
fn fut_content_base_follows_the_configured_host() {
|
|
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
|
|
assert_eq!(c.fut_content_base(), "http://192.168.1.50:8085/fut/");
|
|
|
|
let c = ServerConfig::parse("host=fut.mylan.home\nfut_content_port=9110\n").unwrap();
|
|
assert_eq!(c.fut_content_base(), "http://fut.mylan.home:9110/fut/");
|
|
}
|
|
|
|
/// A cfg written before `fut_content_port` existed must still parse, taking
|
|
/// the default rather than failing the whole config (which would disarm the
|
|
/// network redirect too).
|
|
#[test]
|
|
fn cfg_without_content_port_takes_the_default() {
|
|
let c = ServerConfig::parse(
|
|
"host=10.0.0.5\nhttps_port=8443\nblaze_redirector_port=42127\nblaze_main_port=42130\n",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(c.ports.fut_content, default_ports::FUT_CONTENT);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_ipv4_becomes_correct_sockaddr() {
|
|
// Resolve an IPv4 literal and confirm the sin_addr value.
|
|
let c = ServerConfig::parse("host=192.168.1.50\n").unwrap();
|
|
let r = c.resolve().unwrap();
|
|
assert_eq!(r.redirect_ip, Ipv4Addr::new(192, 168, 1, 50));
|
|
// sin_addr is the octets as a native-endian u32.
|
|
assert_eq!(
|
|
sin_addr_from_ipv4(r.redirect_ip),
|
|
u32::from_ne_bytes([192, 168, 1, 50])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn loopback_sockaddr_matches_legacy_constant() {
|
|
// Guards the byte-order contract: the old hooks hardcoded 0x0100_007F
|
|
// for 127.0.0.1. Our helper must reproduce it on this (LE) host.
|
|
assert_eq!(sin_addr_from_ipv4(Ipv4Addr::new(127, 0, 0, 1)), 0x0100_007F);
|
|
}
|
|
|
|
#[test]
|
|
fn sin_port_is_network_byte_order() {
|
|
// 443 -> 0xBB01, 42127 -> 0x8FA4 (matches the legacy hook constants).
|
|
assert_eq!(sin_port_nbo(443), 0xBB01);
|
|
assert_eq!(sin_port_nbo(42127), 0x8FA4);
|
|
assert_eq!(sin_port_nbo(10041), 0x3927);
|
|
}
|
|
|
|
#[test]
|
|
fn port_mapping_source_to_destination() {
|
|
let p = OpenFutPorts::default();
|
|
assert_eq!(p.map_source_port(ea_ports::HTTPS), Some(8443));
|
|
assert_eq!(p.map_source_port(ea_ports::BLAZE_REDIRECTOR), Some(42127));
|
|
assert_eq!(
|
|
p.map_source_port(ea_ports::FIFA17_BLAZE_REDIRECTOR),
|
|
Some(42127)
|
|
);
|
|
assert_eq!(p.map_source_port(ea_ports::BLAZE_MAIN), Some(42130));
|
|
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();
|
|
let r = c.resolve().unwrap();
|
|
assert_eq!(r.redirect_ip, Ipv4Addr::LOCALHOST);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_empty_host_errors() {
|
|
let c = ServerConfig {
|
|
host: " ".into(),
|
|
ports: OpenFutPorts::default(),
|
|
};
|
|
assert_eq!(c.resolve().unwrap_err(), ConfigError::ServerMissing);
|
|
}
|
|
|
|
#[test]
|
|
fn redirect_maps_every_fifa17_route_to_configured_server() {
|
|
// The canonical staging cfg. Ports come from the file, not constants.
|
|
let resolved = ServerConfig::parse(
|
|
"host=10.10.0.120\nhttps_port=8443\nblaze_redirector_port=42127\nblaze_main_port=42130\n",
|
|
)
|
|
.unwrap()
|
|
.resolve()
|
|
.unwrap();
|
|
let server = Ipv4Addr::new(10, 10, 0, 120);
|
|
// (EA source port [host order], expected OpenFUT dest port)
|
|
for (ea, dest) in [
|
|
(443u16, 8443u16),
|
|
(10041, 42127),
|
|
(42230, 42127),
|
|
(42127, 42130),
|
|
] {
|
|
let r = resolved
|
|
.redirect_for_ea_port(ea.to_be())
|
|
.unwrap_or_else(|| panic!("EA port {ea} should be a route"));
|
|
assert_eq!(u16::from_be(r.port_nbo), dest, "EA {ea} -> dest");
|
|
assert_eq!(r.redirect_ip, server, "EA {ea} -> server ip");
|
|
assert_eq!(
|
|
r.addr_nbo,
|
|
sin_addr_from_ipv4(server),
|
|
"EA {ea} -> sin_addr"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn redirect_leaves_unknown_ports_untouched() {
|
|
let resolved = ServerConfig::parse("host=10.10.0.120\n")
|
|
.unwrap()
|
|
.resolve()
|
|
.unwrap();
|
|
assert!(resolved.redirect_for_ea_port(8080u16.to_be()).is_none());
|
|
assert!(resolved.redirect_for_ea_port(22u16.to_be()).is_none());
|
|
assert!(resolved.redirect_for_ea_port(443u16.to_be()).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn redirect_targets_configured_remote_host_not_loopback() {
|
|
let resolved = ServerConfig::parse("host=10.10.0.120\n")
|
|
.unwrap()
|
|
.resolve()
|
|
.unwrap();
|
|
// FIFA17 redirector (hardcoded EA IP 159.153.51.20:42230) must be rewritten
|
|
// to the configured REMOTE server, never 127.0.0.1.
|
|
let r = resolved.redirect_for_ea_port(42230u16.to_be()).unwrap();
|
|
assert_eq!(r.redirect_ip, Ipv4Addr::new(10, 10, 0, 120));
|
|
assert_ne!(r.redirect_ip, Ipv4Addr::LOCALHOST);
|
|
}
|
|
}
|