Files
OpenFUT/openfut-lsx/src/main.rs
T
funman300 750d6c2e18 feat(companions): port the launcher's two Python services to Rust
The launcher spawned `python3 lsx_responder_v2.py` and `python3 autopatch.py`. Both are
now Rust workspace crates, and the launcher spawns the binaries (gitlink 1cd4f18).

openfut-lsx (2244 lines, 57 tests) — EA Origin LSX emulator on loopback 4216.
Dependency-light on purpose: `aes` for the one security-shaped primitive, parking_lot
per the project lock rule. AES-128-ECB is the whole cipher requirement, so the
surrounding framing (PKCS7, lowercase hex, NUL-termination) stays explicit and separate
because it is protocol, not cryptography.

openfut-autopatch (43 tests) — ProtoSSL cert gates plus the CardsDLL store patches,
applied over /proc/<pid>/mem. Deliberately dependency-free: a tool that writes another
process's memory should be auditable end to end without a dependency tree. std has no
getuid and no local-time formatting, so it carries a small TZif reader rather than
pulling in chrono to reproduce Python's strftime('%H:%M:%S').

The Python remains in fifa17-recon/tools. It is NOT dead: the docker entrypoint,
client_arm.sh, the runbooks and test_autopatch_guard.py still use it. Only the
launcher's dependency on Python is gone, which is what was asked for; deleting the
recon toolchain's implementation would have broken unrelated workflows.

VERIFICATION — the ports are checked against the Python, not against themselves:

* Crypto parity across THREE implementations. The Rust tests assert the Rust's own
  constants, which proves consistency, not parity, and the Python cannot run here
  (pycryptodome absent) with the client host unreachable. So the LCG and key derivation
  were transcribed from the Python and run as plain arithmetic, and every AES value came
  from the openssl CLI. All agree: msvcr_rand(7)==61, _TAIL_CONST
  954f64f2e4e86e9eee82d20216684899, the 96-hex emu challenge shape, the derived session
  key 6a9da3e78615153cc2f10eec25ae6382, the framing rule at both boundaries (an aligned
  payload gains a whole block), and the port's pinned 4-block login-frame ciphertext.
* LSX end to end on the real port. 4216 here is a docker forward into the production
  netns, so the smoke test runs under `unshare -n` — the real binary on the port the
  client actually dials, with no port-override hack and no risk to production. A
  hand-written client read the unprompted <Challenge>, completed the handshake, and
  decrypted the GetProfileResponse (PersonaId 33068179, Persona CAGE) with a session key
  derived INDEPENDENTLY of the Rust, then observed the Login pushes across all three
  candidate senders.
* autopatch behaviourally. The startup banner, the --launcher-pid watchdog exiting with
  the exact Python message, dual stdout+logfile output, and a missing value rejected
  with Python's own "invalid --launcher-pid". The subagent additionally cross-checked
  every constant by executing the Python module and drove the binary against a synthetic
  client (correct comm, a CardsDLL mapping, gates mmapped at their absolute VAs),
  confirming all eleven patches byte-exact in table order.
* The `[store-guard] verified capability …` line is byte-identical to openfut-launcher's
  own parser fixture, so backend capability registration still works.

Workspace builds; openfut-lsx 57, openfut-autopatch 43, openfut-launcher 74 tests green.
2026-08-18 05:31:00 +00:00

279 lines
10 KiB
Rust

//! `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<dyn std::error::Error>> {
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<Conn>,
responder: &Responder,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. plaintext Challenge
conn.send_plain(&format!(
r#"<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" build="{BUILD}" version="{VERSION}"/></Event></LSX>"#
))?;
// 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<Vec<u8>> = 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<dyn std::error::Error>> {
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#"<Event sender=""><Login IsLoggedIn="true"/></Event>"#),
&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("<AuthCode value="), &r)?;
let redacted = safe_xml_for_log(r#"<AuthCode value="secret" Code="secret" Return="secret"/>"#);
check(
!redacted.contains("secret") && redacted.matches("[REDACTED]").count() == 3,
&redacted,
)?;
let status = safe_xml_for_log(r#"<ErrorSuccess Code="0" Description=""/>"#);
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}")))
}
}