//! One-click client arming — the GUI equivalent of `client_arm.sh`, driven by //! [`LauncherConfig`] so it repairs exactly what [`crate::preflight`] checks. //! //! Everything the game reaches by a routable address is redirected to the //! OpenFUT server; two things are inherently local and are NOT touched here (they //! are managed as child processes, see [`crate::local_services`]): the LSX/Origin //! emulator on loopback `:4216` and `autopatch`. //! //! The three privileged steps run in ONE elevated batch (a single `pkexec` //! prompt), mirroring the volatile state `client_arm.sh` set by hand: //! //! 1. `kernel.yama.ptrace_scope=0` — so `autopatch` can write FIFA's `/proc/PID/mem`. //! 2. DNAT EA's hardcoded redirector IP → `server:redirector_port` (+ MASQUERADE //! on the reply path, required for a DNAT to a remote host). //! 3. Point each dead EA hostname at the server in `/etc/hosts`. //! //! All of it is idempotent: the DNAT deletes any prior copy before adding, and //! every `/etc/hosts` line for a managed hostname is removed first — including a //! foreign single-machine-era `127.0.0.1 easw.easports.com` shadow that //! `client_arm.sh` could not remove, because it only deleted its own `# openfut` //! lines and glibc returns the FIRST match. use crate::config::LauncherConfig; /// Accept only hostname/IP characters. These values come from config fields that /// are ever only IPs or hostnames, so a surprising character is a bug — reject it /// rather than try to escape it into an elevated shell command. #[cfg(unix)] fn safe_host(s: &str) -> anyhow::Result<&str> { let t = s.trim(); if t.is_empty() { anyhow::bail!("empty host/address"); } if t.bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b':' | b'-' | b'_')) { Ok(t) } else { anyhow::bail!("refusing to arm with an unexpected character in {t:?}"); } } /// Build the privileged arming script. Pure and unit-tested; the effectful part /// ([`arm`]) only validates config and hands this to the elevated runner. #[cfg(unix)] pub(crate) fn arming_script( server: &str, redirector_port: u16, ea_ip: &str, hostnames: &[String], ) -> anyhow::Result { let server = safe_host(server)?; let ea_ip = safe_host(ea_ip)?; let mut s = String::from("set -eu\n"); // 1) ptrace_scope for autopatch's /proc/PID/mem write. s.push_str("sysctl -q kernel.yama.ptrace_scope=0\n"); // 2) DNAT EA's hardcoded redirector IP to the server; SNAT the redirected // flow (a DNAT from OUTPUT to a remote host needs a matching MASQUERADE or // the server's replies won't match the game's conntrack entry). Both are // delete-then-add so re-running and IP changes stay clean. s.push_str(&format!( "while iptables -t nat -D OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port} 2>/dev/null; do :; done\n\ iptables -t nat -A OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port}\n\ while iptables -t nat -D POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE 2>/dev/null; do :; done\n\ iptables -t nat -A POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE\n" )); // 3) Every dead EA hostname resolves to the server. Delete ALL existing lines // listing the name (foreign shadow included) BEFORE writing ours, so the // first-match-wins resolution can never land on a stale loopback line. for host in hostnames { let host = safe_host(host)?; let re = host.replace('.', "\\."); s.push_str(&format!( "sed -ri '/[[:space:]]{re}([[:space:]]|$)/d' /etc/hosts\n\ printf '%s\\t%s\\t# openfut\\n' '{server}' '{host}' >> /etc/hosts\n" )); } Ok(s) } /// Human-readable list of what [`arm`] changed, in the order the script applies /// it. Logged by the UI so the user sees exactly what was set — not just that /// "something" ran under `pkexec`. #[cfg(unix)] pub(crate) fn arming_summary( server: &str, redirector_port: u16, ea_ip: &str, hostnames: &[String], ) -> Vec { let mut out = vec![ "kernel.yama.ptrace_scope = 0 (autopatch can attach)".to_string(), format!("DNAT {ea_ip} -> {server}:{redirector_port} (+ MASQUERADE reply path)"), ]; for host in hostnames { out.push(format!("hosts: {host} -> {server}")); } out } /// Arm the client from config, under one elevated prompt. Requires the same /// fields preflight reads; a missing one is a clear error, never a silent /// loopback fallback. Returns the applied changes for the UI to surface. /// On native Windows there is nothing to arm: routing is the `openfut.cfg` the /// client-files step writes into the game directory (read by the version.dll /// hook), and there is no `ptrace_scope`, DNAT, or `/etc/hosts` to set. Returns /// no changes so the launch sequence treats client preparation as satisfied. #[cfg(windows)] pub fn arm(_cfg: &LauncherConfig) -> anyhow::Result> { Ok(Vec::new()) } #[cfg(unix)] pub fn arm(cfg: &LauncherConfig) -> anyhow::Result> { let server = cfg.openfut_server_host.trim(); if server.is_empty() { anyhow::bail!("Set the OpenFUT server host in Settings before arming."); } let ea_ip = cfg.ea_redirect_probe_ip.trim(); if ea_ip.is_empty() { anyhow::bail!("Set the EA redirector IP (Settings) before arming."); } if cfg.ea_hostnames.is_empty() { anyhow::bail!( "Add at least one EA hostname (e.g. easw.easports.com) in Settings before arming." ); } let redirector_port = cfg.openfut_blaze_redirector_port; let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?; crate::setup::run_elevated(&script)?; Ok(arming_summary( server, redirector_port, ea_ip, &cfg.ea_hostnames, )) } #[cfg(all(test, unix))] mod tests { use super::*; fn script() -> String { arming_script( "10.10.0.120", 42127, "159.153.51.20", &["easw.easports.com".to_string()], ) .unwrap() } #[test] fn sets_ptrace_scope_zero() { assert!(script().contains("sysctl -q kernel.yama.ptrace_scope=0")); } #[test] fn dnats_ea_ip_to_server_and_masquerades() { let s = script(); assert!(s.contains( "iptables -t nat -A OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 10.10.0.120:42127" )); assert!(s.contains( "iptables -t nat -A POSTROUTING -p tcp -d 10.10.0.120 --dport 42127 -j MASQUERADE" )); } #[test] fn dnat_is_delete_then_add_for_idempotence() { let s = script(); // The delete loop precedes the add, so re-arming never stacks duplicates. let del = s.find("-D OUTPUT").unwrap(); let add = s.find("-A OUTPUT").unwrap(); assert!(del < add, "delete must run before add"); } #[test] fn removes_shadowing_hosts_line_before_writing_ours() { let s = script(); // Deletes any existing easw.easports.com line (foreign shadow included)… assert!( s.contains("sed -ri '/[[:space:]]easw\\.easports\\.com([[:space:]]|$)/d' /etc/hosts") ); // …then appends the OpenFUT-tagged mapping to the server. assert!(s.contains( "printf '%s\\t%s\\t# openfut\\n' '10.10.0.120' 'easw.easports.com' >> /etc/hosts" )); let del = s.find("sed -ri").unwrap(); let add = s.find("printf").unwrap(); assert!(del < add, "shadow removal must precede our line"); } #[test] fn multiple_hostnames_each_get_a_mapping() { let s = arming_script( "10.10.0.120", 42127, "159.153.51.20", &["easw.easports.com".into(), "utas.fut.ea.com".into()], ) .unwrap(); assert!(s.contains("'easw.easports.com' >> /etc/hosts")); assert!(s.contains("'utas.fut.ea.com' >> /etc/hosts")); } #[test] fn rejects_shell_metacharacters_in_config() { assert!(arming_script("10.0.0.1; rm -rf /", 42127, "159.153.51.20", &[]).is_err()); assert!(arming_script("10.0.0.1", 42127, "$(evil)", &[]).is_err()); assert!( arming_script("10.0.0.1", 42127, "159.153.51.20", &["a b`c`".into()]).is_err(), "a hostname with a backtick is rejected" ); } #[test] fn arm_requires_server_ea_ip_and_hostname() { let mut c = LauncherConfig::default(); assert!(arm(&c).unwrap_err().to_string().contains("server host")); c.openfut_server_host = "10.10.0.120".into(); assert!(arm(&c) .unwrap_err() .to_string() .contains("EA redirector IP")); c.ea_redirect_probe_ip = "159.153.51.20".into(); assert!(arm(&c).unwrap_err().to_string().contains("EA hostname")); } #[test] fn summary_lists_ptrace_dnat_and_each_host() { let s = arming_summary( "10.10.0.120", 42127, "159.153.51.20", &["easw.easports.com".into(), "utas.fut.ea.com".into()], ); assert!(s.iter().any(|l| l.contains("ptrace_scope = 0"))); assert!(s .iter() .any(|l| l.contains("DNAT 159.153.51.20 -> 10.10.0.120:42127"))); assert!(s .iter() .any(|l| l == "hosts: easw.easports.com -> 10.10.0.120")); assert!(s .iter() .any(|l| l == "hosts: utas.fut.ea.com -> 10.10.0.120")); } }