Files
openfut-launcher/src/preflight.rs
T
funman300 1cd4f18e92 feat: spawn the Rust companion binaries, not Python scripts
The launcher shelled out to `python3 lsx_responder_v2.py` and `python3 autopatch.py`
from a configured tools directory. Both are now Rust binaries built from this
workspace (openfut-lsx, openfut-autopatch), so the launch contract loses the
interpreter and the script directory entirely: nothing to locate, nothing to
configure, and no way to run a stale checkout's copy of a responder.

Service::script() becomes Service::binary(), and resolve_binary() prefers a sibling
of the running launcher -- what a workspace build and any sane install layout both
produce -- falling back to the bare name so a PATH install still works. It returns the
bare name rather than failing so that spawn() stays the single place a missing binary
is reported, instead of two error paths for one condition.

foreign_pid() now matches an argv entry's FILE NAME rather than a suffix, so
`/path/to/openfut-lsx` matches while an unrelated argument that merely ends with the
same text does not. It deliberately still reads argv and not comm: comm is truncated
to 15 characters by the kernel, which would misreport both of these names -- the same
trap that made an earlier `pgrep -f` guard match its own shell.

Dead configuration removed rather than left vestigial: fifa17_python and
fifa17_tools_dir, their Settings controls, and validate_local_services(), whose only
two checks were those fields. A validation hook that can only return Ok(()) would
claim the launcher verifies local-service configuration when there is none. The
preflight tools-dir gate is gone too, while the ptrace_scope check it gated is kept --
that check is real and repairable via "Arm client"; only the gate died.

The env contract is unchanged, so the binaries are drop-in: LSX still receives
FUT_PERSONA_ID/FUT_PERSONA_NAME (the persona has to agree with Blaze's
LoginResponse.SESS.PDTL and UTAS's userInfo.personaId), autopatch still receives
OPENFUT_AUTOPATCH_LOG under XDG_RUNTIME_DIR and --launcher-pid so it cannot outlive
its owner, and each companion still gets its own process group.

74 tests green.
2026-08-18 05:30:35 +00:00

485 lines
18 KiB
Rust

//! 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(),
ea_redirect(cfg),
hostname_mapping(cfg),
backend_reachable(cfg),
hook_config(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.
///
/// Unconditional. autopatch is a workspace binary that ships alongside the
/// launcher, so there is no configuration that could make this inapplicable —
/// every launch runs it.
fn ptrace_scope() -> Check {
const NAME: &str = "ptrace_scope (autopatch)";
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?
pub(crate) 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(", ")))
}
}
/// The deployed `openfut.cfg` is the only server address the *game* can see.
///
/// Every panel in this launcher reads the in-memory config, so a settings change
/// that never reached the file produces the worst possible failure: the UI shows
/// the new server online while FIFA connects to the old one. Compare the two.
fn hook_config(cfg: &LauncherConfig) -> Check {
const NAME: &str = "Hook server address";
let game_dir = cfg.fifa_game_dir.trim();
if game_dir.is_empty() {
return Check::skip(NAME, "no FIFA game dir configured");
}
let Some(body) = crate::setup::read_hook_config(std::path::Path::new(game_dir)) else {
return Check::skip(
NAME,
format!("no {} deployed yet", crate::setup::HOOK_CFG_FILE),
);
};
let deployed = match openfut_common::ServerConfig::parse(&body) {
Ok(parsed) => parsed,
// Unparseable means the hook cannot read it either, and nothing else in
// the stack recovers from that — so this one is a genuine failure.
Err(e) => {
return Check::fail(
NAME,
format!("{} is unreadable: {e}", crate::setup::HOOK_CFG_FILE),
)
}
};
let wanted = cfg.server_config();
if deployed == wanted {
return Check::pass(NAME, format!("hook redirects to {}", wanted.host));
}
// Warn, not fail: the launch path rewrites this file before starting the
// game, so the drift is real but already covered. Naming both addresses is
// what makes it actionable.
Check::warn(
NAME,
format!(
"deployed hook still points at {} (settings say {}) — launching rewrites it",
deployed.host, wanted.host
),
)
}
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 a column
// of green ticks. "Not checked" is not "checked and fine".
//
// `ptrace_scope` is excluded because it is no longer configuration
// dependent: it reads this machine's Yama setting and reports a real
// verdict either way. `only_ptrace_scope_zero_lets_autopatch_work`
// covers it.
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.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
let checks: Vec<Check> = run(&c)
.into_iter()
.filter(|k| k.name != "ptrace_scope (autopatch)")
.collect();
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 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.
///
/// Asserts the hostname check itself rather than counting states across the
/// whole run: `backend_reachable` opens real sockets, so an aggregate count
/// silently asserts that THIS machine has the OpenFUT ports open. That made
/// the test pass only on the server host and fail on the game machine, which
/// is precisely where someone building the launcher runs the suite.
#[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()];
let check = hostname_mapping(&c);
assert_eq!(check.state, State::Warn, "{}", check.detail);
assert!(
check.detail.contains("localhost"),
"the warning must name the shadowed host: {}",
check.detail
);
}
#[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 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);
}
/// A temp game dir holding one `openfut.cfg` body.
fn game_dir_with_cfg(tag: &str, body: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("openfut-preflight-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(crate::setup::HOOK_CFG_FILE), body).unwrap();
dir
}
#[test]
fn a_stale_hook_config_is_reported_and_names_both_addresses() {
// The silent failure this check exists for: settings changed, the file
// the game reads did not.
let mut c = cfg();
c.openfut_server_host = "10.0.0.2".into();
let old = openfut_common::ServerConfig {
host: "10.0.0.1".into(),
ports: c.server_config().ports,
};
let dir = game_dir_with_cfg("stale", &old.to_cfg_string());
c.fifa_game_dir = dir.to_string_lossy().into_owned();
let check = hook_config(&c);
assert_eq!(check.state, State::Warn, "{}", check.detail);
assert!(check.detail.contains("10.0.0.1"), "{}", check.detail);
assert!(check.detail.contains("10.0.0.2"), "{}", check.detail);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn a_hook_config_matching_settings_passes() {
let mut c = cfg();
c.openfut_server_host = "10.0.0.2".into();
let dir = game_dir_with_cfg("fresh", &c.server_config().to_cfg_string());
c.fifa_game_dir = dir.to_string_lossy().into_owned();
assert_eq!(hook_config(&c).state, State::Pass);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn a_missing_hook_config_is_skipped_not_passed() {
let mut c = cfg();
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
assert_eq!(hook_config(&c).state, State::Skipped);
}
}