From d619c992c1eac21e237c312a3b8bd5bca50a69ce Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 12 Aug 2026 17:58:48 +0000 Subject: [PATCH] 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. --- Cargo.lock | 5 + Cargo.toml | 1 + openfut-common/Cargo.lock | 7 + openfut-common/Cargo.toml | 11 + openfut-common/src/lib.rs | 479 ++++++++++++++++ src/account_sync.rs | 177 ++++++ src/app.rs | 1100 ++++++++++++++++++++++++++----------- src/arm.rs | 239 ++++++++ src/config.rs | 623 ++++++++++++++++++++- src/game_launch.rs | 449 +++++++++++++++ src/health.rs | 133 +++++ src/local_services.rs | 368 +++++++++++++ src/main.rs | 8 +- src/netcheck.rs | 77 +++ src/preflight.rs | 398 ++++++++++++++ src/process.rs | 134 ----- src/setup.rs | 116 ++-- 17 files changed, 3814 insertions(+), 511 deletions(-) create mode 100644 openfut-common/Cargo.lock create mode 100644 openfut-common/Cargo.toml create mode 100644 openfut-common/src/lib.rs create mode 100644 src/account_sync.rs create mode 100644 src/arm.rs create mode 100644 src/game_launch.rs create mode 100644 src/health.rs create mode 100644 src/local_services.rs create mode 100644 src/netcheck.rs create mode 100644 src/preflight.rs delete mode 100644 src/process.rs diff --git a/Cargo.lock b/Cargo.lock index ccb2aeb..24d9295 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2279,6 +2279,10 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openfut-common" +version = "0.1.0" + [[package]] name = "openfut-launcher" version = "0.1.0" @@ -2288,6 +2292,7 @@ dependencies = [ "dirs", "eframe", "egui", + "openfut-common", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 4576e0b..e1e5e25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "5" chrono = { version = "0.4", features = ["serde"] } +openfut-common = { path = "openfut-common" } diff --git a/openfut-common/Cargo.lock b/openfut-common/Cargo.lock new file mode 100644 index 0000000..cb9e157 --- /dev/null +++ b/openfut-common/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "openfut-common" +version = "0.1.0" diff --git a/openfut-common/Cargo.toml b/openfut-common/Cargo.toml new file mode 100644 index 0000000..c62141c --- /dev/null +++ b/openfut-common/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "openfut-common" +version = "0.1.0" +edition = "2021" +description = "Shared OpenFUT server-destination configuration, port mapping, and address conversion used by both the launcher and openfut_hook.dll. Pure std, no dependencies, so it stays small inside the injected DLL and is unit-testable on any host." + +[lib] +# Rlib only. Deliberately dependency-free so linking it into openfut_hook.dll +# (a cdylib) adds no runtime weight and no cross-platform risk. + +[dependencies] diff --git a/openfut-common/src/lib.rs b/openfut-common/src/lib.rs new file mode 100644 index 0000000..2bd5033 --- /dev/null +++ b/openfut-common/src/lib.rs @@ -0,0 +1,479 @@ +//! Shared OpenFUT destination configuration. +//! +//! This crate is the **single source of truth** for where FIFA's intercepted +//! EA traffic is redirected. It is consumed by both the launcher (which writes +//! `openfut.cfg`) and `openfut_hook.dll` (which reads it and rewrites sockets). +//! +//! Design rules enforced here: +//! * There is exactly ONE configured OpenFUT destination (host + ports). +//! * Missing/invalid configuration is a hard error — it NEVER silently +//! degrades to `127.0.0.1`/localhost. Loopback is only ever used if the +//! user explicitly configures it. +//! * The address/port -> WinSock representation helpers live here and are +//! unit-tested on the host, so the byte-order logic the socket hooks depend +//! on is verified without needing WinSock or FIFA. +//! +//! ## EA source ports vs OpenFUT destination ports +//! +//! FIFA connects to a handful of EA endpoints on fixed ports. Those source +//! ports are *signatures* used to recognise traffic that must be intercepted — +//! they are hardcoded on purpose (see [`ea_ports`]). Each recognised EA source +//! port maps to an OpenFUT *destination* port, which comes from configuration +//! ([`OpenFutPorts`]). Source port = fixed EA signature; destination port = +//! configurable OpenFUT service. + +use std::fmt; +use std::net::{Ipv4Addr, ToSocketAddrs}; +use std::str::FromStr; + +/// EA source ports FIFA dials. These are fixed EA endpoint signatures used to +/// recognise traffic for interception — NOT OpenFUT configuration. Do not make +/// these configurable; changing them would stop us recognising EA traffic. +pub mod ea_ports { + /// EA HTTPS (UTAS / EAWebKit / footapi) — the game dials EA hosts on 443. + pub const HTTPS: u16 = 443; + /// EA Blaze redirector (gosredirector) source port. + pub const BLAZE_REDIRECTOR: u16 = 10041; + /// FIFA 17's `winter15.gosredirector.ea.com` endpoint source port. This is + /// an observed, fixed vendor-port signature; it maps to the same configured + /// OpenFUT redirector destination as the newer 10041 endpoint. + pub const FIFA17_BLAZE_REDIRECTOR: u16 = 42230; + /// EA Blaze main server source port. + pub const BLAZE_MAIN: u16 = 42127; +} + +/// Default OpenFUT *destination* ports, derived from the current OpenFUT server +/// implementation (bridge listens on 8443 for HTTPS; Blaze services keep their +/// native ports). The launcher may pre-populate these; the user may change them +/// without recompiling anything. +pub mod default_ports { + /// OpenFUT bridge HTTPS listener (EA :443 is redirected here). + pub const HTTPS: u16 = 8443; + /// OpenFUT FIFA 17 Blaze redirector listener. + pub const BLAZE_REDIRECTOR: u16 = 42127; + /// OpenFUT FIFA 17 Blaze main listener. + pub const BLAZE_MAIN: u16 = 42130; +} + +/// OpenFUT destination ports. Each field is where an intercepted EA source port +/// is redirected. Not collapsed into one port: OpenFUT runs distinct services +/// (bridge HTTPS + two Blaze listeners) that FIFA reaches on distinct ports. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OpenFutPorts { + /// Destination for EA :443 traffic (bridge HTTPS). + pub https: u16, + /// Destination for EA :10041 traffic (Blaze redirector). + pub blaze_redirector: u16, + /// Destination for EA :42127 traffic (Blaze main). + pub blaze_main: u16, +} + +impl Default for OpenFutPorts { + fn default() -> Self { + Self { + https: default_ports::HTTPS, + blaze_redirector: default_ports::BLAZE_REDIRECTOR, + blaze_main: default_ports::BLAZE_MAIN, + } + } +} + +impl OpenFutPorts { + /// Map a recognised EA *source* port to its OpenFUT *destination* port. + /// Returns `None` for ports we don't intercept (the socket hook then leaves + /// the connection untouched). + pub fn map_source_port(&self, ea_source_port: u16) -> Option { + match ea_source_port { + ea_ports::HTTPS => Some(self.https), + ea_ports::BLAZE_REDIRECTOR | ea_ports::FIFA17_BLAZE_REDIRECTOR => { + Some(self.blaze_redirector) + } + ea_ports::BLAZE_MAIN => Some(self.blaze_main), + _ => None, + } + } +} + +/// The parsed OpenFUT server configuration: what host to redirect EA traffic to +/// and which destination ports to use. `host` may be an IPv4 literal or a +/// hostname; resolution to a concrete [`Ipv4Addr`] happens in [`Self::resolve`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerConfig { + /// User-configured OpenFUT server host (IPv4 literal or hostname). Never + /// defaulted to loopback — an empty host is a [`ConfigError::ServerMissing`]. + pub host: String, + pub ports: OpenFutPorts, +} + +/// A [`ServerConfig`] whose host has been resolved to a concrete IPv4 address. +/// This is what the socket hooks consume — all three interception layers share +/// exactly this resolved destination. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedServer { + /// The concrete IPv4 the EA connection's destination is rewritten to. + pub redirect_ip: Ipv4Addr, + pub ports: OpenFutPorts, +} + +/// Errors loading/validating OpenFUT server configuration. Every one of these +/// must BLOCK operation — none of them may fall back to loopback. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigError { + /// No config file present (e.g. `openfut.cfg` missing). + ConfigMissing, + /// Config present but no server host set. + ServerMissing, + /// Host could not be parsed/resolved to an IPv4 address. + InvalidAddress(String), + /// A port value was not a valid `u16` / was zero. + InvalidPort(String), + /// Config bytes were structurally unparseable. + MalformedConfig(String), +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigError::ConfigMissing => { + write!(f, "No OpenFUT server configured (config file missing)") + } + ConfigError::ServerMissing => write!( + f, + "No OpenFUT server configured. Enter the hostname or IP address of your OpenFUT server." + ), + ConfigError::InvalidAddress(a) => write!(f, "Invalid OpenFUT server address: {a}"), + ConfigError::InvalidPort(p) => write!(f, "Invalid OpenFUT port: {p}"), + ConfigError::MalformedConfig(m) => write!(f, "Malformed OpenFUT config: {m}"), + } + } +} + +impl std::error::Error for ConfigError {} + +impl ServerConfig { + /// Parse `openfut.cfg` contents. + /// + /// Two accepted formats: + /// * **Structured** (preferred), one `key=value` per line: + /// ```text + /// host=192.168.1.50 + /// https_port=8443 + /// blaze_redirector_port=10041 + /// blaze_main_port=42127 + /// ``` + /// `host` is required; any omitted port uses its default. + /// * **Legacy**, a single bare line containing just the host + /// (IPv4 or hostname). Ports default. This keeps old deployments working. + /// + /// An empty/whitespace-only body is [`ConfigError::ServerMissing`], NOT a + /// silent loopback fallback. + pub fn parse(contents: &str) -> Result { + let trimmed = contents.trim(); + if trimmed.is_empty() { + return Err(ConfigError::ServerMissing); + } + + // Legacy single-token form: no '=' anywhere and a single line. + let has_kv = trimmed.lines().any(|l| l.contains('=')); + if !has_kv { + let host = trimmed.trim(); + if host.is_empty() { + return Err(ConfigError::ServerMissing); + } + return Ok(Self { + host: host.to_string(), + ports: OpenFutPorts::default(), + }); + } + + let mut host: Option = None; + let mut ports = OpenFutPorts::default(); + + for (lineno, raw) in trimmed.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, value) = line.split_once('=').ok_or_else(|| { + ConfigError::MalformedConfig(format!("line {}: expected key=value", lineno + 1)) + })?; + let key = key.trim(); + let value = value.trim(); + match key { + "host" | "server" | "redirect_ip" => { + if value.is_empty() { + return Err(ConfigError::ServerMissing); + } + host = Some(value.to_string()); + } + "https_port" => ports.https = parse_port(value)?, + "blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?, + "blaze_main_port" => ports.blaze_main = parse_port(value)?, + other => { + return Err(ConfigError::MalformedConfig(format!( + "line {}: unknown key '{other}'", + lineno + 1 + ))); + } + } + } + + let host = host.ok_or(ConfigError::ServerMissing)?; + Ok(Self { host, ports }) + } + + /// Serialize to the structured `openfut.cfg` format. + pub fn to_cfg_string(&self) -> String { + format!( + "host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\n", + self.host, self.ports.https, self.ports.blaze_redirector, self.ports.blaze_main + ) + } + + /// Validate the host is a syntactically acceptable IPv4 literal or hostname. + /// Does NOT perform DNS (no network) — use [`Self::resolve`] for that. + pub fn validate(&self) -> Result<(), ConfigError> { + let host = self.host.trim(); + if host.is_empty() { + return Err(ConfigError::ServerMissing); + } + if Ipv4Addr::from_str(host).is_ok() { + return Ok(()); + } + if is_plausible_hostname(host) { + Ok(()) + } else { + Err(ConfigError::InvalidAddress(host.to_string())) + } + } + + /// Resolve the configured host to a concrete IPv4 destination. + /// + /// This is the single shared resolution path all hooks rely on: an IPv4 + /// literal is used directly; a hostname is resolved via the platform + /// resolver ([`ToSocketAddrs`]). Only IPv4 results are used (WinSock + /// interception here is IPv4). Never falls back to loopback. + pub fn resolve(&self) -> Result { + let host = self.host.trim(); + if host.is_empty() { + return Err(ConfigError::ServerMissing); + } + + // IPv4 literal: no DNS needed. + if let Ok(ip) = Ipv4Addr::from_str(host) { + return Ok(ResolvedServer { + redirect_ip: ip, + ports: self.ports, + }); + } + + // Hostname: resolve via the platform resolver. Port is irrelevant to + // the lookup; we only need an address. Take the first IPv4 answer. + let ip = (host, 0u16) + .to_socket_addrs() + .map_err(|e| ConfigError::InvalidAddress(format!("{host}: {e}")))? + .find_map(|sa| match sa.ip() { + std::net::IpAddr::V4(v4) => Some(v4), + std::net::IpAddr::V6(_) => None, + }) + .ok_or_else(|| ConfigError::InvalidAddress(format!("{host}: no IPv4 address found")))?; + + Ok(ResolvedServer { + redirect_ip: ip, + ports: self.ports, + }) + } +} + +fn parse_port(value: &str) -> Result { + let n: u16 = value + .parse() + .map_err(|_| ConfigError::InvalidPort(value.to_string()))?; + if n == 0 { + return Err(ConfigError::InvalidPort(value.to_string())); + } + Ok(n) +} + +/// Lenient hostname sanity check (RFC-1123-ish). Not a full validator — just +/// enough to reject obvious garbage while accepting `.local`/`.home` names. +fn is_plausible_hostname(host: &str) -> bool { + if host.is_empty() || host.len() > 253 { + return false; + } + host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + && !label.starts_with('-') + && !label.ends_with('-') + }) +} + +// ── WinSock representation helpers ─────────────────────────────────────────── +// These convert a resolved destination into the exact in-memory representation +// WinSock's `sockaddr_in` expects. Kept here (not in the DLL) so the byte-order +// contract the socket hooks depend on is unit-tested on the host. + +/// Value to store in `sockaddr_in.sin_addr.S_un.S_addr`. +/// +/// WinSock stores the four IPv4 octets in network byte order in memory. Reading +/// those bytes back as a native-endian `u32` (how the hook's `SockaddrIn.sin_addr` +/// field is typed) is exactly `from_ne_bytes(octets)`. On little-endian x86_64 +/// this yields e.g. `127.0.0.1 -> 0x0100_007F`, matching the value the original +/// hooks hardcoded — confirming the conversion is correct. +pub fn sin_addr_from_ipv4(ip: Ipv4Addr) -> u32 { + u32::from_ne_bytes(ip.octets()) +} + +/// Value to store in `sockaddr_in.sin_port` — the port in network byte order. +pub fn sin_port_nbo(port: u16) -> u16 { + port.to_be() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configured_ipv4_survives_parsing() { + let c = ServerConfig::parse("host=192.168.1.50\n").unwrap(); + assert_eq!(c.host, "192.168.1.50"); + assert_eq!(c.ports, OpenFutPorts::default()); + } + + #[test] + fn legacy_bare_host_line_parses() { + let c = ServerConfig::parse("10.0.0.7\n").unwrap(); + assert_eq!(c.host, "10.0.0.7"); + assert_eq!(c.ports, OpenFutPorts::default()); + } + + #[test] + fn missing_server_does_not_become_loopback() { + // Empty config is an explicit error, never 127.0.0.1. + assert_eq!( + ServerConfig::parse("").unwrap_err(), + ConfigError::ServerMissing + ); + assert_eq!( + ServerConfig::parse(" \n\n").unwrap_err(), + ConfigError::ServerMissing + ); + assert_eq!( + ServerConfig::parse("https_port=8443\n").unwrap_err(), + ConfigError::ServerMissing + ); + } + + #[test] + fn invalid_server_is_rejected() { + let c = ServerConfig { + host: "not a host!!".into(), + ports: OpenFutPorts::default(), + }; + assert!(matches!(c.validate(), Err(ConfigError::InvalidAddress(_)))); + } + + #[test] + fn configured_port_survives_parsing() { + let c = ServerConfig::parse( + "host=srv.home\nhttps_port=9000\nblaze_redirector_port=11000\nblaze_main_port=42000\n", + ) + .unwrap(); + assert_eq!(c.ports.https, 9000); + assert_eq!(c.ports.blaze_redirector, 11000); + assert_eq!(c.ports.blaze_main, 42000); + } + + #[test] + fn invalid_port_is_rejected() { + assert!(matches!( + ServerConfig::parse("host=x\nhttps_port=0\n"), + Err(ConfigError::InvalidPort(_)) + )); + assert!(matches!( + ServerConfig::parse("host=x\nhttps_port=99999\n"), + Err(ConfigError::InvalidPort(_)) + )); + } + + #[test] + fn unknown_key_is_malformed() { + assert!(matches!( + ServerConfig::parse("host=x\nbogus=1\n"), + Err(ConfigError::MalformedConfig(_)) + )); + } + + #[test] + fn roundtrip_cfg_string() { + let c = ServerConfig { + host: "192.168.1.50".into(), + ports: OpenFutPorts { + https: 8443, + blaze_redirector: 10041, + blaze_main: 42127, + }, + }; + let s = c.to_cfg_string(); + assert_eq!(ServerConfig::parse(&s).unwrap(), c); + } + + #[test] + fn configured_ipv4_becomes_correct_sockaddr() { + // Resolve an IPv4 literal and confirm the sin_addr value. + let c = ServerConfig::parse("host=192.168.1.50\n").unwrap(); + let r = c.resolve().unwrap(); + assert_eq!(r.redirect_ip, Ipv4Addr::new(192, 168, 1, 50)); + // sin_addr is the octets as a native-endian u32. + assert_eq!( + sin_addr_from_ipv4(r.redirect_ip), + u32::from_ne_bytes([192, 168, 1, 50]) + ); + } + + #[test] + fn loopback_sockaddr_matches_legacy_constant() { + // Guards the byte-order contract: the old hooks hardcoded 0x0100_007F + // for 127.0.0.1. Our helper must reproduce it on this (LE) host. + assert_eq!(sin_addr_from_ipv4(Ipv4Addr::new(127, 0, 0, 1)), 0x0100_007F); + } + + #[test] + fn sin_port_is_network_byte_order() { + // 443 -> 0xBB01, 42127 -> 0x8FA4 (matches the legacy hook constants). + assert_eq!(sin_port_nbo(443), 0xBB01); + assert_eq!(sin_port_nbo(42127), 0x8FA4); + assert_eq!(sin_port_nbo(10041), 0x3927); + } + + #[test] + fn port_mapping_source_to_destination() { + let p = OpenFutPorts::default(); + assert_eq!(p.map_source_port(ea_ports::HTTPS), Some(8443)); + assert_eq!(p.map_source_port(ea_ports::BLAZE_REDIRECTOR), Some(42127)); + assert_eq!( + p.map_source_port(ea_ports::FIFA17_BLAZE_REDIRECTOR), + Some(42127) + ); + assert_eq!(p.map_source_port(ea_ports::BLAZE_MAIN), Some(42130)); + assert_eq!(p.map_source_port(12345), None); + } + + #[test] + fn resolve_literal_ipv4_no_dns() { + let c = ServerConfig::parse("host=127.0.0.1\n").unwrap(); + let r = c.resolve().unwrap(); + assert_eq!(r.redirect_ip, Ipv4Addr::LOCALHOST); + } + + #[test] + fn resolve_empty_host_errors() { + let c = ServerConfig { + host: " ".into(), + ports: OpenFutPorts::default(), + }; + assert_eq!(c.resolve().unwrap_err(), ConfigError::ServerMissing); + } +} diff --git a/src/account_sync.rs b/src/account_sync.rs new file mode 100644 index 0000000..cddaf2e --- /dev/null +++ b/src/account_sync.rs @@ -0,0 +1,177 @@ +use crate::config::LauncherConfig; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync"; +const TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AccountSyncRequest<'a> { + persona_id: u64, + persona_name: &'a str, + level: u32, + experience: u32, + experience_max: u32, + account_funds: u32, + account_funds_cap: u32, +} + +#[derive(Debug, Deserialize)] +pub struct AccountSyncResult { + pub account: AccountSummary, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountSummary { + pub persona_id: u64, + pub persona_name: String, + pub level: u32, + pub experience: u32, + pub account_funds: u32, + pub coins: i64, + pub unopened_packs: usize, +} + +/// Select the persistent EA/FUT account before LSX and FIFA start. +/// +/// This deliberately uses a tiny stdlib HTTP client so the launcher does not +/// acquire an async runtime solely for one bounded control-plane request. +pub fn sync(config: &LauncherConfig) -> Result { + config.validate_server()?; + config.validate_account()?; + + let host = config.openfut_server_host.trim(); + let port = config.openfut_account_sync_port; + let address = (host, port) + .to_socket_addrs() + .map_err(|error| format!("cannot resolve account server {host}:{port}: {error}"))? + .next() + .ok_or_else(|| format!("account server {host}:{port} resolved to no addresses"))?; + let mut stream = TcpStream::connect_timeout(&address, TIMEOUT) + .map_err(|error| format!("cannot connect to account server {host}:{port}: {error}"))?; + stream + .set_read_timeout(Some(TIMEOUT)) + .map_err(|error| format!("cannot set account sync timeout: {error}"))?; + stream + .set_write_timeout(Some(TIMEOUT)) + .map_err(|error| format!("cannot set account sync timeout: {error}"))?; + + let payload = serde_json::to_vec(&AccountSyncRequest { + persona_id: config.fut_persona_id, + persona_name: config.fut_persona_name.trim(), + level: config.fut_account_level, + experience: config.fut_account_experience, + experience_max: config.fut_account_experience_max, + account_funds: config.fut_account_funds, + account_funds_cap: config.fut_account_funds_cap, + }) + .map_err(|error| format!("cannot encode account sync request: {error}"))?; + + let request = format!( + "POST {ACCOUNT_SYNC_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ); + stream + .write_all(request.as_bytes()) + .and_then(|()| stream.write_all(&payload)) + .map_err(|error| format!("cannot send account sync request: {error}"))?; + + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .map_err(|error| format!("cannot read account sync response: {error}"))?; + let separator = response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or_else(|| "account server returned a malformed HTTP response".to_string())?; + let headers = std::str::from_utf8(&response[..separator]) + .map_err(|_| "account server returned non-UTF-8 headers".to_string())?; + let status = headers + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| "account server returned a malformed status line".to_string())?; + let body = &response[separator + 4..]; + if !(200..300).contains(&status) { + let detail = String::from_utf8_lossy(body); + return Err(format!( + "account server rejected sync (HTTP {status}): {detail}" + )); + } + let envelope: AccountSyncResult = serde_json::from_slice(body) + .map_err(|error| format!("account server returned invalid JSON: {error}"))?; + if envelope.account.persona_id != config.fut_persona_id { + return Err(format!( + "account server selected persona {} instead of {}", + envelope.account.persona_id, config.fut_persona_id + )); + } + Ok(envelope.account) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + use std::thread; + + #[test] + fn sync_posts_account_and_reads_selected_profile() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + loop { + let mut chunk = [0; 1024]; + let count = socket.read(&mut chunk).unwrap(); + assert!(count > 0); + request.extend_from_slice(&chunk[..count]); + if let Some(separator) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..separator]); + let length = headers + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .unwrap() + .parse::() + .unwrap(); + if request.len() >= separator + 4 + length { + break; + } + } + } + let request = String::from_utf8_lossy(&request); + assert!(request.starts_with("POST /openfut/account/sync HTTP/1.1")); + assert!(request.contains("\"personaId\":12345678")); + assert!(request.contains("\"personaName\":\"TEST_USER\"")); + let body = r#"{"status":"OK","account":{"personaId":12345678,"personaName":"TEST_USER","level":7,"experience":200,"accountFunds":50,"coins":15000,"unopenedPacks":1}}"#; + write!( + socket, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + + let config = LauncherConfig { + openfut_server_host: "127.0.0.1".into(), + openfut_account_sync_port: port, + fut_persona_id: 12345678, + fut_persona_name: "TEST_USER".into(), + fut_account_level: 7, + fut_account_experience: 200, + ..LauncherConfig::default() + }; + let selected = sync(&config).unwrap(); + assert_eq!(selected.persona_name, "TEST_USER"); + assert_eq!(selected.coins, 15000); + assert_eq!(selected.unopened_packs, 1); + server.join().unwrap(); + } +} diff --git a/src/app.rs b/src/app.rs index 2637a76..3bfce37 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,14 +1,10 @@ use std::sync::{Arc, Mutex}; -use egui::{ - Color32, FontId, RichText, ScrollArea, Ui, Vec2, -}; +use egui::{Color32, FontId, RichText, ScrollArea, Ui, Vec2}; use crate::{ - config::LauncherConfig, - logs::LogBuffer, - process::{ServiceHandle, ServiceStatus}, - setup, + config::LauncherConfig, game_launch, health::HealthMonitor, logs::LogBuffer, netcheck, + preflight, setup, }; #[derive(PartialEq)] @@ -19,249 +15,577 @@ enum Tab { Config, } +/// The launcher is a *client-side* tool: the OpenFUT servers run elsewhere +/// (e.g. Docker on the server host). It monitors server health read-only, +/// prepares the FIFA integration (hook DLL + cert), and launches the game. pub struct LauncherApp { config: LauncherConfig, config_dirty: bool, - core: ServiceHandle, - bridge: ServiceHandle, - core_logs: Arc>, - bridge_logs: Arc>, + /// Read-only health monitor for the configured (remote) server. + health: HealthMonitor, + /// Game process output + launcher messages. + game_logs: Arc>, active_tab: Tab, - log_tab: usize, // 0 = core, 1 = bridge log_follow: bool, // Setup state hook_deployed: bool, - bridge_has_cap: bool, cert_path: Option, - setup_message: Option<(bool, String)>, // (success, text) + setup_message: Option<(bool, String)>, + /// Result of the last "Test Connection" click. + test_message: Option<(bool, String)>, + + // FIFA 17 local companion services (client-side daemons). + lsx: crate::local_services::ManagedService, + autopatch: crate::local_services::ManagedService, + local_services_message: Option<(bool, String)>, + + /// Last pre-launch check results. `None` until run — deliberately not run + /// automatically on every frame: the checks open sockets, and a 2s probe + /// on the UI thread would stall the window. + preflight: Option>, + + /// Result of the last "Arm client" click, shown inline beneath the button so + /// the outcome appears where the user acted — not on another tab. + arm_status: Option<(bool, String)>, } impl LauncherApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { - // Slightly larger default font let mut style = (*cc.egui_ctx.style()).clone(); - style.text_styles.insert( - egui::TextStyle::Body, - FontId::proportional(14.0), - ); - style.text_styles.insert( - egui::TextStyle::Monospace, - FontId::monospace(13.0), - ); + style + .text_styles + .insert(egui::TextStyle::Body, FontId::proportional(14.0)); + style + .text_styles + .insert(egui::TextStyle::Monospace, FontId::monospace(13.0)); cc.egui_ctx.set_style(style); let config = LauncherConfig::load(); let cert_path = setup::find_bridge_cert(&config.bridge_captures_dir); - let hook_deployed = - setup::hook_dll_deployed(std::path::Path::new(&config.fifa_game_dir)); - let bridge_has_cap = - setup::bridge_has_cap443(std::path::Path::new(&config.bridge_binary)); + let hook_deployed = setup::hook_dll_deployed(std::path::Path::new(&config.fifa_game_dir)); + + let health = HealthMonitor::new(); + health.set_target(config.health_target()); Self { config, config_dirty: false, - core: ServiceHandle::new(), - bridge: ServiceHandle::new(), - core_logs: Arc::new(Mutex::new(LogBuffer::new())), - bridge_logs: Arc::new(Mutex::new(LogBuffer::new())), + health, + game_logs: Arc::new(Mutex::new(LogBuffer::new())), active_tab: Tab::Dashboard, - log_tab: 0, log_follow: true, hook_deployed, - bridge_has_cap, cert_path, setup_message: None, + test_message: None, + lsx: crate::local_services::ManagedService::default(), + autopatch: crate::local_services::ManagedService::default(), + local_services_message: None, + preflight: None, + arm_status: None, } } - fn start_core(&mut self) { - let env = self.config.core_env(); - if let Err(e) = self.core.start( - &self.config.core_binary.clone(), - &env, - Arc::clone(&self.core_logs), - ) { - self.core_logs - .lock() - .unwrap() - .push(format!("[launcher] Failed to start core: {e}")); - } - } - - fn start_bridge(&mut self) { - let env = self.config.bridge_env(); - if let Err(e) = self.bridge.start( - &self.config.bridge_binary.clone(), - &env, - Arc::clone(&self.bridge_logs), - ) { - self.bridge_logs - .lock() - .unwrap() - .push(format!("[launcher] Failed to start bridge: {e}")); - } - } - - fn stop_all(&mut self) { - self.bridge.stop(); - self.core.stop(); + /// Re-point the health monitor whenever the server address may have changed. + fn refresh_health_target(&self) { + self.health.set_target(self.config.health_target()); } // ── UI sections ─────────────────────────────────────────────────────────── fn ui_dashboard(&mut self, ui: &mut Ui) { ui.add_space(8.0); - ui.heading("Services"); + ui.heading("OpenFUT Server"); ui.add_space(6.0); - let core_running = self.core.is_running(); - let bridge_running = self.bridge.is_running(); + let server_ok = self.config.validate_server().is_ok(); - egui::Grid::new("svc_grid") - .num_columns(4) + if !server_ok { + ui.colored_label( + Color32::from_rgb(220, 150, 0), + "No OpenFUT server configured. Enter the hostname or IP address of \ + your OpenFUT server in the Setup tab.", + ); + ui.add_space(6.0); + } + + // ── Server health (read-only) ───────────────────────────────────────── + let health = self.health.snapshot(); + egui::Grid::new("health_grid") + .num_columns(2) .spacing([16.0, 8.0]) .show(ui, |ui| { - // Header - ui.strong("Service"); + ui.strong("Server"); + ui.monospace(if self.config.openfut_server_host.is_empty() { + "—".to_string() + } else { + format!( + "{}:{}", + self.config.openfut_server_host, self.config.openfut_https_port + ) + }); + ui.end_row(); + ui.strong("Status"); - ui.label(""); // start btn - ui.label(""); // stop btn + match health.reachable { + None => ui.colored_label(Color32::from_rgb(150, 150, 150), "Unknown"), + Some(true) => ui.colored_label(Color32::from_rgb(80, 200, 120), "● Online"), + Some(false) => { + ui.colored_label(Color32::from_rgb(220, 60, 60), "● Unreachable") + } + }; ui.end_row(); - // Core - ui.label("openfut-core"); - self.status_badge(ui, &self.core.status()); - ui.add_enabled_ui(!core_running, |ui| { - if ui.button("▶ Start").clicked() { - self.start_core(); - } - }); - ui.add_enabled_ui(core_running, |ui| { - if ui.button("■ Stop").clicked() { - self.core.stop(); - } - }); + ui.label("Detail"); + ui.label(RichText::new(&health.detail).weak()); ui.end_row(); - // Bridge - ui.label("openfut-bridge"); - self.status_badge(ui, &self.bridge.status()); - ui.add_enabled_ui(!bridge_running, |ui| { - if ui.button("▶ Start").clicked() { - self.start_bridge(); - } - }); - ui.add_enabled_ui(bridge_running, |ui| { - if ui.button("■ Stop").clicked() { - self.bridge.stop(); - } - }); - ui.end_row(); + if let Some(t) = health.last_checked { + ui.label("Checked"); + ui.label(RichText::new(format!("{}s ago", t.elapsed().as_secs())).weak()); + ui.end_row(); + } }); - ui.add_space(12.0); - ui.separator(); - ui.add_space(8.0); - - // Quick-launch buttons - ui.horizontal(|ui| { - if ui - .add_sized([120.0, 32.0], egui::Button::new("▶▶ Start All")) - .clicked() - { - self.start_core(); - self.start_bridge(); - } - ui.add_space(8.0); - if ui - .add_sized([120.0, 32.0], egui::Button::new("■■ Stop All")) - .clicked() - { - self.stop_all(); - } - }); + ui.add_space(6.0); + ui.label( + RichText::new( + "The server runs elsewhere (e.g. Docker on the server host). This \ + launcher only monitors it — it does not start or stop it.", + ) + .weak() + .small(), + ); ui.add_space(16.0); ui.separator(); ui.add_space(8.0); - ui.heading("Quick Status"); - ui.add_space(4.0); - egui::Grid::new("quick_status") + // ── FIFA 17 local companion services ────────────────────────────────── + self.ui_local_services(ui); + + ui.add_space(16.0); + ui.separator(); + ui.add_space(8.0); + + // ── Game launch ─────────────────────────────────────────────────────── + ui.heading("Game"); + ui.add_space(6.0); + + let launch_config = self.config.validate_launch_config(); + let hook_ready = self.hook_deployed; + let can_launch = launch_config.is_ok() && hook_ready; + + egui::Grid::new("game_status_grid") .num_columns(2) .spacing([12.0, 4.0]) .show(ui, |ui| { ui.label("Hook DLL:"); - if self.hook_deployed { + if hook_ready { ui.colored_label(Color32::from_rgb(80, 200, 120), "Deployed (version.dll)"); } else { - ui.colored_label(Color32::from_rgb(220, 150, 0), "Not deployed"); + ui.colored_label( + Color32::from_rgb(220, 150, 0), + "Not deployed — see Setup tab", + ); } ui.end_row(); - ui.label("Bridge cert:"); - match &self.cert_path { - Some(p) => { - ui.colored_label( - Color32::from_rgb(80, 200, 120), - format!("Found ({})", p.file_name().unwrap_or_default().to_string_lossy()), - ); - } - None => { - ui.colored_label(Color32::from_rgb(220, 150, 0), "Not generated yet — start bridge first"); - } + ui.label("Launch command:"); + if self.config.game_profile.configured() { + ui.monospace(format!( + "{} {}", + self.config.game_profile.runner, self.config.game_profile.executable + )); + } else if can_launch { + ui.monospace(&self.config.game_launch_command); + } else { + ui.colored_label( + Color32::from_rgb(220, 150, 0), + "Not set — configure it in the Config tab", + ); } ui.end_row(); - - ui.label("Core URL:"); - ui.monospace(&self.config.core_listen_addr); - ui.end_row(); - - ui.label("Bridge URL:"); - ui.monospace(&self.config.bridge_listen_addr); - ui.end_row(); }); + + ui.add_space(10.0); + self.preflight_ui(ui); + ui.add_space(10.0); + + if ui + .add_enabled( + can_launch, + egui::Button::new( + RichText::new("▶ Start Local Services & Launch Game").size(16.0), + ) + .min_size(Vec2::new(160.0, 40.0)), + ) + .clicked() + { + self.launch_game(); + } + + if let Err(message) = &launch_config { + ui.add_space(4.0); + ui.colored_label(Color32::from_rgb(220, 150, 0), message); + } + + if !server_ok { + ui.add_space(4.0); + ui.colored_label( + Color32::from_rgb(220, 150, 0), + "Tip: the game can launch, but without a configured/reachable server \ + FUT features won't connect.", + ); + } + } + + /// Dashboard section for the two client-side FIFA 17 daemons. + fn ui_local_services(&mut self, ui: &mut Ui) { + use crate::local_services::Service; + + ui.heading("FIFA 17 local services"); + ui.add_space(4.0); + ui.label( + RichText::new( + "LSX (Origin emulator, loopback 4216) and autopatch (ProtoSSL cert-verify) \ + run on THIS machine — the game needs them locally. The Blaze/UTAS/roster/POW \ + responders run in the server container. Start these before launching the game.", + ) + .weak() + .small(), + ); + ui.add_space(6.0); + + let configured = !self.config.fifa17_tools_dir.trim().is_empty(); + if !configured { + ui.colored_label( + Color32::from_rgb(220, 150, 0), + "FIFA 17 tools dir not set — configure it in the Config tab.", + ); + return; + } + + let lsx_running = self.lsx.running(&self.game_logs, Service::Lsx.label()); + let ap_running = self + .autopatch + .running(&self.game_logs, Service::Autopatch.label()); + let lsx_stopping = self.lsx.stopping(); + let ap_stopping = self.autopatch.stopping(); + + egui::Grid::new("local_services_grid") + .num_columns(3) + .spacing([12.0, 8.0]) + .show(ui, |ui| { + // LSX row + ui.strong("LSX"); + if lsx_stopping { + ui.colored_label(Color32::from_rgb(220, 150, 0), "◌ Stopping"); + } else if lsx_running { + ui.colored_label(Color32::from_rgb(80, 200, 120), "● Running"); + } else { + ui.colored_label(Color32::from_rgb(150, 150, 150), "○ Stopped"); + } + if lsx_stopping { + ui.add_enabled(false, egui::Button::new("Stopping…")); + } else if lsx_running { + if ui.button("Stop").clicked() { + self.lsx.stop(&self.game_logs, Service::Lsx); + } + } else if ui.button("Start").clicked() { + let _ = self.start_local_service(Service::Lsx); + } + ui.end_row(); + + // autopatch row + ui.strong("autopatch"); + if ap_stopping { + ui.colored_label(Color32::from_rgb(220, 150, 0), "◌ Stopping"); + } else if ap_running { + ui.colored_label(Color32::from_rgb(80, 200, 120), "● Running"); + } else { + ui.colored_label(Color32::from_rgb(150, 150, 150), "○ Stopped"); + } + if ap_stopping { + ui.add_enabled(false, egui::Button::new("Stopping…")); + } else if ap_running { + if ui.button("Stop").clicked() { + self.autopatch.stop(&self.game_logs, Service::Autopatch); + } + } else if ui.button("Start").clicked() { + let _ = self.start_local_service(Service::Autopatch); + } + ui.end_row(); + }); + + ui.add_space(6.0); + if ui + .button(RichText::new("Start both local services").size(14.0)) + .clicked() + { + if !lsx_running { + let _ = self.start_local_service(Service::Lsx); + } + if !ap_running { + let _ = self.start_local_service(Service::Autopatch); + } + } + + if let Some((ok, msg)) = &self.local_services_message { + let color = if *ok { + Color32::from_rgb(80, 200, 120) + } else { + Color32::from_rgb(220, 90, 90) + }; + ui.add_space(4.0); + ui.colored_label(color, msg); + } + } + + /// The pre-launch checklist. + /// + /// Advisory by design: a failing check colours the row red and explains the + /// fix, but never disables Launch. Every one of these checks can itself be + /// wrong, and a wrong check that locks the user out of their own game is a + /// worse failure than the one it is guarding against. + fn preflight_ui(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + if ui.button("Run pre-launch checks").clicked() { + self.preflight = Some(preflight::run(&self.config)); + } + if ui + .button("Arm client") + .on_hover_text( + "Sets ptrace_scope, the EA-redirector DNAT, and /etc/hosts in one step \ + (asks for your password once). Replaces client_arm.sh.", + ) + .clicked() + { + match crate::arm::arm(&self.config) { + Ok(summary) => { + { + let mut logs = self.game_logs.lock().unwrap(); + logs.push("[launcher] client armed:".to_string()); + for line in &summary { + logs.push(format!("[launcher] - {line}")); + } + } + self.arm_status = Some(( + true, + format!("Client armed — {} change(s) applied.", summary.len()), + )); + // Re-run the checklist so the result shows immediately. + self.preflight = Some(preflight::run(&self.config)); + } + Err(e) => { + self.game_logs + .lock() + .unwrap() + .push(format!("[launcher] arming failed: {e}")); + self.arm_status = Some((false, format!("Arming failed: {e}"))); + } + } + } + if let Some(checks) = &self.preflight { + let bad = preflight::failures(checks); + let warn = preflight::warnings(checks); + // States what was found, never what will happen. An earlier + // version predicted "the game will probably fail" on a warning + // and the game then reached the FUT hub — a checklist that + // overstates its own findings gets ignored. + let (color, text) = match (bad, warn) { + (0, 0) => ( + Color32::from_rgb(80, 200, 120), + "no problems found".to_string(), + ), + (0, w) => ( + Color32::from_rgb(220, 150, 0), + format!("{w} warning(s) — worth fixing, usually not fatal"), + ), + (b, 0) => ( + Color32::from_rgb(220, 90, 90), + format!("{b} problem(s) — expect the game to fail"), + ), + (b, w) => ( + Color32::from_rgb(220, 90, 90), + format!("{b} problem(s), {w} warning(s)"), + ), + }; + ui.colored_label(color, text); + } + }); + + if let Some((ok, msg)) = &self.arm_status { + let color = if *ok { + Color32::from_rgb(80, 200, 120) + } else { + Color32::from_rgb(220, 90, 90) + }; + ui.colored_label(color, msg); + } + + let Some(checks) = &self.preflight else { + return; + }; + ui.add_space(4.0); + for c in checks { + let (mark, color) = match c.state { + preflight::State::Pass => ("OK ", Color32::from_rgb(80, 200, 120)), + preflight::State::Warn => ("WARN", Color32::from_rgb(220, 150, 0)), + preflight::State::Fail => ("FAIL", Color32::from_rgb(220, 90, 90)), + // Grey, never green: "not checked" must not read as "fine". + preflight::State::Skipped => ("-- ", Color32::from_gray(140)), + }; + ui.horizontal(|ui| { + ui.colored_label(color, RichText::new(mark).monospace()); + ui.colored_label(color, &c.name); + ui.label(RichText::new(&c.detail).weak()); + }); + } + } + + fn launch_game(&mut self) { + if let Err(message) = self.config.validate_launch_config() { + self.game_logs + .lock() + .unwrap() + .push(format!("[launcher] launch blocked: {message}")); + self.local_services_message = Some((false, message)); + self.active_tab = Tab::Logs; + return; + } + if !self.hook_deployed { + let message = "Hook DLL is not deployed. Complete Setup before launching.".to_string(); + self.game_logs + .lock() + .unwrap() + .push(format!("[launcher] launch blocked: {message}")); + self.local_services_message = Some((false, message)); + self.active_tab = Tab::Logs; + return; + } + let account = match crate::account_sync::sync(&self.config) { + Ok(account) => account, + Err(message) => { + self.game_logs + .lock() + .unwrap() + .push(format!("[launcher] account sync failed: {message}")); + self.local_services_message = Some((false, message)); + self.active_tab = Tab::Logs; + return; + } + }; + self.game_logs.lock().unwrap().push(format!( + "[launcher] account synchronized: {}/{} level={} XP={} account-funds={} FUT-coins={} unopened-packs={}", + account.persona_id, + account.persona_name, + account.level, + account.experience, + account.account_funds, + account.coins, + account.unopened_packs, + )); + if let Err(message) = self.ensure_local_services() { + self.game_logs + .lock() + .unwrap() + .push(format!("[launcher] launch blocked: {message}")); + self.local_services_message = Some((false, message)); + self.active_tab = Tab::Logs; + return; + } + + // Prefer the native profile; fall back to the user's shell command. + // The fallback is why an existing setup keeps working after upgrading, + // and why a profile that misbehaves is recoverable without a rebuild. + let result = if self.config.game_profile.configured() { + game_launch::launch(&self.config.game_profile, &self.game_logs) + } else { + let cmd = self.config.game_launch_command.clone(); + let workdir = self.config.game_launch_workdir.clone(); + setup::launch_game(&cmd, &workdir, Arc::clone(&self.game_logs)) + }; + if let Err(e) = result { + self.game_logs + .lock() + .unwrap() + .push(format!("[launcher] launch failed: {e}")); + } + self.active_tab = Tab::Logs; + } + + /// Start one companion service, recording a user-facing result message. + fn ensure_local_services(&mut self) -> Result<(), String> { + use crate::local_services::Service; + + if !self.lsx.running(&self.game_logs, Service::Lsx.label()) { + self.start_local_service(Service::Lsx)?; + } + if !self + .autopatch + .running(&self.game_logs, Service::Autopatch.label()) + { + self.start_local_service(Service::Autopatch)?; + } + Ok(()) + } + + fn start_local_service(&mut self, which: crate::local_services::Service) -> Result<(), String> { + use crate::local_services::spawn; + let py = self.config.fifa17_python.clone(); + let dir = self.config.fifa17_tools_dir.clone(); + let slot = match which { + crate::local_services::Service::Lsx => &mut self.lsx, + crate::local_services::Service::Autopatch => &mut self.autopatch, + }; + match spawn( + which, + &py, + &dir, + self.config.fut_persona_id, + &self.config.fut_persona_name, + Arc::clone(&self.game_logs), + ) { + Ok(child) => { + *slot = crate::local_services::ManagedService::from_child(child); + self.local_services_message = Some((true, format!("{} started.", which.label()))); + Ok(()) + } + Err(e) => { + let message = format!("{}: {e}", which.label()); + self.local_services_message = Some((false, message.clone())); + Err(message) + } + } } fn ui_logs(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { - ui.selectable_value(&mut self.log_tab, 0, "Core"); - ui.selectable_value(&mut self.log_tab, 1, "Bridge"); + ui.strong("Game / launcher output"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Clear").clicked() { - if self.log_tab == 0 { - self.core_logs.lock().unwrap().clear(); - } else { - self.bridge_logs.lock().unwrap().clear(); - } + self.game_logs.lock().unwrap().clear(); } ui.checkbox(&mut self.log_follow, "Follow"); }); }); ui.separator(); - let buf = if self.log_tab == 0 { - Arc::clone(&self.core_logs) - } else { - Arc::clone(&self.bridge_logs) - }; - let follow = self.log_follow; ScrollArea::vertical() .auto_shrink([false, false]) .stick_to_bottom(follow) .show(ui, |ui| { - let guard = buf.lock().unwrap(); + let guard = self.game_logs.lock().unwrap(); for line in guard.lines() { let color = log_line_color(line); ui.add( - egui::Label::new( - RichText::new(line).monospace().color(color).size(12.5), - ) - .wrap(), + egui::Label::new(RichText::new(line).monospace().color(color).size(12.5)) + .wrap(), ); } }); @@ -271,20 +595,138 @@ impl LauncherApp { use std::path::Path; ui.add_space(8.0); - ui.heading("System Setup"); + ui.heading("FIFA Integration Setup"); ui.add_space(4.0); - ui.label("These steps route FIFA 23 traffic through the emulator via DLL injection (no hosts file changes needed)."); + ui.label( + "These steps route the game's EA traffic to your OpenFUT server via DLL \ + injection (no hosts file changes needed).", + ); + ui.add_space(12.0); + + // ── Server address ──────────────────────────────────────────────────── + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.strong("Step 1 — OpenFUT server address"); + ui.add_space(4.0); + ui.label( + "The IP or hostname of your OpenFUT server. FIFA's intercepted EA \ + traffic is redirected here. No loopback default — an empty value \ + blocks setup.", + ); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ui.label("Server (IP or hostname):"); + let changed = ui + .add( + egui::TextEdit::singleline(&mut self.config.openfut_server_host) + .hint_text("e.g. 10.10.0.120 or fut.mylan.home") + .desired_width(220.0), + ) + .changed(); + if changed { + self.config_dirty = true; + self.test_message = None; + self.refresh_health_target(); + } + }); + ui.horizontal(|ui| { + ui.label("Ports:"); + ui.label("HTTPS"); + let mut p = self.config.openfut_https_port.to_string(); + if ui + .add(egui::TextEdit::singleline(&mut p).desired_width(64.0)) + .changed() + { + if let Ok(v) = p.parse() { + self.config.openfut_https_port = v; + } + self.config_dirty = true; + self.refresh_health_target(); + } + ui.label("Blaze-redir"); + let mut r = self.config.openfut_blaze_redirector_port.to_string(); + if ui + .add(egui::TextEdit::singleline(&mut r).desired_width(64.0)) + .changed() + { + if let Ok(v) = r.parse() { + self.config.openfut_blaze_redirector_port = v; + } + self.config_dirty = true; + } + ui.label("Blaze-main"); + let mut m = self.config.openfut_blaze_main_port.to_string(); + if ui + .add(egui::TextEdit::singleline(&mut m).desired_width(64.0)) + .changed() + { + if let Ok(v) = m.parse() { + self.config.openfut_blaze_main_port = v; + } + self.config_dirty = true; + } + }); + + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("Test Connection").clicked() { + match self.config.validate_server() { + Ok(()) => { + let outcome = netcheck::test_connection(&self.config.server_config()); + self.test_message = Some((outcome.ok, outcome.message)); + } + Err(msg) => self.test_message = Some((false, msg)), + } + } + if ui + .add_enabled(self.hook_deployed, egui::Button::new("Save & Update hook")) + .clicked() + { + match self.config.hook_cfg_contents() { + Ok(cfg) => match setup::update_hook_config( + Path::new(&self.config.fifa_game_dir), + &cfg, + ) { + Ok(()) => { + self.config.save(); + self.config_dirty = false; + self.setup_message = Some(( + true, + format!( + "Config updated — hook will redirect to {}.", + self.config.openfut_server_host + ), + )); + } + Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))), + }, + Err(msg) => self.setup_message = Some((false, msg)), + } + } + }); + if let Some((ok, msg)) = &self.test_message { + let color = if *ok { + Color32::from_rgb(80, 200, 120) + } else { + Color32::from_rgb(220, 90, 90) + }; + ui.colored_label(color, msg); + } + }); + ui.add_space(12.0); // ── Hook DLL deployment ─────────────────────────────────────────────── ui.group(|ui| { ui.set_min_width(ui.available_width()); - ui.strong("Step 1 — Deploy network hook DLL"); + ui.strong("Step 2 — Deploy network hook DLL"); ui.add_space(4.0); - ui.label("Copies openfut_hook.dll into the FIFA 23 folder as version.dll. \ - When Proton loads the game it will intercept network calls and redirect \ - EA hostnames to the local bridge — no hosts file changes required."); - + ui.label( + "Copies openfut_hook.dll into the game folder as version.dll. When \ + Proton loads the game it intercepts network calls and redirects EA \ + hostnames to your OpenFUT server — no hosts file changes required.", + ); ui.add_space(6.0); let game_dir = Path::new(&self.config.fifa_game_dir); @@ -296,7 +738,7 @@ impl LauncherApp { if !dll_built { ui.colored_label( Color32::from_rgb(220, 150, 0), - "⚠ Hook DLL not built yet. Run in openfut-hook/:", + "⚠ Hook DLL not built yet. Build in openfut-hook/:", ); ui.monospace("cargo build --release --target x86_64-pc-windows-gnu"); ui.add_space(4.0); @@ -318,60 +760,36 @@ impl LauncherApp { } else { ui.colored_label(Color32::from_rgb(220, 60, 60), "✘ Not deployed"); ui.add_space(8.0); - if ui.add_enabled(dll_built, egui::Button::new("Deploy")).clicked() { - match setup::deploy_hook_dll( - dll_src, - game_dir, - &self.config.hook_redirect_ip, - ) { - Ok(()) => { - self.setup_message = Some((true, "Hook DLL deployed as version.dll.".into())); - self.hook_deployed = true; - } - Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))), + if ui + .add_enabled(dll_built, egui::Button::new("Deploy")) + .clicked() + { + match self.config.hook_cfg_contents() { + Ok(cfg) => match setup::deploy_hook_dll(dll_src, game_dir, &cfg) { + Ok(()) => { + self.setup_message = + Some((true, "Hook DLL deployed as version.dll.".into())); + self.hook_deployed = true; + } + Err(e) => { + self.setup_message = Some((false, format!("Failed: {e}"))) + } + }, + Err(msg) => self.setup_message = Some((false, msg)), } } } }); ui.add_space(8.0); - - // IP configuration — always editable; Update writes openfut.cfg in-place - ui.horizontal(|ui| { - ui.label("Redirect IP:"); - let changed = ui - .add(egui::TextEdit::singleline(&mut self.config.hook_redirect_ip) - .desired_width(160.0)) - .changed(); - if changed { - self.config_dirty = true; - } - if ui.add_enabled(self.hook_deployed, egui::Button::new("Update")).clicked() { - match setup::update_hook_config( - Path::new(&self.config.fifa_game_dir), - &self.config.hook_redirect_ip, - ) { - Ok(()) => { - self.config.save(); - self.config_dirty = false; - self.setup_message = Some((true, format!( - "Config updated — hook will redirect to {}.", - self.config.hook_redirect_ip - ))); - } - Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))), - } - } - }); - ui.label(egui::RichText::new("Tip: use 127.0.0.1 if the emulator is on this machine, or the LAN IP if it's on another.") - .weak().small()); - - ui.add_space(8.0); - ui.separator(); - ui.add_space(4.0); - ui.strong("Steam Launch Options"); - ui.label("Paste this into FIFA 23 → Properties → Launch Options in Steam:"); - ui.add_space(2.0); + ui.label( + RichText::new( + "For Steam, paste this into the game's Launch Options; for a custom \ + launch script, export it before running the game:", + ) + .weak() + .small(), + ); let launch_opt = setup::STEAM_LAUNCH_OPTIONS; ui.horizontal(|ui| { ui.monospace(launch_opt); @@ -383,73 +801,16 @@ impl LauncherApp { ui.add_space(12.0); - // ── Port 443 capability ─────────────────────────────────────────────── - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.strong("Step 2 — Grant port 443 capability"); - ui.add_space(4.0); - ui.label("Allows the bridge to bind port 443 directly — the same port FIFA 23 \ - uses for HTTPS — without running as root. No iptables rules needed."); - ui.add_space(6.0); - - // Clone to avoid holding a borrow on config while we mutate it below. - let binary_path = std::path::PathBuf::from(&self.config.bridge_binary); - self.bridge_has_cap = setup::bridge_has_cap443(&binary_path); - - ui.horizontal(|ui| { - if self.bridge_has_cap { - ui.colored_label(Color32::from_rgb(80, 200, 120), "✔ cap_net_bind_service granted"); - } else { - ui.colored_label(Color32::from_rgb(220, 60, 60), "✘ Not set"); - ui.add_space(8.0); - let binary_exists = binary_path.exists(); - if ui - .add_enabled(binary_exists, egui::Button::new("Grant (requires sudo)")) - .clicked() - { - match setup::setcap_bridge_443(&binary_path) { - Ok(()) => { - self.bridge_has_cap = true; - // Switch listen addr to 443 automatically - self.config.bridge_listen_addr = "0.0.0.0:443".into(); - self.config.save(); - self.setup_message = Some(( - true, - "cap_net_bind_service granted. Bridge listen addr set to 0.0.0.0:443.".into(), - )); - } - Err(e) => self.setup_message = Some((false, format!("setcap failed: {e}"))), - } - } - if !binary_exists { - ui.add_space(4.0); - ui.colored_label( - Color32::from_rgb(220, 150, 0), - "⚠ Bridge binary not found — build it first.", - ); - } - } - }); - - ui.add_space(4.0); - ui.label( - egui::RichText::new( - "Re-run this step any time the bridge binary is rebuilt (setcap is cleared on recompile).", - ) - .weak() - .small(), - ); - }); - - ui.add_space(12.0); - // ── Cert install ────────────────────────────────────────────────────── ui.group(|ui| { ui.set_min_width(ui.available_width()); ui.strong("Step 3 — Install TLS certificate"); ui.add_space(4.0); - ui.label("Installs the bridge's self-signed cert into the Wine/Proton cert store \ - so the game accepts HTTPS connections to the bridge."); + ui.label( + "Installs the bridge's self-signed cert into the Wine/Proton cert store \ + so the game accepts HTTPS connections to the bridge. The cert file must \ + be reachable at the configured captures path (copy it from the server).", + ); ui.add_space(6.0); self.cert_path = setup::find_bridge_cert(&self.config.bridge_captures_dir); @@ -458,7 +819,8 @@ impl LauncherApp { None => { ui.colored_label( Color32::from_rgb(220, 150, 0), - "⚠ Cert not found — start the bridge at least once to generate it.", + "⚠ Cert not found at the captures path. Copy bridge_cert.pem \ + from the server into that directory.", ); } Some(cert) => { @@ -466,7 +828,9 @@ impl LauncherApp { ui.add_space(4.0); if ui.button("Install cert (Wine cert store)").clicked() { match setup::install_cert(cert) { - Ok(()) => self.setup_message = Some((true, "Certificate installed.".into())), + Ok(()) => { + self.setup_message = Some((true, "Certificate installed.".into())) + } Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))), } } @@ -492,58 +856,160 @@ impl LauncherApp { ui.add_space(8.0); let mut changed = false; + let mut server_changed = false; egui::Grid::new("config_grid") .num_columns(2) .spacing([12.0, 8.0]) - .min_col_width(120.0) + .min_col_width(140.0) .show(ui, |ui| { - ui.strong("Core"); + ui.strong("Game"); ui.label(""); ui.end_row(); - ui.label("Binary path:"); - changed |= ui.text_edit_singleline(&mut self.config.core_binary).changed(); + ui.label("Launch command:"); + changed |= ui + .add( + egui::TextEdit::singleline(&mut self.config.game_launch_command) + .hint_text("e.g. ~/Desktop/launch-fifa17.sh"), + ) + .changed(); ui.end_row(); - ui.label("Listen addr:"); - changed |= ui.text_edit_singleline(&mut self.config.core_listen_addr).changed(); + ui.label("Launch workdir:"); + changed |= ui + .add( + egui::TextEdit::singleline(&mut self.config.game_launch_workdir) + .hint_text("optional, e.g. /mnt/games/FIFA 17"), + ) + .changed(); ui.end_row(); - ui.label("Database URL:"); - changed |= ui.text_edit_singleline(&mut self.config.core_database_url).changed(); + ui.label("FIFA game dir:"); + changed |= ui + .text_edit_singleline(&mut self.config.fifa_game_dir) + .changed(); ui.end_row(); - ui.label("Data dir:"); - changed |= ui.text_edit_singleline(&mut self.config.core_data_dir).changed(); + ui.label("FIFA17 tools dir:"); + changed |= ui + .add( + egui::TextEdit::singleline(&mut self.config.fifa17_tools_dir) + .hint_text("fifa17-recon/tools (LSX + autopatch scripts)"), + ) + .changed(); + ui.end_row(); + + ui.label("Python:"); + changed |= ui + .add( + egui::TextEdit::singleline(&mut self.config.fifa17_python) + .hint_text("python3"), + ) + .changed(); ui.end_row(); ui.label(""); ui.label(""); ui.end_row(); - ui.strong("Bridge"); + ui.strong("OpenFUT server"); ui.label(""); ui.end_row(); - ui.label("Binary path:"); - changed |= ui.text_edit_singleline(&mut self.config.bridge_binary).changed(); + ui.label("Server host:"); + let r = ui.text_edit_singleline(&mut self.config.openfut_server_host); + if r.changed() { + changed = true; + server_changed = true; + } ui.end_row(); - ui.label("Listen addr:"); - changed |= ui.text_edit_singleline(&mut self.config.bridge_listen_addr).changed(); + ui.label("HTTPS port:"); + let mut p = self.config.openfut_https_port.to_string(); + if ui.text_edit_singleline(&mut p).changed() { + if let Ok(v) = p.parse() { + self.config.openfut_https_port = v; + } + changed = true; + server_changed = true; + } ui.end_row(); - ui.label("Core URL:"); - changed |= ui.text_edit_singleline(&mut self.config.bridge_core_url).changed(); + ui.label("Account/UTAS port:"); + let mut p = self.config.openfut_account_sync_port.to_string(); + if ui.text_edit_singleline(&mut p).changed() { + if let Ok(v) = p.parse() { + self.config.openfut_account_sync_port = v; + } + changed = true; + } + ui.end_row(); + + ui.label(""); + ui.label(""); + ui.end_row(); + + ui.strong("EA / Origin account"); + ui.label(""); + ui.end_row(); + + ui.label("Persona ID:"); + changed |= ui + .add(egui::DragValue::new(&mut self.config.fut_persona_id).speed(1)) + .changed(); + ui.end_row(); + + ui.label("Persona name:"); + changed |= ui + .add( + egui::TextEdit::singleline(&mut self.config.fut_persona_name) + .hint_text("EA/Origin display name"), + ) + .changed(); + ui.end_row(); + + ui.label("Account-bar level:"); + changed |= ui + .add( + egui::DragValue::new(&mut self.config.fut_account_level) + .range(1..=u32::MAX), + ) + .changed(); + ui.end_row(); + + ui.label("Account-bar XP:"); + ui.horizontal(|ui| { + changed |= ui + .add(egui::DragValue::new( + &mut self.config.fut_account_experience, + )) + .changed(); + ui.label("/"); + changed |= ui + .add(egui::DragValue::new( + &mut self.config.fut_account_experience_max, + )) + .changed(); + }); + ui.end_row(); + + ui.label("Account-bar funds:"); + ui.horizontal(|ui| { + changed |= ui + .add(egui::DragValue::new(&mut self.config.fut_account_funds)) + .changed(); + ui.label("/ cap"); + changed |= ui + .add(egui::DragValue::new(&mut self.config.fut_account_funds_cap)) + .changed(); + }); ui.end_row(); ui.label("Captures dir:"); - changed |= ui.text_edit_singleline(&mut self.config.bridge_captures_dir).changed(); - ui.end_row(); - - ui.label("TLS enabled:"); - changed |= ui.checkbox(&mut self.config.bridge_tls_enabled, "").changed(); + changed |= ui + .text_edit_singleline(&mut self.config.bridge_captures_dir) + .changed(); ui.end_row(); ui.label(""); @@ -555,21 +1021,18 @@ impl LauncherApp { ui.end_row(); ui.label("Hook DLL path:"); - changed |= ui.text_edit_singleline(&mut self.config.hook_dll_path).changed(); - ui.end_row(); - - ui.label("FIFA 23 game dir:"); - changed |= ui.text_edit_singleline(&mut self.config.fifa_game_dir).changed(); - ui.end_row(); - - ui.label("Redirect IP:"); - changed |= ui.text_edit_singleline(&mut self.config.hook_redirect_ip).changed(); + changed |= ui + .text_edit_singleline(&mut self.config.hook_dll_path) + .changed(); ui.end_row(); }); if changed { self.config_dirty = true; } + if server_changed { + self.refresh_health_target(); + } ui.add_space(12.0); @@ -580,24 +1043,19 @@ impl LauncherApp { { self.config.save(); self.config_dirty = false; + self.refresh_health_target(); } if ui.button("Reset to defaults").clicked() { self.config = LauncherConfig::default(); self.config_dirty = true; + self.refresh_health_target(); } }); } - - fn status_badge(&self, ui: &mut Ui, status: &ServiceStatus) { - let color = status.color(); - let label = status.label(); - ui.colored_label(color, label); - } } impl eframe::App for LauncherApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Repaint regularly to keep log views and status fresh ctx.request_repaint_after(std::time::Duration::from_millis(500)); egui::TopBottomPanel::top("tab_bar").show(ctx, |ui| { diff --git a/src/arm.rs b/src/arm.rs new file mode 100644 index 0000000..7c70e91 --- /dev/null +++ b/src/arm.rs @@ -0,0 +1,239 @@ +//! One-click client arming — the GUI equivalent of `client_arm.sh`, driven by +//! [`LauncherConfig`] so it repairs exactly what [`crate::preflight`] checks. +//! +//! Everything the game reaches by a routable address is redirected to the +//! OpenFUT server; two things are inherently local and are NOT touched here (they +//! are managed as child processes, see [`crate::local_services`]): the LSX/Origin +//! emulator on loopback `:4216` and `autopatch`. +//! +//! The three privileged steps run in ONE elevated batch (a single `pkexec` +//! prompt), mirroring the volatile state `client_arm.sh` set by hand: +//! +//! 1. `kernel.yama.ptrace_scope=0` — so `autopatch` can write FIFA's `/proc/PID/mem`. +//! 2. DNAT EA's hardcoded redirector IP → `server:redirector_port` (+ MASQUERADE +//! on the reply path, required for a DNAT to a remote host). +//! 3. Point each dead EA hostname at the server in `/etc/hosts`. +//! +//! All of it is idempotent: the DNAT deletes any prior copy before adding, and +//! every `/etc/hosts` line for a managed hostname is removed first — including a +//! foreign single-machine-era `127.0.0.1 easw.easports.com` shadow that +//! `client_arm.sh` could not remove, because it only deleted its own `# openfut` +//! lines and glibc returns the FIRST match. + +use crate::config::LauncherConfig; + +/// Accept only hostname/IP characters. These values come from config fields that +/// are ever only IPs or hostnames, so a surprising character is a bug — reject it +/// rather than try to escape it into an elevated shell command. +fn safe_host(s: &str) -> anyhow::Result<&str> { + let t = s.trim(); + if t.is_empty() { + anyhow::bail!("empty host/address"); + } + if t.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b':' | b'-' | b'_')) + { + Ok(t) + } else { + anyhow::bail!("refusing to arm with an unexpected character in {t:?}"); + } +} + +/// Build the privileged arming script. Pure and unit-tested; the effectful part +/// ([`arm`]) only validates config and hands this to the elevated runner. +pub(crate) fn arming_script( + server: &str, + redirector_port: u16, + ea_ip: &str, + hostnames: &[String], +) -> anyhow::Result { + let server = safe_host(server)?; + let ea_ip = safe_host(ea_ip)?; + + let mut s = String::from("set -eu\n"); + + // 1) ptrace_scope for autopatch's /proc/PID/mem write. + s.push_str("sysctl -q kernel.yama.ptrace_scope=0\n"); + + // 2) DNAT EA's hardcoded redirector IP to the server; SNAT the redirected + // flow (a DNAT from OUTPUT to a remote host needs a matching MASQUERADE or + // the server's replies won't match the game's conntrack entry). Both are + // delete-then-add so re-running and IP changes stay clean. + s.push_str(&format!( + "while iptables -t nat -D OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port} 2>/dev/null; do :; done\n\ + iptables -t nat -A OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port}\n\ + while iptables -t nat -D POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE 2>/dev/null; do :; done\n\ + iptables -t nat -A POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE\n" + )); + + // 3) Every dead EA hostname resolves to the server. Delete ALL existing lines + // listing the name (foreign shadow included) BEFORE writing ours, so the + // first-match-wins resolution can never land on a stale loopback line. + for host in hostnames { + let host = safe_host(host)?; + let re = host.replace('.', "\\."); + s.push_str(&format!( + "sed -ri '/[[:space:]]{re}([[:space:]]|$)/d' /etc/hosts\n\ + printf '%s\\t%s\\t# openfut\\n' '{server}' '{host}' >> /etc/hosts\n" + )); + } + + Ok(s) +} + +/// Human-readable list of what [`arm`] changed, in the order the script applies +/// it. Logged by the UI so the user sees exactly what was set — not just that +/// "something" ran under `pkexec`. +pub(crate) fn arming_summary( + server: &str, + redirector_port: u16, + ea_ip: &str, + hostnames: &[String], +) -> Vec { + let mut out = vec![ + "kernel.yama.ptrace_scope = 0 (autopatch can attach)".to_string(), + format!("DNAT {ea_ip} -> {server}:{redirector_port} (+ MASQUERADE reply path)"), + ]; + for host in hostnames { + out.push(format!("hosts: {host} -> {server}")); + } + out +} + +/// Arm the client from config, under one elevated prompt. Requires the same +/// fields preflight reads; a missing one is a clear error, never a silent +/// loopback fallback. Returns the applied changes for the UI to surface. +pub fn arm(cfg: &LauncherConfig) -> anyhow::Result> { + let server = cfg.openfut_server_host.trim(); + if server.is_empty() { + anyhow::bail!("Set the OpenFUT server host in the Config tab before arming."); + } + let ea_ip = cfg.ea_redirect_probe_ip.trim(); + if ea_ip.is_empty() { + anyhow::bail!("Set the EA redirector IP (Config tab) before arming."); + } + if cfg.ea_hostnames.is_empty() { + anyhow::bail!("Add at least one EA hostname (e.g. easw.easports.com) in the Config tab before arming."); + } + let redirector_port = cfg.openfut_blaze_redirector_port; + let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?; + crate::setup::run_elevated(&script)?; + Ok(arming_summary( + server, + redirector_port, + ea_ip, + &cfg.ea_hostnames, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn script() -> String { + arming_script( + "10.10.0.120", + 42127, + "159.153.51.20", + &["easw.easports.com".to_string()], + ) + .unwrap() + } + + #[test] + fn sets_ptrace_scope_zero() { + assert!(script().contains("sysctl -q kernel.yama.ptrace_scope=0")); + } + + #[test] + fn dnats_ea_ip_to_server_and_masquerades() { + let s = script(); + assert!(s.contains( + "iptables -t nat -A OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 10.10.0.120:42127" + )); + assert!(s.contains( + "iptables -t nat -A POSTROUTING -p tcp -d 10.10.0.120 --dport 42127 -j MASQUERADE" + )); + } + + #[test] + fn dnat_is_delete_then_add_for_idempotence() { + let s = script(); + // The delete loop precedes the add, so re-arming never stacks duplicates. + let del = s.find("-D OUTPUT").unwrap(); + let add = s.find("-A OUTPUT").unwrap(); + assert!(del < add, "delete must run before add"); + } + + #[test] + fn removes_shadowing_hosts_line_before_writing_ours() { + let s = script(); + // Deletes any existing easw.easports.com line (foreign shadow included)… + assert!( + s.contains("sed -ri '/[[:space:]]easw\\.easports\\.com([[:space:]]|$)/d' /etc/hosts") + ); + // …then appends the OpenFUT-tagged mapping to the server. + assert!(s.contains( + "printf '%s\\t%s\\t# openfut\\n' '10.10.0.120' 'easw.easports.com' >> /etc/hosts" + )); + let del = s.find("sed -ri").unwrap(); + let add = s.find("printf").unwrap(); + assert!(del < add, "shadow removal must precede our line"); + } + + #[test] + fn multiple_hostnames_each_get_a_mapping() { + let s = arming_script( + "10.10.0.120", + 42127, + "159.153.51.20", + &["easw.easports.com".into(), "utas.fut.ea.com".into()], + ) + .unwrap(); + assert!(s.contains("'easw.easports.com' >> /etc/hosts")); + assert!(s.contains("'utas.fut.ea.com' >> /etc/hosts")); + } + + #[test] + fn rejects_shell_metacharacters_in_config() { + assert!(arming_script("10.0.0.1; rm -rf /", 42127, "159.153.51.20", &[]).is_err()); + assert!(arming_script("10.0.0.1", 42127, "$(evil)", &[]).is_err()); + assert!( + arming_script("10.0.0.1", 42127, "159.153.51.20", &["a b`c`".into()]).is_err(), + "a hostname with a backtick is rejected" + ); + } + + #[test] + fn arm_requires_server_ea_ip_and_hostname() { + let mut c = LauncherConfig::default(); + assert!(arm(&c).unwrap_err().to_string().contains("server host")); + c.openfut_server_host = "10.10.0.120".into(); + assert!(arm(&c) + .unwrap_err() + .to_string() + .contains("EA redirector IP")); + c.ea_redirect_probe_ip = "159.153.51.20".into(); + assert!(arm(&c).unwrap_err().to_string().contains("EA hostname")); + } + + #[test] + fn summary_lists_ptrace_dnat_and_each_host() { + let s = arming_summary( + "10.10.0.120", + 42127, + "159.153.51.20", + &["easw.easports.com".into(), "utas.fut.ea.com".into()], + ); + assert!(s.iter().any(|l| l.contains("ptrace_scope = 0"))); + assert!(s + .iter() + .any(|l| l.contains("DNAT 159.153.51.20 -> 10.10.0.120:42127"))); + assert!(s + .iter() + .any(|l| l == "hosts: easw.easports.com -> 10.10.0.120")); + assert!(s + .iter() + .any(|l| l == "hosts: utas.fut.ea.com -> 10.10.0.120")); + } +} diff --git a/src/config.rs b/src/config.rs index be9e540..19d7170 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,101 @@ use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// One `dosdevices` entry to create inside the Wine prefix before launching. +/// `link` is relative to the prefix (e.g. `dosdevices/w:`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PrefixLink { + pub link: String, + pub target: String, +} + +/// A DRM licence file the game refuses to start without, and the executable +/// that recreates it. A crashed launch deletes the licence, so this is a +/// per-launch precondition rather than a one-time setup step. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LicenseCheck { + /// Absolute, or relative to the Wine prefix. + pub path: String, + /// Executable run through the profile's runner to regenerate it. + pub generator: String, + #[serde(default = "default_license_timeout")] + pub timeout_secs: u64, +} + +/// Everything needed to start one game, as data. +/// +/// This is what keeps the launcher game-independent: FIFA 17's runner, prefix, +/// executable, `w:` drive and licence id live here in the user's config, never +/// in launcher code. An unconfigured profile means "fall back to +/// `game_launch_command`", so upgrading cannot break a working setup. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct GameProfile { + /// Program that starts the game (e.g. `umu-run`). Empty = profile unused. + #[serde(default)] + pub runner: String, + /// Argument passed to the runner (e.g. `FIFA17.exe`). + #[serde(default)] + pub executable: String, + /// Working directory the runner is started from. + #[serde(default)] + pub game_dir: String, + /// `WINEPREFIX` for the game. Exported automatically when set. + #[serde(default)] + pub wine_prefix: String, + /// Extra environment for the runner (`GAMEID`, `PROTONPATH`, …). + #[serde(default)] + pub env: BTreeMap, + #[serde(default)] + pub prefix_links: Vec, + #[serde(default)] + pub license: Option, +} + +impl GameProfile { + /// Whether this profile is filled in enough to launch from. + pub fn configured(&self) -> bool { + !self.runner.trim().is_empty() + && !self.executable.trim().is_empty() + && !self.game_dir.trim().is_empty() + } + + /// Reject a half-filled profile rather than launching something surprising. + pub fn validate(&self) -> Result<(), String> { + if self.runner.trim().is_empty() { + return Err("Game profile has no runner (e.g. umu-run).".into()); + } + if self.executable.trim().is_empty() { + return Err("Game profile has no executable.".into()); + } + if self.game_dir.trim().is_empty() { + return Err("Game profile has no game directory.".into()); + } + if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() { + return Err("Game profile defines prefix links but no wine_prefix.".into()); + } + for l in &self.prefix_links { + if l.link.trim().is_empty() || l.target.trim().is_empty() { + return Err("Game profile has a prefix link with an empty link or target.".into()); + } + if std::path::Path::new(&l.link).is_absolute() { + return Err(format!( + "Prefix link {:?} must be relative to the Wine prefix.", + l.link + )); + } + } + if let Some(lic) = &self.license { + if lic.path.trim().is_empty() || lic.generator.trim().is_empty() { + return Err("Game profile licence needs both a path and a generator.".into()); + } + } + Ok(()) + } +} + +fn default_license_timeout() -> u64 { + 60 +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LauncherConfig { @@ -15,8 +112,104 @@ pub struct LauncherConfig { pub hook_dll_path: String, /// FIFA 23 game folder inside the Proton prefix (where the DLL is deployed). pub fifa_game_dir: String, - /// IP the hook DLL redirects EA hostnames to (written to openfut.cfg). - pub hook_redirect_ip: String, + /// The OpenFUT server FIFA's EA traffic is redirected to. IPv4 literal or + /// hostname. Empty means "not configured" — launching is blocked until set. + /// There is intentionally NO loopback default. + #[serde(default, alias = "hook_redirect_ip")] + pub openfut_server_host: String, + /// OpenFUT destination port for intercepted EA :443 (bridge HTTPS). + #[serde(default = "default_https_port")] + pub openfut_https_port: u16, + /// OpenFUT destination port for intercepted EA :10041 (Blaze redirector). + #[serde(default = "default_blaze_redirector_port")] + pub openfut_blaze_redirector_port: u16, + /// OpenFUT destination port for intercepted EA :42127 (Blaze main). + #[serde(default = "default_blaze_main_port")] + pub openfut_blaze_main_port: u16, + /// Plain HTTP UTAS/control-plane port used to select the active account. + #[serde(default = "default_account_sync_port")] + pub openfut_account_sync_port: u16, + /// EA/Origin persona selected for this local single-player profile. + #[serde(default)] + pub fut_persona_id: u64, + #[serde(default)] + pub fut_persona_name: String, + /// EASFC/POW account-bar state (separate from FUT club coins). + #[serde(default = "default_account_level")] + pub fut_account_level: u32, + #[serde(default)] + pub fut_account_experience: u32, + #[serde(default = "default_account_experience_max")] + pub fut_account_experience_max: u32, + #[serde(default)] + pub fut_account_funds: u32, + #[serde(default = "default_account_funds_cap")] + pub fut_account_funds_cap: u32, + /// Shell command the launcher runs to start the game. Run via `sh -c`, from + /// `game_launch_workdir` if set. Empty means "not configured" — the Launch + /// Game button is disabled until the user provides one. This keeps the + /// launcher agnostic to Steam vs umu-run vs a custom script. + #[serde(default)] + pub game_launch_command: String, + /// Optional working directory for `game_launch_command`. Empty = inherit. + #[serde(default)] + pub game_launch_workdir: String, + /// Native launch definition. When [`GameProfile::configured`], the launcher + /// starts the game itself and `game_launch_command` is not used; the command + /// remains as a fallback so an existing setup keeps working after upgrade. + #[serde(default)] + pub game_profile: GameProfile, + + // ── Pre-launch checks (see `preflight`) ───────────────────────────────── + /// EA's hardcoded redirector IP, probed to confirm the client-side DNAT is + /// armed. Empty = the check is skipped. A game fact, so it is configuration. + #[serde(default)] + pub ea_redirect_probe_ip: String, + /// Dead EA hostnames that must resolve to `openfut_server_host`. + #[serde(default)] + pub ea_hostnames: Vec, + + // ── FIFA 17 local companion services (client-side, run on THIS machine) ── + // FIFA 17's FUT flow needs two pieces that are inherently local to the game + // box and cannot move to the server: the LSX Origin emulator (the game dials + // it on the hardcoded loopback 127.0.0.1:4216) and autopatch (patches + // FIFA17.exe process memory for ProtoSSL cert-verify). The launcher manages + // both as child processes. The heavy responders (Blaze/UTAS/roster/POW) run + // in the server container; these two stay here. + /// Directory holding the FIFA 17 Python responders (fifa17-recon `tools/`). + /// Empty means the local-services feature is unconfigured and its controls + /// stay disabled. + #[serde(default)] + pub fifa17_tools_dir: String, + /// Python interpreter used to run the local companion services. + #[serde(default = "default_python")] + pub fifa17_python: String, +} + +fn default_python() -> String { + "python3".to_string() +} + +fn default_https_port() -> u16 { + openfut_common::default_ports::HTTPS +} +fn default_blaze_redirector_port() -> u16 { + openfut_common::default_ports::BLAZE_REDIRECTOR +} +fn default_blaze_main_port() -> u16 { + openfut_common::default_ports::BLAZE_MAIN +} +fn default_account_sync_port() -> u16 { + 8099 +} +fn default_account_level() -> u32 { + 1 +} +fn default_account_experience_max() -> u32 { + 1000 +} +fn default_account_funds_cap() -> u32 { + 100_000 } impl Default for LauncherConfig { @@ -57,7 +250,32 @@ impl Default for LauncherConfig { .unwrap_or_default() .to_string_lossy() .into(), - hook_redirect_ip: "127.0.0.1".into(), + // No server configured by default — the user MUST enter one. There + // is deliberately no loopback/localhost default. + openfut_server_host: String::new(), + openfut_https_port: default_https_port(), + openfut_blaze_redirector_port: default_blaze_redirector_port(), + openfut_blaze_main_port: default_blaze_main_port(), + openfut_account_sync_port: default_account_sync_port(), + fut_persona_id: 0, + fut_persona_name: String::new(), + fut_account_level: default_account_level(), + fut_account_experience: 0, + fut_account_experience_max: default_account_experience_max(), + fut_account_funds: 0, + fut_account_funds_cap: default_account_funds_cap(), + game_launch_command: String::new(), + game_launch_workdir: String::new(), + // Empty by default, exactly like the server host: the launcher must + // never invent a path to somebody's game install. + game_profile: GameProfile::default(), + ea_redirect_probe_ip: String::new(), + ea_hostnames: Vec::new(), + fifa17_tools_dir: base + .join("fifa17-recon/tools") + .to_string_lossy() + .into(), + fifa17_python: default_python(), } } } @@ -88,22 +306,391 @@ impl LauncherConfig { } } - pub fn core_env(&self) -> Vec<(String, String)> { - vec![ - ("DATABASE_URL".into(), self.core_database_url.clone()), - ("DATA_DIR".into(), self.core_data_dir.clone()), - ("LISTEN_ADDR".into(), self.core_listen_addr.clone()), - ("RUST_LOG".into(), "openfut_core=info,tower_http=info".into()), - ] + /// The (host, port) the health monitor should poll, or None when no server + /// is configured. Uses the bridge HTTPS port — the port the FIFA client + /// actually connects to — so "reachable" means what the game will see. + pub fn health_target(&self) -> Option<(String, u16)> { + let host = self.openfut_server_host.trim(); + if host.is_empty() { + None + } else { + Some((host.to_string(), self.openfut_https_port)) + } } - pub fn bridge_env(&self) -> Vec<(String, String)> { - vec![ - ("CORE_URL".into(), self.bridge_core_url.clone()), - ("LISTEN_ADDR".into(), self.bridge_listen_addr.clone()), - ("CAPTURES_DIR".into(), self.bridge_captures_dir.clone()), - ("TLS_ENABLED".into(), self.bridge_tls_enabled.to_string()), - ("RUST_LOG".into(), "openfut_bridge=info".into()), - ] + /// Build the shared [`ServerConfig`] from the launcher's configured server + /// host + destination ports. This is the single place the launcher turns UI + /// fields into the canonical config consumed by the hook. + pub fn server_config(&self) -> openfut_common::ServerConfig { + openfut_common::ServerConfig { + host: self.openfut_server_host.trim().to_string(), + ports: openfut_common::OpenFutPorts { + https: self.openfut_https_port, + blaze_redirector: self.openfut_blaze_redirector_port, + blaze_main: self.openfut_blaze_main_port, + }, + } + } + + /// Validate the configured server (syntax only, no DNS). Returns the same + /// user-facing message the task specifies when nothing is configured. + pub fn validate_server(&self) -> Result<(), String> { + if self.openfut_server_host.trim().is_empty() { + return Err("No OpenFUT server configured. Please enter the hostname \ + or IP address of your OpenFUT server." + .to_string()); + } + self.server_config().validate().map_err(|e| e.to_string()) + } + + /// Validate the client-local FIFA 17 service configuration. Filesystem + /// existence is checked by the process launcher immediately before spawn; + /// this ensures required user configuration is never silently invented. + pub fn validate_local_services(&self) -> Result<(), String> { + if self.fifa17_tools_dir.trim().is_empty() { + return Err("No FIFA 17 tools dir configured. Set it in the Config tab.".into()); + } + if self.fifa17_python.trim().is_empty() { + return Err("No Python interpreter configured. Set it in the Config tab.".into()); + } + Ok(()) + } + + /// Validate every configuration value required by the one-button FIFA 17 + /// launch path. Runtime state such as hook deployment is checked by the UI. + pub fn validate_launch_config(&self) -> Result<(), String> { + self.validate_server()?; + self.validate_account()?; + // Either launch route is acceptable, but a half-filled profile is not: + // silently falling back to the shell command would hide the mistake, so + // ANY profile that has been touched must be complete. + if self.game_profile != GameProfile::default() { + self.game_profile.validate()?; + } else if self.game_launch_command.trim().is_empty() { + return Err( + "No game configured. Fill in the game profile, or set a launch command, \ + in the Config tab." + .into(), + ); + } + self.validate_local_services() + } + + pub fn validate_account(&self) -> Result<(), String> { + if self.fut_persona_id == 0 { + return Err("No EA persona ID configured. Set the account in the Config tab.".into()); + } + if self.fut_persona_name.trim().is_empty() { + return Err("No EA persona name configured. Set the account in the Config tab.".into()); + } + if self.fut_account_level == 0 { + return Err("EA account level must be at least 1.".into()); + } + if self.fut_account_experience_max == 0 + || self.fut_account_experience > self.fut_account_experience_max + { + return Err("EA account XP must not exceed a nonzero XP maximum.".into()); + } + if self.fut_account_funds > self.fut_account_funds_cap { + return Err("EA account funds must not exceed the funds cap.".into()); + } + Ok(()) + } + + /// The exact `openfut.cfg` bytes to write for the hook, or an error if the + /// server isn't validly configured (never emits a loopback fallback). + /// + /// The deployed FIFA 17 hook reads this structured format through the same + /// shared parser, so changing a destination port never requires recompiling + /// the DLL. Fixed EA source ports remain protocol signatures in the hook. + pub fn hook_cfg_contents(&self) -> Result { + self.validate_server()?; + Ok(self.server_config().to_cfg_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_has_no_server_and_blocks_launch() { + let c = LauncherConfig::default(); + assert!(c.openfut_server_host.is_empty()); + let err = c.validate_server().unwrap_err(); + assert!(err.contains("No OpenFUT server configured")); + assert!(c.hook_cfg_contents().is_err()); + } + + #[test] + fn configured_server_roundtrips_into_hook_cfg() { + let c = LauncherConfig { + openfut_server_host: "192.168.1.50".into(), + openfut_https_port: 9443, + openfut_blaze_redirector_port: 43127, + openfut_blaze_main_port: 43130, + ..LauncherConfig::default() + }; + let cfg = c + .hook_cfg_contents() + .expect("valid server should produce cfg"); + let parsed = openfut_common::ServerConfig::parse(&cfg).unwrap(); + assert_eq!(parsed.host, "192.168.1.50"); + assert_eq!(parsed.ports, c.server_config().ports); + } + + #[test] + fn changing_server_changes_hook_cfg_no_rebuild() { + // Models the Server A -> Server B acceptance test at the config layer: + // only the value changes; the same code path produces the new cfg. + let mut c = LauncherConfig { + openfut_server_host: "10.0.0.1".into(), + ..LauncherConfig::default() + }; + let a = c.hook_cfg_contents().unwrap(); + c.openfut_server_host = "10.0.0.2".into(); + let b = c.hook_cfg_contents().unwrap(); + assert_ne!(a, b); + assert_eq!( + openfut_common::ServerConfig::parse(&b).unwrap().host, + "10.0.0.2" + ); + } + + #[test] + fn health_target_none_until_configured() { + let mut c = LauncherConfig::default(); + assert!(c.health_target().is_none()); + c.openfut_server_host = "10.10.0.120".into(); + let (host, port) = c.health_target().expect("configured host yields a target"); + assert_eq!(host, "10.10.0.120"); + assert_eq!(port, c.openfut_https_port); + } + + #[test] + fn legacy_hook_redirect_ip_field_is_read() { + // Old configs stored the address under `hook_redirect_ip`; serde alias + // must map it onto the new field so upgrades keep working. + let json = r#"{ + "core_binary":"","bridge_binary":"","core_database_url":"", + "core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"", + "bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true, + "hook_dll_path":"","fifa_game_dir":"","hook_redirect_ip":"192.168.5.5" + }"#; + let c: LauncherConfig = serde_json::from_str(json).unwrap(); + assert_eq!(c.openfut_server_host, "192.168.5.5"); + } + + #[test] + fn local_services_require_tools_dir_and_python() { + let mut c = LauncherConfig::default(); + c.fifa17_tools_dir.clear(); + assert!(c + .validate_local_services() + .unwrap_err() + .contains("tools dir")); + + c.fifa17_tools_dir = "/tmp/fifa17-tools".into(); + c.fifa17_python.clear(); + assert!(c.validate_local_services().unwrap_err().contains("Python")); + } + + #[test] + fn local_services_accept_explicit_configuration() { + let c = LauncherConfig { + fifa17_tools_dir: "/tmp/fifa17-tools".into(), + fifa17_python: "/usr/bin/python3".into(), + ..LauncherConfig::default() + }; + assert!(c.validate_local_services().is_ok()); + } + + #[test] + fn launch_config_requires_server_local_services_and_command() { + let mut c = LauncherConfig::default(); + assert!(c.validate_launch_config().is_err()); + + c.openfut_server_host = "10.10.0.120".into(); + c.fut_persona_id = 12345678; + c.fut_persona_name = "TEST_USER".into(); + assert!(c + .validate_launch_config() + .unwrap_err() + .contains("launch command")); + + c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into(); + c.fifa17_tools_dir.clear(); + assert!(c + .validate_launch_config() + .unwrap_err() + .contains("tools dir")); + + c.fifa17_tools_dir = "/home/alex/Documents/OpenFUT/fifa17-recon/tools".into(); + c.fifa17_python = "/usr/bin/python3".into(); + assert!(c.validate_launch_config().is_ok()); + } + + /// An old config.json has no `game_profile` key at all. It must keep + /// launching exactly as before rather than failing to parse or silently + /// switching route. + #[test] + fn a_config_without_a_game_profile_still_uses_the_shell_command() { + let json = r#"{ + "core_binary":"","bridge_binary":"","core_database_url":"", + "core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"", + "bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true, + "hook_dll_path":"","fifa_game_dir":"","openfut_server_host":"10.0.0.1", + "game_launch_command":"/home/u/launch.sh" + }"#; + let c: LauncherConfig = serde_json::from_str(json).unwrap(); + assert!(!c.game_profile.configured()); + assert_eq!(c.game_profile, GameProfile::default()); + assert!(c.ea_hostnames.is_empty()); + } + + #[test] + fn a_configured_profile_satisfies_launch_without_a_shell_command() { + let mut c = LauncherConfig { + openfut_server_host: "10.10.0.120".into(), + fut_persona_id: 1, + fut_persona_name: "X".into(), + fifa17_tools_dir: "/tmp/tools".into(), + fifa17_python: "/usr/bin/python3".into(), + ..LauncherConfig::default() + }; + c.game_launch_command.clear(); + assert!( + c.validate_launch_config().is_err(), + "neither route configured" + ); + + c.game_profile = GameProfile { + runner: "umu-run".into(), + executable: "FIFA17.exe".into(), + game_dir: "/mnt/games/FIFA 17".into(), + ..GameProfile::default() + }; + assert!(c.game_profile.configured()); + assert!(c.validate_launch_config().is_ok()); + } + + /// The trap this guards: a profile filled in halfway would fail + /// `configured()` and quietly fall through to the shell command, so the user + /// edits the profile and nothing they change has any effect. + #[test] + fn a_half_filled_profile_is_an_error_not_a_silent_fallback() { + let mut c = LauncherConfig { + openfut_server_host: "10.10.0.120".into(), + fut_persona_id: 1, + fut_persona_name: "X".into(), + fifa17_tools_dir: "/tmp/tools".into(), + fifa17_python: "/usr/bin/python3".into(), + game_launch_command: "/home/u/launch.sh".into(), + ..LauncherConfig::default() + }; + c.game_profile.runner = "umu-run".into(); // and nothing else + let err = c.validate_launch_config().unwrap_err(); + assert!(err.contains("executable"), "{err}"); + } + + /// The exact profile block deployed to the FIFA 17 machine. + /// + /// `load()` swallows a parse error and returns `Default` — so a config this + /// binary cannot read would not produce an error, it would silently discard + /// the user's persona, server and ports. That makes "the shipped config + /// actually deserializes" a property worth asserting, not assuming. + #[test] + fn the_deployed_fifa17_profile_parses_exactly() { + let json = r#"{ + "core_binary":"","bridge_binary":"","core_database_url":"", + "core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"", + "bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true, + "hook_dll_path":"","fifa_game_dir":"", + "openfut_server_host":"10.10.0.120", + "ea_hostnames":["easw.easports.com"], + "ea_redirect_probe_ip":"159.153.51.20", + "game_profile":{ + "env":{"GAMEID":"fifa17","PROTONPATH":"UMU-Proton-10.0-4","STEAM_COMPAT_CONFIG":"sdlinput"}, + "executable":"FIFA17.exe", + "game_dir":"/mnt/games/FIFA 17", + "license":{ + "generator":"_fifa17.exe", + "path":"drive_c/ProgramData/Electronic Arts/EA Services/License/1027460.dlf", + "timeout_secs":60 + }, + "prefix_links":[{"link":"dosdevices/w:","target":"/mnt"}], + "runner":"umu-run", + "wine_prefix":"/home/alex/Games/umu/fifa17" + } + }"#; + let c: LauncherConfig = serde_json::from_str(json).expect("deployed config must parse"); + let p = &c.game_profile; + assert!(p.configured()); + assert!(p.validate().is_ok()); + assert_eq!(p.runner, "umu-run"); + assert_eq!( + p.env.get("STEAM_COMPAT_CONFIG").map(String::as_str), + Some("sdlinput") + ); + assert_eq!(p.prefix_links.len(), 1); + let lic = p.license.as_ref().expect("licence block"); + assert_eq!(lic.timeout_secs, 60); + assert!(lic.path.ends_with("1027460.dlf")); + assert_eq!(c.ea_redirect_probe_ip, "159.153.51.20"); + } + + /// `configured()` alone decides which launch route runs, so it is pinned + /// directly rather than only through `validate_launch_config`. All three + /// fields are required: a profile missing any of them cannot start a game. + #[test] + fn configured_requires_runner_executable_and_dir() { + let mut p = GameProfile::default(); + assert!(!p.configured()); + p.runner = "umu-run".into(); + assert!(!p.configured(), "runner alone is not launchable"); + p.executable = "G.exe".into(); + assert!(!p.configured(), "no game_dir is not launchable"); + p.game_dir = "/games/G".into(); + assert!(p.configured()); + // Whitespace is not configuration. + p.executable = " ".into(); + assert!(!p.configured()); + } + + #[test] + fn prefix_links_must_be_relative_and_have_a_prefix() { + let mut p = GameProfile { + runner: "umu-run".into(), + executable: "G.exe".into(), + game_dir: "/games/G".into(), + prefix_links: vec![PrefixLink { + link: "dosdevices/w:".into(), + target: "/mnt".into(), + }], + ..GameProfile::default() + }; + assert!( + p.validate().unwrap_err().contains("wine_prefix"), + "links without a prefix have nowhere to go" + ); + + p.wine_prefix = "/prefix".into(); + assert!(p.validate().is_ok()); + + // An absolute link would be created outside the prefix entirely. + p.prefix_links[0].link = "/etc/w:".into(); + assert!(p.validate().unwrap_err().contains("relative")); + } + + #[test] + fn launch_requires_a_valid_ea_account() { + let mut c = LauncherConfig::default(); + assert!(c.validate_account().unwrap_err().contains("persona ID")); + c.fut_persona_id = 12345678; + assert!(c.validate_account().unwrap_err().contains("persona name")); + c.fut_persona_name = "TEST_USER".into(); + assert!(c.validate_account().is_ok()); + c.fut_account_experience = 1001; + assert!(c.validate_account().unwrap_err().contains("XP")); } } diff --git a/src/game_launch.rs b/src/game_launch.rs new file mode 100644 index 0000000..ba87c91 --- /dev/null +++ b/src/game_launch.rs @@ -0,0 +1,449 @@ +//! Launch the game directly, without an external shell script. +//! +//! # Why this exists +//! +//! The launcher used to shell out to a user-written script (`game_launch_command`) +//! that set the Proton environment, prepared the Wine prefix, regenerated the +//! DRM licence and finally ran the game. That script lived on the user's Desktop +//! — and on 2026-08-11 it was moved to the Trash, after which every launch failed +//! with `sh: No such file or directory`. Three unrelated client-side faults that +//! morning each looked like "the game crashed"; none of them were. +//! +//! Everything the script did is mechanical and belongs inside the launcher, where +//! it cannot be deleted, is covered by tests, and reports failures into the same +//! log buffer as the rest of the launch. +//! +//! # What stays out of this file +//! +//! Every FIFA-17 fact — the runner, the executable, the prefix path, the `w:` +//! drive symlink, the licence file id — is [`GameProfile`] *data*, not code. +//! OpenFUT is not a FIFA 17 project; FIFA 17 is its first reference target. A +//! second game must be a different profile, never a second branch in here. +//! +//! `game_launch_command` remains as an escape hatch: an unconfigured profile +//! falls back to it, so an existing working setup cannot be broken by upgrading. + +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use crate::config::GameProfile; +use crate::logs::LogBuffer; + +type Log = Arc>; + +fn say(log: &Log, msg: impl Into) { + log.lock().unwrap().push(msg.into()); +} + +/// Prepare the prefix, satisfy the licence precondition, and start the game. +/// +/// Returns once the game process has been spawned; its output continues to +/// stream into `log` on background threads. +pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { + profile.validate().map_err(anyhow::Error::msg)?; + + let game_dir = PathBuf::from(&profile.game_dir); + if !game_dir.is_dir() { + anyhow::bail!("game_dir does not exist: {}", game_dir.display()); + } + + prepare_prefix(profile, log)?; + ensure_license(profile, log)?; + + let mut cmd = Command::new(&profile.runner); + cmd.arg(&profile.executable) + .current_dir(&game_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (k, v) in &profile.env { + cmd.env(k, v); + } + if !profile.wine_prefix.trim().is_empty() { + cmd.env("WINEPREFIX", &profile.wine_prefix); + } + + say( + log, + format!( + "[launcher] launching {} {} (cwd {})", + profile.runner, + profile.executable, + game_dir.display() + ), + ); + + let child = cmd + .spawn() + .map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?; + stream(child, log.clone(), "[launcher] game process exited."); + Ok(()) +} + +/// Create the Wine prefix's `dosdevices` entries the profile asks for. +/// +/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn `: +/// an existing link is replaced, so re-running is harmless. +fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { + if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() { + return Ok(()); + } + let prefix = PathBuf::from(&profile.wine_prefix); + for link in &profile.prefix_links { + let path = prefix.join(&link.link); + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("prefix link has no parent: {}", link.link))?; + std::fs::create_dir_all(parent)?; + // Replace rather than fail: `ln -sfn` semantics. Only ever remove a + // symlink — refusing on a real file avoids destroying prefix contents + // if a profile is misconfigured. + match std::fs::symlink_metadata(&path) { + Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_file(&path)?, + Ok(_) => anyhow::bail!( + "refusing to replace {}: it exists and is not a symlink", + path.display() + ), + Err(_) => {} + } + std::os::unix::fs::symlink(&link.target, &path)?; + say( + log, + format!( + "[launcher] prefix link {} -> {}", + path.display(), + link.target + ), + ); + } + Ok(()) +} + +/// Make sure the DRM licence file exists, running the generator if it does not. +/// +/// A crashed or failed launch deletes the licence, so this runs before every +/// launch rather than only on first setup — that is the behaviour the shell +/// script proved, and it is why a crash is normally self-healing on the next try. +fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { + let Some(lic) = &profile.license else { + return Ok(()); + }; + let path = resolve_under_prefix(&profile.wine_prefix, &lic.path); + if non_empty_file(&path) { + return Ok(()); + } + + say( + log, + format!( + "[launcher] licence missing ({}) — running {} to regenerate it", + path.display(), + lic.generator + ), + ); + + let mut cmd = Command::new(&profile.runner); + cmd.arg(&lic.generator) + .current_dir(&profile.game_dir) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (k, v) in &profile.env { + cmd.env(k, v); + } + if !profile.wine_prefix.trim().is_empty() { + cmd.env("WINEPREFIX", &profile.wine_prefix); + } + let mut child = cmd + .spawn() + .map_err(|e| anyhow::anyhow!("could not start licence generator: {e}"))?; + + let deadline = Instant::now() + Duration::from_secs(lic.timeout_secs.max(1)); + while Instant::now() < deadline { + if non_empty_file(&path) { + stop_generator(&mut child, lic, log); + say(log, "[launcher] licence regenerated."); + return Ok(()); + } + std::thread::sleep(Duration::from_millis(500)); + } + + stop_generator(&mut child, lic, log); + anyhow::bail!( + "{} did not create {} within {}s. Run it manually, choose GENERATE, then launch again.", + lic.generator, + path.display(), + lic.timeout_secs + ) +} + +/// Stop the licence generator and the Windows process it started. +/// +/// Killing the runner is not enough: it launches the executable through Proton, +/// so the `.exe` outlives its parent. The shell script used `pkill -f` for this +/// and it is reproduced deliberately — the pattern is a Windows executable name, +/// which cannot match the launcher or a shell running it. (A `pkill -f` pattern +/// that *can* match its own caller is a real hazard; this one cannot.) +fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) { + let _ = child.kill(); + let _ = child.wait(); + match Command::new("pkill").arg("-f").arg(&lic.generator).status() { + Ok(_) => {} + Err(e) => say( + log, + format!( + "[launcher] note: could not run pkill for {}: {e}", + lic.generator + ), + ), + } +} + +/// A relative licence path is taken as relative to the Wine prefix; an absolute +/// one is used as given. +fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf { + let p = Path::new(path); + if p.is_absolute() || prefix.trim().is_empty() { + p.to_path_buf() + } else { + Path::new(prefix).join(p) + } +} + +/// The script's `[[ -s FILE ]]`: present *and* non-empty. A zero-byte licence is +/// as useless as a missing one, and treating it as valid would skip the +/// regeneration that fixes it. +fn non_empty_file(path: &Path) -> bool { + std::fs::metadata(path) + .map(|m| m.len() > 0) + .unwrap_or(false) +} + +/// Pump a child's stdout and stderr into the log buffer and reap it. +pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) { + if let Some(out) = child.stdout.take() { + let buf = Arc::clone(&log); + std::thread::spawn(move || { + for line in BufReader::new(out).lines().map_while(Result::ok) { + buf.lock().unwrap().push(line); + } + }); + } + if let Some(err) = child.stderr.take() { + let buf = Arc::clone(&log); + std::thread::spawn(move || { + for line in BufReader::new(err).lines().map_while(Result::ok) { + buf.lock().unwrap().push(line); + } + }); + } + std::thread::spawn(move || { + let _ = child.wait(); + log.lock().unwrap().push(exit_msg.to_string()); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{LicenseCheck, PrefixLink}; + + fn log() -> Log { + Arc::new(Mutex::new(LogBuffer::new())) + } + + fn tmpdir(tag: &str) -> PathBuf { + let d = + std::env::temp_dir().join(format!("openfut-launch-test-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn a_relative_licence_path_is_resolved_under_the_prefix() { + assert_eq!( + resolve_under_prefix("/p", "drive_c/lic.dlf"), + PathBuf::from("/p/drive_c/lic.dlf") + ); + // Absolute wins, so a profile can point outside the prefix. + assert_eq!( + resolve_under_prefix("/p", "/elsewhere/lic.dlf"), + PathBuf::from("/elsewhere/lic.dlf") + ); + } + + #[test] + fn a_zero_byte_licence_does_not_count_as_present() { + let d = tmpdir("empty-lic"); + let f = d.join("lic.dlf"); + std::fs::write(&f, b"").unwrap(); + assert!( + !non_empty_file(&f), + "an empty licence must trigger regeneration" + ); + std::fs::write(&f, b"x").unwrap(); + assert!(non_empty_file(&f)); + } + + #[test] + fn prefix_links_are_created_and_are_idempotent() { + let d = tmpdir("links"); + let prefix = d.join("prefix"); + let target = d.join("target"); + std::fs::create_dir_all(&target).unwrap(); + + let profile = GameProfile { + runner: "true".into(), + executable: "x.exe".into(), + game_dir: d.to_string_lossy().into(), + wine_prefix: prefix.to_string_lossy().into(), + prefix_links: vec![PrefixLink { + link: "dosdevices/w:".into(), + target: target.to_string_lossy().into(), + }], + ..GameProfile::default() + }; + + prepare_prefix(&profile, &log()).expect("first run creates the link"); + let link = prefix.join("dosdevices/w:"); + assert!(std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink()); + + // Re-running must not fail — the launcher prepares the prefix on EVERY + // launch, so a second launch would break if this were not idempotent. + prepare_prefix(&profile, &log()).expect("second run replaces the link"); + assert_eq!(std::fs::read_link(&link).unwrap(), target); + } + + #[test] + fn a_real_file_where_a_link_belongs_is_refused_not_deleted() { + let d = tmpdir("clobber"); + let prefix = d.join("prefix"); + std::fs::create_dir_all(prefix.join("dosdevices")).unwrap(); + let occupied = prefix.join("dosdevices/w:"); + std::fs::write(&occupied, b"important").unwrap(); + + let profile = GameProfile { + runner: "true".into(), + executable: "x.exe".into(), + game_dir: d.to_string_lossy().into(), + wine_prefix: prefix.to_string_lossy().into(), + prefix_links: vec![PrefixLink { + link: "dosdevices/w:".into(), + target: "/tmp".into(), + }], + ..GameProfile::default() + }; + + assert!(prepare_prefix(&profile, &log()).is_err()); + assert_eq!( + std::fs::read(&occupied).unwrap(), + b"important", + "a misconfigured profile must not destroy prefix contents" + ); + } + + #[test] + fn a_present_licence_skips_the_generator_entirely() { + let d = tmpdir("lic-present"); + let lic = d.join("lic.dlf"); + std::fs::write(&lic, b"valid").unwrap(); + + let profile = GameProfile { + runner: "/nonexistent/runner".into(), // would fail if it were run + executable: "x.exe".into(), + game_dir: d.to_string_lossy().into(), + wine_prefix: d.to_string_lossy().into(), + license: Some(LicenseCheck { + path: "lic.dlf".into(), + generator: "_gen.exe".into(), + timeout_secs: 1, + }), + ..GameProfile::default() + }; + + // Proves the skip: the runner path is invalid, so reaching the generator + // would error. Ok() means it never tried. + ensure_license(&profile, &log()).expect("present licence must short-circuit"); + } + + /// The whole point of the licence step: a missing licence must actually run + /// the generator and wait for it. Without this, deleting `ensure_license` + /// entirely would still pass every other test in this file. + #[test] + fn a_missing_licence_runs_the_generator_and_waits_for_it() { + let d = tmpdir("lic-regen"); + let lic = d.join("lic.dlf"); + let gen = d.join("gen.sh"); + // Sleeps first, so passing requires actually waiting rather than + // happening to observe a file that was already there. The target path + // is baked in: `generator` is passed as ONE argument, exactly as + // `umu-run "_fifa17.exe"` is. + std::fs::write( + &gen, + format!( + "#!/bin/sh\nsleep 1\nprintf licensed > '{}'\n", + lic.display() + ), + ) + .unwrap(); + + let profile = GameProfile { + runner: "/bin/sh".into(), + executable: "unused".into(), + game_dir: d.to_string_lossy().into(), + wine_prefix: d.to_string_lossy().into(), + license: Some(LicenseCheck { + generator: gen.to_string_lossy().into(), + path: "lic.dlf".into(), + timeout_secs: 10, + }), + ..GameProfile::default() + }; + + assert!(!non_empty_file(&lic)); + ensure_license(&profile, &log()).expect("generator should produce the licence"); + assert!(non_empty_file(&lic), "licence was not created"); + } + + #[test] + fn a_generator_that_never_delivers_times_out_with_an_actionable_error() { + let d = tmpdir("lic-timeout"); + let gen = d.join("gen.sh"); + std::fs::write(&gen, "#!/bin/sh\nexit 0\n").unwrap(); + let profile = GameProfile { + runner: "/bin/sh".into(), + executable: "unused".into(), + game_dir: d.to_string_lossy().into(), + wine_prefix: d.to_string_lossy().into(), + license: Some(LicenseCheck { + generator: gen.to_string_lossy().into(), // runs, writes nothing + path: "lic.dlf".into(), + timeout_secs: 1, + }), + ..GameProfile::default() + }; + let err = ensure_license(&profile, &log()).unwrap_err().to_string(); + assert!(err.contains("did not create"), "{err}"); + assert!( + err.contains("GENERATE"), + "the error must say what to do: {err}" + ); + } + + #[test] + fn launch_refuses_a_missing_game_dir_before_touching_anything() { + let profile = GameProfile { + runner: "true".into(), + executable: "x.exe".into(), + game_dir: "/definitely/not/here".into(), + ..GameProfile::default() + }; + let err = launch(&profile, &log()).unwrap_err().to_string(); + assert!(err.contains("game_dir does not exist"), "{err}"); + } +} diff --git a/src/health.rs b/src/health.rs new file mode 100644 index 0000000..8220e01 --- /dev/null +++ b/src/health.rs @@ -0,0 +1,133 @@ +//! Read-only health monitoring of the (remote) OpenFUT server. +//! +//! The launcher no longer *controls* the servers — they run elsewhere (e.g. in +//! Docker on the server host). This module polls the configured server in a +//! background thread and exposes a snapshot the UI can render. It never starts, +//! stops, or assumes anything about how the server is hosted; it only asks +//! "can the FIFA client reach it right now?". + +use std::{ + net::{TcpStream, ToSocketAddrs}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + thread, + time::{Duration, Instant}, +}; + +const POLL_INTERVAL: Duration = Duration::from_secs(3); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); + +/// A snapshot of the last health probe, rendered by the dashboard. +#[derive(Clone)] +pub struct HealthState { + /// None = not yet checked / no target; Some(true/false) = reachable or not. + pub reachable: Option, + pub detail: String, + pub last_checked: Option, +} + +impl Default for HealthState { + fn default() -> Self { + Self { + reachable: None, + detail: "No server configured.".into(), + last_checked: None, + } + } +} + +/// Background poller. Holds a shared target (host, port) the UI can update when +/// the user changes the server address, and a shared state the UI reads. +pub struct HealthMonitor { + pub state: Arc>, + target: Arc>>, + running: Arc, +} + +impl HealthMonitor { + pub fn new() -> Self { + let state = Arc::new(Mutex::new(HealthState::default())); + let target: Arc>> = Arc::new(Mutex::new(None)); + let running = Arc::new(AtomicBool::new(true)); + + let t_state = Arc::clone(&state); + let t_target = Arc::clone(&target); + let t_running = Arc::clone(&running); + thread::spawn(move || { + while t_running.load(Ordering::Relaxed) { + let target = t_target.lock().unwrap().clone(); + match target { + None => { + *t_state.lock().unwrap() = HealthState::default(); + } + Some((host, port)) => { + let snapshot = probe(&host, port); + *t_state.lock().unwrap() = snapshot; + } + } + thread::sleep(POLL_INTERVAL); + } + }); + + Self { + state, + target, + running, + } + } + + /// Point the monitor at a new server address (host + bridge port). Passing + /// None (e.g. no server configured) puts it back into the idle state. + pub fn set_target(&self, target: Option<(String, u16)>) { + *self.target.lock().unwrap() = target; + } + + pub fn snapshot(&self) -> HealthState { + self.state.lock().unwrap().clone() + } +} + +impl Drop for HealthMonitor { + fn drop(&mut self) { + self.running.store(false, Ordering::Relaxed); + } +} + +/// A single reachability probe: DNS-resolve host:port and attempt a bounded TCP +/// connect. A successful connect proves the FIFA client can reach the bridge. +fn probe(host: &str, port: u16) -> HealthState { + let now = Some(Instant::now()); + let addrs = match (host, port).to_socket_addrs() { + Ok(a) => a.collect::>(), + Err(e) => { + return HealthState { + reachable: Some(false), + detail: format!("Cannot resolve {host}: {e}"), + last_checked: now, + }; + } + }; + if addrs.is_empty() { + return HealthState { + reachable: Some(false), + detail: format!("{host} resolved to no addresses"), + last_checked: now, + }; + } + for addr in &addrs { + if TcpStream::connect_timeout(addr, CONNECT_TIMEOUT).is_ok() { + return HealthState { + reachable: Some(true), + detail: format!("Reachable at {addr}"), + last_checked: now, + }; + } + } + HealthState { + reachable: Some(false), + detail: format!("{host}:{port} not reachable"), + last_checked: now, + } +} diff --git a/src/local_services.rs b/src/local_services.rs new file mode 100644 index 0000000..66c533b --- /dev/null +++ b/src/local_services.rs @@ -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//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, +} + +/// 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() + .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>, 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>, +) -> 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 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")); + } +} diff --git a/src/main.rs b/src/main.rs index 7d925b7..d6140bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,13 @@ +mod account_sync; mod app; +mod arm; mod config; +mod game_launch; +mod health; +mod local_services; mod logs; -mod process; +mod netcheck; +mod preflight; mod setup; fn main() -> eframe::Result<()> { diff --git a/src/netcheck.rs b/src/netcheck.rs new file mode 100644 index 0000000..5171e25 --- /dev/null +++ b/src/netcheck.rs @@ -0,0 +1,77 @@ +//! "Test Connection" support: verify the configured OpenFUT server is actually +//! reachable before the user launches FIFA. +//! +//! This resolves the configured host through the SAME shared path the hook uses +//! ([`openfut_common::ServerConfig::resolve`]) and then does a bounded TCP +//! connect to the OpenFUT destination port(s). It never falls back to loopback: +//! if the server isn't configured/resolvable, it reports that plainly. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use openfut_common::ServerConfig; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); + +/// Outcome of a connection test, suitable for showing in the UI. +pub struct TestOutcome { + pub ok: bool, + pub message: String, +} + +/// Resolve `cfg` and attempt to reach the OpenFUT server. Checks the HTTPS +/// destination port (the one EA :443 traffic is redirected to) since that is the +/// service the client relies on first. On success, also reports whether the core +/// `/health` endpoint answered (best-effort; a plain-text probe, TLS not spoken). +pub fn test_connection(cfg: &ServerConfig) -> TestOutcome { + let resolved = match cfg.resolve() { + Ok(r) => r, + Err(e) => { + return TestOutcome { + ok: false, + message: format!("Cannot resolve OpenFUT server: {e}"), + }; + } + }; + + let addr = SocketAddr::from((resolved.redirect_ip, resolved.ports.https)); + match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) { + Ok(mut stream) => { + // Best-effort HTTP probe of /health. The bridge front door speaks + // TLS, so a plaintext request may not get a clean 200 — a successful + // TCP connect already proves reachability, so we don't fail on this. + let health = probe_health(&mut stream); + let detail = match health { + Some(true) => " (core /health responded OK)".to_string(), + _ => String::new(), + }; + TestOutcome { + ok: true, + message: format!( + "Reachable: {}:{} is accepting connections{detail}.", + resolved.redirect_ip, resolved.ports.https + ), + } + } + Err(e) => TestOutcome { + ok: false, + message: format!( + "Could not reach {}:{} — {e}. Check the server is running and the \ + address/port are correct.", + resolved.redirect_ip, resolved.ports.https + ), + }, + } +} + +fn probe_health(stream: &mut TcpStream) -> Option { + let _ = stream.set_read_timeout(Some(CONNECT_TIMEOUT)); + let _ = stream.set_write_timeout(Some(CONNECT_TIMEOUT)); + let req = "GET /health HTTP/1.0\r\nConnection: close\r\n\r\n"; + stream.write_all(req.as_bytes()).ok()?; + let mut buf = [0u8; 512]; + let n = stream.read(&mut buf).ok()?; + let text = String::from_utf8_lossy(&buf[..n]); + Some(text.contains("200") || text.contains("\"status\"")) +} diff --git a/src/preflight.rs b/src/preflight.rs new file mode 100644 index 0000000..08d6956 --- /dev/null +++ b/src/preflight.rs @@ -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) -> Self { + Self { + name: name.into(), + state: State::Pass, + detail: detail.into(), + } + } + fn fail(name: &str, detail: impl Into) -> Self { + Self { + name: name.into(), + state: State::Fail, + detail: detail.into(), + } + } + fn warn(name: &str, detail: impl Into) -> Self { + Self { + name: name.into(), + state: State::Warn, + detail: detail.into(), + } + } + fn skip(name: &str, detail: impl Into) -> 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 { + 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::() 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> { + Ok((host, 0u16).to_socket_addrs()?.map(|a| a.ip()).collect()) +} + +fn join(ips: &[IpAddr]) -> String { + ips.iter() + .map(|i| i.to_string()) + .collect::>() + .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); + } +} diff --git a/src/process.rs b/src/process.rs deleted file mode 100644 index aef6869..0000000 --- a/src/process.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::{ - io::{BufRead, BufReader}, - process::{Child, Command, Stdio}, - sync::{Arc, Mutex}, - thread, -}; - -use crate::logs::LogBuffer; - -#[derive(Debug, Clone, PartialEq)] -pub enum ServiceStatus { - Stopped, - Starting, - Running, - Failed(String), -} - -impl ServiceStatus { - pub fn label(&self) -> &str { - match self { - ServiceStatus::Stopped => "Stopped", - ServiceStatus::Starting => "Starting…", - ServiceStatus::Running => "Running", - ServiceStatus::Failed(_) => "Failed", - } - } - - pub fn color(&self) -> egui::Color32 { - match self { - ServiceStatus::Running => egui::Color32::from_rgb(80, 200, 120), - ServiceStatus::Starting => egui::Color32::from_rgb(255, 200, 0), - ServiceStatus::Failed(_) => egui::Color32::from_rgb(220, 60, 60), - ServiceStatus::Stopped => egui::Color32::from_rgb(150, 150, 150), - } - } -} - -pub struct ServiceHandle { - child: Option, - pub status: Arc>, -} - -impl ServiceHandle { - pub fn new() -> Self { - Self { - child: None, - status: Arc::new(Mutex::new(ServiceStatus::Stopped)), - } - } - - pub fn start( - &mut self, - binary: &str, - env_pairs: &[(String, String)], - log_buf: Arc>, - ) -> anyhow::Result<()> { - if self.is_running() { - return Ok(()); - } - - *self.status.lock().unwrap() = ServiceStatus::Starting; - - let mut cmd = Command::new(binary); - for (k, v) in env_pairs { - cmd.env(k, v); - } - cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); - - let mut child = cmd.spawn().map_err(|e| { - *self.status.lock().unwrap() = ServiceStatus::Failed(e.to_string()); - e - })?; - - // Drain stdout - if let Some(stdout) = child.stdout.take() { - let buf = Arc::clone(&log_buf); - let status = Arc::clone(&self.status); - thread::spawn(move || { - *status.lock().unwrap() = ServiceStatus::Running; - for line in BufReader::new(stdout).lines().map_while(Result::ok) { - buf.lock().unwrap().push(line); - } - }); - } - - // Drain stderr - if let Some(stderr) = child.stderr.take() { - let buf = Arc::clone(&log_buf); - thread::spawn(move || { - for line in BufReader::new(stderr).lines().map_while(Result::ok) { - buf.lock().unwrap().push(line); - } - }); - } - - self.child = Some(child); - Ok(()) - } - - pub fn stop(&mut self) { - if let Some(mut child) = self.child.take() { - let _ = child.kill(); - let _ = child.wait(); - } - *self.status.lock().unwrap() = ServiceStatus::Stopped; - } - - pub fn is_running(&mut self) -> bool { - if let Some(child) = &mut self.child { - match child.try_wait() { - Ok(Some(_)) => { - // process exited - self.child = None; - *self.status.lock().unwrap() = ServiceStatus::Stopped; - false - } - Ok(None) => true, - Err(_) => false, - } - } else { - false - } - } - - pub fn status(&self) -> ServiceStatus { - self.status.lock().unwrap().clone() - } -} - -impl Drop for ServiceHandle { - fn drop(&mut self) { - self.stop(); - } -} diff --git a/src/setup.rs b/src/setup.rs index 86cb64d..0cb7ae0 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,25 +1,7 @@ -use std::{path::{Path, PathBuf}, process::Command}; - -// ── Port 443 capability ─────────────────────────────────────────────────────── - -/// Check whether the bridge binary already has cap_net_bind_service set. -pub fn bridge_has_cap443(binary: &Path) -> bool { - std::process::Command::new("getcap") - .arg(binary) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).contains("cap_net_bind_service")) - .unwrap_or(false) -} - -/// Grant cap_net_bind_service to the bridge binary so it can bind port 443 -/// without running as root. Uses pkexec (or sudo as fallback). -pub fn setcap_bridge_443(binary: &Path) -> anyhow::Result<()> { - let script = format!( - "setcap cap_net_bind_service=+ep '{}'", - binary.to_string_lossy() - ); - run_elevated(&script) -} +use std::{ + path::{Path, PathBuf}, + process::Command, +}; // ── Cert installation ───────────────────────────────────────────────────────── @@ -67,17 +49,13 @@ fn try_wine_certutil(cert_src: &Path) -> anyhow::Result<()> { } } -fn run_elevated(script: &str) -> anyhow::Result<()> { - let status = Command::new("pkexec") - .args(["sh", "-c", script]) - .status(); +pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> { + let status = Command::new("pkexec").args(["sh", "-c", script]).status(); match status { Ok(s) if s.success() => Ok(()), _ => { - let s = Command::new("sudo") - .args(["sh", "-c", script]) - .status()?; + let s = Command::new("sudo").args(["sh", "-c", script]).status()?; if s.success() { Ok(()) } else { @@ -90,10 +68,13 @@ fn run_elevated(script: &str) -> anyhow::Result<()> { // ── DLL hook deployment ─────────────────────────────────────────────────────── /// Deploy openfut_hook.dll into the FIFA 23 game directory and write -/// openfut.cfg with the redirect IP the hook will use. +/// openfut.cfg with the structured server configuration the hook reads. +/// `cfg_contents` must be the full `openfut.cfg` body (see +/// `LauncherConfig::hook_cfg_contents`) — this function does not invent any +/// address itself, so a missing server can never silently become loopback. /// Uses `version.dll` as the hijack name — FIFA 23 loads it but defers to /// the system copy, so Proton picks up our local one first. -pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, redirect_ip: &str) -> anyhow::Result<()> { +pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> { if !dll_src.exists() { anyhow::bail!( "Hook DLL not found at {}. Build it first with:\n\ @@ -104,17 +85,18 @@ pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, redirect_ip: &str) -> an } std::fs::create_dir_all(game_dir)?; std::fs::copy(dll_src, game_dir.join("version.dll"))?; - std::fs::write(game_dir.join("openfut.cfg"), redirect_ip)?; + std::fs::write(game_dir.join("openfut.cfg"), cfg_contents)?; Ok(()) } -/// Update only openfut.cfg without redeploying the DLL. -pub fn update_hook_config(game_dir: &Path, redirect_ip: &str) -> anyhow::Result<()> { +/// Update only openfut.cfg without redeploying the DLL. `cfg_contents` is the +/// full structured `openfut.cfg` body. +pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> { let cfg = game_dir.join("openfut.cfg"); if !cfg.exists() { anyhow::bail!("Hook DLL not deployed yet — deploy first."); } - std::fs::write(cfg, redirect_ip)?; + std::fs::write(cfg, cfg_contents)?; Ok(()) } @@ -134,5 +116,65 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool { /// The Steam launch options the user needs to paste in to enable the override. /// Proton loads local DLLs named in WINEDLLOVERRIDES ahead of system ones. -pub const STEAM_LAUNCH_OPTIONS: &str = - "WINEDLLOVERRIDES=\"version=n,b\" %command%"; +pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%"; + +// ── Game launch ─────────────────────────────────────────────────────────────── + +/// Launch the game via the user-provided shell command. Runs `sh -c ` +/// (optionally from `workdir`), streaming stdout+stderr into `log_buf` on a +/// background thread. The launcher does not assume Steam vs umu-run vs a custom +/// script — whatever the user configured is what runs. +pub fn launch_game( + command: &str, + workdir: &str, + log_buf: std::sync::Arc>, +) -> anyhow::Result<()> { + use std::io::{BufRead, BufReader}; + use std::process::{Command, Stdio}; + + if command.trim().is_empty() { + anyhow::bail!("No game launch command configured (set it in the Config tab)."); + } + + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(command); + if !workdir.trim().is_empty() { + cmd.current_dir(workdir); + } + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + log_buf + .lock() + .unwrap() + .push(format!("[launcher] launching game: {command}")); + + let mut child = cmd.spawn()?; + + if let Some(out) = child.stdout.take() { + let buf = std::sync::Arc::clone(&log_buf); + std::thread::spawn(move || { + for line in BufReader::new(out).lines().map_while(Result::ok) { + buf.lock().unwrap().push(line); + } + }); + } + if let Some(err) = child.stderr.take() { + let buf = std::sync::Arc::clone(&log_buf); + std::thread::spawn(move || { + for line in BufReader::new(err).lines().map_while(Result::ok) { + buf.lock().unwrap().push(line); + } + }); + } + // Reap the child in the background so a finished game doesn't linger as a + // zombie; we don't block the UI on it. + std::thread::spawn(move || { + let _ = child.wait(); + log_buf + .lock() + .unwrap() + .push("[launcher] game process exited.".to_string()); + }); + + Ok(()) +}