057cf92c3b
Port the egui launcher to run natively on Windows (no Wine/Proton). The GUI,
launch state machine, config, health/account monitors, and openfut.cfg writing
are unchanged and cross-platform; only the effect layer is branched:
- game_launch: cfg(windows) launch spawns the game executable directly with its
working dir (the version.dll hijack loads from the game dir; no WINEDLLOVERRIDES,
Wine prefix, or licence regen). Requires the launcher to run elevated so the
child inherits admin. Linux Proton path gated cfg(unix).
- arm: cfg(windows) is a no-op (routing is openfut.cfg, written by the client-files
step; no ptrace_scope/DNAT/hosts). Linux arming gated cfg(unix).
- local_services: on Windows LSX/autopatch are in-process (stp-origin_emu.dll +
version.dll hook), so ensure_running reports ready without spawning. Gated the
unix-only CommandExt/process_group.
- preflight: cfg(windows) run() keeps only backend-reachable + hook-config checks.
- config: GameProfile configured()/validate() accept a runner-less Windows profile.
theme: fix a latent cross-platform panic — egui 0.29 keeps a Style per theme, so
set_style only reached the active one and TextStyle::resolve("Hero") panicked when
the other theme rendered. Install the full style into both themes and pin Dark.
Cross-built for x86_64-pc-windows-gnu; Linux build + 75 tests unchanged.
255 lines
9.6 KiB
Rust
255 lines
9.6 KiB
Rust
//! 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.
|
|
#[cfg(unix)]
|
|
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.
|
|
#[cfg(unix)]
|
|
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`.
|
|
#[cfg(unix)]
|
|
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.
|
|
/// On native Windows there is nothing to arm: routing is the `openfut.cfg` the
|
|
/// client-files step writes into the game directory (read by the version.dll
|
|
/// hook), and there is no `ptrace_scope`, DNAT, or `/etc/hosts` to set. Returns
|
|
/// no changes so the launch sequence treats client preparation as satisfied.
|
|
#[cfg(windows)]
|
|
pub fn arm(_cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
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 Settings before arming.");
|
|
}
|
|
let ea_ip = cfg.ea_redirect_probe_ip.trim();
|
|
if ea_ip.is_empty() {
|
|
anyhow::bail!("Set the EA redirector IP (Settings) before arming.");
|
|
}
|
|
if cfg.ea_hostnames.is_empty() {
|
|
anyhow::bail!(
|
|
"Add at least one EA hostname (e.g. easw.easports.com) in Settings 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(all(test, unix))]
|
|
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"));
|
|
}
|
|
}
|