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,398 @@
|
||||
//! 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<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Pass,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn fail(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Fail,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn warn(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Warn,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn skip(name: &str, detail: impl Into<String>) -> 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<Check> {
|
||||
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::<IpAddr>() 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<Vec<IpAddr>> {
|
||||
Ok((host, 0u16).to_socket_addrs()?.map(|a| a.ip()).collect())
|
||||
}
|
||||
|
||||
fn join(ips: &[IpAddr]) -> String {
|
||||
ips.iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user