Initial commit: OpenFUT Core + Bridge scaffold
Two-repo monorepo for an offline FUT backend inspired by SPT. openfut-core: game-independent REST API backend (Axum, SQLite, SQLx) - 19 API endpoints: auth, profiles, clubs, cards, packs, squads, objectives, SBCs, match rewards, NPC market, statistics - JSON-driven card/pack/objective/SBC data (fully moddable) - SQLx migrations, weighted pack generator, SBC validation engine - 5 integration tests passing openfut-bridge: FIFA 23 traffic proxy + reverse-engineering scaffold - Catch-all HTTP proxy with request capture to captures/ - Known-route mapper (FUT paths → Core API calls) - Placeholder responses for unknown endpoints - Admin endpoints: captures, unknown endpoint list - 4 unit tests passing cargo fmt ✓ cargo clippy -D warnings ✓ cargo test ✓ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+1889
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "openfut-bridge"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["OpenFUT Contributors"]
|
||||
description = "FIFA 23 integration layer and reverse-engineering proxy"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/openfut/openfut-bridge"
|
||||
|
||||
[lib]
|
||||
name = "openfut_bridge"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "openfut-bridge"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
dotenvy = "0.15"
|
||||
http = "1"
|
||||
bytes = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -0,0 +1,101 @@
|
||||
# OpenFUT Bridge
|
||||
|
||||
**FIFA 23 integration layer and reverse-engineering proxy.**
|
||||
|
||||
OpenFUT Bridge sits between the FIFA 23 client and the internet. It intercepts all FUT API traffic, logs every request, and routes known endpoints to OpenFUT Core. Unknown endpoints receive safe placeholder responses so the client keeps running.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
- Transparent HTTP proxy — point FIFA 23 here instead of EA's servers
|
||||
- Logs every request (method, path, headers, body, timestamp) to `captures/`
|
||||
- Maps known FUT endpoints to OpenFUT Core API calls
|
||||
- Returns placeholder JSON for unknown endpoints (prevents client crashes)
|
||||
- Admin API for reviewing captures and identifying unmapped routes
|
||||
- Foundation for building the full FIFA 23 integration
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
FIFA 23 client
|
||||
│
|
||||
▼
|
||||
OpenFUT Bridge (port 8443)
|
||||
│
|
||||
├── Known route → OpenFUT Core (port 8080)
|
||||
└── Unknown route → Placeholder JSON + saved to captures/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run Core first
|
||||
cd ../openfut-core && cargo run
|
||||
|
||||
# Run Bridge
|
||||
cargo run
|
||||
|
||||
# Or with config
|
||||
CORE_URL=http://127.0.0.1:8080 BRIDGE_LISTEN_ADDR=127.0.0.1:8443 cargo run
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `BRIDGE_LISTEN_ADDR` | `127.0.0.1:8443` | Bridge listen address |
|
||||
| `CORE_URL` | `http://127.0.0.1:8080` | OpenFUT Core URL |
|
||||
| `CAPTURES_DIR` | `captures` | Where to save captured requests |
|
||||
| `PLACEHOLDER_MODE` | `true` | Return 200 for unknown endpoints |
|
||||
|
||||
---
|
||||
|
||||
## Admin API
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/_bridge/health` | Bridge status |
|
||||
| `GET` | `/_bridge/captures` | All captured requests |
|
||||
| `GET` | `/_bridge/unknown` | Unknown endpoints only |
|
||||
|
||||
---
|
||||
|
||||
## Reverse Engineering Workflow
|
||||
|
||||
1. Point FIFA 23 at the Bridge (see `docs/setup-notes.md` for proxy setup)
|
||||
2. Start a game session
|
||||
3. Check `captures/` or `/_bridge/unknown` for new endpoints
|
||||
4. Document the endpoint in `docs/endpoint-map.md`
|
||||
5. Add a mapping in `src/mapper.rs`
|
||||
6. Implement the Core handler
|
||||
|
||||
---
|
||||
|
||||
## Proxy Setup (Research Notes)
|
||||
|
||||
See `docs/setup-notes.md` for notes on:
|
||||
- Windows `hosts` file redirection
|
||||
- RPCS3 network config (PS3)
|
||||
- Certificate bypass approaches
|
||||
- mitmproxy integration
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo clippy -- -D warnings
|
||||
cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,36 @@
|
||||
# OpenFUT Bridge — TODO
|
||||
|
||||
## Reverse Engineering
|
||||
- [ ] #1 Set up mitmproxy and capture a real FIFA 23 FUT session
|
||||
- [ ] #2 Document all observed endpoints in `docs/endpoint-map.md`
|
||||
- [ ] #3 Document FIFA 23 auth flow (token format, headers, session lifecycle)
|
||||
- [ ] #4 Document request body formats for auth, squad, packs
|
||||
- [ ] #5 Identify which endpoints are mandatory vs optional for FUT to load
|
||||
- [ ] #6 Test hosts-file redirect approach on PC
|
||||
- [ ] #7 Research certificate pinning in FIFA 23 PC build
|
||||
- [ ] #8 Test RPCS3 network proxy configuration
|
||||
- [ ] #9 Document response formats EA uses (some differ from request format)
|
||||
- [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify)
|
||||
|
||||
## Proxy
|
||||
- [ ] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS
|
||||
- [ ] #12 Add replay CLI: `openfut-bridge replay captures/some_file.json`
|
||||
- [ ] #13 Add `DELETE /_bridge/captures` to wipe capture folder
|
||||
- [ ] #14 Add capture deduplication (same method+path within 1 second)
|
||||
- [ ] #15 Add request diff tool: show what changed between two captures
|
||||
|
||||
## Mapper
|
||||
- [ ] #16 Implement actual auth endpoint mapping (static token response)
|
||||
- [ ] #17 Map squad read endpoint when confirmed
|
||||
- [ ] #18 Map pack details endpoint when confirmed
|
||||
- [ ] #19 Add X-UT-SID session header pass-through to Core
|
||||
- [ ] #20 Add phishing token passthrough
|
||||
|
||||
## Admin UI
|
||||
- [ ] #21 Build a simple web dashboard for viewing captures
|
||||
- [ ] #22 Add endpoint status page (known vs unknown vs confirmed)
|
||||
- [ ] #23 Add live capture stream via SSE
|
||||
|
||||
## Testing
|
||||
- [ ] #24 Add test for placeholder response format
|
||||
- [ ] #25 Add integration test that fires real HTTP at the Bridge
|
||||
@@ -0,0 +1,64 @@
|
||||
# FUT → OpenFUT Core Endpoint Map
|
||||
|
||||
This document maps confirmed or suspected FIFA 23 FUT API endpoints to their OpenFUT Core equivalents.
|
||||
|
||||
## Legend
|
||||
|
||||
- ✅ Confirmed + implemented
|
||||
- 🟡 Suspected — implemented with placeholder
|
||||
- ❌ Unknown — not yet mapped
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
|
||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| `POST /ut/auth` | `POST /auth/local` | 🟡 | EA OAuth flow → local profile creation |
|
||||
|
||||
## Profile / Club
|
||||
|
||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| `GET /ut/game/fut/user/settings` | `GET /profile` | 🟡 | |
|
||||
| `GET /ut/game/fut/usermassinfo` | `GET /club` | 🟡 | EA bulk endpoint |
|
||||
|
||||
## Squad
|
||||
|
||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| `GET /ut/game/fut/squad/active` | `GET /squad` | 🟡 | |
|
||||
| `PUT /ut/game/fut/squad/active` | `POST /squad` | ❌ | |
|
||||
|
||||
## Packs
|
||||
|
||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| `GET /ut/game/fut/store/packdetails` | `GET /packs` | 🟡 | |
|
||||
| `POST /ut/game/fut/pack/buy` | `POST /packs/open/:id` | ❌ | |
|
||||
|
||||
## Transfer Market
|
||||
|
||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| `GET /ut/game/fut/transfermarket` | `GET /market` | 🟡 | |
|
||||
| `DELETE /ut/game/fut/trade/:id` | `POST /market/sell` | ❌ | |
|
||||
|
||||
## Matches / Seasons
|
||||
|
||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| Unknown | `POST /matches/result` | ❌ | Needs capture |
|
||||
|
||||
## Objectives
|
||||
|
||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| Unknown | `GET /objectives` | ❌ | Needs capture |
|
||||
|
||||
## SBCs
|
||||
|
||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| Unknown | `GET /sbc` | ❌ | Needs capture |
|
||||
| Unknown | `POST /sbc/submit` | ❌ | Needs capture |
|
||||
@@ -0,0 +1,85 @@
|
||||
# Reverse Engineering Notes — FIFA 23 FUT API
|
||||
|
||||
This document tracks what is known and unknown about EA's FUT API as used by FIFA 23.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
🔴 Very early — almost nothing confirmed. All mappings in `src/mapper.rs` are speculative.
|
||||
|
||||
---
|
||||
|
||||
## Known / Suspected Endpoints
|
||||
|
||||
These are guesses based on:
|
||||
- Common FUT API patterns from public research
|
||||
- Observations from older FIFA titles
|
||||
- Community reverse-engineering work
|
||||
|
||||
| Method | Path | Purpose | Status |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/ut/auth` | Authentication / session | Suspected |
|
||||
| `GET` | `/ut/game/fut/user/settings` | User settings | Suspected |
|
||||
| `GET` | `/ut/game/fut/usermassinfo` | Club + profile bulk | Suspected |
|
||||
| `GET` | `/ut/game/fut/squad/active` | Active squad | Suspected |
|
||||
| `GET` | `/ut/game/fut/store/packdetails` | Pack store | Suspected |
|
||||
| `GET` | `/ut/game/fut/transfermarket` | Transfer market | Suspected |
|
||||
|
||||
---
|
||||
|
||||
## Unknown Endpoints
|
||||
|
||||
Run `GET /_bridge/unknown` after a game session to see what new routes appeared.
|
||||
Each entry represents a real FIFA 23 request that hasn't been mapped yet.
|
||||
|
||||
---
|
||||
|
||||
## Request Format Notes
|
||||
|
||||
### Auth
|
||||
|
||||
EA FUT auth appears to use a multi-step token flow:
|
||||
1. EA account auth (OAuth2-style)
|
||||
2. FUT-specific auth with a "nucleus ID"
|
||||
3. Session token issued
|
||||
|
||||
For offline purposes, OpenFUT Bridge returns a static token that satisfies the client.
|
||||
|
||||
### Headers
|
||||
|
||||
Common headers seen in FUT traffic:
|
||||
- `X-UT-SID` — session token
|
||||
- `X-UT-PHISHING-TOKEN` — anti-CSRF token
|
||||
- `Content-Type: application/json`
|
||||
- `X-HTTP-Method-Override` — EA sometimes uses POST + this header instead of DELETE/PUT
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
- [mitmproxy](https://mitmproxy.org/) — HTTPS interception
|
||||
- [Fiddler](https://www.telerik.com/fiddler) — Windows-friendly proxy
|
||||
- Wireshark — low-level packet capture
|
||||
- OpenFUT Bridge `captures/` folder — automatic request logging
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- Previous FIFA FUT API research: search GitHub for "fifa-ut-api", "easfc", "futapi"
|
||||
- ea.com documentation: none public
|
||||
- Community wikis: FUT Trading community resources
|
||||
|
||||
---
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Capture a real FIFA 23 session via mitmproxy
|
||||
- [ ] Document the auth flow completely
|
||||
- [ ] Map the squad endpoints
|
||||
- [ ] Map the pack opening endpoints
|
||||
- [ ] Map the objectives endpoints
|
||||
- [ ] Map the SBC endpoints
|
||||
- [ ] Map the transfer market endpoints
|
||||
- [ ] Identify which endpoints are critical vs optional
|
||||
@@ -0,0 +1,92 @@
|
||||
# Proxy Setup Notes
|
||||
|
||||
Research notes for routing FIFA 23 traffic through OpenFUT Bridge.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Intercept all FUT API calls from FIFA 23 so the Bridge can:
|
||||
1. Log every request for reverse engineering
|
||||
2. Route known endpoints to OpenFUT Core
|
||||
3. Return placeholder responses for the rest
|
||||
|
||||
---
|
||||
|
||||
## PC (FIFA 23 via EA App / Steam)
|
||||
|
||||
### Option A: Windows hosts file
|
||||
|
||||
Redirect `utas.fut.ea.com` and related domains to localhost.
|
||||
|
||||
```
|
||||
# C:\Windows\System32\drivers\etc\hosts
|
||||
127.0.0.1 utas.fut.ea.com
|
||||
127.0.0.1 utas2.fut.ea.com
|
||||
127.0.0.1 ea.com
|
||||
```
|
||||
|
||||
The Bridge must listen on port 443 (HTTPS) or 80 (HTTP).
|
||||
FIFA 23 expects HTTPS — you'll need a self-signed cert and need to trust it.
|
||||
|
||||
### Option B: mitmproxy
|
||||
|
||||
1. Install mitmproxy
|
||||
2. Set Windows system proxy to 127.0.0.1:8080
|
||||
3. Configure mitmproxy to forward FUT traffic to Bridge
|
||||
|
||||
```bash
|
||||
mitmproxy --mode transparent \
|
||||
--listen-host 127.0.0.1 \
|
||||
--listen-port 8080
|
||||
```
|
||||
|
||||
⚠️ FIFA 23 may use certificate pinning — this is unconfirmed. If pinning is active, the hosts file approach or a traffic-level redirect may be needed instead.
|
||||
|
||||
---
|
||||
|
||||
## RPCS3 (PS3 emulator)
|
||||
|
||||
RPCS3 has built-in network settings:
|
||||
|
||||
1. Open RPCS3 → Configuration → Network
|
||||
2. Set DNS to point to your OpenFUT Bridge IP
|
||||
3. Configure `PSN Status` to `RPCN` or custom
|
||||
4. The Bridge intercepts DNS and HTTP/HTTPS traffic
|
||||
|
||||
Research needed: RPCS3 FIFA 23 title ID and specific network behavior.
|
||||
|
||||
---
|
||||
|
||||
## Certificate Handling
|
||||
|
||||
EA's FUT API uses HTTPS. Options:
|
||||
|
||||
1. **Self-signed cert** — generate with `openssl` and add to system trust store
|
||||
2. **HTTP downgrade** — if the client accepts HTTP (unlikely for production)
|
||||
3. **Traffic-level redirect** — use iptables/nftables to redirect port 443 traffic
|
||||
4. **No cert** — if FIFA 23 PC doesn't verify certs (to be tested)
|
||||
|
||||
### Generating a self-signed cert
|
||||
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes \
|
||||
-subj "/CN=utas.fut.ea.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
🔴 Not tested yet. All of the above is research / theoretical.
|
||||
The first step is to capture real FIFA 23 traffic with mitmproxy and document actual endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Set up mitmproxy on a FIFA 23 PC
|
||||
2. Start FIFA 23 and navigate to FUT mode
|
||||
3. Export the mitmproxy capture
|
||||
4. Add real endpoints to `docs/endpoint-map.md`
|
||||
5. Add mappings to `src/mapper.rs`
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,48 @@
|
||||
use openfut_bridge::{capture::CapturedRequest, mapper::map_to_core};
|
||||
|
||||
#[test]
|
||||
fn test_known_endpoint_maps_to_core() {
|
||||
let mapping = map_to_core("POST", "/ut/auth");
|
||||
assert!(mapping.is_some());
|
||||
let m = mapping.unwrap();
|
||||
assert_eq!(m.core_path, "/auth/local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_endpoint_returns_none() {
|
||||
let mapping = map_to_core("GET", "/ut/game/fut/some/unknown/path");
|
||||
assert!(mapping.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capture_serializes_cleanly() {
|
||||
let capture = CapturedRequest::new(
|
||||
"GET",
|
||||
"/ut/game/fut/user/settings",
|
||||
None,
|
||||
vec![("user-agent".into(), "FIFA23/1.0".into())],
|
||||
None,
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&capture).expect("serialize");
|
||||
let back: CapturedRequest = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back.path, "/ut/game/fut/user/settings");
|
||||
assert_eq!(back.method, "GET");
|
||||
assert!(back.mapped_to_core.is_none());
|
||||
assert!(back.response_status.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capture_with_response() {
|
||||
let capture = CapturedRequest::new(
|
||||
"POST",
|
||||
"/ut/auth",
|
||||
None,
|
||||
vec![],
|
||||
Some(r#"{"token":"abc"}"#.into()),
|
||||
)
|
||||
.with_response(200, Some(r#"{"status":"ok"}"#.into()));
|
||||
|
||||
assert_eq!(capture.response_status, Some(200));
|
||||
assert!(capture.response_body.is_some());
|
||||
}
|
||||
Reference in New Issue
Block a user