3 Commits

Author SHA1 Message Date
funman300 4e44a3792f wip(hook): preserve local FIFA17 configurable-server redirect + hook refactors
Preserved uncommitted openfut-hook WIP from the feat/launcher-arming checkout so the
canonical launcher can move to the reconciled merge ca7ce26 without losing it. This is
WORK-IN-PROGRESS (not production-polished): a configurable OpenFUT-server redirect for the
injected hook — new src/server.rs (server::set / sin_addr / dest_port_nbo_from_source_nbo,
referenced by lib.rs + connect_hook + connectex_hook) plus supporting changes to config.rs,
connect_hook.rs, connectex_hook.rs, hooks.rs, iat.rs, lib.rs, origin_spy.rs, ssl_patch.rs,
tls_bypass.rs, Cargo.{toml,lock}.

NOTE: this overlaps (same files, different approach) the SBC-tracing lineage's openfut-hook
changes now merged in ca7ce26; reconciling the two hook variants is a separate, deliberate
task. This commit only PRESERVES the local WIP on a dedicated branch.
2026-08-13 15:10:12 +00:00
funman300 13339c1478 feat(fifa17): report verified client patch capability
Launcher side of the verified patched-client capability handshake. When
autopatch proves the CardsDLL empty-My-Packs resolver guard is active for the
CURRENT FIFA process, the launcher advertises that to the backend so the backend
may drop the synthetic 65534 sentinel for that session only. Additive and
fail-closed: any parse/registration failure leaves the backend on its default
sentinel path.

- New src/fifa17_capability.rs:
  * Fifa17ClientCapabilities { empty_mypacks_resolver: Option<u32> } — per-FIFA-
    process state, UNKNOWN at each launch, discarded when that process ends
    (never persisted, so a prior launch's capability cannot leak).
  * parse_capability_line() / parse_fifa_pid() — pure parsers for autopatch's
    stdout token `[store-guard] verified capability fifa17.empty_mypacks_resolver=<v>
    fifa_pid=<pid>`; the non-advertising `guard status=...` line yields None.
  * register() — tiny stdlib-HTTP POST /openfut/fifa17/capability, modeled on
    account_sync::sync (Connection: close, 3s timeouts, 2xx check).
- local_services::spawn: autopatch stdout reader parses each raw line; on the
  first verified line it sets the shared capability sink, logs, and fires exactly
  one backend register() for this FIFA process. Capability wiring is bundled in a
  CapabilityWiring struct (Some for autopatch, None for LSX). LSX unchanged.
- app.rs: LauncherApp holds the shared Fifa17ClientCapabilities; it is reset to
  UNKNOWN at the start of launch_game (and when autopatch is stopped) so a new
  FIFA process never inherits a previous launch's capability.
- Tests: parse (verified/non-advertising/unrelated/version-2) + a register()
  round-trip against an in-process listener.

Design + contract: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (superproject).
Pre-existing openfut-hook/* working-tree changes are intentionally left uncommitted.
2026-08-13 04:03:08 +00:00
funman300 d619c992c1 feat(launcher): one-click client arming + modular preflight/services
Add a GUI "Arm client" button that reproduces client_arm.sh in a single
pkexec batch: kernel.yama.ptrace_scope=0, DNAT of EA's hardcoded redirector
IP to the OpenFUT server (+ MASQUERADE reply path), and /etc/hosts rewrites
for every dead EA hostname (removing foreign shadow lines first, so glibc's
first-match resolution can't land on a stale loopback entry). All steps are
idempotent (delete-then-add) and injection-safe: config values are charset-
validated and rejected on a surprising character, never shell-escaped. arm()
returns the concrete change list, which the button logs line-by-line and
echoes as an inline pass/fail status on the pre-launch tab (no tab jump, no
reuse of the local-services toast).

This necessarily lands the surrounding launcher modularization the arm
feature is built on, extracted from the former monolithic app.rs/process.rs:
- preflight: advisory pre-launch checks (ptrace, redirector DNAT, hostnames,
  backend reachability) that colour rows but never block Launch
- local_services: launcher-owned LSX/autopatch child processes
- game_launch, account_sync, health, netcheck helpers
- openfut-common: dependency-free shared server-destination/port mapping,
  used by both the launcher and (separately) openfut_hook.dll

openfut-hook RE changes are intentionally left uncommitted (separate concern).
fmt + clippy -D warnings clean; 46 tests pass.
2026-08-12 17:58:48 +00:00
30 changed files with 4592 additions and 702 deletions
Generated
+5
View File
@@ -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",
+1
View File
@@ -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" }
+7
View File
@@ -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"
+11
View File
@@ -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]
+479
View File
@@ -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);
}
}
+5
View File
@@ -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",
]
+4
View File
@@ -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
View File
@@ -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> {
+94 -39
View File
@@ -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,
+61 -40
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 (),
);
}
}
+39 -14
View File
@@ -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
}
+45
View File
@@ -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)
}
+27 -17
View File
@@ -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
}
+10 -5
View File
@@ -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() {
+177
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+239
View File
@@ -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
View File
@@ -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"));
}
}
+224
View File
@@ -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();
}
}
+449
View File
@@ -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
View File
@@ -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,
}
}
+429
View File
@@ -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
View File
@@ -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<()> {
+77
View File
@@ -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\""))
}
+398
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}