//! 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://: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 `/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 { 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 { 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 { 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 = 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 { 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 { 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); } }