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).
This commit is contained in:
funman300
2026-08-20 20:38:10 +00:00
parent 966e92b304
commit 16f3452990
5 changed files with 249 additions and 1 deletions
+89
View File
@@ -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<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)]
@@ -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);
}
}
+5
View File
@@ -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",
]
+4
View File
@@ -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"
+84 -1
View File
@@ -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<openfut_common::ResolvedServer> = 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:<redirect_ip> — 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;
+67
View File
@@ -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<openfut_common::ResolvedServer> {
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`.