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
+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))
}