750d6c2e18
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.
357 lines
13 KiB
Rust
357 lines
13 KiB
Rust
//! PUSHED EVENTS -- the whole reason v2 exists, plus the per-connection state
|
|
//! they need.
|
|
//!
|
|
//! Frame shape is identical to the server-initiated `<Challenge>` that already
|
|
//! works, i.e. `<LSX><Event sender="..."><Element .../></Event></LSX>`. No `id`
|
|
//! attribute (the Challenge has none; the matcher never reads one).
|
|
|
|
use std::io::{self, Write};
|
|
use std::net::{SocketAddr, TcpStream};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use parking_lot::{Mutex, RwLock};
|
|
|
|
use crate::crypto::{self, Key};
|
|
use crate::{env_flag, log, ConfigError, Env};
|
|
|
|
/// `sender` is strcmp'd against the handler's registered service name. A mismatch
|
|
/// is SILENTLY DROPPED -- it costs us nothing -- so we emit every candidate.
|
|
///
|
|
/// Event handlers are keyed on `serviceNames[facility]` (registrar 0x14710df80);
|
|
/// with our empty GetConfigResponse those names are "", so the handlers expect
|
|
/// `sender=""`. "" first; the named variants are harmless no-ops (dropped
|
|
/// silently) and become correct once GetConfigResponse populates the table.
|
|
/// "LOGIN_EVENT" is table index 14 (the one the event-handler factory uses) and
|
|
/// "LOGIN" is index 8 (the plain service name); exactly one of the three will
|
|
/// match.
|
|
pub const LOGIN_EVENT_SENDERS: [&str; 3] = ["", "LOGIN_EVENT", "LOGIN"];
|
|
pub const ONLINE_EVENT_SENDERS: [&str; 2] = ["", "ONLINE_STATUS_EVENT"];
|
|
|
|
pub fn event(sender: &str, element: &str) -> String {
|
|
format!(r#"<LSX><Event sender="{sender}"><{element}/></Event></LSX>"#)
|
|
}
|
|
|
|
/// The frames that flip `OriginMgr.m_isLoggedIn` ([OriginMgr+0x13]) to 1.
|
|
///
|
|
/// `IsLoggedIn` is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE. Keep the
|
|
/// value literally "true" anyway: it is what a real Origin client sends and it
|
|
/// keeps the log readable.
|
|
pub fn login_event_frames() -> Vec<String> {
|
|
let mut out = Vec::with_capacity(LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len());
|
|
out.extend(LOGIN_EVENT_SENDERS.iter().map(|s| event(s, r#"Login IsLoggedIn="true""#)));
|
|
out.extend(
|
|
ONLINE_EVENT_SENDERS
|
|
.iter()
|
|
.map(|s| event(s, r#"OnlineStatusEvent isOnline="true""#)),
|
|
);
|
|
out
|
|
}
|
|
|
|
/// Event tuning.
|
|
///
|
|
/// Pushes are idempotent state notifications, so re-sending is harmless and is
|
|
/// cheap insurance against FIFA registering its `<Login>` handler later than our
|
|
/// first push. Set `OPENFUT_LSX_EVENTS=0` to fall back to v1 behaviour (useful as
|
|
/// an A/B control if you want to prove the events are what moved the needle).
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct EventConfig {
|
|
/// `OPENFUT_LSX_EVENTS != "0"` (default on).
|
|
pub enabled: bool,
|
|
/// `OPENFUT_LSX_EVENT_PERIOD` seconds (default 5).
|
|
pub period_secs: f64,
|
|
/// `OPENFUT_LSX_EVENT_COUNT` heartbeat repeats (default 24).
|
|
pub count: i64,
|
|
/// EXPERIMENT (`OPENFUT_LSX_LOGIN_PLAINTEXT`, default off): push the Login
|
|
/// Event in PLAINTEXT right after ChallengeAccepted (before the stream goes
|
|
/// encrypted) instead of via the encrypted heartbeat. Tests the workflow's
|
|
/// strongest remaining hypothesis -- that FIFA drops encrypted mid-session
|
|
/// Events (the emu's only Event, the Challenge, is plaintext and pre-key).
|
|
/// See REPACK_INTEL.md sec.4 step 2.
|
|
pub login_plaintext: bool,
|
|
}
|
|
|
|
impl Default for EventConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
period_secs: 5.0,
|
|
count: 24,
|
|
login_plaintext: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl EventConfig {
|
|
pub fn from_env() -> Result<Self, ConfigError> {
|
|
Self::from_vars(&crate::os_env)
|
|
}
|
|
|
|
pub fn from_vars(env: Env<'_>) -> Result<Self, ConfigError> {
|
|
let period_secs = match env("OPENFUT_LSX_EVENT_PERIOD") {
|
|
Some(v) => v.trim().parse::<f64>().map_err(|_| ConfigError {
|
|
var: "OPENFUT_LSX_EVENT_PERIOD",
|
|
value: v.clone(),
|
|
expected: "a number of seconds",
|
|
})?,
|
|
None => 5.0,
|
|
};
|
|
let count = match env("OPENFUT_LSX_EVENT_COUNT") {
|
|
Some(v) => v.trim().parse::<i64>().map_err(|_| ConfigError {
|
|
var: "OPENFUT_LSX_EVENT_COUNT",
|
|
value: v.clone(),
|
|
expected: "an integer",
|
|
})?,
|
|
None => 24,
|
|
};
|
|
Ok(Self {
|
|
enabled: env_flag(env, "OPENFUT_LSX_EVENTS", true),
|
|
period_secs,
|
|
count,
|
|
login_plaintext: env_flag(env, "OPENFUT_LSX_LOGIN_PLAINTEXT", false),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Render a period the way Python's `float` repr does, so the startup line reads
|
|
/// `period=5.0s` and not `period=5s`.
|
|
pub fn fmt_secs(secs: f64) -> String {
|
|
if secs.is_finite() && secs.fract() == 0.0 && secs.abs() < 1e16 {
|
|
format!("{secs:.1}")
|
|
} else {
|
|
format!("{secs}")
|
|
}
|
|
}
|
|
|
|
/// Socket + session key + a send lock.
|
|
///
|
|
/// The lock matters: pushes come from a heartbeat thread while the request loop
|
|
/// may be writing a Response. LSX frames are NUL-delimited, so two interleaved
|
|
/// writes would corrupt the stream and the client would drop the connection
|
|
/// (which would look exactly like a protocol bug).
|
|
pub struct Conn {
|
|
pub peer: SocketAddr,
|
|
/// A `try_clone` of the accepted socket: the request loop reads from the
|
|
/// original while this half is serialised behind the send lock.
|
|
writer: Mutex<TcpStream>,
|
|
key: RwLock<Option<Key>>,
|
|
alive: AtomicBool,
|
|
pushed_login: AtomicBool,
|
|
/// Set once GetAuthCode has been issued, so the heartbeat stops re-pushing
|
|
/// Login/OnlineStatus events. Re-pushing after the auth code is granted
|
|
/// re-enters FIFA's state-mutating Origin event dispatcher (case 2
|
|
/// @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts on the FE
|
|
/// bus) ~24 more times DURING Blaze login, which we do not want.
|
|
stop_events: AtomicBool,
|
|
events: EventConfig,
|
|
}
|
|
|
|
impl Conn {
|
|
pub fn new(sock: &TcpStream, peer: SocketAddr, events: EventConfig) -> io::Result<Arc<Self>> {
|
|
Ok(Arc::new(Self {
|
|
peer,
|
|
writer: Mutex::new(sock.try_clone()?),
|
|
key: RwLock::new(None),
|
|
alive: AtomicBool::new(true),
|
|
pushed_login: AtomicBool::new(false),
|
|
stop_events: AtomicBool::new(false),
|
|
events,
|
|
}))
|
|
}
|
|
|
|
pub fn events(&self) -> &EventConfig {
|
|
&self.events
|
|
}
|
|
|
|
pub fn set_key(&self, key: Key) {
|
|
*self.key.write() = Some(key);
|
|
}
|
|
|
|
pub fn key(&self) -> Option<Key> {
|
|
*self.key.read()
|
|
}
|
|
|
|
// These flags are advisory hand-offs between the request loop and the
|
|
// heartbeat thread; no data travels with them, so Relaxed is enough.
|
|
pub fn is_alive(&self) -> bool {
|
|
self.alive.load(Ordering::Relaxed)
|
|
}
|
|
|
|
pub fn mark_dead(&self) {
|
|
self.alive.store(false, Ordering::Relaxed);
|
|
}
|
|
|
|
pub fn stop_events(&self) {
|
|
self.stop_events.store(true, Ordering::Relaxed);
|
|
}
|
|
|
|
pub fn events_stopped(&self) -> bool {
|
|
self.stop_events.load(Ordering::Relaxed)
|
|
}
|
|
|
|
pub fn send_plain(&self, xml: &str) -> io::Result<()> {
|
|
let mut buf = Vec::with_capacity(xml.len() + 1);
|
|
buf.extend_from_slice(xml.as_bytes());
|
|
buf.push(0);
|
|
self.writer.lock().write_all(&buf)
|
|
}
|
|
|
|
pub fn send_enc(&self, xml: &str) -> io::Result<()> {
|
|
let key = self
|
|
.key()
|
|
.ok_or_else(|| io::Error::other("session key not initialised"))?;
|
|
let frame = crypto::lsx_encrypt(xml, &key);
|
|
self.writer.lock().write_all(&frame)
|
|
}
|
|
|
|
pub fn push_login_state(&self, why: &str) {
|
|
if !self.events.enabled {
|
|
return;
|
|
}
|
|
for frame in login_event_frames() {
|
|
if let Err(e) = self.send_enc(&frame) {
|
|
self.mark_dead();
|
|
log!("push failed: {e}");
|
|
return;
|
|
}
|
|
log!("PUSH ({why}) >> {frame}");
|
|
}
|
|
if !self.pushed_login.swap(true, Ordering::Relaxed) {
|
|
log!(
|
|
"*** first <Login IsLoggedIn=\"true\"> pushed. Watch for \
|
|
GetAuthCode next. ***"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Re-push the login state a bounded number of times.
|
|
///
|
|
/// FIFA builds its Origin event handlers lazily; if our first push lands
|
|
/// before the `<Login>` handler is registered the matcher simply finds no
|
|
/// handler and drops it. Re-pushing removes that race without needing to
|
|
/// guess the exact registration moment.
|
|
pub fn heartbeat(&self) {
|
|
let period = match Duration::try_from_secs_f64(self.events.period_secs) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
// Python's time.sleep() would raise here and kill just this
|
|
// thread; the connection keeps serving requests either way.
|
|
log!(
|
|
"heartbeat disabled: OPENFUT_LSX_EVENT_PERIOD={} is not a usable delay ({e})",
|
|
self.events.period_secs
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
for _ in 0..self.events.count.max(0) {
|
|
std::thread::sleep(period);
|
|
if !self.is_alive() || self.events_stopped() {
|
|
return;
|
|
}
|
|
self.push_login_state("heartbeat");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::crypto::{derive_session_key, lsx_decrypt, lsx_encrypt};
|
|
use std::collections::HashMap;
|
|
|
|
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
|
let map: HashMap<String, String> = pairs
|
|
.iter()
|
|
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
|
|
.collect();
|
|
move |k: &str| map.get(k).cloned()
|
|
}
|
|
|
|
#[test]
|
|
fn frames_are_the_five_candidate_pushes_with_the_empty_sender_first() {
|
|
let frames = login_event_frames();
|
|
assert_eq!(
|
|
frames.len(),
|
|
LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len()
|
|
);
|
|
assert_eq!(
|
|
frames,
|
|
vec![
|
|
r#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#,
|
|
r#"<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>"#,
|
|
r#"<LSX><Event sender="LOGIN"><Login IsLoggedIn="true"/></Event></LSX>"#,
|
|
r#"<LSX><Event sender=""><OnlineStatusEvent isOnline="true"/></Event></LSX>"#,
|
|
r#"<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>"#,
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn frames_round_trip_through_the_session_codec() {
|
|
let k = derive_session_key(&crate::crypto::challenge_response(
|
|
"18a70055a3541fb27ab8e0f47afad18c",
|
|
"",
|
|
)
|
|
.unwrap());
|
|
for f in login_event_frames() {
|
|
assert_eq!(lsx_decrypt(&lsx_encrypt(&f, &k), &k).unwrap(), f);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn defaults_match_the_python() {
|
|
let cfg = EventConfig::from_vars(&env_of(&[])).unwrap();
|
|
assert_eq!(cfg, EventConfig::default());
|
|
assert!(cfg.enabled);
|
|
assert_eq!(cfg.period_secs, 5.0);
|
|
assert_eq!(cfg.count, 24);
|
|
assert!(!cfg.login_plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn events_are_disabled_only_by_the_literal_zero() {
|
|
for (value, enabled) in [("0", false), ("1", true), ("", true), ("false", true)] {
|
|
let cfg = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENTS", value)])).unwrap();
|
|
assert_eq!(cfg.enabled, enabled, "OPENFUT_LSX_EVENTS={value:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn login_plaintext_is_enabled_by_anything_but_zero() {
|
|
for (value, on) in [("0", false), ("1", true), ("", true), ("no", true)] {
|
|
let cfg =
|
|
EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_LOGIN_PLAINTEXT", value)])).unwrap();
|
|
assert_eq!(cfg.login_plaintext, on, "OPENFUT_LSX_LOGIN_PLAINTEXT={value:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn period_and_count_are_overridable() {
|
|
let cfg = EventConfig::from_vars(&env_of(&[
|
|
("OPENFUT_LSX_EVENT_PERIOD", "0.25"),
|
|
("OPENFUT_LSX_EVENT_COUNT", "3"),
|
|
]))
|
|
.unwrap();
|
|
assert_eq!(cfg.period_secs, 0.25);
|
|
assert_eq!(cfg.count, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_period_is_refused() {
|
|
let err = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_PERIOD", "soon")]))
|
|
.unwrap_err();
|
|
assert_eq!(err.var, "OPENFUT_LSX_EVENT_PERIOD");
|
|
let err =
|
|
EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_COUNT", "many")])).unwrap_err();
|
|
assert_eq!(err.var, "OPENFUT_LSX_EVENT_COUNT");
|
|
}
|
|
|
|
#[test]
|
|
fn period_renders_like_a_python_float() {
|
|
assert_eq!(fmt_secs(5.0), "5.0");
|
|
assert_eq!(fmt_secs(0.25), "0.25");
|
|
assert_eq!(fmt_secs(0.0), "0.0");
|
|
}
|
|
}
|