//! Pre-launch checks for the client-side state FIFA depends on. //! //! # Why //! //! On 2026-08-11 the game machine rebooted. Everything `client_arm.sh` sets — //! `ptrace_scope=0`, the DNAT of EA's hardcoded redirector IP, the //! `easw.easports.com` mapping — is volatile and was silently gone. The launcher //! started, the local services started, the game started, and forty minutes later //! the only symptom was FIFA's own dialog: *"the servers for this title have been //! shut down"*. Nothing in the stack said anything, because nothing was looking. //! //! Every one of those conditions is observable **without privilege**. This module //! looks, and reports before the user clicks Launch. //! //! # Deliberately not checked here //! //! Certificate parity across the FIFA-facing TLS services — the fault that cost //! three redirector gates — is the single most valuable check available, but the //! launcher has no TLS dependency (`account_sync` speaks plaintext HTTP by hand) //! and adding one is a decision, not a detail. `scripts/check-tls-parity.sh` on //! the server covers it in the meantime. //! //! # Advisory, not a gate //! //! Results colour the UI; they never disable Launch. A preflight that is itself //! wrong must not be able to lock the user out of their own game. use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs}; use std::time::Duration; use crate::config::LauncherConfig; const PROBE_TIMEOUT: Duration = Duration::from_secs(2); const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum State { Pass, /// Genuinely wrong, but something else in the stack covers it, so the game /// can still work. Kept distinct from [`State::Fail`] because a checker that /// cries "this will fail" and is then contradicted by a working game teaches /// the user to ignore it — which is worse than not checking at all. Warn, Fail, /// Not configured, so there is nothing to assert. Never reported as a pass: /// "we did not look" and "we looked and it was fine" must not look alike. Skipped, } #[derive(Debug, Clone)] pub struct Check { pub name: String, pub state: State, pub detail: String, } impl Check { fn pass(name: &str, detail: impl Into) -> Self { Self { name: name.into(), state: State::Pass, detail: detail.into(), } } fn fail(name: &str, detail: impl Into) -> Self { Self { name: name.into(), state: State::Fail, detail: detail.into(), } } fn warn(name: &str, detail: impl Into) -> Self { Self { name: name.into(), state: State::Warn, detail: detail.into(), } } fn skip(name: &str, detail: impl Into) -> Self { Self { name: name.into(), state: State::Skipped, detail: detail.into(), } } } /// Run every applicable check. Order is the order the game exercises them. pub fn run(cfg: &LauncherConfig) -> Vec { vec![ ptrace_scope(), ea_redirect(cfg), hostname_mapping(cfg), backend_reachable(cfg), hook_config(cfg), ] } /// Checks that will stop the game working. pub fn failures(checks: &[Check]) -> usize { checks.iter().filter(|c| c.state == State::Fail).count() } /// Checks that are wrong but survivable. pub fn warnings(checks: &[Check]) -> usize { checks.iter().filter(|c| c.state == State::Warn).count() } /// autopatch writes to FIFA's process memory; Yama blocks that unless /// `ptrace_scope` is 0. At 1 the patch silently does nothing and the game fails /// its TLS handshake much later, with no message naming the cause. /// /// Unconditional. autopatch is a workspace binary that ships alongside the /// launcher, so there is no configuration that could make this inapplicable — /// every launch runs it. fn ptrace_scope() -> Check { const NAME: &str = "ptrace_scope (autopatch)"; match std::fs::read_to_string(PTRACE_SCOPE) { Ok(v) => ptrace_verdict(&v), // Not every kernel has Yama. Absent means unenforced, which is what we want. Err(_) => Check::skip(NAME, "Yama not present on this kernel"), } } /// The decision, split from the file read so it can be tested. /// /// Reading `/proc` in a test would assert facts about the machine running the /// suite rather than about this code — and left inline, "any value is fine" /// was a mutation no test could catch. fn ptrace_verdict(raw: &str) -> Check { const NAME: &str = "ptrace_scope (autopatch)"; let v = raw.trim(); if v == "0" { Check::pass(NAME, "0 — autopatch can attach") } else { Check::fail( NAME, format!("{v} — autopatch cannot patch FIFA. Click 'Arm client'."), ) } } /// FIFA dials EA's redirector by hardcoded IP. Armed, that address is DNAT'd to /// the OpenFUT server and connects instantly; unarmed it leaves the LAN and /// times out — which is exactly the "servers have been shut down" dialog. /// /// This tests the *effect* rather than reading firewall rules, so it needs no /// privilege and stays honest about what the game will actually experience. fn ea_redirect(cfg: &LauncherConfig) -> Check { const NAME: &str = "EA redirector IP is redirected"; let ip = cfg.ea_redirect_probe_ip.trim(); if ip.is_empty() { return Check::skip(NAME, "no probe IP configured"); } let Ok(addr) = ip.parse::() else { return Check::fail(NAME, format!("ea_redirect_probe_ip is not an IP: {ip:?}")); }; let port = cfg.openfut_blaze_redirector_port; match TcpStream::connect_timeout(&SocketAddr::new(addr, port), PROBE_TIMEOUT) { Ok(_) => Check::pass(NAME, format!("{ip}:{port} answered — redirect is in place")), Err(e) => Check::fail( NAME, format!("{ip}:{port} did not answer ({e}). Click 'Arm client'."), ), } } /// The dead EA hostnames should resolve to the OpenFUT server. /// /// Resolution is done with `getaddrinfo`, the same call the game makes, so a /// duplicate `/etc/hosts` line that shadows the OpenFUT one is caught by its /// effect. Parsing `/etc/hosts` would miss it: the file can contain the right /// line and still resolve to the wrong address, because the first match wins. /// /// # Why a warning and not a failure /// /// Measured, not assumed. On 2026-08-11 this reported `easw.easports.com -> /// ::1,127.0.0.1` and the game reached the FUT hub regardless. The reason is in /// `client_arm.sh`'s own header: the responders run with `OPENFUT_ADVERTISE` /// set, so after the first redirected contact the game is handed the server's /// *address* for every later hop and stops using the hostname. The name is only /// CardsDLL's built-in fallback. /// /// So this is a real misconfiguration worth fixing and not a reason to expect /// failure. Reporting it as fatal, and then being contradicted by a working /// game, is how a checklist trains its user to ignore it. fn hostname_mapping(cfg: &LauncherConfig) -> Check { const NAME: &str = "EA hostnames point at OpenFUT"; if cfg.ea_hostnames.is_empty() { return Check::skip(NAME, "no EA hostnames configured"); } let server = cfg.openfut_server_host.trim(); if server.is_empty() { return Check::skip(NAME, "no OpenFUT server configured"); } let want = match resolve(server) { Ok(ips) if !ips.is_empty() => ips, _ => { return Check::fail( NAME, format!("cannot resolve the OpenFUT server {server:?}"), ) } }; let mut wrong = Vec::new(); for host in &cfg.ea_hostnames { match resolve(host) { Ok(got) if got.iter().any(|ip| want.contains(ip)) => {} Ok(got) => wrong.push(format!( "{host} -> {} (expected {})", join(&got), join(&want) )), Err(e) => wrong.push(format!("{host} -> unresolvable ({e})")), } } if wrong.is_empty() { Check::pass( NAME, format!("{} host(s) resolve to {server}", cfg.ea_hostnames.len()), ) } else { Check::warn( NAME, format!( "{}. Look for an earlier /etc/hosts line shadowing it. \ Usually survivable: the server advertises its address, so the \ game stops using this name after the first hop.", wrong.join("; ") ), ) } } /// The server side of the same question: are the ports the game will use open? pub(crate) fn backend_reachable(cfg: &LauncherConfig) -> Check { const NAME: &str = "OpenFUT server reachable"; let host = cfg.openfut_server_host.trim(); if host.is_empty() { return Check::skip(NAME, "no OpenFUT server configured"); } let ports = [ ("blaze redirector", cfg.openfut_blaze_redirector_port), ("account sync", cfg.openfut_account_sync_port), ]; let mut dead = Vec::new(); for (label, port) in ports { if !connects(host, port) { dead.push(format!("{label} :{port}")); } } if dead.is_empty() { Check::pass(NAME, format!("{host}: all {} ports answering", ports.len())) } else { Check::fail(NAME, format!("{host}: no answer on {}", dead.join(", "))) } } /// The deployed `openfut.cfg` is the only server address the *game* can see. /// /// Every panel in this launcher reads the in-memory config, so a settings change /// that never reached the file produces the worst possible failure: the UI shows /// the new server online while FIFA connects to the old one. Compare the two. fn hook_config(cfg: &LauncherConfig) -> Check { const NAME: &str = "Hook server address"; let game_dir = cfg.fifa_game_dir.trim(); if game_dir.is_empty() { return Check::skip(NAME, "no FIFA game dir configured"); } let Some(body) = crate::setup::read_hook_config(std::path::Path::new(game_dir)) else { return Check::skip( NAME, format!("no {} deployed yet", crate::setup::HOOK_CFG_FILE), ); }; let deployed = match openfut_common::ServerConfig::parse(&body) { Ok(parsed) => parsed, // Unparseable means the hook cannot read it either, and nothing else in // the stack recovers from that — so this one is a genuine failure. Err(e) => { return Check::fail( NAME, format!("{} is unreadable: {e}", crate::setup::HOOK_CFG_FILE), ) } }; let wanted = cfg.server_config(); if deployed == wanted { return Check::pass(NAME, format!("hook redirects to {}", wanted.host)); } // Warn, not fail: the launch path rewrites this file before starting the // game, so the drift is real but already covered. Naming both addresses is // what makes it actionable. Check::warn( NAME, format!( "deployed hook still points at {} (settings say {}) — launching rewrites it", deployed.host, wanted.host ), ) } fn connects(host: &str, port: u16) -> bool { match (host, port).to_socket_addrs() { Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()), Err(_) => false, } } fn resolve(host: &str) -> std::io::Result> { Ok((host, 0u16).to_socket_addrs()?.map(|a| a.ip()).collect()) } fn join(ips: &[IpAddr]) -> String { ips.iter() .map(|i| i.to_string()) .collect::>() .join(",") } #[cfg(test)] mod tests { use super::*; fn cfg() -> LauncherConfig { LauncherConfig::default() } #[test] fn an_unconfigured_launcher_skips_rather_than_passes() { // The distinction that matters: a fresh config must not display a column // of green ticks. "Not checked" is not "checked and fine". // // `ptrace_scope` is excluded because it is no longer configuration // dependent: it reads this machine's Yama setting and reports a real // verdict either way. `only_ptrace_scope_zero_lets_autopatch_work` // covers it. let mut c = cfg(); // `default()` points this at a conventional path whose existence varies // by machine. Pin it so the assertion is about the code, not this box. c.fifa_game_dir = "/nonexistent/fifa-game-dir".into(); let checks: Vec = run(&c) .into_iter() .filter(|k| k.name != "ptrace_scope (autopatch)") .collect(); assert!( checks.iter().all(|k| k.state == State::Skipped), "{checks:#?}" ); assert_eq!(failures(&checks), 0, "nothing configured is not a failure"); } #[test] fn only_ptrace_scope_zero_lets_autopatch_work() { assert_eq!(ptrace_verdict("0\n").state, State::Pass); // 1 is the default on most distributions and is exactly the state that // let autopatch fail silently for forty minutes on 2026-08-11. assert_eq!(ptrace_verdict("1\n").state, State::Fail); assert_eq!(ptrace_verdict("2").state, State::Fail); assert_eq!(ptrace_verdict("3").state, State::Fail); assert!(ptrace_verdict("1").detail.contains("Arm client")); } #[test] fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() { let mut c = cfg(); c.ea_redirect_probe_ip = "not-an-ip".into(); let check = ea_redirect(&c); assert_eq!(check.state, State::Fail); assert!(check.detail.contains("not an IP"), "{}", check.detail); } #[test] fn hostname_check_is_skipped_without_a_server_but_not_passed() { let mut c = cfg(); c.ea_hostnames = vec!["easw.easports.com".into()]; assert_eq!(hostname_mapping(&c).state, State::Skipped); } #[test] fn hostname_check_detects_a_host_pointing_somewhere_else() { // localhost and 127.0.0.1 resolve without a network; this is the // shadowed-/etc/hosts shape without depending on the real one. let mut c = cfg(); c.openfut_server_host = "127.0.0.2".into(); c.ea_hostnames = vec!["localhost".into()]; let check = hostname_mapping(&c); // Warn, not Fail: observed on 2026-08-11 to be survivable, because the // server advertises its address after the first hop. assert_eq!(check.state, State::Warn, "{}", check.detail); assert!(check.detail.contains("localhost -> "), "{}", check.detail); } /// A shadowed hostname must not be counted as a reason to expect failure. /// This is the exact case the first version got wrong. /// /// Asserts the hostname check itself rather than counting states across the /// whole run: `backend_reachable` opens real sockets, so an aggregate count /// silently asserts that THIS machine has the OpenFUT ports open. That made /// the test pass only on the server host and fail on the game machine, which /// is precisely where someone building the launcher runs the suite. #[test] fn a_shadowed_hostname_is_a_warning_not_a_failure() { let mut c = cfg(); c.openfut_server_host = "127.0.0.2".into(); c.ea_hostnames = vec!["localhost".into()]; let check = hostname_mapping(&c); assert_eq!(check.state, State::Warn, "{}", check.detail); assert!( check.detail.contains("localhost"), "the warning must name the shadowed host: {}", check.detail ); } #[test] fn hostname_check_passes_when_it_points_at_the_server() { let mut c = cfg(); c.openfut_server_host = "127.0.0.1".into(); c.ea_hostnames = vec!["localhost".into()]; // `localhost` may resolve to ::1 as well; the check requires only that // one resolved address matches, which mirrors what connecting does. assert_eq!(hostname_mapping(&c).state, State::Pass); } #[test] fn a_dead_backend_port_is_reported_as_a_failure() { let mut c = cfg(); c.openfut_server_host = "127.0.0.1".into(); // Port 1 requires root to bind, so nothing is listening on it. c.openfut_blaze_redirector_port = 1; c.openfut_account_sync_port = 1; let check = backend_reachable(&c); assert_eq!(check.state, State::Fail, "{}", check.detail); assert!(check.detail.contains("no answer on"), "{}", check.detail); } /// A temp game dir holding one `openfut.cfg` body. fn game_dir_with_cfg(tag: &str, body: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("openfut-preflight-{tag}-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(crate::setup::HOOK_CFG_FILE), body).unwrap(); dir } #[test] fn a_stale_hook_config_is_reported_and_names_both_addresses() { // The silent failure this check exists for: settings changed, the file // the game reads did not. let mut c = cfg(); c.openfut_server_host = "10.0.0.2".into(); let old = openfut_common::ServerConfig { host: "10.0.0.1".into(), ports: c.server_config().ports, }; let dir = game_dir_with_cfg("stale", &old.to_cfg_string()); c.fifa_game_dir = dir.to_string_lossy().into_owned(); let check = hook_config(&c); assert_eq!(check.state, State::Warn, "{}", check.detail); assert!(check.detail.contains("10.0.0.1"), "{}", check.detail); assert!(check.detail.contains("10.0.0.2"), "{}", check.detail); std::fs::remove_dir_all(dir).ok(); } #[test] fn a_hook_config_matching_settings_passes() { let mut c = cfg(); c.openfut_server_host = "10.0.0.2".into(); let dir = game_dir_with_cfg("fresh", &c.server_config().to_cfg_string()); c.fifa_game_dir = dir.to_string_lossy().into_owned(); assert_eq!(hook_config(&c).state, State::Pass); std::fs::remove_dir_all(dir).ok(); } #[test] fn a_missing_hook_config_is_skipped_not_passed() { let mut c = cfg(); c.fifa_game_dir = "/nonexistent/fifa-game-dir".into(); assert_eq!(hook_config(&c).state, State::Skipped); } }