Files
OpenFUT-Bridge/src/proxy.rs
T
funman300 658dbe2f24 wip(bridge): retained FIFA request/response mapper + docker packaging
RETAINED PRE-EXISTING WIP (brought forward after verification, not authored
here, not production-ready). Substantive mapper.rs proxy expansion plus a
multi-stage Dockerfile/.dockerignore (rustls, self-signed cert at startup).
No secrets/captures staged. Preserved off the detached base c58e7326a1.
2026-08-20 16:08:24 +00:00

228 lines
7.0 KiB
Rust

use axum::{
body::Body,
extract::{Request, State},
http::StatusCode,
response::Response,
};
use bytes::Bytes;
use serde_json::Value;
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::Instant,
};
use tokio::sync::broadcast;
use crate::{
capture::{save_capture, CapturedRequest},
config::Config,
error::BridgeResult,
mapper::{map_to_core, placeholder_response},
shaper::shape_response,
};
const CAPTURE_DEDUP_SECS: u64 = 1;
#[derive(Clone)]
pub struct ProxyState {
pub config: Arc<Config>,
pub http_client: reqwest::Client,
/// Broadcast channel for streaming new captures to SSE subscribers.
pub capture_tx: Arc<broadcast::Sender<CapturedRequest>>,
/// Deduplication window: (method+path) → last saved instant.
pub dedup: Arc<Mutex<HashMap<String, Instant>>>,
/// PEM-encoded TLS certificate for download. None when TLS is disabled.
pub cert_pem: Option<Arc<Vec<u8>>>,
}
impl ProxyState {
pub fn new(config: Config) -> Self {
let (capture_tx, _) = broadcast::channel(256);
Self {
config: Arc::new(config),
http_client: reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("failed to build HTTP client"),
capture_tx: Arc::new(capture_tx),
dedup: Arc::new(Mutex::new(HashMap::new())),
cert_pem: None,
}
}
pub fn with_cert(mut self, cert_pem: Vec<u8>) -> Self {
self.cert_pem = Some(Arc::new(cert_pem));
self
}
}
/// Returns true if this (method, path) pair was already saved within the dedup window.
fn is_duplicate(dedup: &Mutex<HashMap<String, Instant>>, method: &str, path: &str) -> bool {
let key = format!("{method} {path}");
let mut map = dedup.lock().unwrap();
let threshold = std::time::Duration::from_secs(CAPTURE_DEDUP_SECS);
if let Some(last) = map.get(&key) {
if last.elapsed() < threshold {
return true;
}
}
map.insert(key, Instant::now());
false
}
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();
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.clone(),
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(),
&headers,
)
.await
{
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)
}
}
} 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()));
// Deduplicate: skip saving if same method+path was saved within 1 second
let should_save = !is_duplicate(&state.dedup, &method, &path);
if should_save {
let captures_dir = state.config.captures_dir.clone();
let capture_clone = capture.clone();
let capture_tx = state.capture_tx.clone();
tokio::spawn(async move {
if let Err(e) = save_capture(&captures_dir, &capture_clone) {
tracing::warn!("Failed to save capture: {e}");
}
// Broadcast to SSE subscribers (ignore send errors — no subscribers is OK)
let _ = capture_tx.send(capture_clone);
});
}
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)
}
/// Public alias for use by the replay route in admin.rs.
pub async fn forward_to_core_pub(
state: &ProxyState,
method: &str,
path: &str,
body: Option<&str>,
headers: &[(String, String)],
) -> anyhow::Result<(Value, u16)> {
forward_to_core(state, method, path, body, headers).await
}
async fn forward_to_core(
state: &ProxyState,
method: &str,
path: &str,
body: Option<&str>,
incoming_headers: &[(String, String)],
) -> anyhow::Result<(Value, u16)> {
let url = format!("{}{}", state.config.core_url, path);
let mut 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),
};
// Pass through FUT session and anti-phishing headers so Core can log/correlate them.
// These are used in #19 (X-UT-SID) and #20 (X-UT-PHISHING-TOKEN) passthrough.
for (k, v) in incoming_headers {
let lower = k.to_lowercase();
if lower == "x-ut-sid" || lower == "x-ut-phishing-token" || lower == "x-request-id" {
builder = builder.header(k.as_str(), v.as_str());
}
}
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))
}