feat(launcher): one-click client arming + modular preflight/services
Add a GUI "Arm client" button that reproduces client_arm.sh in a single pkexec batch: kernel.yama.ptrace_scope=0, DNAT of EA's hardcoded redirector IP to the OpenFUT server (+ MASQUERADE reply path), and /etc/hosts rewrites for every dead EA hostname (removing foreign shadow lines first, so glibc's first-match resolution can't land on a stale loopback entry). All steps are idempotent (delete-then-add) and injection-safe: config values are charset- validated and rejected on a surprising character, never shell-escaped. arm() returns the concrete change list, which the button logs line-by-line and echoes as an inline pass/fail status on the pre-launch tab (no tab jump, no reuse of the local-services toast). This necessarily lands the surrounding launcher modularization the arm feature is built on, extracted from the former monolithic app.rs/process.rs: - preflight: advisory pre-launch checks (ptrace, redirector DNAT, hostnames, backend reachability) that colour rows but never block Launch - local_services: launcher-owned LSX/autopatch child processes - game_launch, account_sync, health, netcheck helpers - openfut-common: dependency-free shared server-destination/port mapping, used by both the launcher and (separately) openfut_hook.dll openfut-hook RE changes are intentionally left uncommitted (separate concern). fmt + clippy -D warnings clean; 46 tests pass.
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
//! 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/<FIFA17.exe>/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 std::{
|
||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
use crate::logs::LogBuffer;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct CommandParts {
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<F>(work: F) -> mpsc::Receiver<anyhow::Result<()>>
|
||||
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<Child>,
|
||||
stopping: Option<mpsc::Receiver<anyhow::Result<()>>>,
|
||||
}
|
||||
|
||||
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<Mutex<LogBuffer>>, label: &str) -> bool {
|
||||
if let Some(result) = self.stopping.as_ref() {
|
||||
match result.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] {label} stopped."));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.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().unwrap().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()
|
||||
.unwrap()
|
||||
.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<Mutex<LogBuffer>>, service: Service) {
|
||||
if self.stopping.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let label = service.label();
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn spawn(
|
||||
service: Service,
|
||||
python: &str,
|
||||
tools_dir: &str,
|
||||
persona_id: u64,
|
||||
persona_name: &str,
|
||||
log: Arc<Mutex<LogBuffer>>,
|
||||
) -> anyhow::Result<Child> {
|
||||
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 the Config tab)",
|
||||
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().unwrap().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();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(format!("[{lbl}] {line}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
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().unwrap().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()
|
||||
.unwrap()
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user