feat: replace hosts file with DLL injection hook

Adds openfut-hook/, a Windows DLL (cdylib, x86_64-pc-windows-gnu) that
patches the IAT of FIFA 23 at load time to redirect getaddrinfo calls
for fut.ea.com / utas.*.fut.ea.com to 127.0.0.1, sending all FUT
traffic to the local bridge — no /etc/hosts changes needed.

Deployment: the launcher copies openfut_hook.dll into the FIFA 23 game
folder as version.dll (a DLL FIFA loads but delegates to system).
Proton picks up the local copy automatically when you set:
  WINEDLLOVERRIDES="version=n,b" %command%
in Steam launch options.

Also updates cert install to try the Wine/Proton cert store (wine
certutil) before falling back to the Linux system CA store, and removes
all hosts file code from setup.rs / app.rs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 21:05:35 -07:00
parent fc894a7f77
commit 9b5da57f12
9 changed files with 482 additions and 124 deletions
+74 -29
View File
@@ -33,7 +33,7 @@ pub struct LauncherApp {
log_follow: bool,
// Setup state
hosts_configured: bool,
hook_deployed: bool,
cert_path: Option<std::path::PathBuf>,
setup_message: Option<(bool, String)>, // (success, text)
}
@@ -54,7 +54,8 @@ impl LauncherApp {
let config = LauncherConfig::load();
let cert_path = setup::find_bridge_cert(&config.bridge_captures_dir);
let hosts_configured = setup::hosts_configured();
let hook_deployed =
setup::hook_dll_deployed(std::path::Path::new(&config.fifa_game_dir));
Self {
config,
@@ -66,7 +67,7 @@ impl LauncherApp {
active_tab: Tab::Dashboard,
log_tab: 0,
log_follow: true,
hosts_configured,
hook_deployed,
cert_path,
setup_message: None,
}
@@ -189,11 +190,11 @@ impl LauncherApp {
.num_columns(2)
.spacing([12.0, 4.0])
.show(ui, |ui| {
ui.label("Hosts file:");
if self.hosts_configured {
ui.colored_label(Color32::from_rgb(80, 200, 120), "Configured");
ui.label("Hook DLL:");
if self.hook_deployed {
ui.colored_label(Color32::from_rgb(80, 200, 120), "Deployed (version.dll)");
} else {
ui.colored_label(Color32::from_rgb(220, 150, 0), "Not configured");
ui.colored_label(Color32::from_rgb(220, 150, 0), "Not deployed");
}
ui.end_row();
@@ -263,51 +264,81 @@ impl LauncherApp {
}
fn ui_setup(&mut self, ui: &mut Ui) {
use std::path::Path;
ui.add_space(8.0);
ui.heading("System Setup");
ui.add_space(4.0);
ui.label("These steps route FIFA 23 traffic through the emulator. Both require elevated privileges (polkit/sudo).");
ui.label("These steps route FIFA 23 traffic through the emulator via DLL injection (no hosts file changes needed).");
ui.add_space(12.0);
// ── Hosts file ────────────────────────────────────────────────────────
// ── Hook DLL deployment ───────────────────────────────────────────────
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.strong("Step 1 — Redirect EA hostnames");
ui.strong("Step 1 — Deploy network hook DLL");
ui.add_space(4.0);
ui.label("Adds entries to /etc/hosts so the game connects to the bridge instead of EA's servers.");
ui.label("Copies openfut_hook.dll into the FIFA 23 folder as version.dll. \
When Proton loads the game it will intercept network calls and redirect \
EA hostnames to the local bridge — no hosts file changes required.");
ui.add_space(6.0);
// Refresh status
self.hosts_configured = setup::hosts_configured();
let game_dir = Path::new(&self.config.fifa_game_dir);
self.hook_deployed = setup::hook_dll_deployed(game_dir);
let dll_src = Path::new(&self.config.hook_dll_path);
let dll_built = dll_src.exists();
if !dll_built {
ui.colored_label(
Color32::from_rgb(220, 150, 0),
"⚠ Hook DLL not built yet. Run in openfut-hook/:",
);
ui.monospace("cargo build --release --target x86_64-pc-windows-gnu");
ui.add_space(4.0);
}
ui.horizontal(|ui| {
if self.hosts_configured {
ui.colored_label(Color32::from_rgb(80, 200, 120), "Configured");
if self.hook_deployed {
ui.colored_label(Color32::from_rgb(80, 200, 120), "Deployed (version.dll)");
ui.add_space(8.0);
if ui.button("Remove entries").clicked() {
match setup::remove_hosts_entries() {
if ui.button("Remove").clicked() {
match setup::remove_hook_dll(game_dir) {
Ok(()) => {
self.setup_message = Some((true, "Hosts entries removed.".into()));
self.hosts_configured = false;
self.setup_message = Some((true, "Hook DLL removed.".into()));
self.hook_deployed = false;
}
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
}
}
} else {
ui.colored_label(Color32::from_rgb(220, 60, 60), "✘ Not configured");
ui.colored_label(Color32::from_rgb(220, 60, 60), "✘ Not deployed");
ui.add_space(8.0);
if ui.button("Add entries").clicked() {
match setup::add_hosts_entries() {
if ui.add_enabled(dll_built, egui::Button::new("Deploy")).clicked() {
match setup::deploy_hook_dll(dll_src, game_dir) {
Ok(()) => {
self.setup_message = Some((true, "Hosts entries added.".into()));
self.hosts_configured = true;
self.setup_message = Some((true, "Hook DLL deployed as version.dll.".into()));
self.hook_deployed = true;
}
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
}
}
}
});
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
ui.strong("Steam Launch Options");
ui.label("Paste this into FIFA 23 → Properties → Launch Options in Steam:");
ui.add_space(2.0);
let launch_opt = setup::STEAM_LAUNCH_OPTIONS;
ui.horizontal(|ui| {
ui.monospace(launch_opt);
if ui.small_button("Copy").clicked() {
ui.output_mut(|o| o.copied_text = launch_opt.to_string());
}
});
});
ui.add_space(12.0);
@@ -317,11 +348,10 @@ impl LauncherApp {
ui.set_min_width(ui.available_width());
ui.strong("Step 2 — Install TLS certificate");
ui.add_space(4.0);
ui.label("Installs the bridge's self-signed cert into the system CA store so the game accepts HTTPS connections.");
ui.label("Installs the bridge's self-signed cert into the Wine/Proton cert store \
so the game accepts HTTPS connections to the bridge.");
ui.add_space(6.0);
// Refresh cert path
self.cert_path = setup::find_bridge_cert(&self.config.bridge_captures_dir);
match &self.cert_path.clone() {
@@ -334,7 +364,7 @@ impl LauncherApp {
Some(cert) => {
ui.label(format!("Cert: {}", cert.display()));
ui.add_space(4.0);
if ui.button("Install cert (requires sudo)").clicked() {
if ui.button("Install cert (Wine cert store)").clicked() {
match setup::install_cert(cert) {
Ok(()) => self.setup_message = Some((true, "Certificate installed.".into())),
Err(e) => self.setup_message = Some((false, format!("Failed: {e}"))),
@@ -346,7 +376,6 @@ impl LauncherApp {
ui.add_space(12.0);
// ── Status message ────────────────────────────────────────────────────
if let Some((ok, msg)) = &self.setup_message {
let color = if *ok {
Color32::from_rgb(80, 200, 120)
@@ -416,6 +445,22 @@ impl LauncherApp {
ui.label("TLS enabled:");
changed |= ui.checkbox(&mut self.config.bridge_tls_enabled, "").changed();
ui.end_row();
ui.add_space(4.0);
ui.label("");
ui.end_row();
ui.strong("Hook DLL");
ui.label("");
ui.end_row();
ui.label("Hook DLL path:");
changed |= ui.text_edit_singleline(&mut self.config.hook_dll_path).changed();
ui.end_row();
ui.label("FIFA 23 game dir:");
changed |= ui.text_edit_singleline(&mut self.config.fifa_game_dir).changed();
ui.end_row();
});
if changed {
+13
View File
@@ -11,6 +11,10 @@ pub struct LauncherConfig {
pub bridge_captures_dir: String,
pub bridge_core_url: String,
pub bridge_tls_enabled: bool,
/// Path to the built openfut_hook.dll (Windows DLL for Proton injection).
pub hook_dll_path: String,
/// FIFA 23 game folder inside the Proton prefix (where the DLL is deployed).
pub fifa_game_dir: String,
}
impl Default for LauncherConfig {
@@ -41,6 +45,15 @@ impl Default for LauncherConfig {
.into(),
bridge_core_url: "http://127.0.0.1:8080".into(),
bridge_tls_enabled: true,
hook_dll_path: base
.join("openfut-launcher/openfut-hook/target/x86_64-pc-windows-gnu/release/openfut_hook.dll")
.to_string_lossy()
.into(),
fifa_game_dir: dirs::home_dir()
.map(|h| h.join(".steam/steam/steamapps/common/FIFA 23"))
.unwrap_or_default()
.to_string_lossy()
.into(),
}
}
}
+72 -95
View File
@@ -1,129 +1,106 @@
use std::process::Command;
/// EA FUT hostnames the bridge needs to intercept.
const INTERCEPT_HOSTS: &[&str] = &[
"fut.ea.com",
"utas.mob.v4.fut.ea.com",
"utas.s2.fut.ea.com",
];
const HOSTS_MARKER_START: &str = "# BEGIN openfut-launcher";
const HOSTS_MARKER_END: &str = "# END openfut-launcher";
use std::{path::{Path, PathBuf}, process::Command};
// ── Cert installation ─────────────────────────────────────────────────────────
/// Find the bridge cert in the given captures dir.
pub fn find_bridge_cert(captures_dir: &str) -> Option<std::path::PathBuf> {
let p = std::path::Path::new(captures_dir).join("bridge_cert.pem");
pub fn find_bridge_cert(captures_dir: &str) -> Option<PathBuf> {
let p = Path::new(captures_dir).join("bridge_cert.pem");
p.exists().then_some(p)
}
/// Install the bridge cert into the system CA store.
/// On Linux, copies to /usr/local/share/ca-certificates/ and runs
/// update-ca-certificates via pkexec (polkit graphical sudo).
pub fn install_cert(cert_src: &std::path::Path) -> anyhow::Result<()> {
let dest = std::path::Path::new("/usr/local/share/ca-certificates/openfut-bridge.crt");
/// Install the bridge cert into the Wine/Proton prefix cert store so the game
/// trusts TLS connections to the bridge. Falls back to the system CA store.
pub fn install_cert(cert_src: &Path) -> anyhow::Result<()> {
// Try certutil inside the Proton prefix first (Wine cert store)
let wine_result = try_wine_certutil(cert_src);
if wine_result.is_ok() {
return wine_result;
}
// Write a small shell script that pkexec can run as root
// Fallback: Linux system CA store via pkexec/sudo
let dest = Path::new("/usr/local/share/ca-certificates/openfut-bridge.crt");
let script = format!(
"cp '{}' '{}' && update-ca-certificates",
cert_src.display(),
dest.display()
);
run_elevated(&script)
}
let status = Command::new("pkexec")
.args(["sh", "-c", &script])
fn try_wine_certutil(cert_src: &Path) -> anyhow::Result<()> {
// certutil is available inside a Wine prefix via winetricks or natively
let status = Command::new("wine")
.args([
"certutil",
"-addstore",
"-user",
"Root",
&cert_src.to_string_lossy(),
])
.status();
match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => anyhow::bail!("pkexec exited with status {s}"),
Err(e) => {
// pkexec not available — try sudo
let status = Command::new("sudo")
.args(["sh", "-c", &script])
.status()?;
if status.success() {
Ok(())
} else {
anyhow::bail!("cert install failed: {e}")
}
}
Ok(s) => anyhow::bail!("wine certutil exited {s}"),
Err(e) => anyhow::bail!("wine certutil not available: {e}"),
}
}
// ── Hosts file management ─────────────────────────────────────────────────────
pub fn hosts_configured() -> bool {
let content = std::fs::read_to_string("/etc/hosts").unwrap_or_default();
content.contains(HOSTS_MARKER_START)
}
/// Add OpenFUT intercept entries to /etc/hosts (requires root).
pub fn add_hosts_entries() -> anyhow::Result<()> {
let current = std::fs::read_to_string("/etc/hosts")?;
if current.contains(HOSTS_MARKER_START) {
return Ok(());
}
let entries: String = INTERCEPT_HOSTS
.iter()
.map(|h| format!("127.0.0.1 {h}\n"))
.collect();
let addition = format!("\n{HOSTS_MARKER_START}\n{entries}{HOSTS_MARKER_END}\n");
let new_content = format!("{current}{addition}");
write_hosts(&new_content)
}
/// Remove OpenFUT intercept entries from /etc/hosts (requires root).
pub fn remove_hosts_entries() -> anyhow::Result<()> {
let current = std::fs::read_to_string("/etc/hosts")?;
if !current.contains(HOSTS_MARKER_START) {
return Ok(());
}
let mut out = String::new();
let mut skip = false;
for line in current.lines() {
if line.trim() == HOSTS_MARKER_START {
skip = true;
continue;
}
if line.trim() == HOSTS_MARKER_END {
skip = false;
continue;
}
if !skip {
out.push_str(line);
out.push('\n');
}
}
write_hosts(out.trim_end())
}
fn write_hosts(content: &str) -> anyhow::Result<()> {
// Write to a temp file, then mv into place with elevated privileges
let tmp = "/tmp/openfut_hosts_tmp";
std::fs::write(tmp, content)?;
let script = format!("mv '{tmp}' /etc/hosts && chmod 644 /etc/hosts");
let status = Command::new("pkexec").args(["sh", "-c", &script]).status();
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])
.args(["sh", "-c", script])
.status()?;
if s.success() {
Ok(())
} else {
anyhow::bail!("failed to write /etc/hosts")
anyhow::bail!("elevated command failed")
}
}
}
}
// ── DLL hook deployment ───────────────────────────────────────────────────────
/// Deploy openfut_hook.dll into the FIFA 23 game directory.
/// Uses a name that FIFA 23 loads but doesn't need from system: `version.dll`.
/// Proton will prefer the local copy over the system one.
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path) -> anyhow::Result<()> {
if !dll_src.exists() {
anyhow::bail!(
"Hook DLL not found at {}. Build it first with:\n\
cargo build --release --target x86_64-pc-windows-gnu\n\
(inside openfut-hook/)",
dll_src.display()
);
}
std::fs::create_dir_all(game_dir)?;
let dest = game_dir.join("version.dll");
std::fs::copy(dll_src, &dest)?;
Ok(())
}
/// Remove the deployed hook DLL from the FIFA game directory.
pub fn remove_hook_dll(game_dir: &Path) -> anyhow::Result<()> {
let dest = game_dir.join("version.dll");
if dest.exists() {
std::fs::remove_file(&dest)?;
}
Ok(())
}
/// Check whether our hook DLL is deployed in the game directory.
pub fn hook_dll_deployed(game_dir: &Path) -> bool {
game_dir.join("version.dll").exists()
}
/// 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%";