//! FIFA 17 local companion services — LSX (Origin emulator) + autopatch //! (ProtoSSL cert-verify memory patcher). Both are inherently local to the game //! machine and are managed by the launcher as child processes, mirroring the way //! `setup::launch_game` spawns and log-streams the game. //! //! WHY THESE TWO ARE LOCAL (and the rest is not): the heavy FUT responders //! (Blaze / UTAS / roster / POW) run in the server container. LSX must stay here //! because the game dials it on the hardcoded loopback `127.0.0.1:4216`; //! autopatch must stay here because it writes `/proc//mem`. //! //! Lifecycle: each service is a long-running daemon. We keep the `Child` handle //! so the UI can show running/stopped and stop them. Both run as the launcher //! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous //! Polkit prompt delaying cert patching until after FIFA's first TLS attempt. use parking_lot::Mutex; use std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener}, path::Path, process::{Child, Command, Stdio}, sync::{mpsc, Arc}, time::{Duration, Instant}, }; use std::os::unix::process::CommandExt; use crate::fifa17_capability::{ parse_capability_line, parse_fifa_pid, register, Fifa17ClientCapabilities, }; use crate::logs::LogBuffer; #[derive(Debug, PartialEq, Eq)] struct CommandParts { program: String, args: Vec, } /// Which companion service. The `str` values are used in log prefixes. #[derive(Copy, Clone, PartialEq, Eq)] pub enum Service { /// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged. Lsx, /// autopatch — patches FIFA17.exe process memory after host ptrace arming. Autopatch, } impl Service { pub fn label(self) -> &'static str { match self { Service::Lsx => "LSX", Service::Autopatch => "autopatch", } } /// The responder script filename inside the tools dir. fn script(self) -> &'static str { match self { Service::Lsx => "lsx_responder_v2.py", Service::Autopatch => "autopatch.py", } } } fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts { let mut args = vec![tools_dir .join(service.script()) .to_string_lossy() .into_owned()]; if service == Service::Autopatch { args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]); } CommandParts { program: python.to_string(), args, } } fn dispatch_stop_work(work: F) -> mpsc::Receiver> where F: FnOnce() -> anyhow::Result<()> + Send + 'static, { let (send, receive) = mpsc::channel(); std::thread::spawn(move || { let _ = send.send(work()); }); receive } fn wait_for_listener_ready( child: &mut Child, address: SocketAddr, timeout: Duration, ) -> anyhow::Result<()> { let deadline = Instant::now() + timeout; // Let immediate startup/bind errors surface before accepting an occupied // port as evidence that this child became ready. std::thread::sleep(Duration::from_millis(100)); loop { if let Some(status) = child .try_wait() .map_err(|error| anyhow::anyhow!("could not inspect LSX startup: {error}"))? { anyhow::bail!("LSX exited before becoming ready ({status}); port 4216 may be in use"); } match TcpListener::bind(address) { Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => return Ok(()), Err(error) => anyhow::bail!("could not probe LSX listener {address}: {error}"), Ok(listener) => drop(listener), } if Instant::now() >= deadline { anyhow::bail!( "LSX did not bind {address} within {} ms", timeout.as_millis() ); } std::thread::sleep(Duration::from_millis(50)); } } /// A managed companion service process. #[derive(Default)] pub struct ManagedService { child: Option, stopping: Option>>, } impl ManagedService { /// Wrap an already-spawned child. pub fn from_child(child: Child) -> Self { Self { child: Some(child), stopping: None, } } /// True while the child is spawned and has not yet exited. Reaps the exit /// status if it has, so the UI reflects a service that died on its own. pub fn running(&mut self, log: &Arc>, label: &str) -> bool { if let Some(result) = self.stopping.as_ref() { match result.try_recv() { Ok(Ok(())) => { log.lock().push(format!("[launcher] {label} stopped.")); self.stopping = None; return false; } Ok(Err(error)) => { log.lock() .push(format!("[launcher] failed to stop {label}: {error}")); self.stopping = None; return false; } Err(mpsc::TryRecvError::Empty) => return true, Err(mpsc::TryRecvError::Disconnected) => { log.lock().push(format!( "[launcher] {label} stop worker exited unexpectedly." )); self.stopping = None; return false; } } } match self.child.as_mut() { None => false, Some(c) => match c.try_wait() { Ok(None) => true, Ok(Some(status)) => { log.lock() .push(format!("[launcher] {label} exited ({status}).")); self.child = None; false } Err(_) => true, }, } } pub fn stopping(&self) -> bool { self.stopping.is_some() } /// Begin stopping the service without waiting on the egui UI thread. pub fn stop(&mut self, log: &Arc>, service: Service) { if self.stopping.is_some() { return; } if let Some(mut child) = self.child.take() { let label = service.label(); log.lock().push(format!("[launcher] stopping {label}…")); self.stopping = Some(dispatch_stop_work(move || { child .kill() .map_err(|error| anyhow::anyhow!("kill failed: {error}"))?; child .wait() .map_err(|error| anyhow::anyhow!("reap failed: {error}"))?; Ok(()) })); } } } impl Drop for ManagedService { fn drop(&mut self) { if let Some(mut c) = self.child.take() { let _ = c.kill(); } } } /// Backend-registration wiring handed to the autopatch stdout reader so a /// verified resolver-guard line can advertise the per-FIFA-process capability to /// the backend. `Some(..)` for autopatch; `None` for LSX. pub struct CapabilityWiring { pub server_host: String, pub account_sync_port: u16, pub sink: Arc>, } /// Spawn a companion service. `python` is the interpreter, `tools_dir` the /// directory holding the responder scripts. Streams stdout+stderr into `log`. /// Returns an error (without spawning) if the tools dir or script is missing. /// /// `capability` is the backend-registration wiring + shared per-FIFA-process /// capability sink — `Some(..)` for autopatch (whose stdout advertises the /// verified resolver guard) and `None` for LSX. pub fn spawn( service: Service, python: &str, tools_dir: &str, persona_id: u64, persona_name: &str, capability: Option, log: Arc>, ) -> anyhow::Result { use std::io::{BufRead, BufReader}; let dir = Path::new(tools_dir); if !dir.is_dir() { anyhow::bail!( "FIFA 17 tools dir not found: {} (set it in Settings)", dir.display() ); } let script_path = dir.join(service.script()); if !script_path.exists() { anyhow::bail!( "{} not found in tools dir: {}", service.script(), script_path.display() ); } let label = service.label(); // Both services use the configured interpreter and absolute script path; // neither invents a Python installation path. Autopatch receives launcher // ownership and a per-user runtime log so stale root-owned /tmp files cannot // block startup. let parts = command_parts(service, python, dir); let mut cmd = Command::new(&parts.program); cmd.args(&parts.args); if service == Service::Lsx { cmd.env("FUT_PERSONA_ID", persona_id.to_string()) .env("FUT_PERSONA_NAME", persona_name); } else if service == Service::Autopatch { let log_path = std::env::var_os("XDG_RUNTIME_DIR") .map(std::path::PathBuf::from) .unwrap_or_else(std::env::temp_dir) .join("openfut-autopatch.log"); cmd.env("OPENFUT_AUTOPATCH_LOG", log_path); } // Put each companion in its own process group for lifecycle isolation. cmd.process_group(0); cmd.current_dir(dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()); log.lock().push(format!( "[launcher] starting {label}: {} {}", python, script_path.display(), )); let mut child = cmd .spawn() .map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.script()))?; if let Some(out) = child.stdout.take() { let buf = Arc::clone(&log); let lbl = label.to_string(); // Only autopatch carries capability wiring; LSX passes `None`. let cap_wiring = capability; let cap_persona = persona_id; std::thread::spawn(move || { // Fires the backend registration at most once per FIFA process. let mut registered = false; for line in BufReader::new(out).lines().map_while(Result::ok) { // Every raw line is still mirrored into the log, as before. buf.lock().push(format!("[{lbl}] {line}")); let Some(wiring) = cap_wiring.as_ref() else { continue; }; if registered { continue; } let Some(version) = parse_capability_line(&line) else { continue; }; registered = true; let fifa_pid = parse_fifa_pid(&line).unwrap_or(0); wiring.sink.lock().empty_mypacks_resolver = Some(version); { let mut log = buf.lock(); log.push(format!( "[fifa17] resolver capability verified for FIFA pid {fifa_pid}" )); log.push(format!( "[fifa17] registering capability for session (persona {cap_persona})" )); } match register( &wiring.server_host, wiring.account_sync_port, cap_persona, fifa_pid, version, ) { Ok(()) => buf .lock() .push("[fifa17] capability registered with backend".to_string()), Err(error) => buf .lock() .push(format!("[fifa17] capability registration failed: {error}")), } } }); } if let Some(err) = child.stderr.take() { let buf = Arc::clone(&log); let lbl = label.to_string(); std::thread::spawn(move || { for line in BufReader::new(err).lines().map_while(Result::ok) { buf.lock().push(format!("[{lbl}] {line}")); } }); } if service == Service::Lsx { let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216)); if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) { let _ = child.kill(); let _ = child.wait(); return Err(error); } log.lock() .push("[launcher] LSX ready on 127.0.0.1:4216".to_string()); } Ok(child) } #[cfg(test)] mod tests { use super::*; #[test] fn lsx_runs_python_directly() { let parts = command_parts(Service::Lsx, "/usr/bin/python3", Path::new("/tmp/tools")); assert_eq!(parts.program, "/usr/bin/python3"); assert_eq!(parts.args, vec!["/tmp/tools/lsx_responder_v2.py"]); } #[test] fn autopatch_runs_python_directly_with_launcher_ownership() { let parts = command_parts( Service::Autopatch, "/usr/bin/python3", Path::new("/tmp/tools"), ); assert_eq!(parts.program, "/usr/bin/python3"); assert_eq!( parts.args, vec![ "/tmp/tools/autopatch.py", "--launcher-pid", &std::process::id().to_string(), ] ); } #[test] fn stop_work_is_dispatched_without_blocking_the_caller() { use std::time::{Duration, Instant}; let started = Instant::now(); let done = dispatch_stop_work(|| { std::thread::sleep(Duration::from_millis(250)); Ok(()) }); assert!(started.elapsed() < Duration::from_millis(100)); assert!(done.try_recv().is_err()); assert!(done.recv_timeout(Duration::from_secs(1)).unwrap().is_ok()); } #[test] fn readiness_rejects_an_lsx_child_that_exits_before_binding() { let mut child = Command::new("sh") .args(["-c", "exit 7"]) .spawn() .expect("spawn short-lived child"); let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)); let error = wait_for_listener_ready(&mut child, address, Duration::from_secs(1)) .expect_err("exited child must not be reported ready"); assert!(error.to_string().contains("exited before becoming ready")); } }