//! `openfut-lsx` -- the LSX responder process the launcher spawns. //! //! USAGE: bind BEFORE launching FIFA 17 so the Steampunks stub's `bind()` fails. //! This process does NOT auto-start anything; the launcher owns processes. use std::io::{self, Read}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::process::ExitCode; use std::sync::Arc; use openfut_lsx::crypto; use openfut_lsx::events::{fmt_secs, login_event_frames, Conn, EventConfig}; use openfut_lsx::identity::Identity; use openfut_lsx::protocol::{ parse_request, push_after, py_repr, resp, safe_xml_for_log, Attrs, ProtocolConfig, Responder, }; use openfut_lsx::{log, os_env, BUILD, CHALLENGE_KEY, LSX_PORT, VERSION}; fn main() -> ExitCode { if std::env::args().skip(1).any(|a| a == "--selftest") { return match selftest() { Ok(()) => ExitCode::SUCCESS, Err(e) => { log!("SELFTEST FAILED: {e}"); ExitCode::FAILURE } }; } match serve_forever() { Ok(()) => ExitCode::SUCCESS, Err(e) => { log!("fatal: {e}"); ExitCode::FAILURE } } } fn serve_forever() -> Result<(), Box> { let identity = Identity::from_env()?; let events = EventConfig::from_env()?; let responder = Arc::new(Responder::new(identity, ProtocolConfig::from_env())); // Readiness IS the bind: nothing is logged until 4216 is ours, so the launcher // never reads a ready line for a listener that does not exist. `TcpListener::bind` // already sets SO_REUSEADDR on every non-Windows target, which is the Python's // explicit `setsockopt(SO_REUSEADDR, 1)` -- do not reach for socket2 to re-add it. let bind_host = os_env("OPENFUT_BIND").unwrap_or_else(|| "127.0.0.1".to_string()); let listener = TcpListener::bind((bind_host.as_str(), LSX_PORT))?; // Literal "127.0.0.1:4216" even under OPENFUT_BIND, as the Python logs it: this // line is what the launcher watches for. log!("v2 listening on 127.0.0.1:{LSX_PORT} (start FIFA 17 now)"); log!( "login-state event push: {} (period={}s count={})", if events.enabled { "ENABLED" } else { "DISABLED" }, fmt_secs(events.period_secs), events.count ); for stream in listener.incoming() { let stream = match stream { Ok(s) => s, Err(e) => { log!("accept failed: {e}"); continue; } }; let peer = stream .peer_addr() .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0))); log!("connection from {}", py_addr(peer)); let responder = Arc::clone(&responder); let events = events.clone(); std::thread::spawn(move || serve(stream, peer, &responder, events)); } Ok(()) } /// Python prints the accept tuple, e.g. `('127.0.0.1', 54321)`. fn py_addr(peer: SocketAddr) -> String { format!("('{}', {})", peer.ip(), peer.port()) } fn serve(mut sock: TcpStream, peer: SocketAddr, responder: &Responder, events: EventConfig) { let conn = match Conn::new(&sock, peer, events) { Ok(c) => c, Err(e) => { log!("connection error: {e}"); return; } }; if let Err(e) = session(&mut sock, &conn, responder) { log!("connection error: {e}"); } conn.mark_dead(); // Dropping our halves closes the socket; the heartbeat thread notices on its // next tick (and its own send would fail regardless). drop(sock); log!("connection closed {}", py_addr(peer)); } fn session( sock: &mut TcpStream, conn: &Arc, responder: &Responder, ) -> Result<(), Box> { // 1. plaintext Challenge conn.send_plain(&format!( r#""# ))?; // 2. plaintext ChallengeResponse from the client. The emu parses `response="` // BEFORE `key="` (0x180001f10); extract both so challenge_response can echo // the client's own 3rd block (REPACK_INTEL.md C1/C2). let mut first = [0u8; 4096]; let n = sock.read(&mut first)?; let txt = String::from_utf8_lossy(&first[..n]); let client_key = first_attr(&txt, "key").unwrap_or(CHALLENGE_KEY); let client_resp = first_attr(&txt, "response").unwrap_or(""); let h = crypto::challenge_response(client_key, client_resp)?; conn.set_key(crypto::derive_session_key(&h)); log!("handshake accepted; session crypto initialized"); // 3. plaintext ChallengeAccepted conn.send_plain(&resp( "1", &format!(r#"ChallengeAccepted response="{h}""#), "EALS", ))?; // 3b. EXPERIMENT (OPENFUT_LSX_LOGIN_PLAINTEXT=1): the shipped emu's ONLY // unsolicited Event is the plaintext, pre-session-key Challenge; there is // zero evidence an *encrypted mid-session* Event routes to the same parser // (REPACK_INTEL.md sec.4 step 2). So push the Login Event here, in // PLAINTEXT, right after ChallengeAccepted -- before the stream goes // encrypted -- and suppress the encrypted heartbeat to keep the A/B clean. if conn.events().login_plaintext && conn.events().enabled { conn.stop_events(); for frame in login_event_frames() { conn.send_plain(&frame)?; log!("PUSH (plaintext post-accept) >> {frame}"); } } // 4. encrypted request/response loop let mut heartbeat_started = false; let mut buf = Vec::new(); let mut chunk = [0u8; 65536]; loop { let n = sock.read(&mut chunk)?; if n == 0 { break; } // Buffer partial frames: a 64 KiB read can straddle a NUL boundary, and // splitting without keeping the remainder would silently drop the trailing // partial (C3). buf.extend_from_slice(&chunk[..n]); let complete = match buf.iter().rposition(|b| *b == 0) { Some(i) => i + 1, None => continue, }; let frames: Vec> = buf[..complete] .split(|b| *b == 0) .filter(|f| !f.is_empty()) .map(<[u8]>::to_vec) .collect(); buf.drain(..complete); for frame in frames { let key = conn.key().expect("session key set during the handshake"); let xml = match crypto::lsx_decrypt(&frame, &key) { Ok(x) => x, Err(e) => { log!("decrypt fail: {e}"); continue; } }; let Some(req) = parse_request(&xml) else { log!("<< {}", safe_xml_for_log(&xml)); continue; }; let reply = responder.build_reply(req.id, req.name, &req.attrs, Some(conn), req.recipient); log!( "<< id={} {} recipient={} {}", req.id, req.name, py_repr(req.recipient), req.attrs.py_repr() ); log!(">> {}", safe_xml_for_log(&reply)); conn.send_enc(&reply)?; if let Some(why) = push_after(req.name) { if conn.events().enabled // For GetGameInfo only fire on UPTODATE, otherwise we would push // three times per boot for FREETRIAL/LANGUAGES too. && (req.name != "GetGameInfo" || req.attrs.get("GameInfoId") == Some("UPTODATE")) { conn.push_login_state(why); if !heartbeat_started { heartbeat_started = true; let conn = Arc::clone(conn); std::thread::spawn(move || conn.heartbeat()); } } } } } Ok(()) } /// `re.search(r'name="([^"]*)"', txt)` -- the first occurrence anywhere in the /// plaintext handshake frame, with no word-boundary requirement (which is why the /// needle keeps its `="`). fn first_attr<'a>(txt: &'a str, name: &str) -> Option<&'a str> { let needle = format!("{name}=\""); let start = txt.find(&needle)? + needle.len(); let len = txt[start..].find('"')?; Some(&txt[start..start + len]) } /// No live game needed. Proves the crypto is untouched and the event frames /// encrypt/decrypt cleanly through our own codec. fn selftest() -> Result<(), Box> { let h = crypto::challenge_response("18a70055a3541fb27ab8e0f47afad18c", "")?; check(h.starts_with("e4f5166209929e15"), &h)?; let k = crypto::derive_session_key(&h); let k_hex = crypto::hex_lower(&k); check(k_hex == "6a9da3e78615153cc2f10eec25ae6382", &k_hex)?; println!("[ok] crypto matches the captured 2026-07-30 session verbatim"); let frames = login_event_frames(); check( frames.len() == openfut_lsx::events::LOGIN_EVENT_SENDERS.len() + openfut_lsx::events::ONLINE_EVENT_SENDERS.len(), &format!("{} frames", frames.len()), )?; for f in &frames { let round = crypto::lsx_decrypt(&crypto::lsx_encrypt(f, &k), &k)?; check(round == *f, &round)?; println!("[ok] round-trip: {f}"); } // "" sender first (correct for the current empty service-name table) check( frames[0].contains(r#""#), &frames[0], )?; // conn=None: the selftest must not write the run's success-signal files. let responder = Responder::new(Identity::from_env()?, ProtocolConfig::from_env()); let attrs: Attrs = [("ClientId", "X"), ("Scope", "Y")].into_iter().collect(); let r = responder.build_reply("42", "GetAuthCode", &attrs, None, ""); // `value` is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0) // actually reads; Code=/Return= are legacy padding. check(r.contains(""#); check( !redacted.contains("secret") && redacted.matches("[REDACTED]").count() == 3, &redacted, )?; let status = safe_xml_for_log(r#""#); check(status.contains(r#"Code="0""#), &status)?; println!("[ok] GetAuthCode response shape and log redaction"); println!("[ok] selftest passed"); Ok(()) } /// The Python's `assert cond, value`. fn check(cond: bool, value: &str) -> Result<(), io::Error> { if cond { Ok(()) } else { Err(io::Error::other(format!("assertion failed: {value}"))) } }