//! 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; } /// 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 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, } impl Default for OpenFutPorts { fn default() -> Self { Self { https: default_ports::HTTPS, blaze_redirector: default_ports::BLAZE_REDIRECTOR, blaze_main: default_ports::BLAZE_MAIN, } } } 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), _ => 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, } /// 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)?, 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. pub fn to_cfg_string(&self) -> String { format!( "host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\n", self.host, self.ports.https, self.ports.blaze_redirector, self.ports.blaze_main ) } /// 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, }, }; let s = c.to_cfg_string(); assert_eq!(ServerConfig::parse(&s).unwrap(), c); } #[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); } #[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); } }