//! 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(cfg), ea_redirect(cfg), hostname_mapping(cfg), backend_reachable(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. fn ptrace_scope(cfg: &LauncherConfig) -> Check { const NAME: &str = "ptrace_scope (autopatch)"; // `fifa17_tools_dir` carries a conventional default, so a non-empty value // does not mean the tools are installed. Key off the directory actually // existing: that is what decides whether autopatch will run at all, and it // keeps this from failing on a machine that never uses local services. let tools = cfg.fifa17_tools_dir.trim(); if tools.is_empty() || !std::path::Path::new(tools).is_dir() { return Check::skip(NAME, "no local services installed"); } 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? 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(", "))) } } 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 four // green ticks. "Not checked" is not "checked and fine". 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.fifa17_tools_dir = "/nonexistent/openfut-tools".into(); let checks = run(&c); 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 ptrace_is_skipped_when_the_tools_dir_does_not_exist() { // Regression: the gate used to be "is the field non-empty", and the // field has a default — so this check ran (and failed) on machines that // never use autopatch at all. let mut c = cfg(); c.fifa17_tools_dir = "/nonexistent/openfut-tools".into(); assert_eq!(ptrace_scope(&c).state, State::Skipped); } #[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. #[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()]; c.fifa17_tools_dir = "/nonexistent/openfut-tools".into(); let checks = run(&c); assert_eq!(failures(&checks), 0, "must not be reported as fatal"); assert_eq!(warnings(&checks), 1); } #[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 ptrace_check_is_skipped_when_local_services_are_not_configured() { let mut c = cfg(); c.fifa17_tools_dir.clear(); assert_eq!(ptrace_scope(&c).state, State::Skipped); } #[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); } }