Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e44a3792f | |||
| 13339c1478 | |||
| d619c992c1 |
Generated
+5
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Generated
+7
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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<u16> {
|
||||
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<Self, ConfigError> {
|
||||
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<String> = 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<ResolvedServer, ConfigError> {
|
||||
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<u16, ConfigError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Generated
+5
@@ -2,10 +2,15 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "openfut-common"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-hook"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-common",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ edition = "2021"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# Shared, dependency-free source of truth for the OpenFUT destination
|
||||
# (host + ports) and the sockaddr byte-order helpers. Keeps all three hooks
|
||||
# consistent and keeps this logic host-testable outside WinSock.
|
||||
openfut-common = { path = "../openfut-common" }
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_LibraryLoader",
|
||||
|
||||
+13
-15
@@ -1,20 +1,18 @@
|
||||
/// Reads openfut.cfg from the same directory as this DLL.
|
||||
///
|
||||
/// The file contains a single line: the IP the hook should redirect EA
|
||||
/// hostnames to, e.g. "192.168.1.10" or "127.0.0.1".
|
||||
/// Falls back to 127.0.0.1 if the file is missing or unreadable.
|
||||
//! Loads `openfut.cfg` (next to this DLL) into a shared [`ServerConfig`].
|
||||
//!
|
||||
//! There is intentionally **no loopback fallback**: if the file is missing,
|
||||
//! empty, or invalid, this returns a [`ConfigError`] and the caller logs it and
|
||||
//! declines to redirect. Missing configuration is an error, never `127.0.0.1`.
|
||||
use openfut_common::{ConfigError, ServerConfig};
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleFileNameA;
|
||||
|
||||
pub fn read_redirect_ip(module: windows_sys::Win32::Foundation::HMODULE) -> String {
|
||||
if let Some(cfg_path) = config_path(module) {
|
||||
if let Ok(content) = std::fs::read_to_string(&cfg_path) {
|
||||
let ip = content.trim().to_string();
|
||||
if !ip.is_empty() {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
"127.0.0.1".to_string()
|
||||
/// Read and parse `openfut.cfg` from the same directory as this DLL.
|
||||
pub fn load_config(
|
||||
module: windows_sys::Win32::Foundation::HMODULE,
|
||||
) -> Result<ServerConfig, ConfigError> {
|
||||
let path = config_path(module).ok_or(ConfigError::ConfigMissing)?;
|
||||
let contents = std::fs::read_to_string(&path).map_err(|_| ConfigError::ConfigMissing)?;
|
||||
ServerConfig::parse(&contents)
|
||||
}
|
||||
|
||||
fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option<std::path::PathBuf> {
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
/// Hooks ws2_32!connect via inline detour (no iptables needed).
|
||||
/// Uses unhook/rehook pattern: restores original bytes, calls real function, re-installs hook.
|
||||
/// This avoids trampoline RIP-relocation issues entirely.
|
||||
///
|
||||
/// The redirect destination (IP + port) comes entirely from the shared
|
||||
/// [`crate::server`] state, which is populated once from `openfut.cfg`. This
|
||||
/// hook does NOT choose an address itself — no hardcoded loopback, no per-hook
|
||||
/// redirect IP. EA *source* ports are recognised via `openfut-common`'s port
|
||||
/// map; the matching OpenFUT *destination* port + configured IP are substituted.
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const AF_INET: u16 = 2;
|
||||
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
|
||||
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
|
||||
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
|
||||
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
|
||||
const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian
|
||||
|
||||
#[repr(C)]
|
||||
struct SockaddrIn {
|
||||
sin_family: u16,
|
||||
sin_port: u16,
|
||||
sin_addr: u32,
|
||||
sin_zero: [u8; 8],
|
||||
sin_port: u16,
|
||||
sin_addr: u32,
|
||||
sin_zero: [u8; 8],
|
||||
}
|
||||
|
||||
// Address of ws2_32!connect (set at hook installation)
|
||||
@@ -27,18 +28,26 @@ static mut ORIGINAL_BYTES: [u8; 14] = [0u8; 14];
|
||||
|
||||
// For WSAConnect IAT fallback
|
||||
type WsaConnectFn = unsafe extern "system" fn(
|
||||
s: usize, name: *const u8, namelen: i32,
|
||||
caller: *const (), callee: *const (),
|
||||
sqos: *const (), gqos: *const ()) -> i32;
|
||||
s: usize,
|
||||
name: *const u8,
|
||||
namelen: i32,
|
||||
caller: *const (),
|
||||
callee: *const (),
|
||||
sqos: *const (),
|
||||
gqos: *const (),
|
||||
) -> i32;
|
||||
static REAL_WSA: OnceLock<WsaConnectFn> = OnceLock::new();
|
||||
pub fn set_real_wsa_connect(f: WsaConnectFn) { let _ = REAL_WSA.set(f); }
|
||||
pub fn set_real_wsa_connect(f: WsaConnectFn) {
|
||||
let _ = REAL_WSA.set(f);
|
||||
}
|
||||
|
||||
unsafe fn write_hook(target: *mut u8, dest: u64) {
|
||||
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
// FF 25 00 00 00 00 JMP [rip+0]
|
||||
target.write(0xFF); target.add(1).write(0x25);
|
||||
target.write(0xFF);
|
||||
target.add(1).write(0x25);
|
||||
(target.add(2) as *mut u32).write(0u32);
|
||||
(target.add(6) as *mut u64).write(dest);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
@@ -53,31 +62,46 @@ unsafe fn restore_original(target: *mut u8) {
|
||||
}
|
||||
|
||||
unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 16], i32)> {
|
||||
if namelen < 8 { return None; }
|
||||
if namelen < 8 {
|
||||
return None;
|
||||
}
|
||||
let sa = &*(name as *const SockaddrIn);
|
||||
if sa.sin_family != AF_INET { return None; }
|
||||
if sa.sin_family != AF_INET {
|
||||
return None;
|
||||
}
|
||||
|
||||
let orig = sa.sin_addr.to_le_bytes();
|
||||
let orig_port = u16::from_be(sa.sin_port);
|
||||
|
||||
let new_port_nbo = match sa.sin_port {
|
||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
||||
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
|
||||
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
|
||||
_ => return None,
|
||||
};
|
||||
// Map the EA source port to an OpenFUT destination port from shared config.
|
||||
// Returns None if this port isn't intercepted or no server is configured —
|
||||
// in which case we leave the connection untouched (no loopback fallback).
|
||||
let new_port_nbo = crate::server::dest_port_nbo_from_source_nbo(sa.sin_port)?;
|
||||
// The destination IP is the configured/resolved OpenFUT server — never a
|
||||
// hardcoded address. If unset, dest_port_nbo_from_source_nbo already
|
||||
// returned None above, so this is guaranteed Some here.
|
||||
let new_addr = crate::server::sin_addr()?;
|
||||
let ni = new_addr.to_le_bytes();
|
||||
|
||||
crate::write_log(&format!(
|
||||
"connect_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
|
||||
orig[3], orig[2], orig[1], orig[0], orig_port,
|
||||
"connect_hook: {}.{}.{}.{}:{} → {}.{}.{}.{}:{} (configured OpenFUT server)\n",
|
||||
orig[3],
|
||||
orig[2],
|
||||
orig[1],
|
||||
orig[0],
|
||||
orig_port,
|
||||
ni[0],
|
||||
ni[1],
|
||||
ni[2],
|
||||
ni[3],
|
||||
u16::from_be(new_port_nbo)
|
||||
));
|
||||
|
||||
let mut buf = [0u8; 16];
|
||||
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
|
||||
out.sin_family = AF_INET;
|
||||
out.sin_port = new_port_nbo;
|
||||
out.sin_addr = ADDR_LOOPBACK_NBO;
|
||||
out.sin_port = new_port_nbo;
|
||||
out.sin_addr = new_addr;
|
||||
Some((buf, 16))
|
||||
}
|
||||
|
||||
@@ -95,7 +119,13 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE};
|
||||
let mut ty: i32 = -1;
|
||||
let mut len: i32 = 4;
|
||||
getsockopt(s, SOL_SOCKET as i32, SO_TYPE, &mut ty as *mut i32 as *mut u8, &mut len);
|
||||
getsockopt(
|
||||
s,
|
||||
SOL_SOCKET as i32,
|
||||
SO_TYPE,
|
||||
&mut ty as *mut i32 as *mut u8,
|
||||
&mut len,
|
||||
);
|
||||
ty
|
||||
};
|
||||
crate::write_log(&format!(
|
||||
@@ -111,11 +141,25 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
let (call_name, call_len) = if let Some((buf, len)) = redirect_if_ea(name, namelen) {
|
||||
restore_original(addr);
|
||||
let r = {
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32
|
||||
= core::mem::transmute(addr);
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 =
|
||||
core::mem::transmute(addr);
|
||||
f(s, buf.as_ptr(), len)
|
||||
};
|
||||
// connect() communicates nonblocking progress through WSAGetLastError.
|
||||
// Reinstalling the detour calls VirtualProtect, which may overwrite that
|
||||
// thread-local value before FIFA reads it. Preserve the real call's value
|
||||
// across all hook maintenance and logging.
|
||||
let wsa_error = if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
|
||||
WSAGetLastError()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
write_hook(addr, hooked_connect as u64);
|
||||
if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
|
||||
WSASetLastError(wsa_error);
|
||||
}
|
||||
return r;
|
||||
} else {
|
||||
(name, namelen)
|
||||
@@ -123,28 +167,37 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
|
||||
restore_original(addr);
|
||||
let r = {
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32
|
||||
= core::mem::transmute(addr);
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr);
|
||||
f(s, call_name, call_len)
|
||||
};
|
||||
let wsa_error = if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
|
||||
WSAGetLastError()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
write_hook(addr, hooked_connect as u64);
|
||||
if namelen >= 8 {
|
||||
let sa = &*(call_name as *const SockaddrIn);
|
||||
if sa.sin_family == AF_INET {
|
||||
let err = if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
|
||||
WSAGetLastError()
|
||||
} else { 0 };
|
||||
crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n"));
|
||||
crate::write_log(&format!("connect_hook: result={r} wsa_err={wsa_error}\n"));
|
||||
}
|
||||
}
|
||||
if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
|
||||
WSASetLastError(wsa_error);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_wsa_connect(
|
||||
s: usize, name: *const u8, namelen: i32,
|
||||
caller: *const (), callee: *const (),
|
||||
sqos: *const (), gqos: *const (),
|
||||
s: usize,
|
||||
name: *const u8,
|
||||
namelen: i32,
|
||||
caller: *const (),
|
||||
callee: *const (),
|
||||
sqos: *const (),
|
||||
gqos: *const (),
|
||||
) -> i32 {
|
||||
let real = REAL_WSA.get().copied().unwrap();
|
||||
if let Some((buf, len)) = redirect_if_ea(name, namelen) {
|
||||
@@ -159,7 +212,9 @@ pub unsafe fn install_inline_connect_hook() -> bool {
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
||||
|
||||
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr());
|
||||
if ws2.is_null() { return false; }
|
||||
if ws2.is_null() {
|
||||
return false;
|
||||
}
|
||||
let connect_fn = match GetProcAddress(ws2, b"connect\0".as_ptr()) {
|
||||
Some(f) => f as *mut u8,
|
||||
None => return false,
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
use core::ffi::c_void;
|
||||
/// Intercepts ConnectEx (EA/DirtySDK's preferred async connect API).
|
||||
///
|
||||
/// DirtySDK calls WSAIoctl(SIO_GET_EXTENSION_FUNCTION_POINTER, WSAID_CONNECTEX) once at
|
||||
/// startup to get a ConnectEx function pointer, bypassing all IAT hooks. We hook WSAIoctl
|
||||
/// inline so that when it returns a ConnectEx pointer we swap it for our own wrapper.
|
||||
use core::sync::atomic::{AtomicUsize, Ordering};
|
||||
use core::ffi::c_void;
|
||||
|
||||
const AF_INET: u16 = 2;
|
||||
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
|
||||
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
|
||||
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
|
||||
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
|
||||
const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian
|
||||
|
||||
// SIO_GET_EXTENSION_FUNCTION_POINTER
|
||||
const SIO_GET_EXT_FN: u32 = 0xC8000006;
|
||||
|
||||
// WSAID_CONNECTEX = {25A207B9-DDF3-4660-8EE9-76E58C74063E}
|
||||
const CONNECTEX_GUID: [u8; 16] = [
|
||||
0xB9, 0x07, 0xA2, 0x25,
|
||||
0xF3, 0xDD, 0x60, 0x46,
|
||||
0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E,
|
||||
0xB9, 0x07, 0xA2, 0x25, 0xF3, 0xDD, 0x60, 0x46, 0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E,
|
||||
];
|
||||
|
||||
#[repr(C)]
|
||||
struct SockaddrIn {
|
||||
sin_family: u16,
|
||||
sin_port: u16,
|
||||
sin_addr: u32,
|
||||
sin_zero: [u8; 8],
|
||||
sin_port: u16,
|
||||
sin_addr: u32,
|
||||
sin_zero: [u8; 8],
|
||||
}
|
||||
|
||||
// The real ConnectEx pointer, saved after WSAIoctl returns it
|
||||
@@ -65,7 +58,8 @@ unsafe fn write_hook(target: *mut u8, dest: u64) {
|
||||
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
target.write(0xFF); target.add(1).write(0x25);
|
||||
target.write(0xFF);
|
||||
target.add(1).write(0x25);
|
||||
(target.add(2) as *mut u32).write(0u32);
|
||||
(target.add(6) as *mut u64).write(dest);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
@@ -79,7 +73,7 @@ unsafe fn restore_wsaioctl(target: *mut u8) {
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
}
|
||||
|
||||
/// Our ConnectEx wrapper: redirects EA ports to 127.0.0.1
|
||||
/// Our ConnectEx wrapper: redirects intercepted EA endpoints to the configured OpenFUT server
|
||||
unsafe extern "system" fn hooked_connectex(
|
||||
s: usize,
|
||||
name: *const u8,
|
||||
@@ -96,28 +90,53 @@ unsafe extern "system" fn hooked_connectex(
|
||||
if sa.sin_family == AF_INET {
|
||||
let o = sa.sin_addr.to_le_bytes();
|
||||
let orig_port = u16::from_be(sa.sin_port);
|
||||
let new_port_nbo = match sa.sin_port {
|
||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
||||
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
|
||||
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
|
||||
_ => 0,
|
||||
};
|
||||
if new_port_nbo != 0 {
|
||||
// Destination port + IP come from the shared configured server —
|
||||
// never a hardcoded loopback. None => not intercepted / unconfigured,
|
||||
// so the original connection is passed through untouched.
|
||||
if let (Some(new_port_nbo), Some(new_addr)) = (
|
||||
crate::server::dest_port_nbo_from_source_nbo(sa.sin_port),
|
||||
crate::server::sin_addr(),
|
||||
) {
|
||||
let ni = new_addr.to_le_bytes();
|
||||
crate::write_log(&format!(
|
||||
"connectex_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
|
||||
o[3], o[2], o[1], o[0], orig_port,
|
||||
"connectex_hook: {}.{}.{}.{}:{} → {}.{}.{}.{}:{} (configured OpenFUT server)\n",
|
||||
o[3],
|
||||
o[2],
|
||||
o[1],
|
||||
o[0],
|
||||
orig_port,
|
||||
ni[0],
|
||||
ni[1],
|
||||
ni[2],
|
||||
ni[3],
|
||||
u16::from_be(new_port_nbo)
|
||||
));
|
||||
let mut redirect = [0u8; 16];
|
||||
let out = &mut *(redirect.as_mut_ptr() as *mut SockaddrIn);
|
||||
out.sin_family = AF_INET;
|
||||
out.sin_port = new_port_nbo;
|
||||
out.sin_addr = ADDR_LOOPBACK_NBO;
|
||||
return real_fn(s, redirect.as_ptr(), 16, send_buf, send_data_len, bytes_sent, overlapped);
|
||||
out.sin_port = new_port_nbo;
|
||||
out.sin_addr = new_addr;
|
||||
return real_fn(
|
||||
s,
|
||||
redirect.as_ptr(),
|
||||
16,
|
||||
send_buf,
|
||||
send_data_len,
|
||||
bytes_sent,
|
||||
overlapped,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
real_fn(s, name, namelen, send_buf, send_data_len, bytes_sent, overlapped)
|
||||
real_fn(
|
||||
s,
|
||||
name,
|
||||
namelen,
|
||||
send_buf,
|
||||
send_data_len,
|
||||
bytes_sent,
|
||||
overlapped,
|
||||
)
|
||||
}
|
||||
|
||||
/// Our WSAIoctl hook: when ConnectEx is requested, save the real pointer and return ours
|
||||
@@ -138,25 +157,25 @@ pub unsafe extern "system" fn hooked_wsaioctl(
|
||||
restore_wsaioctl(addr);
|
||||
let result = {
|
||||
let f: WsaIoctlFn = core::mem::transmute(addr);
|
||||
f(s, code, in_buf, in_len, out_buf, out_len, bytes_ret, overlapped, completion)
|
||||
f(
|
||||
s, code, in_buf, in_len, out_buf, out_len, bytes_ret, overlapped, completion,
|
||||
)
|
||||
};
|
||||
write_hook(addr, hooked_wsaioctl as u64);
|
||||
|
||||
// If this was a ConnectEx request that succeeded, swap the pointer
|
||||
if result == 0
|
||||
&& code == SIO_GET_EXT_FN
|
||||
&& in_len == 16
|
||||
&& !in_buf.is_null()
|
||||
{
|
||||
if result == 0 && code == SIO_GET_EXT_FN && in_len == 16 && !in_buf.is_null() {
|
||||
let guid = core::slice::from_raw_parts(in_buf as *const u8, 16);
|
||||
if guid == CONNECTEX_GUID
|
||||
&& out_len >= 8
|
||||
&& !out_buf.is_null()
|
||||
{
|
||||
if guid == CONNECTEX_GUID && out_len >= 8 && !out_buf.is_null() {
|
||||
let out_ptr = out_buf as *mut usize;
|
||||
let real_addr = *out_ptr;
|
||||
if REAL_CONNECTEX.compare_exchange(0, real_addr, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
|
||||
crate::write_log(&format!("connectex_hook: intercepted ConnectEx @ {real_addr:#x}\n"));
|
||||
if REAL_CONNECTEX
|
||||
.compare_exchange(0, real_addr, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
crate::write_log(&format!(
|
||||
"connectex_hook: intercepted ConnectEx @ {real_addr:#x}\n"
|
||||
));
|
||||
}
|
||||
// Return our hook instead
|
||||
*out_ptr = hooked_connectex as usize;
|
||||
@@ -168,7 +187,9 @@ pub unsafe extern "system" fn hooked_wsaioctl(
|
||||
pub unsafe fn install_wsaioctl_hook() -> bool {
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
||||
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr());
|
||||
if ws2.is_null() { return false; }
|
||||
if ws2.is_null() {
|
||||
return false;
|
||||
}
|
||||
let fn_ptr = match GetProcAddress(ws2, b"WSAIoctl\0".as_ptr()) {
|
||||
Some(f) => f as *mut u8,
|
||||
None => return false,
|
||||
|
||||
+35
-20
@@ -1,22 +1,23 @@
|
||||
use std::{
|
||||
ffi::CStr,
|
||||
sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
OnceLock,
|
||||
},
|
||||
};
|
||||
|
||||
use windows_sys::Win32::Networking::WinSock::{ADDRINFOA, getaddrinfo as sys_getaddrinfo};
|
||||
use windows_sys::Win32::Networking::WinSock::{getaddrinfo as sys_getaddrinfo, ADDRINFOA};
|
||||
|
||||
type GetaddrinfoFn = unsafe extern "system" fn(
|
||||
*const u8,
|
||||
*const u8,
|
||||
*const ADDRINFOA,
|
||||
*mut *mut ADDRINFOA,
|
||||
) -> i32;
|
||||
type GetaddrinfoFn =
|
||||
unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32;
|
||||
|
||||
static REAL: OnceLock<GetaddrinfoFn> = OnceLock::new();
|
||||
static REDIRECT_IP: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
// NUL-terminated dotted-quad of the resolved OpenFUT server, built once at init
|
||||
// from the SAME shared config the socket hooks use. getaddrinfo redirects EA
|
||||
// hostnames here so DNS resolves to the configured server. If configuration was
|
||||
// missing/invalid this stays empty and EA hostnames are NOT redirected (no
|
||||
// loopback fallback).
|
||||
static REDIRECT_HOST: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
|
||||
// Flipped to true the first time we successfully apply the runtime cert patch.
|
||||
// The patch is deferred to here (rather than DllMain) because EAWebKit.dll may
|
||||
@@ -27,10 +28,12 @@ pub fn set_real(f: GetaddrinfoFn) {
|
||||
let _ = REAL.set(f);
|
||||
}
|
||||
|
||||
pub fn set_redirect_ip(ip: String) {
|
||||
let mut bytes = ip.into_bytes();
|
||||
/// Install the resolved redirect IPv4 (dotted-quad) getaddrinfo will hand back
|
||||
/// for EA hostnames. Called once at init from the shared resolved server.
|
||||
pub fn set_redirect_ip(ip: std::net::Ipv4Addr) {
|
||||
let mut bytes = ip.to_string().into_bytes();
|
||||
bytes.push(0);
|
||||
let _ = REDIRECT_IP.set(bytes);
|
||||
let _ = REDIRECT_HOST.set(bytes);
|
||||
}
|
||||
|
||||
/// Returns true if `host` is an EA / EA-Sports domain that should be redirected
|
||||
@@ -60,18 +63,30 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
|
||||
if !CERT_PATCHED.load(Ordering::Relaxed) {
|
||||
if crate::ssl_patch::patch_eawebkit_cert_verify() {
|
||||
CERT_PATCHED.store(true, Ordering::Relaxed);
|
||||
crate::write_log("openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n");
|
||||
crate::write_log(
|
||||
"openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n",
|
||||
);
|
||||
} else {
|
||||
crate::write_log("openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n");
|
||||
crate::write_log(
|
||||
"openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let redirect = REDIRECT_IP
|
||||
.get()
|
||||
.map(|v| v.as_ptr())
|
||||
.unwrap_or(b"127.0.0.1\0".as_ptr());
|
||||
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
|
||||
return real(redirect, service_name, hints, result);
|
||||
// Only redirect when a server was configured & resolved. If not,
|
||||
// fall through to the real resolver — we never invent a loopback
|
||||
// destination here.
|
||||
match REDIRECT_HOST.get() {
|
||||
Some(redirect) => {
|
||||
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
|
||||
return real(redirect.as_ptr(), service_name, hints, result);
|
||||
}
|
||||
None => {
|
||||
crate::write_log(
|
||||
"openfut_hook: EA host seen but no OpenFUT server configured — NOT redirecting\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-7
@@ -72,7 +72,11 @@ pub unsafe fn patch_iat(original_fn: *const (), hook_fn: *const ()) -> usize {
|
||||
}
|
||||
|
||||
/// Patch the IAT of a specific already-loaded DLL (e.g. b"EAWebKit.dll\0").
|
||||
pub unsafe fn patch_iat_in(module_name: &[u8], original_fn: *const (), hook_fn: *const ()) -> usize {
|
||||
pub unsafe fn patch_iat_in(
|
||||
module_name: &[u8],
|
||||
original_fn: *const (),
|
||||
hook_fn: *const (),
|
||||
) -> usize {
|
||||
let module = GetModuleHandleA(module_name.as_ptr());
|
||||
if module.is_null() {
|
||||
return 0;
|
||||
@@ -80,11 +84,7 @@ pub unsafe fn patch_iat_in(module_name: &[u8], original_fn: *const (), hook_fn:
|
||||
patch_module(module, original_fn, hook_fn)
|
||||
}
|
||||
|
||||
unsafe fn patch_module(
|
||||
module: HMODULE,
|
||||
original_fn: *const (),
|
||||
hook_fn: *const (),
|
||||
) -> usize {
|
||||
unsafe fn patch_module(module: HMODULE, original_fn: *const (), hook_fn: *const ()) -> usize {
|
||||
if module.is_null() {
|
||||
return 0;
|
||||
}
|
||||
@@ -117,7 +117,12 @@ unsafe fn patch_module(
|
||||
if val == original_fn as usize {
|
||||
let target = iat_slot.add(i) as *const std::ffi::c_void;
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target, std::mem::size_of::<usize>(), PAGE_EXECUTE_READWRITE, &mut old);
|
||||
VirtualProtect(
|
||||
target,
|
||||
std::mem::size_of::<usize>(),
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old,
|
||||
);
|
||||
*iat_slot.add(i) = hook_fn as usize;
|
||||
VirtualProtect(target, std::mem::size_of::<usize>(), old, &mut old);
|
||||
count += 1;
|
||||
|
||||
+119
-34
@@ -4,62 +4,115 @@ mod connectex_hook;
|
||||
mod hooks;
|
||||
mod iat;
|
||||
mod origin_spy;
|
||||
mod server;
|
||||
mod ssl_patch;
|
||||
mod tls_bypass;
|
||||
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{BOOL, HMODULE, TRUE},
|
||||
System::SystemServices::DLL_PROCESS_ATTACH,
|
||||
Networking::WinSock::ADDRINFOA,
|
||||
System::SystemServices::DLL_PROCESS_ATTACH,
|
||||
};
|
||||
|
||||
pub(crate) fn write_log(msg: &str) {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true).append(true)
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(r"C:\openfut_hook.log")
|
||||
{ let _ = f.write_all(msg.as_bytes()); }
|
||||
{
|
||||
let _ = f.write_all(msg.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL {
|
||||
if reason == DLL_PROCESS_ATTACH { install_hooks(module); }
|
||||
if reason == DLL_PROCESS_ATTACH {
|
||||
install_hooks(module);
|
||||
}
|
||||
TRUE
|
||||
}
|
||||
|
||||
unsafe fn install_hooks(module: HMODULE) {
|
||||
write_log("openfut_hook: DllMain fired\n");
|
||||
let ip = config::read_redirect_ip(module);
|
||||
hooks::set_redirect_ip(ip);
|
||||
|
||||
// Load the single source of truth for the OpenFUT destination. If it's
|
||||
// missing/invalid we log and install NO redirection — traffic is left alone
|
||||
// rather than silently sent to loopback.
|
||||
match config::load_config(module).and_then(|c| c.resolve()) {
|
||||
Ok(resolved) => {
|
||||
server::set(resolved);
|
||||
hooks::set_redirect_ip(resolved.redirect_ip);
|
||||
let o = resolved.redirect_ip.octets();
|
||||
write_log(&format!(
|
||||
"openfut_hook: OpenFUT server = {}.{}.{}.{} (https={} blaze_redir={} blaze_main={})\n",
|
||||
o[0], o[1], o[2], o[3],
|
||||
resolved.ports.https, resolved.ports.blaze_redirector, resolved.ports.blaze_main
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
write_log(&format!(
|
||||
"openfut_hook: NO OpenFUT server configured ({e}); redirection DISABLED. \
|
||||
Configure a server in the launcher and relaunch.\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0");
|
||||
if !ga.is_null() {
|
||||
let f: unsafe extern "system" fn(*const u8,*const u8,*const ADDRINFOA,*mut *mut ADDRINFOA)->i32
|
||||
= std::mem::transmute(ga);
|
||||
let f: unsafe extern "system" fn(
|
||||
*const u8,
|
||||
*const u8,
|
||||
*const ADDRINFOA,
|
||||
*mut *mut ADDRINFOA,
|
||||
) -> i32 = std::mem::transmute(ga);
|
||||
hooks::set_real(f);
|
||||
let n = iat::patch_iat(ga, hooks::hooked_getaddrinfo as *const ());
|
||||
let m = iat::patch_iat_in(b"EAWebKit.dll\0", ga, hooks::hooked_getaddrinfo as *const ());
|
||||
let m = iat::patch_iat_in(
|
||||
b"EAWebKit.dll\0",
|
||||
ga,
|
||||
hooks::hooked_getaddrinfo as *const (),
|
||||
);
|
||||
write_log(&format!("openfut_hook: getaddrinfo IAT patched {n}+{m}\n"));
|
||||
}
|
||||
|
||||
if ssl_patch::patch_main_exe_cert_verify() { write_log("ssl: main exe cert-verify patched\n"); }
|
||||
else { write_log("ssl: main exe cert-verify NOT FOUND\n"); }
|
||||
if ssl_patch::patch_eawebkit_cert_verify() { write_log("ssl: EAWebKit cert-verify patched\n"); }
|
||||
else { write_log("ssl: EAWebKit cert-verify deferred\n"); }
|
||||
if ssl_patch::patch_main_exe_cert_verify() {
|
||||
write_log("ssl: main exe cert-verify patched\n");
|
||||
} else {
|
||||
write_log("ssl: main exe cert-verify NOT FOUND\n");
|
||||
}
|
||||
if ssl_patch::patch_eawebkit_cert_verify() {
|
||||
write_log("ssl: EAWebKit cert-verify patched\n");
|
||||
} else {
|
||||
write_log("ssl: EAWebKit cert-verify deferred\n");
|
||||
}
|
||||
|
||||
if connect_hook::install_inline_connect_hook() { write_log("connect: inline-hooked\n"); }
|
||||
else { write_log("connect: hook FAILED\n"); }
|
||||
if connect_hook::install_inline_connect_hook() {
|
||||
write_log("connect: inline-hooked\n");
|
||||
} else {
|
||||
write_log("connect: hook FAILED\n");
|
||||
}
|
||||
let wp = iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0");
|
||||
if !wp.is_null() {
|
||||
let f: unsafe extern "system" fn(usize,*const u8,i32,*const(),*const(),*const(),*const())->i32
|
||||
= std::mem::transmute(wp);
|
||||
let f: unsafe extern "system" fn(
|
||||
usize,
|
||||
*const u8,
|
||||
i32,
|
||||
*const (),
|
||||
*const (),
|
||||
*const (),
|
||||
*const (),
|
||||
) -> i32 = std::mem::transmute(wp);
|
||||
connect_hook::set_real_wsa_connect(f);
|
||||
iat::patch_iat(wp, connect_hook::hooked_wsa_connect as *const ());
|
||||
write_log("connect: WSAConnect IAT patched\n");
|
||||
}
|
||||
|
||||
if connectex_hook::install_wsaioctl_hook() { write_log("connectex: WSAIoctl inline-hooked\n"); }
|
||||
else { write_log("connectex: WSAIoctl hook FAILED\n"); }
|
||||
if connectex_hook::install_wsaioctl_hook() {
|
||||
write_log("connectex: WSAIoctl inline-hooked\n");
|
||||
} else {
|
||||
write_log("connectex: WSAIoctl hook FAILED\n");
|
||||
}
|
||||
|
||||
// recv/send hooks removed — LSX is now handled by the native openfut-bridge
|
||||
// LSX server (port 3216), so in-process interception is no longer needed.
|
||||
@@ -72,31 +125,63 @@ unsafe fn install_hooks(module: HMODULE) {
|
||||
origin_spy::$setter(f);
|
||||
iat::patch_iat(ptr, $handler as *const ());
|
||||
"ok"
|
||||
} else { "miss" }
|
||||
} else {
|
||||
"miss"
|
||||
}
|
||||
}};
|
||||
}
|
||||
let ra = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExA\0", set_real_reg_a,
|
||||
let ra = hook_iat!(
|
||||
b"advapi32.dll\0",
|
||||
b"RegQueryValueExA\0",
|
||||
set_real_reg_a,
|
||||
origin_spy::hooked_reg_query_a,
|
||||
unsafe extern "system" fn(isize,*const u8,*mut u32,*mut u32,*mut u8,*mut u32)->i32);
|
||||
let rw = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExW\0", set_real_reg_w,
|
||||
unsafe extern "system" fn(isize, *const u8, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
|
||||
);
|
||||
let rw = hook_iat!(
|
||||
b"advapi32.dll\0",
|
||||
b"RegQueryValueExW\0",
|
||||
set_real_reg_w,
|
||||
origin_spy::hooked_reg_query_w,
|
||||
unsafe extern "system" fn(isize,*const u16,*mut u32,*mut u32,*mut u8,*mut u32)->i32);
|
||||
let ma = hook_iat!(b"kernel32.dll\0", b"OpenMutexA\0", set_real_mutex_a,
|
||||
unsafe extern "system" fn(isize, *const u16, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
|
||||
);
|
||||
let ma = hook_iat!(
|
||||
b"kernel32.dll\0",
|
||||
b"OpenMutexA\0",
|
||||
set_real_mutex_a,
|
||||
origin_spy::hooked_open_mutex_a,
|
||||
unsafe extern "system" fn(u32,i32,*const u8)->isize);
|
||||
let mw = hook_iat!(b"kernel32.dll\0", b"OpenMutexW\0", set_real_mutex_w,
|
||||
unsafe extern "system" fn(u32, i32, *const u8) -> isize
|
||||
);
|
||||
let mw = hook_iat!(
|
||||
b"kernel32.dll\0",
|
||||
b"OpenMutexW\0",
|
||||
set_real_mutex_w,
|
||||
origin_spy::hooked_open_mutex_w,
|
||||
unsafe extern "system" fn(u32,i32,*const u16)->isize);
|
||||
write_log(&format!("origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n"));
|
||||
unsafe extern "system" fn(u32, i32, *const u16) -> isize
|
||||
);
|
||||
write_log(&format!(
|
||||
"origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n"
|
||||
));
|
||||
|
||||
let cv = iat::resolve(b"crypt32.dll\0", b"CertVerifyCertificateChainPolicy\0");
|
||||
if !cv.is_null() {
|
||||
let f: unsafe extern "system" fn(*const u8,*const(),*const(),*mut u32)->BOOL
|
||||
= std::mem::transmute(cv);
|
||||
let f: unsafe extern "system" fn(*const u8, *const (), *const (), *mut u32) -> BOOL =
|
||||
std::mem::transmute(cv);
|
||||
tls_bypass::set_real(f);
|
||||
iat::patch_iat(cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
|
||||
iat::patch_iat_in(b"EAWebKit.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
|
||||
iat::patch_iat_in(b"winhttp.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
|
||||
iat::patch_iat_in(b"wininet.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
|
||||
iat::patch_iat_in(
|
||||
b"EAWebKit.dll\0",
|
||||
cv,
|
||||
tls_bypass::hooked_cert_verify_chain_policy as *const (),
|
||||
);
|
||||
iat::patch_iat_in(
|
||||
b"winhttp.dll\0",
|
||||
cv,
|
||||
tls_bypass::hooked_cert_verify_chain_policy as *const (),
|
||||
);
|
||||
iat::patch_iat_in(
|
||||
b"wininet.dll\0",
|
||||
cv,
|
||||
tls_bypass::hooked_cert_verify_chain_policy as *const (),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,28 +27,49 @@ static REAL_REG_W: OnceLock<RegQueryValueExWFn> = OnceLock::new();
|
||||
static REAL_MUTEX_A: OnceLock<OpenMutexAFn> = OnceLock::new();
|
||||
static REAL_MUTEX_W: OnceLock<OpenMutexWFn> = OnceLock::new();
|
||||
|
||||
pub fn set_real_reg_a(f: RegQueryValueExAFn) { let _ = REAL_REG_A.set(f); }
|
||||
pub fn set_real_reg_w(f: RegQueryValueExWFn) { let _ = REAL_REG_W.set(f); }
|
||||
pub fn set_real_mutex_a(f: OpenMutexAFn) { let _ = REAL_MUTEX_A.set(f); }
|
||||
pub fn set_real_mutex_w(f: OpenMutexWFn) { let _ = REAL_MUTEX_W.set(f); }
|
||||
pub fn set_real_reg_a(f: RegQueryValueExAFn) {
|
||||
let _ = REAL_REG_A.set(f);
|
||||
}
|
||||
pub fn set_real_reg_w(f: RegQueryValueExWFn) {
|
||||
let _ = REAL_REG_W.set(f);
|
||||
}
|
||||
pub fn set_real_mutex_a(f: OpenMutexAFn) {
|
||||
let _ = REAL_MUTEX_A.set(f);
|
||||
}
|
||||
pub fn set_real_mutex_w(f: OpenMutexWFn) {
|
||||
let _ = REAL_MUTEX_W.set(f);
|
||||
}
|
||||
|
||||
fn narrow_to_string(p: *const u8) -> String {
|
||||
if p.is_null() { return "(null)".into(); }
|
||||
if p.is_null() {
|
||||
return "(null)".into();
|
||||
}
|
||||
let bytes = unsafe { std::ffi::CStr::from_ptr(p as *const i8) };
|
||||
bytes.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn wide_to_string(p: *const u16) -> String {
|
||||
if p.is_null() { return "(null)".into(); }
|
||||
if p.is_null() {
|
||||
return "(null)".into();
|
||||
}
|
||||
let mut len = 0usize;
|
||||
unsafe { while *p.add(len) != 0 { len += 1; } }
|
||||
unsafe {
|
||||
while *p.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) })
|
||||
}
|
||||
|
||||
fn is_interesting(name: &str) -> bool {
|
||||
name.contains("LSX") || name.contains("Origin") || name.contains("EAL") ||
|
||||
name.contains("Client") || name.contains("lsx") || name.contains("Port") ||
|
||||
name.contains("EA") || name.contains("Connection")
|
||||
name.contains("LSX")
|
||||
|| name.contains("Origin")
|
||||
|| name.contains("EAL")
|
||||
|| name.contains("Client")
|
||||
|| name.contains("lsx")
|
||||
|| name.contains("Port")
|
||||
|| name.contains("EA")
|
||||
|| name.contains("Connection")
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_reg_query_a(
|
||||
@@ -93,8 +114,10 @@ pub unsafe extern "system" fn hooked_open_mutex_a(
|
||||
let name = narrow_to_string(lpmutexname);
|
||||
let real = REAL_MUTEX_A.get().copied().unwrap();
|
||||
let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
|
||||
crate::write_log(&format!("origin_spy: OpenMutexA({name}) → {}\n",
|
||||
if handle == 0 { "NOT_FOUND" } else { "FOUND" }));
|
||||
crate::write_log(&format!(
|
||||
"origin_spy: OpenMutexA({name}) → {}\n",
|
||||
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
|
||||
));
|
||||
handle
|
||||
}
|
||||
|
||||
@@ -106,7 +129,9 @@ pub unsafe extern "system" fn hooked_open_mutex_w(
|
||||
let name = wide_to_string(lpmutexname);
|
||||
let real = REAL_MUTEX_W.get().copied().unwrap();
|
||||
let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
|
||||
crate::write_log(&format!("origin_spy: OpenMutexW({name}) → {}\n",
|
||||
if handle == 0 { "NOT_FOUND" } else { "FOUND" }));
|
||||
crate::write_log(&format!(
|
||||
"origin_spy: OpenMutexW({name}) → {}\n",
|
||||
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
|
||||
));
|
||||
handle
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! The single, process-wide resolved OpenFUT destination.
|
||||
//!
|
||||
//! All three interception layers — `getaddrinfo`, `connect`, and `ConnectEx` —
|
||||
//! read the destination from HERE. There is no per-hook redirect state. The
|
||||
//! value is set exactly once during DLL init (after `openfut.cfg` is parsed and
|
||||
//! the host resolved) and is never mutated afterward.
|
||||
//!
|
||||
//! If configuration was missing/invalid, this is never populated, and every
|
||||
//! hook leaves traffic untouched (no loopback fallback).
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use openfut_common::{sin_addr_from_ipv4, sin_port_nbo, ResolvedServer};
|
||||
|
||||
static SERVER: OnceLock<ResolvedServer> = OnceLock::new();
|
||||
|
||||
/// Install the resolved destination. Called once from DLL init. Ignores
|
||||
/// subsequent calls (OnceLock semantics).
|
||||
pub fn set(resolved: ResolvedServer) {
|
||||
let _ = SERVER.set(resolved);
|
||||
}
|
||||
|
||||
/// The resolved destination, if configuration succeeded.
|
||||
pub fn get() -> Option<ResolvedServer> {
|
||||
SERVER.get().copied()
|
||||
}
|
||||
|
||||
/// The resolved redirect IPv4, if configured.
|
||||
pub fn redirect_ip() -> Option<Ipv4Addr> {
|
||||
SERVER.get().map(|s| s.redirect_ip)
|
||||
}
|
||||
|
||||
/// `sockaddr_in.sin_addr` value (native-endian u32) for the configured server.
|
||||
pub fn sin_addr() -> Option<u32> {
|
||||
SERVER.get().map(|s| sin_addr_from_ipv4(s.redirect_ip))
|
||||
}
|
||||
|
||||
/// Given an EA *source* port (network byte order, as seen in `sockaddr_in`),
|
||||
/// return the OpenFUT *destination* port in network byte order — or `None` if
|
||||
/// this port isn't intercepted or no server is configured.
|
||||
pub fn dest_port_nbo_from_source_nbo(source_port_nbo: u16) -> Option<u16> {
|
||||
let s = SERVER.get()?;
|
||||
let source_host = u16::from_be(source_port_nbo);
|
||||
s.ports.map_source_port(source_host).map(sin_port_nbo)
|
||||
}
|
||||
@@ -9,11 +9,9 @@
|
||||
// server's certificate chain. Always returning 1 is equivalent to trusting all certs,
|
||||
// which is the behaviour we want for the local self-signed bridge certificate.
|
||||
|
||||
use windows_sys::Win32::{
|
||||
System::{
|
||||
LibraryLoader::GetModuleHandleA,
|
||||
Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE},
|
||||
},
|
||||
use windows_sys::Win32::System::{
|
||||
LibraryLoader::GetModuleHandleA,
|
||||
Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE},
|
||||
};
|
||||
|
||||
// Unique 22-byte prologue of ProtoSSL's cert-verify function.
|
||||
@@ -21,24 +19,26 @@ use windows_sys::Win32::{
|
||||
const PROLOGUE: &[u8] = &[
|
||||
0x44, 0x89, 0x44, 0x24, 0x18, // mov [rsp+0x18], r8d
|
||||
0x48, 0x89, 0x54, 0x24, 0x10, // mov [rsp+0x10], rdx
|
||||
0x56, // push rsi
|
||||
0x57, // push rdi
|
||||
0x41, 0x55, // push r13
|
||||
0x41, 0x56, // push r14
|
||||
0x41, 0x57, // push r15
|
||||
0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30
|
||||
0x56, // push rsi
|
||||
0x57, // push rdi
|
||||
0x41, 0x55, // push r13
|
||||
0x41, 0x56, // push r14
|
||||
0x41, 0x57, // push r15
|
||||
0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30
|
||||
];
|
||||
|
||||
// Return 0 (PROTOSSL_ERROR_NONE = success). ProtoSSL convention: 0 = ok, negative = error.
|
||||
// The function sets r15d = 0xFFFFFFFF (-1) for its own error returns, confirming 0 = success.
|
||||
const PATCH: &[u8] = &[
|
||||
0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE)
|
||||
0xc3, // ret
|
||||
0x90, 0x90, 0x90, // nop padding
|
||||
0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE)
|
||||
0xc3, // ret
|
||||
0x90, 0x90, 0x90, // nop padding
|
||||
];
|
||||
|
||||
fn patch_module(module: isize, scan_bytes: usize) -> bool {
|
||||
if module == 0 { return false; }
|
||||
if module == 0 {
|
||||
return false;
|
||||
}
|
||||
let base = module as usize;
|
||||
let image: &[u8] = unsafe { core::slice::from_raw_parts(base as *const u8, scan_bytes) };
|
||||
let offset = match image.windows(PROLOGUE.len()).position(|w| w == PROLOGUE) {
|
||||
@@ -48,9 +48,19 @@ fn patch_module(module: isize, scan_bytes: usize) -> bool {
|
||||
let target = (base + offset) as *mut u8;
|
||||
let mut old_prot: u32 = 0;
|
||||
unsafe {
|
||||
VirtualProtect(target as *const core::ffi::c_void, PATCH.len(), PAGE_EXECUTE_READWRITE, &mut old_prot);
|
||||
VirtualProtect(
|
||||
target as *const core::ffi::c_void,
|
||||
PATCH.len(),
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old_prot,
|
||||
);
|
||||
core::ptr::copy_nonoverlapping(PATCH.as_ptr(), target, PATCH.len());
|
||||
VirtualProtect(target as *const core::ffi::c_void, PATCH.len(), old_prot, &mut old_prot);
|
||||
VirtualProtect(
|
||||
target as *const core::ffi::c_void,
|
||||
PATCH.len(),
|
||||
old_prot,
|
||||
&mut old_prot,
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ use windows_sys::Win32::Foundation::BOOL;
|
||||
// CERT_CHAIN_POLICY_STATUS.dwError offset 0 = u32 error code; 0 = success.
|
||||
// We use raw pointers to avoid pulling in the full Cryptography struct tree.
|
||||
type CertVerifyChainPolicyFn = unsafe extern "system" fn(
|
||||
*const u8, // pszPolicyOID
|
||||
*const (), // pChainContext
|
||||
*const (), // pPolicyPara
|
||||
*mut u32, // &mut pPolicyStatus.dwError (first field)
|
||||
*const u8, // pszPolicyOID
|
||||
*const (), // pChainContext
|
||||
*const (), // pPolicyPara
|
||||
*mut u32, // &mut pPolicyStatus.dwError (first field)
|
||||
) -> BOOL;
|
||||
|
||||
static REAL: OnceLock<CertVerifyChainPolicyFn> = OnceLock::new();
|
||||
@@ -25,7 +25,12 @@ pub unsafe extern "system" fn hooked_cert_verify_chain_policy(
|
||||
p_policy_status: *mut u32,
|
||||
) -> BOOL {
|
||||
if let Some(real) = REAL.get().copied() {
|
||||
real(psz_policy_oid, p_chain_context, p_policy_para, p_policy_status);
|
||||
real(
|
||||
psz_policy_oid,
|
||||
p_chain_context,
|
||||
p_policy_para,
|
||||
p_policy_status,
|
||||
);
|
||||
}
|
||||
// Clear the error field of CERT_CHAIN_POLICY_STATUS regardless
|
||||
if !p_policy_status.is_null() {
|
||||
|
||||
@@ -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<AccountSummary, String> {
|
||||
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::<u16>().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::<usize>()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
+807
-321
File diff suppressed because it is too large
Load Diff
+239
@@ -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<String> {
|
||||
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<String> {
|
||||
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<Vec<String>> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
+605
-18
@@ -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<String, String>,
|
||||
#[serde(default)]
|
||||
pub prefix_links: Vec<PrefixLink>,
|
||||
#[serde(default)]
|
||||
pub license: Option<LicenseCheck>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
|
||||
// ── 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<String, String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! FIFA 17 verified patched-client capability negotiation (launcher side).
|
||||
//!
|
||||
//! The FIFA 17 backend suppresses its synthetic empty-My-Packs sentinel (pack id
|
||||
//! 65534) only when the *current* FIFA process has positively verified the
|
||||
//! CardsDLL resolver guard. autopatch proves that at runtime and advertises it on
|
||||
//! its stdout; the launcher parses that line, records the capability for the live
|
||||
//! FIFA process, and registers it with the backend over the same tiny stdlib-HTTP
|
||||
//! transport used by [`crate::account_sync`]. See
|
||||
//! `docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md`.
|
||||
//!
|
||||
//! Everything here is fail-closed: a line we cannot parse, or a registration POST
|
||||
//! that fails, simply leaves the backend on its default active-sentinel path.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
const CAPABILITY_NAME: &str = "empty_mypacks_resolver";
|
||||
const CAPABILITY_PATH: &str = "/openfut/fifa17/capability";
|
||||
const TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Capabilities verified for the *current* FIFA process. Starts UNKNOWN at each
|
||||
/// launch and is discarded when that FIFA process ends — it is never persisted,
|
||||
/// so a previous launch's capability can never leak into a later one.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Fifa17ClientCapabilities {
|
||||
/// `Some(version)` once autopatch has verified the resolver guard for the
|
||||
/// live FIFA process; `None` while unknown / unverified.
|
||||
pub empty_mypacks_resolver: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CapabilityRegistration<'a> {
|
||||
capability: &'a str,
|
||||
version: u32,
|
||||
persona_id: u64,
|
||||
fifa_pid: u64,
|
||||
}
|
||||
|
||||
/// Pure parser for an autopatch stdout line. Returns `Some(version)` iff the raw
|
||||
/// line advertises the capability — it must contain both `verified capability`
|
||||
/// and `fifa17.empty_mypacks_resolver=<N>` (with `<N>` a `u32`). Non-advertising
|
||||
/// lines (e.g. `guard status=UNSUPPORTED_BUILD …`) and unrelated log output
|
||||
/// return `None`. Robust to a trailing ` fifa_pid=<pid>`.
|
||||
pub fn parse_capability_line(line: &str) -> Option<u32> {
|
||||
if !line.contains("verified capability") {
|
||||
return None;
|
||||
}
|
||||
parse_u32_after(line, "fifa17.empty_mypacks_resolver=")
|
||||
}
|
||||
|
||||
/// Extract the FIFA pid from a `fifa_pid=<n>` token if present.
|
||||
pub fn parse_fifa_pid(line: &str) -> Option<u64> {
|
||||
let digits = digits_after(line, "fifa_pid=")?;
|
||||
digits.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn parse_u32_after(line: &str, marker: &str) -> Option<u32> {
|
||||
digits_after(line, marker)?.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
fn digits_after<'a>(line: &'a str, marker: &str) -> Option<&'a str> {
|
||||
let start = line.find(marker)? + marker.len();
|
||||
let rest = &line[start..];
|
||||
let end = rest
|
||||
.find(|c: char| !c.is_ascii_digit())
|
||||
.unwrap_or(rest.len());
|
||||
if end == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(&rest[..end])
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the verified capability with the backend via `POST
|
||||
/// /openfut/fifa17/capability`. Modeled exactly on [`crate::account_sync::sync`]:
|
||||
/// a tiny stdlib `TcpStream` client, `Connection: close`, 3s timeouts, status
|
||||
/// line parsed, and any non-2xx (or connect/IO error) returned as `Err`. The
|
||||
/// caller logs the outcome; a failure is fail-closed — the backend records
|
||||
/// nothing and keeps the sentinel.
|
||||
pub fn register(
|
||||
host: &str,
|
||||
port: u16,
|
||||
persona_id: u64,
|
||||
fifa_pid: u64,
|
||||
version: u32,
|
||||
) -> Result<(), String> {
|
||||
let host = host.trim();
|
||||
let address = (host, port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|error| format!("cannot resolve capability server {host}:{port}: {error}"))?
|
||||
.next()
|
||||
.ok_or_else(|| format!("capability server {host}:{port} resolved to no addresses"))?;
|
||||
let mut stream = TcpStream::connect_timeout(&address, TIMEOUT)
|
||||
.map_err(|error| format!("cannot connect to capability server {host}:{port}: {error}"))?;
|
||||
stream
|
||||
.set_read_timeout(Some(TIMEOUT))
|
||||
.map_err(|error| format!("cannot set capability timeout: {error}"))?;
|
||||
stream
|
||||
.set_write_timeout(Some(TIMEOUT))
|
||||
.map_err(|error| format!("cannot set capability timeout: {error}"))?;
|
||||
|
||||
let payload = serde_json::to_vec(&CapabilityRegistration {
|
||||
capability: CAPABILITY_NAME,
|
||||
version,
|
||||
persona_id,
|
||||
fifa_pid,
|
||||
})
|
||||
.map_err(|error| format!("cannot encode capability request: {error}"))?;
|
||||
|
||||
let request = format!(
|
||||
"POST {CAPABILITY_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 capability request: {error}"))?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut response)
|
||||
.map_err(|error| format!("cannot read capability response: {error}"))?;
|
||||
let separator = response
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.ok_or_else(|| "capability server returned a malformed HTTP response".to_string())?;
|
||||
let headers = std::str::from_utf8(&response[..separator])
|
||||
.map_err(|_| "capability 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::<u16>().ok())
|
||||
.ok_or_else(|| "capability server returned a malformed status line".to_string())?;
|
||||
if !(200..300).contains(&status) {
|
||||
let detail = String::from_utf8_lossy(&response[separator + 4..]);
|
||||
return Err(format!(
|
||||
"capability server rejected registration (HTTP {status}): {detail}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn parses_the_verified_capability_line() {
|
||||
let line =
|
||||
"[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242";
|
||||
assert_eq!(parse_capability_line(line), Some(1));
|
||||
assert_eq!(parse_fifa_pid(line), Some(4242));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_advertising_status_line_yields_none() {
|
||||
let line =
|
||||
"[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=4242 (no capability advertised)";
|
||||
assert_eq!(parse_capability_line(line), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_log_line_yields_none() {
|
||||
let line = "[autopatch] patched /proc/4242/mem at rva 0x14858";
|
||||
assert_eq!(parse_capability_line(line), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_gating_is_left_to_the_backend() {
|
||||
let line = "[store-guard] verified capability fifa17.empty_mypacks_resolver=2 fifa_pid=7";
|
||||
assert_eq!(parse_capability_line(line), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_posts_capability_to_the_backend() {
|
||||
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::<usize>()
|
||||
.unwrap();
|
||||
if request.len() >= separator + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&request);
|
||||
assert!(request.starts_with("POST /openfut/fifa17/capability HTTP/1.1"));
|
||||
assert!(request.contains("\"capability\":\"empty_mypacks_resolver\""));
|
||||
assert!(request.contains("\"version\":1"));
|
||||
assert!(request.contains("\"personaId\":12345678"));
|
||||
assert!(request.contains("\"fifaPid\":4242"));
|
||||
let body = r#"{"status":"OK"}"#;
|
||||
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();
|
||||
});
|
||||
|
||||
register("127.0.0.1", port, 12345678, 4242, 1).unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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<Mutex<LogBuffer>>;
|
||||
|
||||
fn say(log: &Log, msg: impl Into<String>) {
|
||||
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 <target> <link>`:
|
||||
/// 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}");
|
||||
}
|
||||
}
|
||||
+133
@@ -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<bool>,
|
||||
pub detail: String,
|
||||
pub last_checked: Option<Instant>,
|
||||
}
|
||||
|
||||
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<Mutex<HealthState>>,
|
||||
target: Arc<Mutex<Option<(String, u16)>>>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl HealthMonitor {
|
||||
pub fn new() -> Self {
|
||||
let state = Arc::new(Mutex::new(HealthState::default()));
|
||||
let target: Arc<Mutex<Option<(String, u16)>>> = 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::<Vec<_>>(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! FIFA 17 local companion services — LSX (Origin emulator) + autopatch
|
||||
//! (ProtoSSL cert-verify memory patcher). Both are inherently local to the game
|
||||
//! machine and are managed by the launcher as child processes, mirroring the way
|
||||
//! `setup::launch_game` spawns and log-streams the game.
|
||||
//!
|
||||
//! WHY THESE TWO ARE LOCAL (and the rest is not): the heavy FUT responders
|
||||
//! (Blaze / UTAS / roster / POW) run in the server container. LSX must stay here
|
||||
//! because the game dials it on the hardcoded loopback `127.0.0.1:4216`;
|
||||
//! autopatch must stay here because it writes `/proc/<FIFA17.exe>/mem`.
|
||||
//!
|
||||
//! Lifecycle: each service is a long-running daemon. We keep the `Child` handle
|
||||
//! so the UI can show running/stopped and stop them. Both run as the launcher
|
||||
//! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous
|
||||
//! Polkit prompt delaying cert patching until after FIFA's first TLS attempt.
|
||||
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
use crate::fifa17_capability::{
|
||||
parse_capability_line, parse_fifa_pid, register, Fifa17ClientCapabilities,
|
||||
};
|
||||
use crate::logs::LogBuffer;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct CommandParts {
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which companion service. The `str` values are used in log prefixes.
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
pub enum Service {
|
||||
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
|
||||
Lsx,
|
||||
/// autopatch — patches FIFA17.exe process memory after host ptrace arming.
|
||||
Autopatch,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Service::Lsx => "LSX",
|
||||
Service::Autopatch => "autopatch",
|
||||
}
|
||||
}
|
||||
|
||||
/// The responder script filename inside the tools dir.
|
||||
fn script(self) -> &'static str {
|
||||
match self {
|
||||
Service::Lsx => "lsx_responder_v2.py",
|
||||
Service::Autopatch => "autopatch.py",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts {
|
||||
let mut args = vec![tools_dir
|
||||
.join(service.script())
|
||||
.to_string_lossy()
|
||||
.into_owned()];
|
||||
if service == Service::Autopatch {
|
||||
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
|
||||
}
|
||||
CommandParts {
|
||||
program: python.to_string(),
|
||||
args,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stop_work<F>(work: F) -> mpsc::Receiver<anyhow::Result<()>>
|
||||
where
|
||||
F: FnOnce() -> anyhow::Result<()> + Send + 'static,
|
||||
{
|
||||
let (send, receive) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = send.send(work());
|
||||
});
|
||||
receive
|
||||
}
|
||||
|
||||
fn wait_for_listener_ready(
|
||||
child: &mut Child,
|
||||
address: SocketAddr,
|
||||
timeout: Duration,
|
||||
) -> anyhow::Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
// Let immediate startup/bind errors surface before accepting an occupied
|
||||
// port as evidence that this child became ready.
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
loop {
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.map_err(|error| anyhow::anyhow!("could not inspect LSX startup: {error}"))?
|
||||
{
|
||||
anyhow::bail!("LSX exited before becoming ready ({status}); port 4216 may be in use");
|
||||
}
|
||||
match TcpListener::bind(address) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => return Ok(()),
|
||||
Err(error) => anyhow::bail!("could not probe LSX listener {address}: {error}"),
|
||||
Ok(listener) => drop(listener),
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"LSX did not bind {address} within {} ms",
|
||||
timeout.as_millis()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// A managed companion service process.
|
||||
#[derive(Default)]
|
||||
pub struct ManagedService {
|
||||
child: Option<Child>,
|
||||
stopping: Option<mpsc::Receiver<anyhow::Result<()>>>,
|
||||
}
|
||||
|
||||
impl ManagedService {
|
||||
/// Wrap an already-spawned child.
|
||||
pub fn from_child(child: Child) -> Self {
|
||||
Self {
|
||||
child: Some(child),
|
||||
stopping: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True while the child is spawned and has not yet exited. Reaps the exit
|
||||
/// status if it has, so the UI reflects a service that died on its own.
|
||||
pub fn running(&mut self, log: &Arc<Mutex<LogBuffer>>, label: &str) -> bool {
|
||||
if let Some(result) = self.stopping.as_ref() {
|
||||
match result.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] {label} stopped."));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] failed to stop {label}: {error}"));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => return true,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
log.lock().unwrap().push(format!(
|
||||
"[launcher] {label} stop worker exited unexpectedly."
|
||||
));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match self.child.as_mut() {
|
||||
None => false,
|
||||
Some(c) => match c.try_wait() {
|
||||
Ok(None) => true,
|
||||
Ok(Some(status)) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] {label} exited ({status})."));
|
||||
self.child = None;
|
||||
false
|
||||
}
|
||||
Err(_) => true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stopping(&self) -> bool {
|
||||
self.stopping.is_some()
|
||||
}
|
||||
|
||||
/// Begin stopping the service without waiting on the egui UI thread.
|
||||
pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) {
|
||||
if self.stopping.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let label = service.label();
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] stopping {label}…"));
|
||||
|
||||
self.stopping = Some(dispatch_stop_work(move || {
|
||||
child
|
||||
.kill()
|
||||
.map_err(|error| anyhow::anyhow!("kill failed: {error}"))?;
|
||||
|
||||
child
|
||||
.wait()
|
||||
.map_err(|error| anyhow::anyhow!("reap failed: {error}"))?;
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedService {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut c) = self.child.take() {
|
||||
let _ = c.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-registration wiring handed to the autopatch stdout reader so a
|
||||
/// verified resolver-guard line can advertise the per-FIFA-process capability to
|
||||
/// the backend. `Some(..)` for autopatch; `None` for LSX.
|
||||
pub struct CapabilityWiring {
|
||||
pub server_host: String,
|
||||
pub account_sync_port: u16,
|
||||
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// `capability` is the backend-registration wiring + shared per-FIFA-process
|
||||
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the
|
||||
/// verified resolver guard) and `None` for LSX.
|
||||
pub fn spawn(
|
||||
service: Service,
|
||||
python: &str,
|
||||
tools_dir: &str,
|
||||
persona_id: u64,
|
||||
persona_name: &str,
|
||||
capability: Option<CapabilityWiring>,
|
||||
log: Arc<Mutex<LogBuffer>>,
|
||||
) -> anyhow::Result<Child> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
let dir = Path::new(tools_dir);
|
||||
if !dir.is_dir() {
|
||||
anyhow::bail!(
|
||||
"FIFA 17 tools dir not found: {} (set it in the Config tab)",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
let script_path = dir.join(service.script());
|
||||
if !script_path.exists() {
|
||||
anyhow::bail!(
|
||||
"{} not found in tools dir: {}",
|
||||
service.script(),
|
||||
script_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let label = service.label();
|
||||
|
||||
// Both services use the configured interpreter and absolute script path;
|
||||
// neither invents a Python installation path. Autopatch receives launcher
|
||||
// ownership and a per-user runtime log so stale root-owned /tmp files cannot
|
||||
// block startup.
|
||||
let parts = command_parts(service, python, dir);
|
||||
let mut cmd = Command::new(&parts.program);
|
||||
cmd.args(&parts.args);
|
||||
if service == Service::Lsx {
|
||||
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
|
||||
.env("FUT_PERSONA_NAME", persona_name);
|
||||
} else if service == Service::Autopatch {
|
||||
let log_path = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join("openfut-autopatch.log");
|
||||
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
|
||||
}
|
||||
// Put each companion in its own process group for lifecycle isolation.
|
||||
cmd.process_group(0);
|
||||
cmd.current_dir(dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
log.lock().unwrap().push(format!(
|
||||
"[launcher] starting {label}: {} {}",
|
||||
python,
|
||||
script_path.display(),
|
||||
));
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.script()))?;
|
||||
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let buf = Arc::clone(&log);
|
||||
let lbl = label.to_string();
|
||||
// Only autopatch carries capability wiring; LSX passes `None`.
|
||||
let cap_wiring = capability;
|
||||
let cap_persona = persona_id;
|
||||
std::thread::spawn(move || {
|
||||
// Fires the backend registration at most once per FIFA process.
|
||||
let mut registered = false;
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
// Every raw line is still mirrored into the log, as before.
|
||||
buf.lock().unwrap().push(format!("[{lbl}] {line}"));
|
||||
|
||||
let Some(wiring) = cap_wiring.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if registered {
|
||||
continue;
|
||||
}
|
||||
let Some(version) = parse_capability_line(&line) else {
|
||||
continue;
|
||||
};
|
||||
registered = true;
|
||||
let fifa_pid = parse_fifa_pid(&line).unwrap_or(0);
|
||||
wiring.sink.lock().unwrap().empty_mypacks_resolver = Some(version);
|
||||
{
|
||||
let mut log = buf.lock().unwrap();
|
||||
log.push(format!(
|
||||
"[fifa17] resolver capability verified for FIFA pid {fifa_pid}"
|
||||
));
|
||||
log.push(format!(
|
||||
"[fifa17] registering capability for session (persona {cap_persona})"
|
||||
));
|
||||
}
|
||||
match register(
|
||||
&wiring.server_host,
|
||||
wiring.account_sync_port,
|
||||
cap_persona,
|
||||
fifa_pid,
|
||||
version,
|
||||
) {
|
||||
Ok(()) => buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("[fifa17] capability registered with backend".to_string()),
|
||||
Err(error) => buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("[fifa17] capability registration failed: {error}")),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
let buf = Arc::clone(&log);
|
||||
let lbl = label.to_string();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().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"));
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -1,7 +1,14 @@
|
||||
mod account_sync;
|
||||
mod app;
|
||||
mod arm;
|
||||
mod config;
|
||||
mod fifa17_capability;
|
||||
mod game_launch;
|
||||
mod health;
|
||||
mod local_services;
|
||||
mod logs;
|
||||
mod process;
|
||||
mod netcheck;
|
||||
mod preflight;
|
||||
mod setup;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
|
||||
@@ -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<bool> {
|
||||
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\""))
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Pre-launch checks for the client-side state FIFA depends on.
|
||||
//!
|
||||
//! # Why
|
||||
//!
|
||||
//! On 2026-08-11 the game machine rebooted. Everything `client_arm.sh` sets —
|
||||
//! `ptrace_scope=0`, the DNAT of EA's hardcoded redirector IP, the
|
||||
//! `easw.easports.com` mapping — is volatile and was silently gone. The launcher
|
||||
//! started, the local services started, the game started, and forty minutes later
|
||||
//! the only symptom was FIFA's own dialog: *"the servers for this title have been
|
||||
//! shut down"*. Nothing in the stack said anything, because nothing was looking.
|
||||
//!
|
||||
//! Every one of those conditions is observable **without privilege**. This module
|
||||
//! looks, and reports before the user clicks Launch.
|
||||
//!
|
||||
//! # Deliberately not checked here
|
||||
//!
|
||||
//! Certificate parity across the FIFA-facing TLS services — the fault that cost
|
||||
//! three redirector gates — is the single most valuable check available, but the
|
||||
//! launcher has no TLS dependency (`account_sync` speaks plaintext HTTP by hand)
|
||||
//! and adding one is a decision, not a detail. `scripts/check-tls-parity.sh` on
|
||||
//! the server covers it in the meantime.
|
||||
//!
|
||||
//! # Advisory, not a gate
|
||||
//!
|
||||
//! Results colour the UI; they never disable Launch. A preflight that is itself
|
||||
//! wrong must not be able to lock the user out of their own game.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::LauncherConfig;
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
Pass,
|
||||
/// Genuinely wrong, but something else in the stack covers it, so the game
|
||||
/// can still work. Kept distinct from [`State::Fail`] because a checker that
|
||||
/// cries "this will fail" and is then contradicted by a working game teaches
|
||||
/// the user to ignore it — which is worse than not checking at all.
|
||||
Warn,
|
||||
Fail,
|
||||
/// Not configured, so there is nothing to assert. Never reported as a pass:
|
||||
/// "we did not look" and "we looked and it was fine" must not look alike.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Check {
|
||||
pub name: String,
|
||||
pub state: State,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Check {
|
||||
fn pass(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Pass,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn fail(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Fail,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn warn(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Warn,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn skip(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Skipped,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run every applicable check. Order is the order the game exercises them.
|
||||
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
||||
vec![
|
||||
ptrace_scope(cfg),
|
||||
ea_redirect(cfg),
|
||||
hostname_mapping(cfg),
|
||||
backend_reachable(cfg),
|
||||
]
|
||||
}
|
||||
|
||||
/// Checks that will stop the game working.
|
||||
pub fn failures(checks: &[Check]) -> usize {
|
||||
checks.iter().filter(|c| c.state == State::Fail).count()
|
||||
}
|
||||
|
||||
/// Checks that are wrong but survivable.
|
||||
pub fn warnings(checks: &[Check]) -> usize {
|
||||
checks.iter().filter(|c| c.state == State::Warn).count()
|
||||
}
|
||||
|
||||
/// autopatch writes to FIFA's process memory; Yama blocks that unless
|
||||
/// `ptrace_scope` is 0. At 1 the patch silently does nothing and the game fails
|
||||
/// its TLS handshake much later, with no message naming the cause.
|
||||
fn ptrace_scope(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "ptrace_scope (autopatch)";
|
||||
// `fifa17_tools_dir` carries a conventional default, so a non-empty value
|
||||
// does not mean the tools are installed. Key off the directory actually
|
||||
// existing: that is what decides whether autopatch will run at all, and it
|
||||
// keeps this from failing on a machine that never uses local services.
|
||||
let tools = cfg.fifa17_tools_dir.trim();
|
||||
if tools.is_empty() || !std::path::Path::new(tools).is_dir() {
|
||||
return Check::skip(NAME, "no local services installed");
|
||||
}
|
||||
match std::fs::read_to_string(PTRACE_SCOPE) {
|
||||
Ok(v) => ptrace_verdict(&v),
|
||||
// Not every kernel has Yama. Absent means unenforced, which is what we want.
|
||||
Err(_) => Check::skip(NAME, "Yama not present on this kernel"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision, split from the file read so it can be tested.
|
||||
///
|
||||
/// Reading `/proc` in a test would assert facts about the machine running the
|
||||
/// suite rather than about this code — and left inline, "any value is fine"
|
||||
/// was a mutation no test could catch.
|
||||
fn ptrace_verdict(raw: &str) -> Check {
|
||||
const NAME: &str = "ptrace_scope (autopatch)";
|
||||
let v = raw.trim();
|
||||
if v == "0" {
|
||||
Check::pass(NAME, "0 — autopatch can attach")
|
||||
} else {
|
||||
Check::fail(
|
||||
NAME,
|
||||
format!("{v} — autopatch cannot patch FIFA. Click 'Arm client'."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// FIFA dials EA's redirector by hardcoded IP. Armed, that address is DNAT'd to
|
||||
/// the OpenFUT server and connects instantly; unarmed it leaves the LAN and
|
||||
/// times out — which is exactly the "servers have been shut down" dialog.
|
||||
///
|
||||
/// This tests the *effect* rather than reading firewall rules, so it needs no
|
||||
/// privilege and stays honest about what the game will actually experience.
|
||||
fn ea_redirect(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "EA redirector IP is redirected";
|
||||
let ip = cfg.ea_redirect_probe_ip.trim();
|
||||
if ip.is_empty() {
|
||||
return Check::skip(NAME, "no probe IP configured");
|
||||
}
|
||||
let Ok(addr) = ip.parse::<IpAddr>() else {
|
||||
return Check::fail(NAME, format!("ea_redirect_probe_ip is not an IP: {ip:?}"));
|
||||
};
|
||||
let port = cfg.openfut_blaze_redirector_port;
|
||||
match TcpStream::connect_timeout(&SocketAddr::new(addr, port), PROBE_TIMEOUT) {
|
||||
Ok(_) => Check::pass(NAME, format!("{ip}:{port} answered — redirect is in place")),
|
||||
Err(e) => Check::fail(
|
||||
NAME,
|
||||
format!("{ip}:{port} did not answer ({e}). Click 'Arm client'."),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The dead EA hostnames should resolve to the OpenFUT server.
|
||||
///
|
||||
/// Resolution is done with `getaddrinfo`, the same call the game makes, so a
|
||||
/// duplicate `/etc/hosts` line that shadows the OpenFUT one is caught by its
|
||||
/// effect. Parsing `/etc/hosts` would miss it: the file can contain the right
|
||||
/// line and still resolve to the wrong address, because the first match wins.
|
||||
///
|
||||
/// # Why a warning and not a failure
|
||||
///
|
||||
/// Measured, not assumed. On 2026-08-11 this reported `easw.easports.com ->
|
||||
/// ::1,127.0.0.1` and the game reached the FUT hub regardless. The reason is in
|
||||
/// `client_arm.sh`'s own header: the responders run with `OPENFUT_ADVERTISE`
|
||||
/// set, so after the first redirected contact the game is handed the server's
|
||||
/// *address* for every later hop and stops using the hostname. The name is only
|
||||
/// CardsDLL's built-in fallback.
|
||||
///
|
||||
/// So this is a real misconfiguration worth fixing and not a reason to expect
|
||||
/// failure. Reporting it as fatal, and then being contradicted by a working
|
||||
/// game, is how a checklist trains its user to ignore it.
|
||||
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "EA hostnames point at OpenFUT";
|
||||
if cfg.ea_hostnames.is_empty() {
|
||||
return Check::skip(NAME, "no EA hostnames configured");
|
||||
}
|
||||
let server = cfg.openfut_server_host.trim();
|
||||
if server.is_empty() {
|
||||
return Check::skip(NAME, "no OpenFUT server configured");
|
||||
}
|
||||
let want = match resolve(server) {
|
||||
Ok(ips) if !ips.is_empty() => ips,
|
||||
_ => {
|
||||
return Check::fail(
|
||||
NAME,
|
||||
format!("cannot resolve the OpenFUT server {server:?}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let mut wrong = Vec::new();
|
||||
for host in &cfg.ea_hostnames {
|
||||
match resolve(host) {
|
||||
Ok(got) if got.iter().any(|ip| want.contains(ip)) => {}
|
||||
Ok(got) => wrong.push(format!(
|
||||
"{host} -> {} (expected {})",
|
||||
join(&got),
|
||||
join(&want)
|
||||
)),
|
||||
Err(e) => wrong.push(format!("{host} -> unresolvable ({e})")),
|
||||
}
|
||||
}
|
||||
|
||||
if wrong.is_empty() {
|
||||
Check::pass(
|
||||
NAME,
|
||||
format!("{} host(s) resolve to {server}", cfg.ea_hostnames.len()),
|
||||
)
|
||||
} else {
|
||||
Check::warn(
|
||||
NAME,
|
||||
format!(
|
||||
"{}. Look for an earlier /etc/hosts line shadowing it. \
|
||||
Usually survivable: the server advertises its address, so the \
|
||||
game stops using this name after the first hop.",
|
||||
wrong.join("; ")
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The server side of the same question: are the ports the game will use open?
|
||||
fn backend_reachable(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "OpenFUT server reachable";
|
||||
let host = cfg.openfut_server_host.trim();
|
||||
if host.is_empty() {
|
||||
return Check::skip(NAME, "no OpenFUT server configured");
|
||||
}
|
||||
let ports = [
|
||||
("blaze redirector", cfg.openfut_blaze_redirector_port),
|
||||
("account sync", cfg.openfut_account_sync_port),
|
||||
];
|
||||
let mut dead = Vec::new();
|
||||
for (label, port) in ports {
|
||||
if !connects(host, port) {
|
||||
dead.push(format!("{label} :{port}"));
|
||||
}
|
||||
}
|
||||
if dead.is_empty() {
|
||||
Check::pass(NAME, format!("{host}: all {} ports answering", ports.len()))
|
||||
} else {
|
||||
Check::fail(NAME, format!("{host}: no answer on {}", dead.join(", ")))
|
||||
}
|
||||
}
|
||||
|
||||
fn connects(host: &str, port: u16) -> bool {
|
||||
match (host, port).to_socket_addrs() {
|
||||
Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(host: &str) -> std::io::Result<Vec<IpAddr>> {
|
||||
Ok((host, 0u16).to_socket_addrs()?.map(|a| a.ip()).collect())
|
||||
}
|
||||
|
||||
fn join(ips: &[IpAddr]) -> String {
|
||||
ips.iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> LauncherConfig {
|
||||
LauncherConfig::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unconfigured_launcher_skips_rather_than_passes() {
|
||||
// The distinction that matters: a fresh config must not display four
|
||||
// green ticks. "Not checked" is not "checked and fine".
|
||||
let mut c = cfg();
|
||||
// `default()` points this at a conventional path whose existence varies
|
||||
// by machine. Pin it so the assertion is about the code, not this box.
|
||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
||||
let checks = run(&c);
|
||||
assert!(
|
||||
checks.iter().all(|k| k.state == State::Skipped),
|
||||
"{checks:#?}"
|
||||
);
|
||||
assert_eq!(failures(&checks), 0, "nothing configured is not a failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ptrace_scope_zero_lets_autopatch_work() {
|
||||
assert_eq!(ptrace_verdict("0\n").state, State::Pass);
|
||||
// 1 is the default on most distributions and is exactly the state that
|
||||
// let autopatch fail silently for forty minutes on 2026-08-11.
|
||||
assert_eq!(ptrace_verdict("1\n").state, State::Fail);
|
||||
assert_eq!(ptrace_verdict("2").state, State::Fail);
|
||||
assert_eq!(ptrace_verdict("3").state, State::Fail);
|
||||
assert!(ptrace_verdict("1").detail.contains("Arm client"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptrace_is_skipped_when_the_tools_dir_does_not_exist() {
|
||||
// Regression: the gate used to be "is the field non-empty", and the
|
||||
// field has a default — so this check ran (and failed) on machines that
|
||||
// never use autopatch at all.
|
||||
let mut c = cfg();
|
||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
||||
assert_eq!(ptrace_scope(&c).state, State::Skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
|
||||
let mut c = cfg();
|
||||
c.ea_redirect_probe_ip = "not-an-ip".into();
|
||||
let check = ea_redirect(&c);
|
||||
assert_eq!(check.state, State::Fail);
|
||||
assert!(check.detail.contains("not an IP"), "{}", check.detail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_check_is_skipped_without_a_server_but_not_passed() {
|
||||
let mut c = cfg();
|
||||
c.ea_hostnames = vec!["easw.easports.com".into()];
|
||||
assert_eq!(hostname_mapping(&c).state, State::Skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_check_detects_a_host_pointing_somewhere_else() {
|
||||
// localhost and 127.0.0.1 resolve without a network; this is the
|
||||
// shadowed-/etc/hosts shape without depending on the real one.
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.2".into();
|
||||
c.ea_hostnames = vec!["localhost".into()];
|
||||
let check = hostname_mapping(&c);
|
||||
// Warn, not Fail: observed on 2026-08-11 to be survivable, because the
|
||||
// server advertises its address after the first hop.
|
||||
assert_eq!(check.state, State::Warn, "{}", check.detail);
|
||||
assert!(check.detail.contains("localhost -> "), "{}", check.detail);
|
||||
}
|
||||
|
||||
/// A shadowed hostname must not be counted as a reason to expect failure.
|
||||
/// This is the exact case the first version got wrong.
|
||||
#[test]
|
||||
fn a_shadowed_hostname_is_a_warning_not_a_failure() {
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.2".into();
|
||||
c.ea_hostnames = vec!["localhost".into()];
|
||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
||||
let checks = run(&c);
|
||||
assert_eq!(failures(&checks), 0, "must not be reported as fatal");
|
||||
assert_eq!(warnings(&checks), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_check_passes_when_it_points_at_the_server() {
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.1".into();
|
||||
c.ea_hostnames = vec!["localhost".into()];
|
||||
// `localhost` may resolve to ::1 as well; the check requires only that
|
||||
// one resolved address matches, which mirrors what connecting does.
|
||||
assert_eq!(hostname_mapping(&c).state, State::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptrace_check_is_skipped_when_local_services_are_not_configured() {
|
||||
let mut c = cfg();
|
||||
c.fifa17_tools_dir.clear();
|
||||
assert_eq!(ptrace_scope(&c).state, State::Skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_backend_port_is_reported_as_a_failure() {
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.1".into();
|
||||
// Port 1 requires root to bind, so nothing is listening on it.
|
||||
c.openfut_blaze_redirector_port = 1;
|
||||
c.openfut_account_sync_port = 1;
|
||||
let check = backend_reachable(&c);
|
||||
assert_eq!(check.state, State::Fail, "{}", check.detail);
|
||||
assert!(check.detail.contains("no answer on"), "{}", check.detail);
|
||||
}
|
||||
}
|
||||
-134
@@ -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<Child>,
|
||||
pub status: Arc<Mutex<ServiceStatus>>,
|
||||
}
|
||||
|
||||
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<Mutex<LogBuffer>>,
|
||||
) -> 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();
|
||||
}
|
||||
}
|
||||
+79
-37
@@ -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 <command>`
|
||||
/// (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<std::sync::Mutex<crate::logs::LogBuffer>>,
|
||||
) -> 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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user