Initial commit: OpenFUT Bridge

FIFA 23 reverse-engineering proxy and integration scaffold.

- Catch-all HTTP proxy that captures all incoming FIFA 23 traffic
- Known-route mapper (speculative FUT paths → Core API calls)
- Placeholder JSON responses for unmapped endpoints
- Admin endpoints: GET /_bridge/captures, GET /_bridge/unknown
- Capture persistence to captures/*.json for RE analysis
- 4 unit tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 15:09:47 -07:00
commit a826e5f7d3
19 changed files with 2897 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::Path;
use uuid::Uuid;
/// A captured HTTP request from the FIFA 23 client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapturedRequest {
pub id: String,
pub timestamp: String,
pub method: String,
pub path: String,
pub query: Option<String>,
pub headers: Vec<(String, String)>,
pub body: Option<String>,
pub response_status: Option<u16>,
pub response_body: Option<String>,
pub mapped_to_core: Option<String>,
}
impl CapturedRequest {
pub fn new(
method: &str,
path: &str,
query: Option<&str>,
headers: Vec<(String, String)>,
body: Option<String>,
) -> Self {
Self {
id: Uuid::new_v4().to_string(),
timestamp: Utc::now().to_rfc3339(),
method: method.to_string(),
path: path.to_string(),
query: query.map(String::from),
headers,
body,
response_status: None,
response_body: None,
mapped_to_core: None,
}
}
pub fn with_response(mut self, status: u16, body: Option<String>) -> Self {
self.response_status = Some(status);
self.response_body = body;
self
}
}
/// Persist a capture to disk as JSON.
pub fn save_capture(captures_dir: &str, capture: &CapturedRequest) -> anyhow::Result<()> {
let dir = Path::new(captures_dir);
std::fs::create_dir_all(dir)?;
let filename = format!(
"{}_{}_{}.json",
capture.timestamp.replace(':', "-"),
capture.method,
capture.id
);
let path = dir.join(filename);
let json = serde_json::to_string_pretty(capture)?;
std::fs::write(&path, json)?;
tracing::debug!("Capture saved: {:?}", path);
Ok(())
}
/// Load all captures from the captures directory.
pub fn load_all_captures(captures_dir: &str) -> anyhow::Result<Vec<CapturedRequest>> {
let dir = Path::new(captures_dir);
if !dir.exists() {
return Ok(vec![]);
}
let mut captures = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
let content = std::fs::read_to_string(&path)?;
if let Ok(capture) = serde_json::from_str::<CapturedRequest>(&content) {
captures.push(capture);
}
}
}
captures.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
Ok(captures)
}
+27
View File
@@ -0,0 +1,27 @@
use anyhow::Result;
#[derive(Debug, Clone)]
pub struct Config {
/// Address the Bridge proxy listens on (FIFA 23 should point here)
pub listen_addr: String,
/// OpenFUT Core base URL
pub core_url: String,
/// Where to persist captures
pub captures_dir: String,
/// If true, return placeholder 200 responses for unknown routes
pub placeholder_mode: bool,
}
impl Config {
pub fn from_env() -> Result<Self> {
Ok(Self {
listen_addr: std::env::var("BRIDGE_LISTEN_ADDR")
.unwrap_or_else(|_| "127.0.0.1:8443".into()),
core_url: std::env::var("CORE_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
captures_dir: std::env::var("CAPTURES_DIR").unwrap_or_else(|_| "captures".into()),
placeholder_mode: std::env::var("PLACEHOLDER_MODE")
.map(|v| v == "true" || v == "1")
.unwrap_or(true),
})
}
}
+40
View File
@@ -0,0 +1,40 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BridgeError {
#[allow(dead_code)]
#[error("upstream error: {0}")]
Upstream(String),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("internal error: {0}")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for BridgeError {
fn into_response(self) -> Response {
let (status, msg) = match &self {
BridgeError::Upstream(e) => (StatusCode::BAD_GATEWAY, e.clone()),
BridgeError::Serialization(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
BridgeError::Io(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
BridgeError::Internal(e) => {
tracing::error!("Bridge internal error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
};
(status, Json(json!({ "error": msg }))).into_response()
}
}
pub type BridgeResult<T> = Result<T, BridgeError>;
+6
View File
@@ -0,0 +1,6 @@
pub mod capture;
pub mod config;
pub mod error;
pub mod mapper;
pub mod proxy;
pub mod routes;
+52
View File
@@ -0,0 +1,52 @@
use anyhow::Result;
use axum::{
routing::{any, get},
Router,
};
use openfut_bridge::{config::Config, proxy::ProxyState, routes};
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "openfut_bridge=debug,tower_http=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let cfg = Config::from_env()?;
let listen_addr = cfg.listen_addr.clone();
info!("OpenFUT Bridge starting on {listen_addr}");
info!("Core URL: {}", cfg.core_url);
info!("Placeholder mode: {}", cfg.placeholder_mode);
info!("Captures dir: {}", cfg.captures_dir);
std::fs::create_dir_all(&cfg.captures_dir)?;
let state = ProxyState::new(cfg);
let app = Router::new()
.route("/_bridge/health", get(routes::health::get_health))
.route("/_bridge/captures", get(routes::admin::get_captures))
.route(
"/_bridge/unknown",
get(routes::admin::get_unknown_endpoints),
)
.fallback(any(openfut_bridge::proxy::catch_all_handler))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
info!("Bridge listening on http://{listen_addr}");
axum::serve(listener, app).await?;
Ok(())
}
+105
View File
@@ -0,0 +1,105 @@
/// Mapping layer: translates known FIFA 23 API paths to OpenFUT Core endpoints.
/// Unknown paths return None and will be recorded for reverse engineering.
#[derive(Debug, Clone)]
pub struct CoreMapping {
pub method: &'static str,
pub core_path: &'static str,
#[allow(dead_code)]
pub notes: &'static str,
}
/// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call.
/// Returns Some(mapping) if the route is known, None otherwise.
pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
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
(
"POST",
"/ut/auth",
CoreMapping {
method: "POST",
core_path: "/auth/local",
notes: "FUT auth → Core local auth",
},
),
(
"GET",
"/ut/game/fut/user/settings",
CoreMapping {
method: "GET",
core_path: "/profile",
notes: "FUT settings → Core profile",
},
),
// Club
(
"GET",
"/ut/game/fut/usermassinfo",
CoreMapping {
method: "GET",
core_path: "/club",
notes: "FUT mass info → Core club",
},
),
// Squad
(
"GET",
"/ut/game/fut/squad/active",
CoreMapping {
method: "GET",
core_path: "/squad",
notes: "FUT active squad → Core squad",
},
),
// Packs
(
"GET",
"/ut/game/fut/store/packdetails",
CoreMapping {
method: "GET",
core_path: "/packs",
notes: "FUT pack store → Core pack list",
},
),
// Transfer market (guessed)
(
"GET",
"/ut/game/fut/transfermarket",
CoreMapping {
method: "GET",
core_path: "/market",
notes: "FUT transfer market → Core NPC market",
},
),
];
for (km, kp, mapping) in known {
if *km == m && *kp == p {
return Some(mapping.clone());
}
}
None
}
/// Return a safe placeholder response for unknown endpoints.
/// This 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",
method,
path
);
serde_json::json!({
"status": "ok",
"openfut_note": "This endpoint has not been mapped yet. Check captures/ for details.",
"method": method,
"path": path,
})
}
+154
View File
@@ -0,0 +1,154 @@
use axum::{
body::Body,
extract::{Request, State},
http::StatusCode,
response::Response,
};
use bytes::Bytes;
use serde_json::Value;
use std::sync::Arc;
use crate::{
capture::{save_capture, CapturedRequest},
config::Config,
error::BridgeResult,
mapper::{map_to_core, placeholder_response},
};
#[derive(Clone)]
pub struct ProxyState {
pub config: Arc<Config>,
pub http_client: reqwest::Client,
}
impl ProxyState {
pub fn new(config: Config) -> Self {
Self {
config: Arc::new(config),
http_client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("failed to build HTTP client"),
}
}
}
pub async fn catch_all_handler(
State(state): State<ProxyState>,
req: Request,
) -> BridgeResult<Response<Body>> {
let method = req.method().to_string();
let uri = req.uri().clone();
let path = uri.path().to_string();
let query = uri.query().map(String::from);
let headers: Vec<(String, String)> = req
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string()))
.collect();
// Extract body via axum's built-in mechanism
let (_parts, body) = req.into_parts();
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024)
.await
.unwrap_or_default();
let body_str = if body_bytes.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&body_bytes).to_string())
};
tracing::info!(
"→ {} {}{}",
method,
path,
query
.as_deref()
.map(|q| format!("?{q}"))
.unwrap_or_default()
);
let mut capture =
CapturedRequest::new(&method, &path, query.as_deref(), headers, body_str.clone());
let (response_body, status_code): (Value, u16) =
if let Some(mapping) = map_to_core(&method, &path) {
tracing::info!(
" ↳ Mapped to Core: {} {}",
mapping.method,
mapping.core_path
);
capture.mapped_to_core = Some(mapping.core_path.to_string());
match forward_to_core(
&state,
mapping.method,
mapping.core_path,
body_str.as_deref(),
)
.await
{
Ok((body, status)) => (body, status),
Err(e) => {
tracing::error!("Core request failed: {e}");
(serde_json::json!({ "error": e.to_string() }), 502)
}
}
} else if state.config.placeholder_mode {
(placeholder_response(&method, &path), 200)
} else {
(serde_json::json!({ "error": "endpoint not mapped" }), 404)
};
capture = capture.with_response(status_code, Some(response_body.to_string()));
let captures_dir = state.config.captures_dir.clone();
let capture_clone = capture.clone();
tokio::spawn(async move {
if let Err(e) = save_capture(&captures_dir, &capture_clone) {
tracing::warn!("Failed to save capture: {e}");
}
});
let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK);
let json_bytes =
serde_json::to_vec(&response_body).map_err(crate::error::BridgeError::Serialization)?;
let response = Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Body::from(json_bytes))
.map_err(|e| anyhow::anyhow!("response build error: {e}"))?;
Ok(response)
}
async fn forward_to_core(
state: &ProxyState,
method: &str,
path: &str,
body: Option<&str>,
) -> anyhow::Result<(Value, u16)> {
let url = format!("{}{}", state.config.core_url, path);
let builder = match method {
"POST" => state.http_client.post(&url),
"PUT" => state.http_client.put(&url),
"DELETE" => state.http_client.delete(&url),
_ => state.http_client.get(&url),
};
let builder = if let Some(b) = body {
builder
.header("content-type", "application/json")
.body(b.to_string())
} else {
builder
};
let resp = builder.send().await?;
let status = resp.status().as_u16();
let body: Value = resp.json().await.unwrap_or(Value::Null);
Ok((body, status))
}
+51
View File
@@ -0,0 +1,51 @@
use axum::{extract::State, Json};
use serde_json::{json, Value};
use crate::{capture::load_all_captures, error::BridgeResult, proxy::ProxyState};
/// List all captured requests.
pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
let captures = load_all_captures(&state.config.captures_dir)
.map_err(crate::error::BridgeError::Internal)?;
let unknown: Vec<_> = captures
.iter()
.filter(|c| c.mapped_to_core.is_none())
.collect();
Ok(Json(json!({
"total": captures.len(),
"unknown_endpoints": unknown.len(),
"captures": captures,
})))
}
/// List only unknown (unmapped) endpoints.
pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
let captures = load_all_captures(&state.config.captures_dir)
.map_err(crate::error::BridgeError::Internal)?;
let mut seen = std::collections::HashSet::new();
let unknown: Vec<Value> = captures
.iter()
.filter(|c| c.mapped_to_core.is_none())
.filter_map(|c| {
let key = format!("{} {}", c.method, c.path);
if seen.insert(key) {
Some(json!({
"method": c.method,
"path": c.path,
"first_seen": c.timestamp,
"capture_id": c.id,
}))
} else {
None
}
})
.collect();
Ok(Json(json!({
"unknown_endpoint_count": unknown.len(),
"endpoints": unknown,
})))
}
+14
View File
@@ -0,0 +1,14 @@
use axum::{http::StatusCode, Json};
use serde_json::{json, Value};
pub async fn get_health() -> (StatusCode, Json<Value>) {
(
StatusCode::OK,
Json(json!({
"status": "ok",
"service": "openfut-bridge",
"version": env!("CARGO_PKG_VERSION"),
"note": "This proxy captures FIFA 23 traffic and routes known endpoints to OpenFUT Core."
})),
)
}
+2
View File
@@ -0,0 +1,2 @@
pub mod admin;
pub mod health;