From 16f345299018c005e481f54a3ed1936caa1def57 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 20 Aug 2026 20:38:10 +0000 Subject: [PATCH] feat(fifa17-hook): redirect FIFA17 sockets from shared config The fifa17 hook path installed no connection redirect (only a module-map dump), so FIFA17 relied entirely on Linux iptables DNAT / hosts (and had no Windows equivalent). Add an in-process, config-driven redirect for the fifa17 build: - openfut-common gains ResolvedServer::redirect_for_ea_port(): the single shared decision (EA source-port signature -> configured OpenFUT host+port, network byte order), reused by the hook so it and openfut.cfg agree by construction. Covers 443->https, 10041/42230->blaze_redirector, 42127->blaze_main. - connect_hook: redirect_if_ea now dispatches to a config-driven rewrite when armed (rewrites to the CONFIGURED, possibly remote, server -- not hardcoded 127.0.0.1), matching by EA source port so a hardcoded EA IP (159.153.51.20 redirector) and a DNS-resolved one both land on the server. Legacy loopback path retained only for the not-yet-retired FIFA23 build. - fifa17::install() reads openfut.cfg next to FIFA17.exe via openfut-common and installs connect + WSAConnect (IAT) + ConnectEx (shared redirect). Fail-safe: missing/invalid config installs NO redirect (traffic untouched), never a corrupt sockaddr. Tests: openfut-common redirect map/sockaddr/endian/remote-host/unknown-port. Cross-builds x86_64-pc-windows-gnu --features fifa17; clippy -D warnings clean. No Linux fallback removed (migration gate). --- openfut-common/src/lib.rs | 89 ++++++++++++++++++++++++++++++++ openfut-hook/Cargo.lock | 5 ++ openfut-hook/Cargo.toml | 4 ++ openfut-hook/src/connect_hook.rs | 85 +++++++++++++++++++++++++++++- openfut-hook/src/fifa17.rs | 67 ++++++++++++++++++++++++ 5 files changed, 249 insertions(+), 1 deletion(-) diff --git a/openfut-common/src/lib.rs b/openfut-common/src/lib.rs index 2bd5033..71eaeca 100644 --- a/openfut-common/src/lib.rs +++ b/openfut-common/src/lib.rs @@ -115,6 +115,41 @@ pub struct ResolvedServer { 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)] @@ -476,4 +511,58 @@ mod tests { }; 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); + } } diff --git a/openfut-hook/Cargo.lock b/openfut-hook/Cargo.lock index f02d3a6..c75ff37 100644 --- a/openfut-hook/Cargo.lock +++ b/openfut-hook/Cargo.lock @@ -2,10 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "openfut-common" +version = "0.1.0" + [[package]] name = "openfut-hook" version = "0.1.0" dependencies = [ + "openfut-common", "windows-sys", ] diff --git a/openfut-hook/Cargo.toml b/openfut-hook/Cargo.toml index 6c8e75b..2d9ab23 100644 --- a/openfut-hook/Cargo.toml +++ b/openfut-hook/Cargo.toml @@ -39,6 +39,10 @@ windows-sys = { version = "0.59", features = [ "Win32_System_Diagnostics_Debug", "Win32_System_Kernel", ] } +# Single source of truth for the OpenFUT redirect config (openfut.cfg schema, +# EA-port -> OpenFUT-port map, WinSock byte-order helpers). Shared with the +# launcher so the hook and openfut.cfg agree by construction. +openfut-common = { path = "../openfut-common" } [profile.release] opt-level = "s" diff --git a/openfut-hook/src/connect_hook.rs b/openfut-hook/src/connect_hook.rs index aeff08b..fd06bfd 100644 --- a/openfut-hook/src/connect_hook.rs +++ b/openfut-hook/src/connect_hook.rs @@ -97,7 +97,7 @@ unsafe fn restore_original(target: *mut u8) { /// The returned buffer is 28 bytes (enough for a `sockaddr_in6`); the second value is /// how many of those bytes are meaningful (16 for v4, 28 for v6). `pub(crate)` so the /// ConnectEx path can share this one implementation. -pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> { +unsafe fn redirect_loopback(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> { if namelen < 8 || name.is_null() { return None; } @@ -172,6 +172,89 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u } } +/// The armed FIFA17 redirect target, resolved once from `openfut.cfg` via +/// `openfut-common`. When set, `redirect_if_ea` rewrites matched EA connections +/// to this configured server; when unset, the legacy loopback path is used. +static REDIRECT: OnceLock = OnceLock::new(); + +/// Arm the config-driven redirect (FIFA17). Idempotent: the first call wins. +pub fn set_redirect(server: openfut_common::ResolvedServer) { + let _ = REDIRECT.set(server); +} + +/// Dispatch: config-driven (FIFA17, shared `openfut-common` map + configured +/// host) when armed, else the legacy hardcoded-loopback rewrite. +pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> { + if namelen < 8 || name.is_null() { + return None; + } + match REDIRECT.get() { + Some(server) => redirect_configured(server, name, namelen), + None => redirect_loopback(name, namelen), + } +} + +/// FIFA17 config-driven rewrite. Destination host+port come from `openfut.cfg` +/// through `openfut-common`, so the hook and the launcher agree by construction. +/// Matching is by EA source-port signature only (see `openfut_common::ea_ports`), +/// so a hardcoded EA IP (e.g. the `159.153.51.20:42230` redirector) and a +/// DNS-resolved one both land on the configured — possibly remote — server. An +/// unrecognised port returns `None` (connection left untouched). Never corrupts +/// the sockaddr: it only writes into a fresh 28-byte buffer. +unsafe fn redirect_configured( + server: &openfut_common::ResolvedServer, + name: *const u8, + namelen: i32, +) -> Option<([u8; 28], i32)> { + let family = *(name as *const u16); + let mut buf = [0u8; 28]; + match family { + AF_INET => { + let sa = &*(name as *const SockaddrIn); + let redir = server.redirect_for_ea_port(sa.sin_port)?; + crate::write_log(&format!( + "connect_hook: v4 :{} → {}:{}\n", + u16::from_be(sa.sin_port), + redir.redirect_ip, + u16::from_be(redir.port_nbo) + )); + let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn); + out.sin_family = AF_INET; + out.sin_port = redir.port_nbo; + out.sin_addr = redir.addr_nbo; + Some((buf, 16)) + } + AF_INET6 => { + if namelen < 28 { + return None; + } + let sa6 = &*(name as *const SockaddrIn6); + let redir = server.redirect_for_ea_port(sa6.sin6_port)?; + // ::ffff: — a v4-mapped v6 target so a v6 socket sends + // real IPv4 packets to the configured server. + let o = redir.redirect_ip.octets(); + let mut v4mapped = [0u8; 16]; + v4mapped[10] = 0xff; + v4mapped[11] = 0xff; + v4mapped[12..16].copy_from_slice(&o); + crate::write_log(&format!( + "connect_hook: v6 :{} → ::ffff:{}:{}\n", + u16::from_be(sa6.sin6_port), + redir.redirect_ip, + u16::from_be(redir.port_nbo) + )); + let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6); + out.sin6_family = AF_INET6; + out.sin6_port = redir.port_nbo; + out.sin6_flowinfo = 0; + out.sin6_addr = v4mapped; + out.sin6_scope_id = 0; + Some((buf, 28)) + } + _ => None, + } +} + pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: i32) -> i32 { let addr = CONNECT_ADDR.load(Ordering::Relaxed) as *mut u8; diff --git a/openfut-hook/src/fifa17.rs b/openfut-hook/src/fifa17.rs index bb361a8..20d8500 100644 --- a/openfut-hook/src/fifa17.rs +++ b/openfut-hook/src/fifa17.rs @@ -67,6 +67,29 @@ unsafe fn dump_modules() { CloseHandle(snap); } +/// Read `openfut.cfg` from the game directory (next to `FIFA17.exe`) and resolve +/// the OpenFUT server via the shared `openfut-common` parser. Returns `None` +/// with a diagnostic when the file is absent or unusable, so the hook fails +/// safe — no redirect installed rather than a corrupt one. +fn load_server() -> Option { + let dir = std::env::current_exe().ok()?.parent()?.to_path_buf(); + let path = dir.join("openfut.cfg"); + let contents = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + write_log(&format!("fifa17: cannot read {}: {e}\n", path.display())); + return None; + } + }; + match openfut_common::ServerConfig::parse(&contents).and_then(|c| c.resolve()) { + Ok(server) => Some(server), + Err(e) => { + write_log(&format!("fifa17: openfut.cfg unusable: {e}\n")); + None + } + } +} + /// Worker that runs AFTER DllMain returns (loader lock released). ToolHelp and /// other loader-touching calls are unsafe under the loader lock, so we defer them /// to this thread. This is what fixed the "game exits right after DllMain" issue. @@ -79,6 +102,50 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 { )); dump_modules(); write_log("fifa17: worker complete (injection healthy)\n"); + + // ── FIFA17 in-process network redirect (Milestone A) ────────────────────── + // Route EA endpoints to the configured OpenFUT server from openfut.cfg + // (openfut-common is the single source of truth). No hosts/iptables/portproxy. + match load_server() { + Some(server) => { + write_log(&format!( + "fifa17: redirect armed → {} https={} redirector={} main={}\n", + server.redirect_ip, + server.ports.https, + server.ports.blaze_redirector, + server.ports.blaze_main + )); + crate::connect_hook::set_redirect(server); + if crate::connect_hook::install_inline_connect_hook() { + write_log("fifa17: connect inline-hooked\n"); + } else { + write_log("fifa17: connect hook FAILED\n"); + } + let wp = crate::iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0"); + if !wp.is_null() { + let f: unsafe extern "system" fn( + usize, + *const u8, + i32, + *const (), + *const (), + *const (), + *const (), + ) -> i32 = core::mem::transmute(wp); + crate::connect_hook::set_real_wsa_connect(f); + crate::iat::patch_iat(wp, crate::connect_hook::hooked_wsa_connect as *const ()); + write_log("fifa17: WSAConnect IAT patched\n"); + } + if crate::connectex_hook::install_wsaioctl_hook() { + write_log("fifa17: ConnectEx (WSAIoctl) hooked\n"); + } else { + write_log("fifa17: ConnectEx hook FAILED\n"); + } + } + None => write_log( + "fifa17: NO redirect installed (openfut.cfg missing/invalid) — EA traffic left untouched\n", + ), + } // The promoted SBC dispatch repair (and the evidence traces it decides on) arms // itself from the build; its safety is the runtime signature/evidence gate. The // remaining legacy experiment modules stay inert unless their env gate is `1`.