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:
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod capture;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod mapper;
|
||||
pub mod proxy;
|
||||
pub mod routes;
|
||||
+52
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
@@ -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."
|
||||
})),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod admin;
|
||||
pub mod health;
|
||||
Reference in New Issue
Block a user