From b8e766e9dd5bb0045bdbf1d267f8633f51c7312f Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 25 Jun 2026 16:32:37 -0700 Subject: [PATCH] Phase 7 (Bridge): response shaper, diff tool, expanded mapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shaper.rs: shape_response() dispatches on core_path to wrap Core JSON in FUT envelope format; shapes auth (/ut/auth → sid/pid/ phishingToken/persona envelope), profile, club, squad, market, packs; unknown paths pass through unchanged - proxy.rs: calls shape_response() on every successful Core response before returning to the FIFA 23 client - mapper.rs: expanded from 6 to 18 speculative FUT endpoint mappings covering auth, profile/settings, club/usermassinfo, item/collection, squad (GET+PUT), packs+purchase, transfer market+watchlist+bid, objectives, events, squad battles, and match result submission - admin.rs: GET /_bridge/captures/diff?a=&b= compares two captures; reports method/path/status changes, header diffs, and structured JSON body diffs (key-level for objects, length for others) - main.rs: registers /_bridge/captures/diff route - Unit tests: 4 shaper tests, 6 mapper tests (10 total, all passing) - All 13 integration tests still passing Co-Authored-By: Claude Sonnet 4.6 --- src/lib.rs | 1 + src/main.rs | 4 + src/mapper.rs | 181 +++++++++++++++++++++++++++++++++++++--- src/proxy.rs | 3 +- src/routes/admin.rs | 137 +++++++++++++++++++++++++++++- src/shaper.rs | 198 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 510 insertions(+), 14 deletions(-) create mode 100644 src/shaper.rs diff --git a/src/lib.rs b/src/lib.rs index 7cd8b42..e397095 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,4 +4,5 @@ pub mod error; pub mod mapper; pub mod proxy; pub mod routes; +pub mod shaper; pub mod tls; diff --git a/src/main.rs b/src/main.rs index abee541..f5431fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,10 @@ async fn main() -> Result<()> { get(routes::admin::get_unknown_endpoints), ) .route("/_bridge/status", get(routes::admin::get_endpoint_status)) + .route( + "/_bridge/captures/diff", + get(routes::admin::get_capture_diff), + ) .fallback(any(openfut_bridge::proxy::catch_all_handler)) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) diff --git a/src/mapper.rs b/src/mapper.rs index 55fb93b..a19bc48 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -11,43 +11,81 @@ pub struct CoreMapping { /// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call. /// Returns Some(mapping) if the route is known, None otherwise. +/// +/// Mappings are speculative — refined as real traffic captures come in. pub fn map_to_core(method: &str, path: &str) -> Option { let m = method.to_uppercase(); let p = path.trim_end_matches('/'); - // These are speculative mappings based on common FUT API patterns. - // They will be refined as reverse engineering progresses. let known: &[(&str, &str, CoreMapping)] = &[ - // Auth + // ── Auth ───────────────────────────────────────────────────────────────── ( "POST", "/ut/auth", CoreMapping { method: "POST", core_path: "/auth/local", - notes: "FUT auth → Core local auth", + notes: "FUT login → Core local auth (shaped into EA envelope format)", }, ), + // ── Profile / Settings ─────────────────────────────────────────────────── ( "GET", "/ut/game/fut/user/settings", CoreMapping { method: "GET", core_path: "/profile", - notes: "FUT settings → Core profile", + notes: "FUT user settings → Core profile", }, ), - // Club + ( + "GET", + "/ut/game/fut/user/accountinfo", + CoreMapping { + method: "GET", + core_path: "/profile", + notes: "FUT account info → Core profile", + }, + ), + // ── Club / Mass info ────────────────────────────────────────────────────── ( "GET", "/ut/game/fut/usermassinfo", CoreMapping { method: "GET", core_path: "/club", - notes: "FUT mass info → Core club", + notes: "FUT mass info (club + coins) → Core club", }, ), - // Squad + ( + "GET", + "/ut/game/fut/club", + CoreMapping { + method: "GET", + core_path: "/club", + notes: "FUT club info → Core club", + }, + ), + // ── Cards / Collection ──────────────────────────────────────────────────── + ( + "GET", + "/ut/game/fut/item", + CoreMapping { + method: "GET", + core_path: "/cards/collection", + notes: "FUT collection → Core player card collection", + }, + ), + ( + "GET", + "/ut/game/fut/club/stats", + CoreMapping { + method: "GET", + core_path: "/stats", + notes: "FUT club stats → Core statistics summary", + }, + ), + // ── Squad ───────────────────────────────────────────────────────────────── ( "GET", "/ut/game/fut/squad/active", @@ -57,7 +95,16 @@ pub fn map_to_core(method: &str, path: &str) -> Option { notes: "FUT active squad → Core squad", }, ), - // Packs + ( + "PUT", + "/ut/game/fut/squad/active", + CoreMapping { + method: "POST", + core_path: "/squad", + notes: "FUT save squad → Core save squad", + }, + ), + // ── Packs ───────────────────────────────────────────────────────────────── ( "GET", "/ut/game/fut/store/packdetails", @@ -67,14 +114,80 @@ pub fn map_to_core(method: &str, path: &str) -> Option { notes: "FUT pack store → Core pack list", }, ), - // Transfer market (guessed) + ( + "POST", + "/ut/game/fut/store/purchase", + CoreMapping { + method: "POST", + core_path: "/packs/buy", + notes: "FUT pack purchase → Core pack buy (guess)", + }, + ), + // ── Transfer Market ─────────────────────────────────────────────────────── ( "GET", "/ut/game/fut/transfermarket", CoreMapping { method: "GET", core_path: "/market", - notes: "FUT transfer market → Core NPC market", + notes: "FUT transfer market search → Core NPC market", + }, + ), + ( + "POST", + "/ut/game/fut/trade/bid", + CoreMapping { + method: "POST", + core_path: "/market/buy", + notes: "FUT bid/buy now → Core market buy", + }, + ), + ( + "GET", + "/ut/game/fut/trade/watchlist", + CoreMapping { + method: "GET", + core_path: "/market", + notes: "FUT watchlist → Core market (approximation)", + }, + ), + // ── Objectives ──────────────────────────────────────────────────────────── + ( + "GET", + "/ut/game/fut/objectives", + CoreMapping { + method: "GET", + core_path: "/objectives", + notes: "FUT objectives → Core objectives list", + }, + ), + // ── Events ──────────────────────────────────────────────────────────────── + ( + "GET", + "/ut/game/fut/events", + CoreMapping { + method: "GET", + core_path: "/events", + notes: "FUT events → Core events list", + }, + ), + // ── Matches / Squad Battles ─────────────────────────────────────────────── + ( + "GET", + "/ut/game/fut/squadbattle/opponent", + CoreMapping { + method: "GET", + core_path: "/matches/opponent", + notes: "FUT squad battles opponent → Core match opponent generator", + }, + ), + ( + "POST", + "/ut/game/fut/result", + CoreMapping { + method: "POST", + core_path: "/matches", + notes: "FUT match result submit → Core match submission", }, ), ]; @@ -89,7 +202,7 @@ pub fn map_to_core(method: &str, path: &str) -> Option { } /// Return a safe placeholder response for unknown endpoints. -/// This prevents the FIFA 23 client from crashing while we log traffic. +/// Prevents the FIFA 23 client from crashing while we log traffic. pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value { tracing::warn!( "UNKNOWN ENDPOINT: {} {} — returning placeholder", @@ -103,3 +216,47 @@ pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value { "path": path, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_known_auth_maps() { + let m = map_to_core("POST", "/ut/auth"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/auth/local"); + } + + #[test] + fn test_known_market_maps() { + let m = map_to_core("GET", "/ut/game/fut/transfermarket"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/market"); + } + + #[test] + fn test_events_maps() { + let m = map_to_core("GET", "/ut/game/fut/events"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/events"); + } + + #[test] + fn test_squad_put_maps() { + let m = map_to_core("PUT", "/ut/game/fut/squad/active"); + assert!(m.is_some()); + assert_eq!(m.unwrap().method, "POST"); + } + + #[test] + fn test_unknown_returns_none() { + assert!(map_to_core("GET", "/ut/game/fut/some/unknown/path").is_none()); + } + + #[test] + fn test_trailing_slash_trimmed() { + let m = map_to_core("POST", "/ut/auth/"); + assert!(m.is_some()); + } +} diff --git a/src/proxy.rs b/src/proxy.rs index fdacd87..5dde254 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -18,6 +18,7 @@ use crate::{ config::Config, error::BridgeResult, mapper::{map_to_core, placeholder_response}, + shaper::shape_response, }; const CAPTURE_DEDUP_SECS: u64 = 1; @@ -118,7 +119,7 @@ pub async fn catch_all_handler( ) .await { - Ok((body, status)) => (body, status), + Ok((body, status)) => (shape_response(mapping.core_path, body), status), Err(e) => { tracing::error!("Core request failed: {e}"); (serde_json::json!({ "error": e.to_string() }), 502) diff --git a/src/routes/admin.rs b/src/routes/admin.rs index da265ed..1b53734 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -1,11 +1,12 @@ use axum::{ - extract::State, + extract::{Query, State}, http::{header, StatusCode}, response::{ sse::{Event, KeepAlive, Sse}, IntoResponse, Json, Response, }, }; +use serde::Deserialize; use serde_json::{json, Value}; use std::convert::Infallible; use tokio_stream::{wrappers::BroadcastStream, StreamExt}; @@ -136,6 +137,140 @@ pub async fn get_endpoint_status(State(state): State) -> BridgeResul }))) } +/// Query params for the diff endpoint. +#[derive(Deserialize)] +pub struct DiffQuery { + pub a: String, + pub b: String, +} + +/// Diff two captures by ID: shows what changed between them. +/// +/// Usage: GET /_bridge/captures/diff?a=&b= +pub async fn get_capture_diff( + State(state): State, + Query(params): Query, +) -> BridgeResult> { + let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?; + + let find = |id: &str| -> Option<&crate::capture::CapturedRequest> { + captures.iter().find(|c| c.id == id) + }; + + let cap_a = find(¶ms.a) + .ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.a)))?; + let cap_b = find(¶ms.b) + .ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.b)))?; + + // Structural JSON diff of request bodies + let body_diff = diff_json_strings(cap_a.body.as_deref(), cap_b.body.as_deref()); + let resp_diff = diff_json_strings( + cap_a.response_body.as_deref(), + cap_b.response_body.as_deref(), + ); + + // Header diff: keys present in one but not the other + let headers_a: std::collections::HashSet = + cap_a.headers.iter().map(|(k, _)| k.clone()).collect(); + let headers_b: std::collections::HashSet = + cap_b.headers.iter().map(|(k, _)| k.clone()).collect(); + + let headers_only_in_a: Vec<_> = headers_a.difference(&headers_b).collect(); + let headers_only_in_b: Vec<_> = headers_b.difference(&headers_a).collect(); + + // Headers present in both but with different values + let headers_changed: Vec = cap_a + .headers + .iter() + .filter_map(|(k, va)| { + cap_b.headers.iter().find(|(kb, _)| kb == k).and_then(|(_, vb)| { + if va != vb { + Some(json!({ "header": k, "a": va, "b": vb })) + } else { + None + } + }) + }) + .collect(); + + Ok(Json(json!({ + "a": { "id": cap_a.id, "timestamp": cap_a.timestamp, "method": cap_a.method, "path": cap_a.path }, + "b": { "id": cap_b.id, "timestamp": cap_b.timestamp, "method": cap_b.method, "path": cap_b.path }, + "method_changed": cap_a.method != cap_b.method, + "path_changed": cap_a.path != cap_b.path, + "status_a": cap_a.response_status, + "status_b": cap_b.response_status, + "status_changed": cap_a.response_status != cap_b.response_status, + "headers": { + "only_in_a": headers_only_in_a, + "only_in_b": headers_only_in_b, + "changed": headers_changed, + }, + "body_diff": body_diff, + "response_diff": resp_diff, + }))) +} + +/// Shallow JSON diff of two optional JSON strings. +/// +/// If both parse as JSON objects, lists keys that differ or are absent. +/// Falls back to string comparison if either is not valid JSON. +fn diff_json_strings(a: Option<&str>, b: Option<&str>) -> Value { + match (a, b) { + (None, None) => json!({ "same": true, "note": "both empty" }), + (Some(_), None) => json!({ "same": false, "note": "b has no body" }), + (None, Some(_)) => json!({ "same": false, "note": "a has no body" }), + (Some(a_str), Some(b_str)) => { + if a_str == b_str { + return json!({ "same": true }); + } + // Try to parse as JSON objects for a structured diff + let va: Option> = + serde_json::from_str(a_str).ok(); + let vb: Option> = + serde_json::from_str(b_str).ok(); + + match (va, vb) { + (Some(ma), Some(mb)) => { + let keys_a: std::collections::HashSet<&str> = + ma.keys().map(|s| s.as_str()).collect(); + let keys_b: std::collections::HashSet<&str> = + mb.keys().map(|s| s.as_str()).collect(); + + let only_in_a: Vec<&str> = keys_a.difference(&keys_b).copied().collect(); + let only_in_b: Vec<&str> = keys_b.difference(&keys_a).copied().collect(); + let changed: Vec = keys_a + .intersection(&keys_b) + .filter_map(|k| { + let va = ma.get(*k)?; + let vb = mb.get(*k)?; + if va != vb { + Some(json!({ "key": k, "a": va, "b": vb })) + } else { + None + } + }) + .collect(); + + json!({ + "same": false, + "type": "json_object_diff", + "keys_only_in_a": only_in_a, + "keys_only_in_b": only_in_b, + "keys_changed": changed, + }) + } + _ => json!({ + "same": false, + "type": "string_diff", + "a_len": a_str.len(), + "b_len": b_str.len(), + }), + } + } + } +} + /// Simple HTML admin dashboard. pub async fn get_admin_dashboard() -> impl IntoResponse { let html = include_str!("../admin.html"); diff --git a/src/shaper.rs b/src/shaper.rs new file mode 100644 index 0000000..81da0e6 --- /dev/null +++ b/src/shaper.rs @@ -0,0 +1,198 @@ +//! Response shaper: transforms OpenFUT Core responses into the envelope format +//! FIFA 23 expects, based on the Core path that was called. +//! +//! These are best-guess wrappers derived from typical EA FUT API patterns. +//! They will be refined as real traffic captures come in. + +use serde_json::{json, Value}; +use uuid::Uuid; + +/// Shape a Core JSON response into the format FIFA 23 expects for this path. +/// Returns the shaped response or the original unchanged if no shaping is defined. +pub fn shape_response(core_path: &str, core_response: Value) -> Value { + match core_path { + "/auth/local" => shape_auth_response(core_response), + "/profile" => shape_profile_response(core_response), + "/club" => shape_club_response(core_response), + "/squad" => shape_squad_response(core_response), + "/market" => shape_market_response(core_response), + "/packs" => shape_packs_response(core_response), + _ => core_response, + } +} + +/// Wraps Core auth response in the FUT auth envelope format. +/// +/// EA's `/ut/auth` returns a top-level object with a SID, PID, and persona. +/// The `sid` is used as a session token in subsequent `X-UT-SID` headers. +fn shape_auth_response(core: Value) -> Value { + let profile_id = core + .get("profile") + .and_then(|p| p.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + let display_name = core + .get("profile") + .and_then(|p| p.get("username")) + .and_then(|v| v.as_str()) + .unwrap_or("FUT Player"); + + let fake_sid = format!("ofut-sid-{}", Uuid::new_v4().simple()); + let fake_pid = format!("1{}", &Uuid::new_v4().simple().to_string()[..9]); + let phishing_token = format!("ptkn-{}", Uuid::new_v4().simple()); + + json!({ + "sid": fake_sid, + "pid": { + "pidId": fake_pid, + "email": format!("{}@openfut.local", display_name.to_lowercase().replace(' ', "_")), + "emailStatus": "VERIFIED", + "strength": "STRONG", + "dob": "1990-01-01", + "country": "US", + }, + "phishingToken": phishing_token, + "persona": { + "personaId": profile_id, + "displayName": display_name, + "isEmailVerified": true, + }, + "userAccountInfo": { + "userAccountInfoSummary": [{ + "personaId": profile_id, + "displayName": display_name, + "clubs": [{ "clubType": "FUT", "supported": true }], + }], + }, + "openfut": true, + }) +} + +/// Wraps Core profile response in the FUT user settings envelope. +fn shape_profile_response(core: Value) -> Value { + let profile = core.get("profile").cloned().unwrap_or(core.clone()); + + json!({ + "settings": { + "personaId": profile.get("id").cloned().unwrap_or(json!("unknown")), + "displayName": profile.get("username").cloned().unwrap_or(json!("FUT Player")), + "level": profile.get("level").cloned().unwrap_or(json!(1)), + "xp": profile.get("xp").cloned().unwrap_or(json!(0)), + }, + "openfut": true, + "_raw": profile, + }) +} + +/// Wraps Core club response in the FUT usermassinfo envelope. +fn shape_club_response(core: Value) -> Value { + let club = core.get("club").cloned().unwrap_or(core.clone()); + + json!({ + "pilluserinfo": { + "clubId": club.get("id").cloned().unwrap_or(json!("unknown")), + "credits": club.get("coins").cloned().unwrap_or(json!(0)), + "purchased": 0, + "sold": 0, + }, + "userInfo": { + "duplicationInfo": { "isDuplicate": false }, + "purchasedPacksInfo": { "premium": 0, "standard": 0 }, + }, + "openfut": true, + "_raw": club, + }) +} + +/// Wraps Core squad response in the FUT squad envelope. +fn shape_squad_response(core: Value) -> Value { + let squad = core.get("squad").cloned().unwrap_or(core.clone()); + let players = core.get("players").cloned().unwrap_or(json!([])); + + json!({ + "squad": { + "squadId": squad.get("id").cloned().unwrap_or(json!("unknown")), + "name": squad.get("name").cloned().unwrap_or(json!("My Squad")), + "formation": squad.get("formation").cloned().unwrap_or(json!("4-4-2")), + "players": players, + "rating": 0, + "chemistry": 0, + }, + "openfut": true, + }) +} + +/// Wraps Core market response in the FUT transfer market envelope. +fn shape_market_response(core: Value) -> Value { + let listings = core.get("listings").cloned().unwrap_or(json!([])); + let total = core + .get("total") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + json!({ + "auctionInfo": listings, + "bidTokens": {}, + "credits": 0, + "currencies": [ + { "name": "COINS", "funds": 0, "finalFunds": 0 }, + ], + "duplicateItemIdList": [], + "itemData": [], + "total": total, + "openfut": true, + }) +} + +/// Wraps Core packs response in the FUT pack store envelope. +fn shape_packs_response(core: Value) -> Value { + let packs = core.get("packs").cloned().unwrap_or(json!([])); + + json!({ + "purchasedPacks": { + "packs": packs, + }, + "openfut": true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_shape_auth_response_has_required_fields() { + let core = json!({ + "profile": { "id": "prof_001", "username": "TestPlayer" } + }); + let shaped = shape_auth_response(core); + assert!(shaped.get("sid").is_some()); + assert!(shaped.get("phishingToken").is_some()); + assert!(shaped.get("persona").is_some()); + assert_eq!(shaped["persona"]["displayName"], "TestPlayer"); + assert_eq!(shaped["openfut"], true); + } + + #[test] + fn test_shape_response_dispatches_correctly() { + let core = json!({ "profile": { "id": "x", "username": "u" } }); + let shaped = shape_response("/auth/local", core); + assert!(shaped.get("sid").is_some()); + } + + #[test] + fn test_shape_response_passthrough_for_unknown_path() { + let core = json!({ "foo": "bar" }); + let shaped = shape_response("/some/unknown/path", core.clone()); + assert_eq!(shaped, core); + } + + #[test] + fn test_shape_market_response_has_auction_info() { + let core = json!({ "listings": [{ "id": "l1" }], "total": 1 }); + let shaped = shape_market_response(core); + assert!(shaped["auctionInfo"].as_array().is_some()); + assert_eq!(shaped["total"], 1); + } +}