d619c992c1
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.
480 lines
18 KiB
Rust
480 lines
18 KiB
Rust
//! 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);
|
|
}
|
|
}
|