//! 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, PathBuf}, 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; /// The loopback endpoint LSX must own. FIFA dials this exact address and nothing /// else, so "is LSX ready?" is answerable without asking LSX anything. pub const LSX_ADDR: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216)); #[derive(Debug, PartialEq, Eq)] struct CommandParts { program: String, args: Vec, } /// Which companion service. The `str` values are used in log prefixes. #[derive(Copy, Clone, Debug, 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 companion's executable name. /// /// These were Python responder scripts run through a configured interpreter. They /// are now Rust binaries built from this workspace (`openfut-lsx`, /// `openfut-autopatch`), which removes the interpreter and the tools directory /// from the launch contract entirely: no `python3` to locate, no script path to /// configure, and no chance of running a stale checkout's copy. fn binary(self) -> &'static str { match self { Service::Lsx => "openfut-lsx", Service::Autopatch => "openfut-autopatch", } } } /// Absolute path to a companion binary. /// /// Prefers a sibling of the running launcher, which is what a workspace build and any /// sane install layout both produce, and falls back to the bare name so a /// PATH-installed binary still works. Returning the bare name rather than failing /// keeps `spawn` responsible for reporting a missing binary, with one error message /// instead of two. fn resolve_binary(service: Service) -> PathBuf { let name = service.binary(); if let Some(dir) = std::env::current_exe() .ok() .and_then(|p| p.parent().map(Path::to_path_buf)) { let sibling = dir.join(name); if sibling.is_file() { return sibling; } } PathBuf::from(name) } fn command_parts(service: Service) -> CommandParts { let mut args = Vec::new(); if service == Service::Autopatch { // autopatch exits when the launcher does, so it cannot outlive its owner and // keep writing to a client the launcher no longer manages. args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]); } CommandParts { program: resolve_binary(service).to_string_lossy().into_owned(), 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() } /// PID of the child this launcher owns, if it owns one. pub fn pid(&self) -> Option { self.child.as_ref().map(Child::id) } /// 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>, } /// What is actually true about one companion service right now. /// /// Deliberately observed, never remembered: a button press is not evidence that /// a service is up, and a service that died on its own must not keep showing /// green because the launcher once started it successfully. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ServiceRuntime { pub running: bool, /// True only while THIS launcher owns the live process. Decides whether /// cleanup is allowed to touch it: a service someone started by hand for a /// debugging session must survive a launch/exit cycle. pub started_by_launcher: bool, pub pid: Option, /// Observed supporting detail for the Advanced panel. Only ever facts the /// launcher actually established. pub detail: Option, } impl ServiceRuntime { /// Whether this service is usable for a launch, as opposed to merely alive. /// For LSX that means the port FIFA dials is genuinely held. pub fn ready(&self) -> bool { self.running } } /// True when something holds LSX's fixed loopback port. pub fn lsx_port_busy() -> bool { match TcpListener::bind(LSX_ADDR) { Err(error) => error.kind() == std::io::ErrorKind::AddrInUse, Ok(listener) => { drop(listener); false } } } /// PID of a process running `service`'s companion binary that this launcher does /// not own, if there is one. /// /// Scans `/proc` — no extra dependency, no privilege, and no guessing: a service /// left running by a previous launcher instance or started by hand from a shell /// is a real state the UI has to be able to report, and cleanup has to respect. /// /// Matches argv entries rather than `comm`, because `comm` is truncated to 15 /// characters by the kernel and would misreport these names. pub fn foreign_pid(service: Service, ours: Option) -> Option { let binary = service.binary(); let self_pid = std::process::id(); let entries = std::fs::read_dir("/proc").ok()?; for entry in entries.flatten() { let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { continue; }; if pid == self_pid || Some(pid) == ours { continue; } let Ok(cmdline) = std::fs::read(entry.path().join("cmdline")) else { continue; }; if cmdline.split(|b| *b == 0).any(|arg| { // Compare the file name, so `/path/to/openfut-lsx` matches while an // unrelated argument that merely ends with the same text does not. Path::new(&*String::from_utf8_lossy(arg)) .file_name() .is_some_and(|n| n == binary) }) { return Some(pid); } } None } /// Whether a stop request may touch this service. /// /// Pure, so the ownership rule is testable without a process: refusing to kill /// something the launcher did not start is the whole reason ownership is tracked, /// and it must not depend on what happens to be running on the test machine. pub fn stop_permitted(runtime: &ServiceRuntime, label: &str) -> Result<(), String> { if runtime.running && !runtime.started_by_launcher { return Err(format!( "{label} was started outside this launcher{} — stop it where it was started.", match runtime.pid { Some(pid) => format!(" (pid {pid})"), None => String::new(), } )); } Ok(()) } /// Owns both companion services and answers "what is running, and who started /// it?" for the whole launcher. /// /// Exists so the launch sequence and the Advanced panel act on the same objects. /// Two independent copies of that state is how a UI ends up claiming Ready while /// the process is dead. pub struct ServiceSupervisor { lsx: ManagedService, autopatch: ManagedService, log: Arc>, } /// Whether [`ServiceSupervisor::ensure_running`] had to do anything. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Ensured { /// Already up — left strictly alone. Reused, Started, } impl ServiceSupervisor { pub fn new(log: Arc>) -> Self { Self { lsx: ManagedService::default(), autopatch: ManagedService::default(), log, } } fn slot(&mut self, service: Service) -> &mut ManagedService { match service { Service::Lsx => &mut self.lsx, Service::Autopatch => &mut self.autopatch, } } /// Observe one service: our own child first, then any foreign instance. pub fn observe(&mut self, service: Service) -> ServiceRuntime { let log = Arc::clone(&self.log); let slot = self.slot(service); if slot.stopping() { return ServiceRuntime { running: true, started_by_launcher: true, pid: None, detail: Some("stopping".into()), }; } let ours = slot.pid(); if slot.running(&log, service.label()) { let mut runtime = ServiceRuntime { running: true, started_by_launcher: true, pid: ours, detail: None, }; if service == Service::Lsx { runtime.detail = Some(if lsx_port_busy() { format!("holding {LSX_ADDR}") } else { // Alive but not listening: real, and not "ready". runtime.running = false; format!("process alive but {LSX_ADDR} is not held") }); } return runtime; } match foreign_pid(service, ours) { Some(pid) => ServiceRuntime { running: true, started_by_launcher: false, pid: Some(pid), detail: Some("started outside this launcher".into()), }, None if service == Service::Lsx && lsx_port_busy() => ServiceRuntime { running: false, started_by_launcher: false, pid: None, detail: Some(format!("{LSX_ADDR} is held by an unrelated process")), }, None => ServiceRuntime::default(), } } /// Start `service` only if it is not already usable. Never restarts a healthy /// service, and never adopts a foreign one as ours. pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result { let runtime = self.observe(service); if runtime.ready() { self.log.lock().push(format!( "[launcher] {} already running{} — reusing it.", service.label(), match runtime.pid { Some(pid) => format!(" (pid {pid})"), None => String::new(), } )); return Ok(Ensured::Reused); } if let Some(detail) = runtime.detail.filter(|_| !runtime.running) { // No service-name prefix: every caller already renders the service it // asked about, and the launch card would print "LSX: LSX: …". return Err(detail); } let child = spawn( service, spec.persona_id, &spec.persona_name, spec.capability, Arc::clone(&self.log), ) .map_err(|e| e.to_string())?; *self.slot(service) = ManagedService::from_child(child); Ok(Ensured::Started) } /// Stop a service the launcher owns. A foreign process is reported, never /// killed: the launcher did not start it and does not know who needs it. pub fn stop(&mut self, service: Service) -> Result<(), String> { let runtime = self.observe(service); stop_permitted(&runtime, service.label())?; let log = Arc::clone(&self.log); self.slot(service).stop(&log, service); Ok(()) } pub fn stopping(&mut self, service: Service) -> bool { self.slot(service).stopping() } } /// Everything [`spawn`] needs, bundled so the launch sequence can hand it over /// as one value per service. pub struct SpawnSpec { pub persona_id: u64, pub persona_name: String, pub capability: Option, } /// Spawn a companion service and stream its stdout+stderr into `log`. /// /// Returns an error without spawning if the binary is missing, which is the only /// precondition left now that the companions are workspace binaries rather than /// Python scripts run from a configured tools directory. /// /// `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, persona_id: u64, persona_name: &str, capability: Option, log: Arc>, ) -> anyhow::Result { use std::io::{BufRead, BufReader}; let label = service.label(); let parts = command_parts(service); let program = Path::new(&parts.program); // Only a resolved absolute path can be checked up front; a bare name is left to // the OS to resolve through PATH, and a failure there is reported by spawn below. if program.is_absolute() && !program.is_file() { anyhow::bail!( "{label} binary not found: {} — build the workspace so it sits beside the launcher", program.display() ); } let mut cmd = Command::new(&parts.program); cmd.args(&parts.args); if service == Service::Lsx { // The persona LSX reports has to equal what Blaze returns in // LoginResponse.SESS.PDTL and what UTAS serves as userInfo.personaId; the // constraint is cross-layer agreement, not any particular value. cmd.env("FUT_PERSONA_ID", persona_id.to_string()) .env("FUT_PERSONA_NAME", persona_name); } else if service == Service::Autopatch { // A per-user runtime log, so a stale root-owned /tmp file cannot block startup. 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.stdout(Stdio::piped()).stderr(Stdio::piped()); log.lock().push(format!( "[launcher] starting {label}: {}{}", parts.program, parts.args.iter().fold(String::new(), |mut acc, a| { acc.push(' '); acc.push_str(a); acc }), )); let mut child = cmd .spawn() .map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.binary()))?; 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 = LSX_ADDR; 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_its_own_binary_with_no_arguments() { let parts = command_parts(Service::Lsx); assert_eq!( Path::new(&parts.program).file_name().unwrap(), "openfut-lsx" ); assert!(parts.args.is_empty(), "{:?}", parts.args); } #[test] fn autopatch_runs_its_own_binary_with_launcher_ownership() { let parts = command_parts(Service::Autopatch); assert_eq!( Path::new(&parts.program).file_name().unwrap(), "openfut-autopatch" ); // The launcher pid is how autopatch learns to exit with its owner. assert_eq!( parts.args, vec!["--launcher-pid", &std::process::id().to_string()] ); } #[test] fn a_companion_binary_is_looked_up_by_file_name_not_a_suffix_match() { // Guards the foreign-process scan: an argv entry that merely ends with the // binary name (a log path, say) must not be mistaken for the service. assert_eq!(Service::Lsx.binary(), "openfut-lsx"); assert_eq!(Service::Autopatch.binary(), "openfut-autopatch"); assert_eq!( Path::new("/var/log/my-openfut-lsx").file_name().unwrap(), "my-openfut-lsx" ); } #[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")); } fn supervisor() -> ServiceSupervisor { ServiceSupervisor::new(Arc::new(Mutex::new(LogBuffer::new()))) } #[test] fn a_service_this_launcher_never_started_is_never_reported_as_ours() { // The old model only knew about children it spawned, so it could not tell // "stopped" from "running, but not mine". Note this box may genuinely have // a foreign responder running — that is a real observation, and the // invariant is about ownership, not about it being absent. let mut sup = supervisor(); let runtime = sup.observe(Service::Autopatch); assert!( !runtime.started_by_launcher, "nothing was spawned here, so nothing may claim launcher ownership" ); } #[test] fn a_launcher_owned_child_is_observed_as_ours_and_reaped_when_it_dies() { let mut sup = supervisor(); let child = Command::new("sh") .args(["-c", "sleep 30"]) .spawn() .expect("spawn long-lived child"); let pid = child.id(); sup.autopatch = ManagedService::from_child(child); let runtime = sup.observe(Service::Autopatch); assert!(runtime.running); assert!(runtime.started_by_launcher, "we spawned it"); assert_eq!(runtime.pid, Some(pid)); // Stopping is allowed precisely because it is ours. sup.stop(Service::Autopatch).expect("ours to stop"); } #[test] fn stopping_a_foreign_service_is_refused_rather_than_killing_it() { // A service someone started by hand for a debugging session must survive a // launch/exit cycle, and the refusal has to say where to stop it. Asserted // on the pure rule so it holds regardless of what this machine is running. let foreign = ServiceRuntime { running: true, started_by_launcher: false, pid: Some(4242), detail: None, }; let error = stop_permitted(&foreign, "autopatch").unwrap_err(); assert!(error.contains("started outside this launcher"), "{error}"); assert!(error.contains("4242"), "{error}"); let ours = ServiceRuntime { running: true, started_by_launcher: true, pid: Some(99), detail: None, }; assert!(stop_permitted(&ours, "autopatch").is_ok()); // Stopping something that is not running is a harmless no-op. assert!(stop_permitted(&ServiceRuntime::default(), "autopatch").is_ok()); assert!( crate::launch::services_to_stop( crate::launch::CleanupPolicy { stop_launcher_started_services: true, }, &[(Service::Autopatch, foreign)], ) .is_empty(), "a foreign service is never in the stop list" ); } #[test] fn foreign_pid_ignores_the_launcher_process_itself() { // The scan matches on the responder script name; this process is not one, // and must never be reported as a service. assert_ne!(foreign_pid(Service::Lsx, None), Some(std::process::id())); assert_ne!( foreign_pid(Service::Autopatch, None), Some(std::process::id()) ); } }