Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07e83fe36c | |||
| c58e7326a1 | |||
| 5aec83ce97 | |||
| e2c6ae8e5b | |||
| 550b23bb26 | |||
| 4c57cf571c | |||
| 55c9757e74 | |||
| 49b3030e34 | |||
| 15a6f877ee | |||
| ce8a3b32ec | |||
| bc6612697a | |||
| 77af7ce3c9 | |||
| 7c779e0d72 | |||
| ab6021adac | |||
| 893ee369c2 | |||
| 72ffe9a806 | |||
| 335398df72 | |||
| 44c3a0ce5b | |||
| 30dfb25cf7 | |||
| ba1b0a09f2 | |||
| 8087564df1 | |||
| 67b84abe7c | |||
| aabbe775b3 | |||
| eae3e72fd9 | |||
| b7da8b6e83 | |||
| 00839f5f1b | |||
| f3ff291ff8 | |||
| b8e766e9dd | |||
| 3b7a4928c9 |
@@ -0,0 +1,190 @@
|
|||||||
|
# OpenFUT Bridge — Claude Code project context
|
||||||
|
|
||||||
|
Read this first. It's the standing context for every task in this project. Each
|
||||||
|
working session will give you ONE bounded task plus a verification clause; this
|
||||||
|
file is the background that stays true across all of them.
|
||||||
|
|
||||||
|
## What this project is
|
||||||
|
|
||||||
|
OpenFUT is an **offline FIFA 23 FUT (Ultimate Team) emulator** for single-player
|
||||||
|
game preservation. EA permanently shut down FIFA 23's online servers in October
|
||||||
|
2025, which removed FUT. This project lets a legitimately-owned copy run FUT
|
||||||
|
against a **local emulated backend** instead of EA's (dead) servers.
|
||||||
|
|
||||||
|
It has two repos:
|
||||||
|
- **OpenFUT Core** — the game-independent FUT economy backend (already largely built).
|
||||||
|
- **OpenFUT Bridge** — the FIFA 23 integration / reverse-engineering layer (this work).
|
||||||
|
|
||||||
|
## Hard constraints (do not violate)
|
||||||
|
|
||||||
|
- **Clean-room only.** Everything is derived from observing the running client's
|
||||||
|
own behaviour. NEVER use, reference, or reproduce leaked EA source code (e.g.
|
||||||
|
the 2021 FIFA 21 / Blaze leak). If a task seems to need it, stop and say so.
|
||||||
|
Clean-room provenance is the legal foundation of the whole project.
|
||||||
|
- **Mark uncertainty, don't invent.** Several things are genuinely unconfirmed:
|
||||||
|
the Fire2 packet framing, ProtoSSL non-blocking return values, and FIFA 23's
|
||||||
|
Blaze component/command IDs. When you don't know a value, leave a clearly
|
||||||
|
labelled `TODO/CONFIRM` rather than guessing a confident-looking number.
|
||||||
|
- **Verify against the client.** The dead servers mean responses are *synthesized
|
||||||
|
and tested against the live offline client*, never captured from EA. The client
|
||||||
|
is the oracle: a gate is "done" when the client advances to the next command.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
FIFA 23 client (offline, native Windows)
|
||||||
|
| ProtoSSLConnect / ProtoSSLSend / ProtoSSLRecv (in-process)
|
||||||
|
openfut_hook.dll — fakes the connection, captures requests, injects responses
|
||||||
|
| plain localhost TCP, length-prefixed frames (no TLS)
|
||||||
|
blaze_brain (Rust) — decodes requests, crafts Blaze responses <-- iteration surface
|
||||||
|
|
|
||||||
|
OpenFUT Core — supplies real FUT economy data for the FUT gates
|
||||||
|
```
|
||||||
|
|
||||||
|
Why in-process (not a localhost TLS server): FIFA 23's ProtoSSL uses modern TLS
|
||||||
|
with cert pinning, so a fake server gets its cert rejected. Hooking ABOVE the
|
||||||
|
crypto (ProtoSSLSend/Recv take/return plaintext) sidesteps TLS entirely.
|
||||||
|
|
||||||
|
## The entry sequence (the gates to reach FUT)
|
||||||
|
|
||||||
|
The client runs these in order; each must be satisfied before the next fires:
|
||||||
|
|
||||||
|
1. **LSX** (port 3216, plaintext loopback) — launcher→game bootstrap.
|
||||||
|
2. **Blaze redirector** — returns a server host/port.
|
||||||
|
3. **Blaze preauth** (UTIL) — config / component list.
|
||||||
|
4. **Blaze login** (AUTHENTICATION) — persona, session key, UID.
|
||||||
|
5. **Blaze postauth** (UTIL) — telemetry/ticker; "fully online".
|
||||||
|
6. **FUT entry check** — FIFA-specific eligibility/entitlement wall.
|
||||||
|
7. **FUT hub load** — club/squad over REST; hand off to OpenFUT Core.
|
||||||
|
|
||||||
|
Gates 2–5 are largely static/replayable (the client validates little). Gates 6–7
|
||||||
|
need internally-consistent data and connect to OpenFUT Core.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Native Windows boot (chosen for predictable PE/hooking behaviour over Proton).
|
||||||
|
- FIFA 23, with EA Anticheat in its offline/neutralized state. Do NOT assume a
|
||||||
|
task is safe to hook unless EAAC is confirmed neutralized.
|
||||||
|
- Rust is the primary language. Hook = `cdylib`, Windows target. Brain = portable.
|
||||||
|
- Self-hosted Gitea (git.aleshym.co, org Quaternions). Self-hosted CI runner.
|
||||||
|
|
||||||
|
## How to work here
|
||||||
|
|
||||||
|
- One rung at a time. Don't run ahead into a gate that depends on an unconfirmed
|
||||||
|
earlier answer.
|
||||||
|
- When decoding a frame, work from REAL captured bytes the user pastes in — not
|
||||||
|
from a guessed layout. Ask for the bytes if they're not provided.
|
||||||
|
- End each change with how the user verifies it on the client.
|
||||||
|
|
||||||
|
|
||||||
|
# OpenFUT Bridge — Task prompts (run in order)
|
||||||
|
|
||||||
|
Each task is its own Claude Code session. Paste the task, attach the named
|
||||||
|
starting file(s), and don't move to the next until the verification clause passes.
|
||||||
|
The project CLAUDE.md is assumed to be in context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 1 — Confirm the transport (read-only memory scan)
|
||||||
|
|
||||||
|
**Goal:** prove whether FIFA 23 uses EA DirtySDK / ProtoSSL before we build any
|
||||||
|
hook around it. Read-only; no hooking, patching, or injection.
|
||||||
|
|
||||||
|
**Do this:**
|
||||||
|
Write a standalone Windows console program in Rust (target
|
||||||
|
`x86_64-pc-windows-msvc` or `-gnu`) that:
|
||||||
|
1. Finds the running FIFA 23 process by name (and accepts a PID argument).
|
||||||
|
2. Enumerates its modules and committed memory regions (e.g. `EnumProcessModules`
|
||||||
|
/ `VirtualQueryEx`), readable regions only.
|
||||||
|
3. Reads memory with `ReadProcessMemory` in chunks (with small overlap so a
|
||||||
|
marker spanning a chunk boundary is still found).
|
||||||
|
4. Searches for these ASCII markers and prints each hit with its absolute address:
|
||||||
|
`protossl:`, `ProtoSSLSend`, `ProtoSSLRecv`, `ProtoSSLConnect`,
|
||||||
|
`gosredirector`, `DirtySDK`, `blaze`.
|
||||||
|
5. Prints a summary: which markers were found, and a clear verdict line
|
||||||
|
("ProtoSSL present" vs "no ProtoSSL markers found").
|
||||||
|
|
||||||
|
Keep it dependency-light (the `windows` crate is fine). Comment it for a Rust
|
||||||
|
beginner. It must only read.
|
||||||
|
|
||||||
|
**Verification:** with FIFA 23 running and sitting at the main menu, the program
|
||||||
|
prints whether the ProtoSSL markers are present. If `protossl:` and the
|
||||||
|
send/recv strings appear, the transport is confirmed and we proceed. If nothing
|
||||||
|
appears after the menu has loaded, STOP — we rethink the transport, don't build
|
||||||
|
the hook.
|
||||||
|
|
||||||
|
**Note:** reach the main menu before scanning; the networking code/strings may
|
||||||
|
not be paged in at the title screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 2 — Loadable DLL shell (prove we can get code in)
|
||||||
|
|
||||||
|
**Goal:** a `version.dll` proxy that loads into FIFA 23 and runs our code, before
|
||||||
|
any hook logic exists. This isolates "can we inject at all" from "is the hook
|
||||||
|
correct".
|
||||||
|
|
||||||
|
**Do this:**
|
||||||
|
Create a Rust `cdylib` (Windows target) that:
|
||||||
|
1. Exports the functions a real `version.dll` exports, forwarding each to the
|
||||||
|
system `version.dll` (proxy/sideload pattern). List the needed exports and
|
||||||
|
implement the forwarding.
|
||||||
|
2. In `DllMain`, on `DLL_PROCESS_ATTACH`, spawns a thread (NOT work in DllMain
|
||||||
|
itself — loader lock) that writes a line to a log file in a known path, e.g.
|
||||||
|
`C:\openfut\hook.log`, with a timestamp.
|
||||||
|
3. Provide the `Cargo.toml` (`crate-type = ["cdylib"]`) and the exact build
|
||||||
|
command.
|
||||||
|
|
||||||
|
Clean-room: this is generic proxy/loader scaffolding, nothing EA-derived.
|
||||||
|
|
||||||
|
**Verification:** drop the built DLL next to the FIFA 23 executable, launch the
|
||||||
|
game, and confirm `hook.log` gets the timestamped line. Game still reaches the
|
||||||
|
menu normally. If it crashes or the line never appears, fix the proxy/forwarding
|
||||||
|
before continuing.
|
||||||
|
|
||||||
|
**Caution:** confirm EAAC is in its offline/neutralized state before loading the
|
||||||
|
DLL. Do not load it into an anticheat-active session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 3 — One landing hook on ProtoSSLSend
|
||||||
|
|
||||||
|
**Goal:** prove we can hook a ProtoSSL function and see real outbound frames.
|
||||||
|
Just one function, and it calls the original (passive observation).
|
||||||
|
|
||||||
|
**Do this:**
|
||||||
|
Extend the Task 2 DLL:
|
||||||
|
1. Add the `retour` crate. Resolve the `ProtoSSLSend` address — first pass: use
|
||||||
|
the absolute address Task 1 reported, converted to a module-relative offset
|
||||||
|
plus the live module base. (We'll switch to a byte-pattern scan later for
|
||||||
|
patch-resilience; leave a TODO for that.)
|
||||||
|
2. Install a `retour` inline detour on `ProtoSSLSend` with signature
|
||||||
|
`extern "C" fn(*mut c_void, *const u8, i32) -> i32` (x64 = one calling
|
||||||
|
convention; `extern "C"` is correct).
|
||||||
|
3. In the detour: log `length` and the first ~32 bytes of the buffer as hex,
|
||||||
|
then CALL THE ORIGINAL and return its result (do not block or alter traffic
|
||||||
|
yet).
|
||||||
|
|
||||||
|
Mark the ProtoSSL return-value conventions and the eventual pattern-scan as
|
||||||
|
`TODO/CONFIRM` per the project rules.
|
||||||
|
|
||||||
|
**Verification:** launch the game, attempt to go online / load FUT, and confirm
|
||||||
|
`hook.log` shows ProtoSSLSend calls with non-trivial byte dumps — these are the
|
||||||
|
client's real outbound Blaze frames (still TLS-bound on the wire, but plaintext
|
||||||
|
here, which is the whole point). Seeing real frames = hooking works, and we now
|
||||||
|
have actual bytes to decode in later tasks. Paste a few of those captured frames
|
||||||
|
into the next session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to attach to each task
|
||||||
|
|
||||||
|
- Task 1: nothing (fresh program), or the Linux `protossl_scan.rs` as a logic
|
||||||
|
reference to port.
|
||||||
|
- Task 2: nothing, or your existing `version.dll` proxy shell if you have one.
|
||||||
|
- Task 3: the Task 2 DLL, plus the `openfut_hook_sketch.rs` as the structural
|
||||||
|
reference, plus the address Task 1 reported.
|
||||||
|
|
||||||
|
Once Task 3 yields real captured frames, later tasks (the brain handlers per
|
||||||
|
gate) should always include the actual pasted bytes — decoding real frames is far
|
||||||
|
more reliable than guessing the Fire2 layout.
|
||||||
Generated
+302
-7
@@ -78,7 +78,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper 1.0.2",
|
"sync_wrapper 1.0.2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -122,6 +122,12 @@ version = "0.21.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -202,6 +208,12 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -325,6 +337,17 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -355,6 +378,25 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http 1.4.2",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -438,7 +480,7 @@ dependencies = [
|
|||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2 0.3.27",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.4.6",
|
"http-body 0.4.6",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -462,6 +504,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2 0.4.15",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -472,6 +515,20 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.24.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"http 0.2.12",
|
||||||
|
"hyper 0.14.32",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -763,6 +820,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -788,11 +851,19 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
|
"hyper 1.10.1",
|
||||||
|
"hyper-util",
|
||||||
|
"rcgen",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tokio-stream",
|
||||||
|
"tower 0.4.13",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -865,12 +936,42 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project"
|
||||||
|
version = "1.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-internal",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-internal"
|
||||||
|
version = "1.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -892,6 +993,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -916,6 +1023,18 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rcgen"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52c4f3084aa3bc7dfbba4eff4fab2a54db4324965d8872ab933565e6fbd83bc6"
|
||||||
|
dependencies = [
|
||||||
|
"pem",
|
||||||
|
"ring 0.16.20",
|
||||||
|
"time",
|
||||||
|
"yasna",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -948,15 +1067,16 @@ version = "0.11.27"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
|
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.21.7",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2 0.3.27",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.4.6",
|
"http-body 0.4.6",
|
||||||
"hyper 0.14.32",
|
"hyper 0.14.32",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-tls",
|
"hyper-tls",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -966,6 +1086,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -974,14 +1095,45 @@ dependencies = [
|
|||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
"winreg",
|
"winreg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.16.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"spin",
|
||||||
|
"untrusted 0.7.1",
|
||||||
|
"web-sys",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted 0.9.0",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -995,13 +1147,35 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.21.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"ring 0.17.14",
|
||||||
|
"rustls-webpki",
|
||||||
|
"sct",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pemfile"
|
name = "rustls-pemfile"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.21.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.101.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
|
||||||
|
dependencies = [
|
||||||
|
"ring 0.17.14",
|
||||||
|
"untrusted 0.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1031,6 +1205,16 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sct"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
|
||||||
|
dependencies = [
|
||||||
|
"ring 0.17.14",
|
||||||
|
"untrusted 0.9.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -1177,6 +1361,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spin"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -1245,7 +1435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -1280,6 +1470,25 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.51"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -1328,6 +1537,28 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-stream"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -1341,6 +1572,21 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.4.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1460,6 +1706,18 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -1484,7 +1742,7 @@ version = "1.23.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.4.3",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -1582,6 +1840,34 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.25.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -1805,6 +2091,15 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yasna"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||||
|
dependencies = [
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
+16
-1
@@ -15,11 +15,17 @@ path = "src/lib.rs"
|
|||||||
name = "openfut-bridge"
|
name = "openfut-bridge"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "openfut-bridge-replay"
|
||||||
|
path = "src/bin/replay.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -27,10 +33,19 @@ thiserror = "1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
http = "1"
|
http = "1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
|
rcgen = "0.11"
|
||||||
|
# TLS: same rustls version as reqwest 0.11 uses internally
|
||||||
|
rustls = "0.21"
|
||||||
|
rustls-pemfile = "1"
|
||||||
|
tokio-rustls = "0.24"
|
||||||
|
# hyper 1.x + hyper-util (same versions axum 0.7 pulls in)
|
||||||
|
hyper = { version = "1", features = ["http1", "http2"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["server-auto", "tokio"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
|||||||
@@ -13,24 +13,43 @@
|
|||||||
- [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify)
|
- [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify)
|
||||||
|
|
||||||
## Proxy
|
## Proxy
|
||||||
- [ ] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS
|
- [x] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS
|
||||||
- [ ] #12 Add replay CLI: `openfut-bridge replay captures/some_file.json`
|
→ `TLS_ENABLED=true` generates cert at startup via rcgen; uses tokio-rustls
|
||||||
- [ ] #13 Add `DELETE /_bridge/captures` to wipe capture folder
|
→ Cert covers localhost, 127.0.0.1, fut.ea.com, utas.mob.v4.fut.ea.com
|
||||||
- [ ] #14 Add capture deduplication (same method+path within 1 second)
|
- [x] #12 Add replay CLI: `openfut-bridge-replay captures/some_file.json [bridge-url]`
|
||||||
- [ ] #15 Add request diff tool: show what changed between two captures
|
→ Accepts single file or entire captures/ directory
|
||||||
|
→ TLS: danger_accept_invalid_certs=true so self-signed certs work
|
||||||
|
- [x] #13 Add `DELETE /_bridge/captures` to wipe capture folder
|
||||||
|
- [x] #14 Add capture deduplication (same method+path within 1 second)
|
||||||
|
- [x] #15 Add request diff tool: `GET /_bridge/captures/diff?a=<id>&b=<id>`
|
||||||
|
→ Reports method/path/status changes, header diffs, JSON body key-level diff
|
||||||
|
|
||||||
## Mapper
|
## Mapper
|
||||||
- [ ] #16 Implement actual auth endpoint mapping (static token response)
|
- [x] #16 Implement auth endpoint mapping + response shaping
|
||||||
- [ ] #17 Map squad read endpoint when confirmed
|
→ `src/shaper.rs` wraps Core `/auth/local` response in FUT auth envelope
|
||||||
- [ ] #18 Map pack details endpoint when confirmed
|
→ Includes sid, phishingToken, persona, pid, userAccountInfo
|
||||||
- [ ] #19 Add X-UT-SID session header pass-through to Core
|
- [x] #17 Map squad read/write endpoints
|
||||||
- [ ] #20 Add phishing token passthrough
|
→ `GET /ut/game/fut/squad/active` → Core GET /squad
|
||||||
|
→ `PUT /ut/game/fut/squad/active` → Core POST /squad
|
||||||
|
- [x] #18 Map pack details endpoint
|
||||||
|
→ `GET /ut/game/fut/store/packdetails` → Core GET /packs
|
||||||
|
→ Response shaped into FUT purchasedPacks envelope
|
||||||
|
- [x] #19 Add X-UT-SID session header pass-through to Core
|
||||||
|
→ Forwarded verbatim on every mapped Core request
|
||||||
|
- [x] #20 Add phishing token passthrough
|
||||||
|
→ X-UT-PHISHING-TOKEN forwarded on every mapped Core request
|
||||||
|
|
||||||
## Admin UI
|
## Admin UI
|
||||||
- [ ] #21 Build a simple web dashboard for viewing captures
|
- [x] #21 Build a simple web dashboard for viewing captures (`GET /_bridge/admin`)
|
||||||
- [ ] #22 Add endpoint status page (known vs unknown vs confirmed)
|
→ Auto-refreshes every 10s; shows stats, endpoint table, recent captures
|
||||||
- [ ] #23 Add live capture stream via SSE
|
- [x] #22 Add endpoint status page (`GET /_bridge/status`)
|
||||||
|
→ Reports each unique endpoint as mapped/known/unknown
|
||||||
|
- [x] #23 Add live capture stream via SSE (`GET /_bridge/captures/stream`)
|
||||||
|
→ `event: capture` events pushed on each new capture
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
- [ ] #24 Add test for placeholder response format
|
- [x] #24 Add test for placeholder response format
|
||||||
- [ ] #25 Add integration test that fires real HTTP at the Bridge
|
- [x] #25 Add integration test that fires real HTTP at the Bridge
|
||||||
|
→ 13 tests: health, placeholder, captures CRUD, status, TLS cert gen
|
||||||
|
- [x] Shaper unit tests (4) — auth envelope shape, dispatch, passthrough, market
|
||||||
|
- [x] Mapper unit tests (6) — auth, market, events, squad PUT, unknown, trailing slash
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# Connection-gate findings (clean-room)
|
||||||
|
|
||||||
|
**Status:** observed behaviour only — derived from running the client and reading
|
||||||
|
the repack's own files. No EA source used. Unconfirmed values are marked
|
||||||
|
`TODO/CONFIRM` rather than guessed.
|
||||||
|
|
||||||
|
## Components (observed)
|
||||||
|
|
||||||
|
| Component | Role |
|
||||||
|
|---|---|
|
||||||
|
| `FIFA23.exe` | Game. Statically links EA's **Ebisu SDK** (online) and **DirtySDK/ProtoSSL** (transport). Loads at fixed base `0x140000000` — no ASLR seen across launches. |
|
||||||
|
| `_ProtoSSLSendPacket` @ `FIFA23.exe+0xEFA530` | DirtySDK TLS record assembler — plaintext in, encrypts in place. Already located + hooked. |
|
||||||
|
| `anadius64.dll` | anadius EA-app/Origin **LSX server** emulator (ASLR'd). Provides offline DRM / entitlements / persona so the game *launches*; deliberately keeps it **offline**. |
|
||||||
|
| `FakeEAACLauncher` | Anti-cheat bypass only. Irrelevant to the online gate. |
|
||||||
|
|
||||||
|
## Observed online-startup flow
|
||||||
|
|
||||||
|
1. Ebisu SDK opens LSX socket(s) to anadius; a **challenge/response handshake**
|
||||||
|
completes — captured XML: `<Challenge>` / `<ChallengeResponse>` /
|
||||||
|
`<ChallengeAccepted>`, `sender="EALS"`, `ContentId 16115019`.
|
||||||
|
2. River/PIN telemetry `<session type=boot>` is emitted.
|
||||||
|
3. **No further socket traffic.** Clicking Ultimate Team → "connecting to the EA
|
||||||
|
Servers…" → **zero** network attempts (no Blaze DNS, no `connect`, nothing on
|
||||||
|
`send`/`recv`/`WSASend`/`WSARecv`) → falls back to offline.
|
||||||
|
|
||||||
|
## Conclusion: the decision is upstream and in-process
|
||||||
|
|
||||||
|
- The online/offline verdict is delivered through anadius's **in-process
|
||||||
|
detoured Ebisu calls**, not socket messages. (No `GetAuthCode`/`GoOnline`/
|
||||||
|
entitlement query appears on any socket, sync or async.)
|
||||||
|
- The game therefore **never reaches DirtySDK/ProtoSSL for Blaze** — it aborts
|
||||||
|
before any `ProtoSSLConnect(gosredirector…)`. The gate is an **Ebisu-SDK
|
||||||
|
connection-state / online-session check upstream of the transport.**
|
||||||
|
- `ONLINE_ACCESS` is a `Blaze::Nucleus::EntitlementType` (enum value `1`).
|
||||||
|
anadius's `GoOnline` LSX handler (`anadius64.dll+0x2BB90`) is a success stub;
|
||||||
|
`anadius64.dll+0xB6B1` is a large entitlement/profile builder that *does*
|
||||||
|
reference `ONLINE_ACCESS`. Reverse-engineering that entitlement-status logic
|
||||||
|
is the **wrong fight** (see strategy).
|
||||||
|
|
||||||
|
## Strategy (decided)
|
||||||
|
|
||||||
|
Do **not** make anadius report "online." anadius exists to keep the game
|
||||||
|
offline, and it can't be removed without breaking launch/DRM. Instead:
|
||||||
|
|
||||||
|
> Let the game run its **real** online flow and answer that flow ourselves via
|
||||||
|
> the hook + brain. Target the Ebisu connection-state check the FUT-entry path
|
||||||
|
> polls; make the game *pass* it so it issues the real `ProtoSSLConnect` to the
|
||||||
|
> Blaze redirector → our redirect + ProtoSSL hook capture the real frames.
|
||||||
|
|
||||||
|
This deletes the "reverse-engineer anadius's entitlement logic" problem.
|
||||||
|
|
||||||
|
## Next RE step (converges with the ProtoSSL hook)
|
||||||
|
|
||||||
|
1. Locate and **read-only hook `ProtoSSLConnect`** (same DirtySDK module and
|
||||||
|
technique that found `_ProtoSSLSendPacket`). Confirms the game never
|
||||||
|
initiates the Blaze connection (pins the gate strictly upstream) and gives us
|
||||||
|
the exact symbol that will flip from "never called" to "called" on success.
|
||||||
|
2. Locate the Ebisu **connection-state getter** the FUT-entry / "connecting" UI
|
||||||
|
polls. Candidate string anchors (observed): `ONLINE_STATUS_EVENT`,
|
||||||
|
`GoOnline` result handling, the "connecting to the EA Servers" UI trigger.
|
||||||
|
3. Determine the minimal intervention to make that getter report **connected**
|
||||||
|
(out-hook it in our `version.dll`, winning over anadius's detour) so the game
|
||||||
|
proceeds to `ProtoSSLConnect`.
|
||||||
|
- `TODO/CONFIRM`: whether out-hooking the getter is sufficient, or secondary
|
||||||
|
checks (auth-token presence) also gate the attempt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M1 results (read-only investigation)
|
||||||
|
|
||||||
|
Method: `protossl-scan` (xref / disasm / callers, app-module-restricted) against
|
||||||
|
the live client, plus the existing `connect`/DNS/LSX hooks. Observed behaviour
|
||||||
|
only — no EA source.
|
||||||
|
|
||||||
|
### Point 1 — is `ProtoSSLConnect` reached on the "connecting" abort? **NO — gate is upstream. CONFIRMED.**
|
||||||
|
- The existing `connect` / `GetAddrInfoW` / `getaddrinfo` hooks show the client
|
||||||
|
makes **zero** network attempts during the "connecting to the EA Servers"
|
||||||
|
abort: no DNS for any `gosredirector.*` host, no `connect` to any external
|
||||||
|
address.
|
||||||
|
- The DirtySDK/ProtoSSL transport is therefore never reached — the abort is
|
||||||
|
entirely upstream and in-process.
|
||||||
|
- `ProtoSSLConnect` has no debug string and sits behind the DirtySDK API, so an
|
||||||
|
explicit hook on it isn't needed to prove this; the behavioural evidence is
|
||||||
|
conclusive. `TODO/CONFIRM`: locate `ProtoSSLConnect` by signature later, as a
|
||||||
|
positive M2 trip-wire (it should fire once we flip the gate).
|
||||||
|
|
||||||
|
### Surface mapped (clean-room)
|
||||||
|
- **Redirector host table** (`FIFA23.exe` .rdata, pointer table @ `+0x83FC858`):
|
||||||
|
`spring18.gosredirector.{sdev,stest,scert}.ea.com` + production
|
||||||
|
`spring18.gosredirector.ea.com`.
|
||||||
|
- **Redirector request/response config** (same region): `X-BLAZE-ERRORCODE`,
|
||||||
|
**`Authorization:`**, `<errorCode>` — the redirector request carries an
|
||||||
|
**Authorization header (a Nucleus token)**.
|
||||||
|
- **Enum-name tables** (serialization only, NOT decision code): `ONLINE_ACCESS`
|
||||||
|
(`Blaze::Nucleus::EntitlementType` = 1), `ONLINE_STATUS_EVENT`, … These are
|
||||||
|
reflection tables keyed by enum *value*; string-xref of them is a dead end for
|
||||||
|
the decision (the decision compares values, not strings).
|
||||||
|
|
||||||
|
### Point 2 — name the connection-state function: **PARTIAL.**
|
||||||
|
- The exact connection-state decision is behind heavy C++ vtable indirection
|
||||||
|
(the redirector config's two code pointers resolved to a virtual-dispatch
|
||||||
|
thunk @ `FIFA23.exe+0x27BD4C0` and a `ret 0` stub @ `+0x4F3CBC0`). The
|
||||||
|
memory-scan toolkit (string / pattern / xref / callers) cannot efficiently
|
||||||
|
navigate this.
|
||||||
|
- **Pinning the exact function needs a static disassembler with decompilation
|
||||||
|
(Ghidra / IDA) on `FIFA23.exe`.** `protossl-scan` remains the bridge to the
|
||||||
|
live process (mapping static addresses to the ASLR'd runtime, confirming hits,
|
||||||
|
installing hooks).
|
||||||
|
- `TODO/CONFIRM`: name the connection-state getter by module+offset via Ghidra.
|
||||||
|
|
||||||
|
### Point 3 — gate count: **TWO gates (state + token). Evidence-backed.**
|
||||||
|
- The online-connect path **reads/requires an auth (Nucleus) token**: the
|
||||||
|
redirector request carries an `Authorization:` header, and the SDK surface has
|
||||||
|
`GetAuthCode` / `FakeAuth` / `<GameToken>`. So it is **not** a single
|
||||||
|
connection-state boolean flip — even with the state forced "online", the client
|
||||||
|
needs a valid token to build the redirector request.
|
||||||
|
- **Conclusion for M2: plan for two gates** — (a) the connection-state decision,
|
||||||
|
and (b) supplying an auth token the client accepts.
|
||||||
|
- `TODO/CONFIRM` the exact abort point (no-token vs state-says-offline vs both) —
|
||||||
|
needs Ghidra-level control-flow tracing.
|
||||||
|
|
||||||
|
### Dynamic probe result (read-only) — `GoOnline` is NOT the gate
|
||||||
|
A read-only detour on anadius's `GoOnline` handler (`anadius64.dll+0x2BB90`)
|
||||||
|
shows the game **does** call `GoOnline` during the "connecting" attempt (incl.
|
||||||
|
on the Ultimate Team click) and anadius returns success — **yet no Blaze
|
||||||
|
connection follows** (still zero external `CONNECT`/DNS).
|
||||||
|
|
||||||
|
Therefore the gate is **downstream of `GoOnline`**: the game decides to go
|
||||||
|
online and the request is accepted, then it aborts at the **auth-token step
|
||||||
|
(`GetAuthCode`) and/or while waiting for the "online established" status
|
||||||
|
callback** (`ONLINE_STATUS_EVENT`), and times out into offline.
|
||||||
|
|
||||||
|
This sharpens the two-gate picture: `GoOnline` passes; the real blocker is the
|
||||||
|
**token / online-status step**. `TODO/CONFIRM` which (token-missing vs
|
||||||
|
status-never-fires) — via a `GetAuthCode` probe and/or Ghidra.
|
||||||
|
|
||||||
|
### Recommendation / next
|
||||||
|
Name the downstream gate. Two complementary routes:
|
||||||
|
- **Dynamic:** probe anadius's `GetAuthCode` handler (does it return a token or
|
||||||
|
fail?) — distinguishes token-gate from status-callback.
|
||||||
|
- **Static (Ghidra):** xref the `GoOnline` / `GetAuthCode` / `ONLINE_STATUS_EVENT`
|
||||||
|
strings in `FIFA23.exe` to find the FUT online-flow code that issues `GoOnline`
|
||||||
|
then waits for the token/status, and decompile it. FIFA isn't ASLR'd, so Ghidra
|
||||||
|
addresses (image base `0x140000000`) map 1:1 to our recorded offsets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M1 COMPLETE — the gate is `GetInternetConnectedState`
|
||||||
|
|
||||||
|
Found without Ghidra, by reading anadius's LSX **command-registration** function
|
||||||
|
(`anadius64.dll+0x14C0`), which lists every command-name → handler inline. NB the
|
||||||
|
`lea rax,[handler]` is **offset by one** from the `lea rdx,[name]` in that listing
|
||||||
|
(verified empirically: `+0x27060` decompiles to `GetProfile` — it builds a
|
||||||
|
`GetProfileResponse` for persona "fun"). Corrected handler map:
|
||||||
|
|
||||||
|
| anadius LSX command | handler (anadius64.dll + …) |
|
||||||
|
|---|---|
|
||||||
|
| GetProfile | 0x27060 |
|
||||||
|
| **GetInternetConnectedState** | **0x27790** |
|
||||||
|
| GoOnline | 0x2BB90 |
|
||||||
|
| GetAuthCode | 0x2BBC0 |
|
||||||
|
|
||||||
|
### The gate, named: `GetInternetConnectedState` @ `anadius64.dll+0x27790`
|
||||||
|
It serializes an LSX `InternetConnectedState` response with a `connected`
|
||||||
|
attribute whose value is:
|
||||||
|
|
||||||
|
```
|
||||||
|
connected = (byte[+0xCAB1B] != 0 || byte[+0xCAB1A] != 0) ? str(+0xAF530)
|
||||||
|
: str(+0xADE64)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both flag bytes **default to 0**, so it reports the offline value (`+0xADE64`).
|
||||||
|
That is precisely why the client sits at "connecting to the EA Servers" and falls
|
||||||
|
back offline.
|
||||||
|
|
||||||
|
### Answers to the three M1 points
|
||||||
|
1. **ProtoSSLConnect reached on the abort? NO — gate upstream.** Confirmed.
|
||||||
|
2. **The connection-state function:** `GetInternetConnectedState` @
|
||||||
|
`anadius64.dll+0x27790`. Decision = the two-flag branch above.
|
||||||
|
3. **Gate count: ONE actionable gate.** The auth token is already satisfied —
|
||||||
|
`GoOnline` (`+0x2BB90`) is called during the attempt and returns success, and
|
||||||
|
anadius provides a fake auth code; the blocker is purely the connection-state.
|
||||||
|
So M2 = make `GetInternetConnectedState` report **connected** (then the game
|
||||||
|
proceeds with its existing token).
|
||||||
|
|
||||||
|
### M2 attempt results — the gate is an async EVENT, not a poll
|
||||||
|
Tried (read/write, EAAC neutralized):
|
||||||
|
- Forced `GetInternetConnectedState` → connected (set flags +0xCAB1A/+0xCAB1B → value
|
||||||
|
`"1"`; strings confirmed: +0xADE64 = `"0"` offline, +0xAF530 = `"1"` connected).
|
||||||
|
- Flipped `GoOnline` to report `"1"` (replicated its builder `+0x25BE0` with the
|
||||||
|
connected string instead of `"0"`).
|
||||||
|
|
||||||
|
**Neither made the game proceed.** Both handlers fire, no crash — but the game
|
||||||
|
**keeps retrying `GoOnline` every ~7s** and never attempts the Blaze connect.
|
||||||
|
That retry-on-timeout pattern means the FUT-online flow is **event-driven**: the
|
||||||
|
game submits `GoOnline`, gets success, then **waits for an async "online
|
||||||
|
established" event** (ONLINE_STATUS_EVENT-class) that anadius — being offline-only
|
||||||
|
— never pushes (the only `<Event sender="EALS">` it ever sends is the Challenge
|
||||||
|
handshake). So flipping poll/return values can't unblock it.
|
||||||
|
|
||||||
|
**Implication:** getting past "connecting" requires **emulating the EA-app online
|
||||||
|
event sequence** the game waits for (inject the online-status event over LSX, in
|
||||||
|
the format/order EbisuSDK expects), not a single function flip. This is a
|
||||||
|
substantially deeper task (and precedes the Blaze backend emulation).
|
||||||
|
|
||||||
|
Next: trace the FIFA-side FUT online-flow (what the game does after `GoOnline`
|
||||||
|
and exactly which event/condition it waits on) — Ghidra on FIFA23.exe (import
|
||||||
|
saved at `C:\openfut\gh-proj`), or RE anadius's LSX event-send path. `TODO/CONFIRM`.
|
||||||
|
|
||||||
|
### M2 deeper finding — worker-thread + event architecture (confirmed)
|
||||||
|
A live call-stack capture from inside the GoOnline detour (manual stack scan,
|
||||||
|
bounded by `GetCurrentThreadStackLimits`) found **zero FIFA23.exe frames** and
|
||||||
|
showed `sp` sitting ~2.4 KB below the thread's stack top. So the handler runs at
|
||||||
|
the top of a short stack — i.e. on an **anadius worker thread** (IOCP/threadpool),
|
||||||
|
not FIFA's calling thread. anadius **queues** the GoOnline command and a worker
|
||||||
|
services it.
|
||||||
|
|
||||||
|
Combined with the retry behaviour, the architecture is now clear and three-way
|
||||||
|
corroborated: **FIFA calls `EbisuSDK::GoOnline` → anadius queues it → returns →
|
||||||
|
FIFA waits for an async "online established" event → anadius (offline-only, no
|
||||||
|
online-event code) never pushes it → timeout/retry.** No handler-response flip
|
||||||
|
can unblock this; the game waits on a *push* anadius never produces.
|
||||||
|
|
||||||
|
**Conclusion:** crossing this gate requires emulating the EA-app online-event
|
||||||
|
sequence (synthesize + inject the online-status event on the worker→game callback
|
||||||
|
/ LSX path, in EbisuSDK's expected format) — a research-grade emulation effort,
|
||||||
|
preceding the Blaze backend. The cheap in-process flips are exhausted.
|
||||||
|
|
||||||
|
Reaching the FIFA-side flow would need either: (a) locate `EbisuSDK::GoOnline` in
|
||||||
|
FIFA23.exe via anadius's detour table, then `callers` to the online-flow; or
|
||||||
|
(b) find anadius's LSX event-send path and reverse the online-event format. Both
|
||||||
|
are deep. `TODO/CONFIRM`.
|
||||||
|
|
||||||
|
### Path A attempt: reach the FIFA-side online-flow (blocked with live toolkit)
|
||||||
|
Goal: find `EbisuSDK::GoOnline` in FIFA23.exe → `callers` → the game's online-flow
|
||||||
|
→ read what event it waits on. Every angle our live-memory toolkit offers is
|
||||||
|
blocked:
|
||||||
|
- **String xref:** FIFA23.exe contains no `"GoOnline"` string (typed SDK call,
|
||||||
|
not a string-built command).
|
||||||
|
- **Call-stack from the handler:** GoOnline runs on an anadius worker thread; a
|
||||||
|
bounded stack scan finds zero FIFA frames.
|
||||||
|
- **Detour scan (`jmpscan`):** scanning FIFA23.exe for function-entry `E9` jumps
|
||||||
|
leaving the module yields ~3875 hits — overwhelmingly false positives, because
|
||||||
|
the 505 MB image is mostly embedded *data* (not code), and the real detours
|
||||||
|
don't cleanly cluster. (A .text-section-only scan would help but the chain
|
||||||
|
after — isolate GoOnline → callers → event format → emulate — remains long and
|
||||||
|
each link is gated by SDK abstraction / anadius indirection / worker threads.)
|
||||||
|
|
||||||
|
**Verdict:** crossing this gate to *playable* FUT is research-grade. It needs an
|
||||||
|
interactive disassembler (IDA/Ghidra GUI, human-driven) to trace the EbisuSDK
|
||||||
|
online-flow, and then a full EA-online + Blaze emulator. The live-memory toolkit
|
||||||
|
(string/xref/disasm/callers/read/jmpscan) has been exhausted for the FIFA side.
|
||||||
|
The clean-room spec (this document) is the finished, valuable artifact.
|
||||||
|
- Check whether the flags at `+0xCAB1A` / `+0xCAB1B` are settable via anadius
|
||||||
|
config / a hidden option (cheapest flip).
|
||||||
|
- Else out-detour `GetInternetConnectedState` in our `version.dll` to force the
|
||||||
|
`+0xAF530` ("connected") path.
|
||||||
|
- Then watch for the client to attempt the real Blaze connect (our `connect`
|
||||||
|
redirect + ProtoSSL hook capture the first plaintext — milestone M3).
|
||||||
|
- `TODO/CONFIRM`: exact text of the offline/connected value strings
|
||||||
|
(`+0xADE64` / `+0xAF530`); whether any secondary check gates the attempt after
|
||||||
|
the state flips.
|
||||||
@@ -48,7 +48,7 @@ This document maps confirmed or suspected FIFA 23 FUT API endpoints to their Ope
|
|||||||
|
|
||||||
| FUT Endpoint | Core Endpoint | Status | Notes |
|
| FUT Endpoint | Core Endpoint | Status | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Unknown | `POST /matches/result` | ❌ | Needs capture |
|
| Unknown | `POST /matches/complete` | ❌ | Needs capture. Body must carry a `match_identity` (exactly-once key). `POST /matches/result` was removed — no transaction, no idempotency key. |
|
||||||
|
|
||||||
## Objectives
|
## Objectives
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# OpenFUT Bridge roadmap — from here to "Squad Battles loads"
|
||||||
|
|
||||||
|
Two **first-class** outcomes (not one goal + a consolation prize):
|
||||||
|
|
||||||
|
- **A. Full playable AI FUT** — the emulator build (M1–M7). Realistically a
|
||||||
|
multi-month, expert-level reverse-engineering effort.
|
||||||
|
- **B. Clean-room spec deliverable** — a documented map of the auth / Blaze /
|
||||||
|
ProtoSSL / Fire2 surface (transport, gates, framing, decision points). Produced
|
||||||
|
incrementally as the findings from M1–M5; **finishable and valuable on its
|
||||||
|
own**, and the foundation any future emulator needs.
|
||||||
|
|
||||||
|
Every milestone's "done" is a **client-observable** result. Unconfirmed
|
||||||
|
dependencies are flagged.
|
||||||
|
|
||||||
|
## Done so far
|
||||||
|
|
||||||
|
- In-process `version.dll` hook (injects, forwards all 17 real exports).
|
||||||
|
- Transport confirmed: DirtySDK/ProtoSSL. `_ProtoSSLSendPacket` @
|
||||||
|
`FIFA23.exe+0xEFA530` hooked (plaintext-capture ready).
|
||||||
|
- `connect` / DNS / LSX (`send`/`recv` + `WSASend`/`WSARecv`) capture; mutexed log.
|
||||||
|
- Gate identified: **upstream Ebisu connection-state, in-process** (see
|
||||||
|
`connection-gate-findings.md`).
|
||||||
|
- RE toolkit: `protossl-scan` (scan / xref / disasm / module+offset).
|
||||||
|
|
||||||
|
## M1 — Locate the connection-state decision point · Outcome B core
|
||||||
|
- Read-only hook `ProtoSSLConnect`; find the Ebisu connection-state getter the
|
||||||
|
FUT-entry path polls.
|
||||||
|
- **Done:** we can name the exact function/return that gates "attempt online."
|
||||||
|
- Unknown: coupling to anadius's detour. **Effort:** ~days.
|
||||||
|
|
||||||
|
## M2 — Flip the gate (force "connected") · pure gate-flip proof
|
||||||
|
- Out-hook the getter / patch the polled state so the game attempts the real
|
||||||
|
connection.
|
||||||
|
- **Done (observable):** the game emits a DNS lookup / `connect` for the Blaze
|
||||||
|
redirector (`gosredirector.*`).
|
||||||
|
- Unknown: a secondary auth-token gate may also block. `TODO/CONFIRM`.
|
||||||
|
**Effort:** ~days.
|
||||||
|
|
||||||
|
## M3 — First real ProtoSSL plaintext on the Blaze connection · SMALLEST END-TO-END PROOF (see "Smallest milestone")
|
||||||
|
- Our redirect catches the Blaze connect; the ProtoSSL hook logs the first
|
||||||
|
plaintext it emits (the TLS **ClientHello** to the redirector).
|
||||||
|
- **Done:** `hook.log` shows the ClientHello from the *real* Blaze connection.
|
||||||
|
- Note: this is a TLS handshake frame, **not yet** a Blaze/Fire2 app-data frame.
|
||||||
|
**Effort:** small once M2 lands.
|
||||||
|
|
||||||
|
## M4 — Answer the redirector + decode first Fire2 frame · Outcome B: first decode
|
||||||
|
- Inject a response via the ProtoSSL recv side so the client advances; decode
|
||||||
|
the first Blaze/Fire2 request frame from real captured bytes.
|
||||||
|
- **Done:** the client advances past the redirector (sends the next gate's frame).
|
||||||
|
- Unknown (**BIG**): **Fire2 framing UNCONFIRMED**; **ProtoSSL recv-injection /
|
||||||
|
TLS-bypass convention UNCONFIRMED**; **Blaze component/command IDs UNCONFIRMED**.
|
||||||
|
**Effort:** high.
|
||||||
|
|
||||||
|
## M5 — Blaze preauth / login / postauth (online session) · Outcome B: full auth surface
|
||||||
|
- Answer UTIL preauth, AUTHENTICATION login (reusing anadius's persona surface),
|
||||||
|
UTIL postauth.
|
||||||
|
- **Done:** client reports online / reaches the FUT entry check.
|
||||||
|
- Depends on M4 framing. **Effort:** high.
|
||||||
|
|
||||||
|
## M6 — FUT entry + hub load (route to OpenFUT Core) · Outcome A
|
||||||
|
- Answer the FUT eligibility check; serve the FUT hub (club/squad) from OpenFUT
|
||||||
|
Core via the bridge.
|
||||||
|
- **Done:** the FUT hub UI loads (club/squad screen).
|
||||||
|
- Depends on Core's FUT REST surface. **Effort:** high.
|
||||||
|
|
||||||
|
## M7 — Squad Battles (AI FUT) · Outcome A goal
|
||||||
|
- Wire Squad Battles match setup / rewards against Core.
|
||||||
|
- **Done:** a Squad Battles match starts and rewards apply.
|
||||||
|
- **Effort:** medium-high after M6.
|
||||||
|
|
||||||
|
## Outcome mapping
|
||||||
|
- **B (spec)** completes as M1–M5 are documented — valuable even if A stalls.
|
||||||
|
- **A (playable AI FUT)** requires M1–M7.
|
||||||
|
|
||||||
|
## Smallest provable milestone (step 4)
|
||||||
|
|
||||||
|
Refined from the proposed candidate. The single smallest result that validates
|
||||||
|
the whole architecture end-to-end:
|
||||||
|
|
||||||
|
> **M3 — the client emits its first ProtoSSL plaintext onto the _real_ Blaze
|
||||||
|
> connection (the ClientHello to the redirector), captured by our hook.**
|
||||||
|
|
||||||
|
It proves both halves at once: (1) we got the game past its connection-state
|
||||||
|
check — it went online **for real**; and (2) our redirect + ProtoSSL hook
|
||||||
|
capture real plaintext from that connection.
|
||||||
|
|
||||||
|
It deliberately stops short of the candidate's "first **Blaze** frame" (a Fire2
|
||||||
|
app-data message), which requires answering the TLS handshake (M4) and depends
|
||||||
|
on the unconfirmed Fire2 / recv-injection work. ClientHello capture needs none
|
||||||
|
of that — making it the smallest, safest proof. The first Fire2 frame is the
|
||||||
|
immediate follow-on (M4).
|
||||||
Generated
+336
@@ -0,0 +1,336 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.186"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libudis86-sys"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "139bbf9ddb1bfc90c1ac64dd2923d9c957cd433cee7315c018125d72ab08a6b0"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mmap-fixed-fixed"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0681853891801e4763dc252e843672faf32bcfee27a0aa3b19733902af450acc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openfut-hook"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"retour",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "region"
|
||||||
|
version = "3.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"libc",
|
||||||
|
"mach2",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "retour"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9af44d40e2400b44d491bfaf8eae111b09f23ac4de6e92728e79d93e699c527"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"generic-array",
|
||||||
|
"libc",
|
||||||
|
"libudis86-sys",
|
||||||
|
"mmap-fixed-fixed",
|
||||||
|
"once_cell",
|
||||||
|
"region",
|
||||||
|
"slice-pool2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slice-pool2"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a3d689654af89bdfeba29a914ab6ac0236d382eb3b764f7454dde052f2821f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.118"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||||
|
dependencies = [
|
||||||
|
"windows-result",
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "openfut-hook"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "FIFA 23 version.dll proxy + ProtoSSL plaintext capture hook"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# Output is `version.dll` (the proxy/sideload name FIFA 23 loads).
|
||||||
|
name = "version"
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Inline-hooking library (installs the detour on _ProtoSSLSendPacket).
|
||||||
|
retour = "0.3"
|
||||||
|
|
||||||
|
# Win32 bindings for the loader/threading/module APIs the hook needs.
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_Networking_WinSock",
|
||||||
|
"Win32_System_LibraryLoader",
|
||||||
|
"Win32_System_ProcessStatus",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_System_SystemInformation",
|
||||||
|
"Win32_System_SystemServices",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# Own workspace root so Cargo doesn't attach this to the openfut-bridge package
|
||||||
|
# in the parent directory.
|
||||||
|
[workspace]
|
||||||
@@ -0,0 +1,920 @@
|
|||||||
|
//! openfut-hook — FIFA 23 `version.dll` proxy + ProtoSSL plaintext capture.
|
||||||
|
//!
|
||||||
|
//! This DLL is built as `version.dll` and dropped next to `FIFA23.exe`. The
|
||||||
|
//! game loads it (DLL search-order / sideload), at which point we:
|
||||||
|
//!
|
||||||
|
//! 1. (Task 2) Forward every real `version.dll` export to the genuine system
|
||||||
|
//! DLL, so the game keeps working. We prove load with a log line.
|
||||||
|
//! 2. (Task 3) Pattern-scan FIFA23.exe for `_ProtoSSLSendPacket` (the DirtySDK
|
||||||
|
//! TLS record assembler we located at FIFA23.exe+0xEFA530) and install an
|
||||||
|
//! inline detour. At its entry the record payload is still PLAINTEXT, so we
|
||||||
|
//! log the content type + the source buffers, then call the original.
|
||||||
|
//!
|
||||||
|
//! Everything is written to `C:\openfut\hook.log` with timestamps, in stages,
|
||||||
|
//! so the log alone tells us how far initialization got.
|
||||||
|
//!
|
||||||
|
//! Clean-room: this is generic proxy/loader/hook scaffolding plus a byte
|
||||||
|
//! pattern derived from observing the binary the user owns. No EA source.
|
||||||
|
|
||||||
|
use core::ffi::c_void;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use retour::RawDetour;
|
||||||
|
use windows::core::{PCSTR, PCWSTR};
|
||||||
|
use windows::Win32::Foundation::{BOOL, HMODULE};
|
||||||
|
use windows::Win32::Networking::WinSock::{WSAGetLastError, AF_INET, SOCKADDR, SOCKADDR_IN};
|
||||||
|
use windows::Win32::System::LibraryLoader::{
|
||||||
|
DisableThreadLibraryCalls, GetModuleHandleW, GetProcAddress, LoadLibraryW,
|
||||||
|
};
|
||||||
|
use windows::Win32::System::ProcessStatus::{GetModuleInformation, MODULEINFO};
|
||||||
|
use windows::Win32::System::SystemInformation::GetLocalTime;
|
||||||
|
use windows::Win32::System::SystemServices::DLL_PROCESS_ATTACH;
|
||||||
|
use windows::Win32::System::Threading::{
|
||||||
|
CreateThread, GetCurrentProcess, GetCurrentThreadStackLimits, Sleep, THREAD_CREATION_FLAGS,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task 2: version.dll export forwarding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Resolved addresses of the real system `version.dll` exports. Each proxy stub
|
||||||
|
/// below tail-jumps to its slot. AtomicUsize (not `static mut`) keeps this sound
|
||||||
|
/// and lets the naked stubs read the raw pointer via a simple memory load.
|
||||||
|
static REAL: [AtomicUsize; 17] = [const { AtomicUsize::new(0) }; 17];
|
||||||
|
|
||||||
|
/// The 17 exports a real `version.dll` provides, in the SAME order as the proxy
|
||||||
|
/// stubs' indices below.
|
||||||
|
const EXPORTS: [&str; 17] = [
|
||||||
|
"GetFileVersionInfoA",
|
||||||
|
"GetFileVersionInfoByHandle",
|
||||||
|
"GetFileVersionInfoExA",
|
||||||
|
"GetFileVersionInfoExW",
|
||||||
|
"GetFileVersionInfoSizeA",
|
||||||
|
"GetFileVersionInfoSizeExA",
|
||||||
|
"GetFileVersionInfoSizeExW",
|
||||||
|
"GetFileVersionInfoSizeW",
|
||||||
|
"GetFileVersionInfoW",
|
||||||
|
"VerFindFileA",
|
||||||
|
"VerFindFileW",
|
||||||
|
"VerInstallFileA",
|
||||||
|
"VerInstallFileW",
|
||||||
|
"VerLanguageNameA",
|
||||||
|
"VerLanguageNameW",
|
||||||
|
"VerQueryValueA",
|
||||||
|
"VerQueryValueW",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Generate an exported, naked proxy stub that tail-jumps to `REAL[idx]`.
|
||||||
|
/// A tail `jmp` leaves every register/stack arg untouched, so it forwards any
|
||||||
|
/// signature correctly regardless of how many arguments the real function takes.
|
||||||
|
macro_rules! proxy_stub {
|
||||||
|
($idx:literal, $name:ident) => {
|
||||||
|
#[no_mangle]
|
||||||
|
#[unsafe(naked)]
|
||||||
|
pub unsafe extern "system" fn $name() {
|
||||||
|
core::arch::naked_asm!(
|
||||||
|
"jmp qword ptr [rip + {base} + {off}]",
|
||||||
|
base = sym REAL,
|
||||||
|
off = const $idx * 8,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_stub!(0, GetFileVersionInfoA);
|
||||||
|
proxy_stub!(1, GetFileVersionInfoByHandle);
|
||||||
|
proxy_stub!(2, GetFileVersionInfoExA);
|
||||||
|
proxy_stub!(3, GetFileVersionInfoExW);
|
||||||
|
proxy_stub!(4, GetFileVersionInfoSizeA);
|
||||||
|
proxy_stub!(5, GetFileVersionInfoSizeExA);
|
||||||
|
proxy_stub!(6, GetFileVersionInfoSizeExW);
|
||||||
|
proxy_stub!(7, GetFileVersionInfoSizeW);
|
||||||
|
proxy_stub!(8, GetFileVersionInfoW);
|
||||||
|
proxy_stub!(9, VerFindFileA);
|
||||||
|
proxy_stub!(10, VerFindFileW);
|
||||||
|
proxy_stub!(11, VerInstallFileA);
|
||||||
|
proxy_stub!(12, VerInstallFileW);
|
||||||
|
proxy_stub!(13, VerLanguageNameA);
|
||||||
|
proxy_stub!(14, VerLanguageNameW);
|
||||||
|
proxy_stub!(15, VerQueryValueA);
|
||||||
|
proxy_stub!(16, VerQueryValueW);
|
||||||
|
|
||||||
|
/// Load the genuine `version.dll` by full path (so we don't re-load ourselves)
|
||||||
|
/// and fill `REAL[]` with each export's address. Done synchronously in DllMain
|
||||||
|
/// so the slots are ready before the game calls any version function.
|
||||||
|
unsafe fn resolve_exports() {
|
||||||
|
let path = wide("C:\\Windows\\System32\\version.dll");
|
||||||
|
let module = match LoadLibraryW(PCWSTR(path.as_ptr())) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("FATAL: could not load real version.dll: {e:?}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut missing = 0;
|
||||||
|
for (i, name) in EXPORTS.iter().enumerate() {
|
||||||
|
let cname = std::ffi::CString::new(*name).unwrap();
|
||||||
|
let addr = GetProcAddress(module, PCSTR(cname.as_ptr() as *const u8))
|
||||||
|
.map(|f| f as usize)
|
||||||
|
.unwrap_or(0);
|
||||||
|
REAL[i].store(addr, Ordering::SeqCst);
|
||||||
|
if addr == 0 {
|
||||||
|
missing += 1;
|
||||||
|
log(&format!("warn: real version.dll missing export {name}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(&format!("forwarded {} version.dll exports", 17 - missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task 3: the _ProtoSSLSendPacket detour
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Trampoline to the original `_ProtoSSLSendPacket`, stored after we hook.
|
||||||
|
static ORIG: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
/// Signature derived from the disassembly (Windows x64 ABI):
|
||||||
|
/// `_ProtoSSLSendPacket(pState, contentType, bufA, lenA, bufB, lenB) -> i32`.
|
||||||
|
type SendPacket =
|
||||||
|
unsafe extern "system" fn(*mut c_void, u32, *const u8, i32, *const u8, i32) -> i32;
|
||||||
|
|
||||||
|
/// Byte pattern for the `_ProtoSSLSendPacket` prologue. The four `0x00` bytes at
|
||||||
|
/// the masked positions are the stack-cookie `mov rax,[rip+disp32]` displacement
|
||||||
|
/// (position-dependent), so they're wildcarded via `MASK`.
|
||||||
|
const PATTERN: [u8; 36] = [
|
||||||
|
0x40, 0x53, 0x55, 0x56, 0x57, 0x41, 0x54, 0x41, 0x55, 0x41, 0x57, 0x48, 0x81, 0xEC, 0xB0,
|
||||||
|
0x00, 0x00, 0x00, 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00, 0x48, 0x33, 0xC4, 0x48, 0x89,
|
||||||
|
0x84, 0x24, 0xA0, 0x00, 0x00, 0x00,
|
||||||
|
];
|
||||||
|
/// `false` = wildcard byte (don't compare). Indices 21..=24 are the disp32.
|
||||||
|
const MASK: [bool; 36] = {
|
||||||
|
let mut m = [true; 36];
|
||||||
|
m[21] = false;
|
||||||
|
m[22] = false;
|
||||||
|
m[23] = false;
|
||||||
|
m[24] = false;
|
||||||
|
m
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Our detour. At entry the record payload is still plaintext, so we log the
|
||||||
|
/// content type and source buffers, then forward to the original unchanged.
|
||||||
|
unsafe extern "system" fn hooked(
|
||||||
|
p_state: *mut c_void,
|
||||||
|
content_type: u32,
|
||||||
|
buf_a: *const u8,
|
||||||
|
len_a: i32,
|
||||||
|
buf_b: *const u8,
|
||||||
|
len_b: i32,
|
||||||
|
) -> i32 {
|
||||||
|
log_frame(content_type as u8, buf_a, len_a, buf_b, len_b);
|
||||||
|
|
||||||
|
let orig = ORIG.load(Ordering::SeqCst);
|
||||||
|
if orig != 0 {
|
||||||
|
let orig: SendPacket = core::mem::transmute(orig);
|
||||||
|
orig(p_state, content_type, buf_a, len_a, buf_b, len_b)
|
||||||
|
} else {
|
||||||
|
// Should never happen (we store ORIG before the game can call us), but
|
||||||
|
// never crash the game if it does.
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background init: pattern-scan FIFA23.exe for the function, then detour it.
|
||||||
|
/// Retries for a while in case the image isn't fully paged in at load.
|
||||||
|
unsafe extern "system" fn init_thread(_: *mut c_void) -> u32 {
|
||||||
|
// Connection capture first. Listen on the LSX port (gate 1, so the launcher
|
||||||
|
// bootstrap succeeds) and on the redirect port (for external TLS/Blaze).
|
||||||
|
store_exe_range();
|
||||||
|
start_listener(LSX_PORT, "LSX");
|
||||||
|
start_listener(LOCAL_PORT, "BLZ");
|
||||||
|
hook_dns();
|
||||||
|
hook_winsock_data();
|
||||||
|
hook_connect();
|
||||||
|
|
||||||
|
// Then the plaintext-capture detour on _ProtoSSLSendPacket, plus the
|
||||||
|
// read-only anadius GoOnline probe (M1). Retry until both are installed.
|
||||||
|
let mut send_done = false;
|
||||||
|
let mut anadius_done = false;
|
||||||
|
for _ in 0..60 {
|
||||||
|
if !send_done {
|
||||||
|
if let Some(addr) = find_send_packet() {
|
||||||
|
install_hook(addr);
|
||||||
|
send_done = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !anadius_done && hook_anadius_probes() {
|
||||||
|
anadius_done = true;
|
||||||
|
}
|
||||||
|
if send_done && anadius_done {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
Sleep(1000);
|
||||||
|
}
|
||||||
|
if !send_done {
|
||||||
|
log("ERROR: _ProtoSSLSendPacket pattern not found after 60s");
|
||||||
|
}
|
||||||
|
if !anadius_done {
|
||||||
|
log("ERROR: anadius64.dll not loaded after 60s; GoOnline probe not installed");
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the FIFA23.exe image for the `_ProtoSSLSendPacket` prologue pattern.
|
||||||
|
unsafe fn find_send_packet() -> Option<usize> {
|
||||||
|
let module = GetModuleHandleW(PCWSTR::null()).ok()?; // null => the EXE itself
|
||||||
|
let base = module.0 as usize;
|
||||||
|
|
||||||
|
let mut info = MODULEINFO::default();
|
||||||
|
GetModuleInformation(
|
||||||
|
GetCurrentProcess(),
|
||||||
|
module,
|
||||||
|
&mut info,
|
||||||
|
core::mem::size_of::<MODULEINFO>() as u32,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
let size = info.SizeOfImage as usize;
|
||||||
|
let hay = core::slice::from_raw_parts(base as *const u8, size);
|
||||||
|
|
||||||
|
let plen = PATTERN.len();
|
||||||
|
if hay.len() < plen {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for i in 0..=hay.len() - plen {
|
||||||
|
// Cheap first-byte gate before the full compare.
|
||||||
|
if hay[i] != PATTERN[0] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut ok = true;
|
||||||
|
for j in 1..plen {
|
||||||
|
if MASK[j] && hay[i + j] != PATTERN[j] {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return Some(base + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the inline detour on the resolved function address.
|
||||||
|
unsafe fn install_hook(addr: usize) {
|
||||||
|
let detour = match RawDetour::new(addr as *const (), hooked as *const ()) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("ERROR: could not create detour: {e:?}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = detour.enable() {
|
||||||
|
log(&format!("ERROR: could not enable detour: {e:?}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Leak the detour so it lives forever (dropping it would un-hook), and
|
||||||
|
// publish its trampoline so `hooked` can call the original.
|
||||||
|
let detour: &'static RawDetour = Box::leak(Box::new(detour));
|
||||||
|
ORIG.store(detour.trampoline() as *const () as usize, Ordering::SeqCst);
|
||||||
|
log(&format!(
|
||||||
|
"hook installed: _ProtoSSLSendPacket @ 0x{addr:X} (FIFA23.exe+0x{:X})",
|
||||||
|
addr - module_base(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Connection capture: log every connect() and redirect external attempts to a
|
||||||
|
// local listener, so the client's TCP succeeds and it starts sending TLS.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Local port our in-DLL listener binds; redirected connections land here.
|
||||||
|
const LOCAL_PORT: u16 = 7777;
|
||||||
|
|
||||||
|
/// The EA launcher LSX port. The game connects here for launcher↔game bootstrap
|
||||||
|
/// (gate 1). We listen so the connect succeeds and we can see the LSX protocol.
|
||||||
|
const LSX_PORT: u16 = 3216;
|
||||||
|
|
||||||
|
/// Trampoline to the original ws2_32 `connect`.
|
||||||
|
static ORIG_CONNECT: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
type ConnectFn = unsafe extern "system" fn(usize, *const SOCKADDR, i32) -> i32;
|
||||||
|
|
||||||
|
/// Our `connect` detour: log the real destination, and for any non-loopback
|
||||||
|
/// IPv4 target, rewrite it to 127.0.0.1:LOCAL_PORT before calling the original.
|
||||||
|
unsafe extern "system" fn hooked_connect(s: usize, name: *const SOCKADDR, namelen: i32) -> i32 {
|
||||||
|
let orig: ConnectFn = core::mem::transmute(ORIG_CONNECT.load(Ordering::SeqCst));
|
||||||
|
|
||||||
|
if !name.is_null() && (*name).sa_family == AF_INET {
|
||||||
|
let sin = name as *const SOCKADDR_IN;
|
||||||
|
let port = u16::from_be((*sin).sin_port);
|
||||||
|
let octets = (*sin).sin_addr.S_un.S_addr.to_ne_bytes();
|
||||||
|
let is_loopback = octets[0] == 127;
|
||||||
|
let dst = format!("{}.{}.{}.{}:{}", octets[0], octets[1], octets[2], octets[3], port);
|
||||||
|
|
||||||
|
if !is_loopback {
|
||||||
|
// Build a fresh 127.0.0.1:LOCAL_PORT address and connect there.
|
||||||
|
let mut local: SOCKADDR_IN = core::mem::zeroed();
|
||||||
|
local.sin_family = AF_INET;
|
||||||
|
local.sin_port = LOCAL_PORT.to_be();
|
||||||
|
local.sin_addr.S_un.S_addr = u32::from_ne_bytes([127, 0, 0, 1]);
|
||||||
|
let ret = orig(
|
||||||
|
s,
|
||||||
|
&local as *const SOCKADDR_IN as *const SOCKADDR,
|
||||||
|
core::mem::size_of::<SOCKADDR_IN>() as i32,
|
||||||
|
);
|
||||||
|
let err = if ret != 0 { WSAGetLastError().0 } else { 0 };
|
||||||
|
log(&format!("CONNECT -> {dst} [redirected->7777] ret={ret} err={err}"));
|
||||||
|
return ret;
|
||||||
|
} else {
|
||||||
|
let ret = orig(s, name, namelen);
|
||||||
|
let err = if ret != 0 { WSAGetLastError().0 } else { 0 };
|
||||||
|
log(&format!("CONNECT -> {dst} ret={ret} err={err}"));
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orig(s, name, namelen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DNS logging: what hostnames does the client try to resolve? ----------
|
||||||
|
|
||||||
|
static ORIG_GAIW: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static ORIG_GAI: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
type GaiWFn = unsafe extern "system" fn(PCWSTR, PCWSTR, *const c_void, *mut *mut c_void) -> i32;
|
||||||
|
type GaiFn = unsafe extern "system" fn(PCSTR, PCSTR, *const c_void, *mut *mut c_void) -> i32;
|
||||||
|
|
||||||
|
unsafe extern "system" fn hooked_gaiw(
|
||||||
|
node: PCWSTR,
|
||||||
|
svc: PCWSTR,
|
||||||
|
hints: *const c_void,
|
||||||
|
res: *mut *mut c_void,
|
||||||
|
) -> i32 {
|
||||||
|
let name = if node.is_null() {
|
||||||
|
"<null>".to_string()
|
||||||
|
} else {
|
||||||
|
node.to_string().unwrap_or_else(|_| "<?>".into())
|
||||||
|
};
|
||||||
|
let orig: GaiWFn = core::mem::transmute(ORIG_GAIW.load(Ordering::SeqCst));
|
||||||
|
let ret = orig(node, svc, hints, res);
|
||||||
|
log(&format!("DNS GetAddrInfoW(\"{name}\") ret={ret}"));
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn hooked_gai(
|
||||||
|
node: PCSTR,
|
||||||
|
svc: PCSTR,
|
||||||
|
hints: *const c_void,
|
||||||
|
res: *mut *mut c_void,
|
||||||
|
) -> i32 {
|
||||||
|
let name = if node.is_null() {
|
||||||
|
"<null>".to_string()
|
||||||
|
} else {
|
||||||
|
node.to_string().unwrap_or_else(|_| "<?>".into())
|
||||||
|
};
|
||||||
|
let orig: GaiFn = core::mem::transmute(ORIG_GAI.load(Ordering::SeqCst));
|
||||||
|
let ret = orig(node, svc, hints, res);
|
||||||
|
log(&format!("DNS getaddrinfo(\"{name}\") ret={ret}"));
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic: resolve `name` in `module`, install a detour to `detour`, store the
|
||||||
|
/// trampoline in `slot`.
|
||||||
|
unsafe fn install_detour(
|
||||||
|
module: HMODULE,
|
||||||
|
name: &[u8],
|
||||||
|
detour: *const (),
|
||||||
|
slot: &AtomicUsize,
|
||||||
|
label: &str,
|
||||||
|
) {
|
||||||
|
let addr = match GetProcAddress(module, PCSTR(name.as_ptr())) {
|
||||||
|
Some(f) => f as usize,
|
||||||
|
None => {
|
||||||
|
log(&format!("ERROR: {label} not found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
install_detour_at(addr, detour, slot, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install an inline detour at a raw address (for non-exported targets such as
|
||||||
|
/// internal anadius handlers located by module+offset).
|
||||||
|
unsafe fn install_detour_at(addr: usize, detour: *const (), slot: &AtomicUsize, label: &str) {
|
||||||
|
let d = match RawDetour::new(addr as *const (), detour) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("ERROR: {label} detour create: {e:?}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if d.enable().is_err() {
|
||||||
|
log(&format!("ERROR: {label} detour enable failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let d: &'static RawDetour = Box::leak(Box::new(d));
|
||||||
|
slot.store(d.trampoline() as *const () as usize, Ordering::SeqCst);
|
||||||
|
log(&format!("{label} hook installed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- M1 read-only probe: anadius GoOnline handler -------------------------
|
||||||
|
|
||||||
|
static ORIG_GOONLINE: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static EXE_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static EXE_SIZE: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static STACK_LOGGED: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
/// Record FIFA23.exe's base + size so we can recognise its frames in a backtrace.
|
||||||
|
unsafe fn store_exe_range() {
|
||||||
|
if let Ok(h) = GetModuleHandleW(PCWSTR::null()) {
|
||||||
|
let mut mi = MODULEINFO::default();
|
||||||
|
if GetModuleInformation(
|
||||||
|
GetCurrentProcess(),
|
||||||
|
h,
|
||||||
|
&mut mi,
|
||||||
|
core::mem::size_of::<MODULEINFO>() as u32,
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
EXE_BASE.store(h.0 as usize, Ordering::SeqCst);
|
||||||
|
EXE_SIZE.store(mi.SizeOfImage as usize, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the raw stack for values that land in FIFA23.exe (the game-side
|
||||||
|
/// online-flow return addresses that called into GoOnline). Unwind-free, so it
|
||||||
|
/// survives the detour trampolines that break RtlCaptureStackBackTrace.
|
||||||
|
/// One-shot to avoid log spam.
|
||||||
|
unsafe fn log_fifa_callstack(tag: &str) {
|
||||||
|
if STACK_LOGGED.swap(1, Ordering::SeqCst) != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let base = EXE_BASE.load(Ordering::SeqCst);
|
||||||
|
let size = EXE_SIZE.load(Ordering::SeqCst);
|
||||||
|
if base == 0 || size == 0 {
|
||||||
|
log(&format!("{tag} stack scan skipped (exe range unknown)"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Address of a local ~= current rsp; the stack grows down, so callers'
|
||||||
|
// return addresses sit at HIGHER addresses. Scan upward, but NEVER past the
|
||||||
|
// committed stack top (reading beyond it faults — that crashed the game).
|
||||||
|
let mut low: usize = 0;
|
||||||
|
let mut high: usize = 0;
|
||||||
|
GetCurrentThreadStackLimits(&mut low, &mut high);
|
||||||
|
let probe: usize = 0;
|
||||||
|
let sp = &probe as *const usize as usize;
|
||||||
|
let end = high; // scan the whole rest of the stack (committed, safe)
|
||||||
|
let mut line = format!(
|
||||||
|
"{tag} stack[low=0x{low:X} high=0x{high:X} sp=0x{sp:X}] FIFA23.exe refs:"
|
||||||
|
);
|
||||||
|
let mut count = 0;
|
||||||
|
let mut p = sp;
|
||||||
|
while p + 8 <= end {
|
||||||
|
let val = *(p as *const usize);
|
||||||
|
if val >= base && val < base + size {
|
||||||
|
line.push_str(&format!(" +0x{:X}", val - base));
|
||||||
|
count += 1;
|
||||||
|
if count >= 40 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p += 8;
|
||||||
|
}
|
||||||
|
log(&line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// M2 flip on anadius's GoOnline handler (anadius64.dll+0x2BB90). The original
|
||||||
|
/// handler is `mov rcx,rdx; lea r8,[+0xADD73]; lea rdx,[+0xADE64 = "0"]; call
|
||||||
|
/// +0x25BE0; mov al,1` — i.e. it builds its ErrorSuccess response with the value
|
||||||
|
/// "0" (offline). We replicate it but pass "1" (+0xAF530 = the connected value),
|
||||||
|
/// so GoOnline reports online, then return success (al=1).
|
||||||
|
unsafe extern "system" fn hooked_goonline(_a: usize, b: usize, _c: usize, _d: usize) -> usize {
|
||||||
|
log_fifa_callstack("GoOnline");
|
||||||
|
let base = ANADIUS_BASE.load(Ordering::SeqCst);
|
||||||
|
if base != 0 {
|
||||||
|
log("FLIP GoOnline -> reporting online (\"1\")");
|
||||||
|
let builder: unsafe extern "system" fn(usize, usize, usize) -> usize =
|
||||||
|
core::mem::transmute(base + 0x25BE0);
|
||||||
|
// 0x25BE0(rcx = handler's rdx, rdx = "1", r8 = +0xADD73)
|
||||||
|
builder(b, base + 0xAF530, base + 0xADD73);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
let orig = ORIG_GOONLINE.load(Ordering::SeqCst);
|
||||||
|
if orig != 0 {
|
||||||
|
let f: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
||||||
|
core::mem::transmute(orig);
|
||||||
|
f(_a, b, _c, _d)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- M2 flip: force GetInternetConnectedState to report "connected" --------
|
||||||
|
|
||||||
|
static ORIG_ICS: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static ANADIUS_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
/// anadius's GetInternetConnectedState handler (anadius64.dll+0x27790) builds an
|
||||||
|
/// LSX response whose `connected` value is:
|
||||||
|
/// (byte[+0xCAB1B] || byte[+0xCAB1A]) ? connected : offline
|
||||||
|
/// Both default to 0 → offline → the game aborts at "connecting". We force both
|
||||||
|
/// flags to 1 before the original runs, so it builds the "connected" response.
|
||||||
|
unsafe extern "system" fn hooked_ics(a: usize, b: usize, c: usize, d: usize) -> usize {
|
||||||
|
let base = ANADIUS_BASE.load(Ordering::SeqCst);
|
||||||
|
if base != 0 {
|
||||||
|
core::ptr::write_volatile((base + 0xCAB1A) as *mut u8, 1u8);
|
||||||
|
core::ptr::write_volatile((base + 0xCAB1B) as *mut u8, 1u8);
|
||||||
|
}
|
||||||
|
log("FLIP GetInternetConnectedState -> forcing connected (flags set)");
|
||||||
|
let orig = ORIG_ICS.load(Ordering::SeqCst);
|
||||||
|
if orig != 0 {
|
||||||
|
let f: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
||||||
|
core::mem::transmute(orig);
|
||||||
|
f(a, b, c, d)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve anadius64.dll's runtime base, detour the GoOnline probe, and install
|
||||||
|
/// the M2 GetInternetConnectedState flip. Returns false if anadius isn't loaded.
|
||||||
|
unsafe fn hook_anadius_probes() -> bool {
|
||||||
|
let base = match GetModuleHandleW(PCWSTR(wide("anadius64.dll").as_ptr())) {
|
||||||
|
Ok(m) => m.0 as usize,
|
||||||
|
Err(_) => return false, // not loaded yet
|
||||||
|
};
|
||||||
|
ANADIUS_BASE.store(base, Ordering::SeqCst);
|
||||||
|
log(&format!("anadius64.dll base = 0x{base:X}"));
|
||||||
|
|
||||||
|
install_detour_at(
|
||||||
|
base + 0x2BB90,
|
||||||
|
hooked_goonline as *const (),
|
||||||
|
&ORIG_GOONLINE,
|
||||||
|
"PROBE anadius GoOnline @ +0x2BB90",
|
||||||
|
);
|
||||||
|
install_detour_at(
|
||||||
|
base + 0x27790,
|
||||||
|
hooked_ics as *const (),
|
||||||
|
&ORIG_ICS,
|
||||||
|
"FLIP anadius GetInternetConnectedState @ +0x27790",
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detour the DNS resolvers so we see every hostname lookup.
|
||||||
|
unsafe fn hook_dns() {
|
||||||
|
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("ERROR: load ws2_32 for DNS: {e:?}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
install_detour(ws2, b"GetAddrInfoW\0", hooked_gaiw as *const (), &ORIG_GAIW, "GetAddrInfoW");
|
||||||
|
install_detour(ws2, b"getaddrinfo\0", hooked_gai as *const (), &ORIG_GAI, "getaddrinfo");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LSX capture: read the Ebisu-SDK <-> anadius XML conversation ----------
|
||||||
|
|
||||||
|
static ORIG_SEND: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static ORIG_RECV: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
type SendFn = unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32;
|
||||||
|
type RecvFn = unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32;
|
||||||
|
|
||||||
|
/// Cheap test: does this buffer look like LSX/Ebisu XML (not TLS/binary)?
|
||||||
|
fn looks_like_lsx(buf: &[u8]) -> bool {
|
||||||
|
let n = buf.len().min(64);
|
||||||
|
let head = &buf[..n];
|
||||||
|
let has_lt = head.iter().any(|&b| b == b'<');
|
||||||
|
let has_gt = head.iter().any(|&b| b == b'>');
|
||||||
|
head.windows(3).any(|w| w == b"LSX")
|
||||||
|
|| head.windows(5).any(|w| w == b"Ebisu")
|
||||||
|
|| (has_lt && has_gt)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
|
||||||
|
if len > 0 && !buf.is_null() {
|
||||||
|
let head = core::slice::from_raw_parts(buf, (len as usize).min(64));
|
||||||
|
if looks_like_lsx(head) {
|
||||||
|
let show = core::slice::from_raw_parts(buf, (len as usize).min(800));
|
||||||
|
log(&format!("LSX send sock={s} {len}B: {}", ascii_render(show)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let orig: SendFn = core::mem::transmute(ORIG_SEND.load(Ordering::SeqCst));
|
||||||
|
orig(s, buf, len, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
|
||||||
|
let orig: RecvFn = core::mem::transmute(ORIG_RECV.load(Ordering::SeqCst));
|
||||||
|
let ret = orig(s, buf, len, flags);
|
||||||
|
if ret > 0 && !buf.is_null() {
|
||||||
|
let head = core::slice::from_raw_parts(buf, (ret as usize).min(64));
|
||||||
|
if looks_like_lsx(head) {
|
||||||
|
let show = core::slice::from_raw_parts(buf, (ret as usize).min(800));
|
||||||
|
log(&format!("LSX recv sock={s} {ret}B: {}", ascii_render(show)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async (overlapped/IOCP) variants. A WSABUF is { len, buf }.
|
||||||
|
#[repr(C)]
|
||||||
|
struct WsaBuf {
|
||||||
|
len: u32,
|
||||||
|
buf: *mut u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
static ORIG_WSASEND: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static ORIG_WSARECV: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
type WsaSendFn = unsafe extern "system" fn(
|
||||||
|
usize,
|
||||||
|
*const WsaBuf,
|
||||||
|
u32,
|
||||||
|
*mut u32,
|
||||||
|
u32,
|
||||||
|
*mut c_void,
|
||||||
|
*mut c_void,
|
||||||
|
) -> i32;
|
||||||
|
type WsaRecvFn = unsafe extern "system" fn(
|
||||||
|
usize,
|
||||||
|
*const WsaBuf,
|
||||||
|
u32,
|
||||||
|
*mut u32,
|
||||||
|
*mut u32,
|
||||||
|
*mut c_void,
|
||||||
|
*mut c_void,
|
||||||
|
) -> i32;
|
||||||
|
|
||||||
|
unsafe extern "system" fn hooked_wsasend(
|
||||||
|
s: usize,
|
||||||
|
bufs: *const WsaBuf,
|
||||||
|
count: u32,
|
||||||
|
sent: *mut u32,
|
||||||
|
flags: u32,
|
||||||
|
ovl: *mut c_void,
|
||||||
|
cr: *mut c_void,
|
||||||
|
) -> i32 {
|
||||||
|
// Outgoing data is readable before the call — capture the first buffer.
|
||||||
|
if !bufs.is_null() && count > 0 {
|
||||||
|
let b0 = &*bufs;
|
||||||
|
if b0.len > 0 && !b0.buf.is_null() {
|
||||||
|
let head = core::slice::from_raw_parts(b0.buf, (b0.len as usize).min(64));
|
||||||
|
if looks_like_lsx(head) {
|
||||||
|
let show = core::slice::from_raw_parts(b0.buf, (b0.len as usize).min(800));
|
||||||
|
log(&format!("LSX WSASend sock={s} {}B: {}", b0.len, ascii_render(show)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let orig: WsaSendFn = core::mem::transmute(ORIG_WSASEND.load(Ordering::SeqCst));
|
||||||
|
orig(s, bufs, count, sent, flags, ovl, cr)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn hooked_wsarecv(
|
||||||
|
s: usize,
|
||||||
|
bufs: *const WsaBuf,
|
||||||
|
count: u32,
|
||||||
|
recvd: *mut u32,
|
||||||
|
flags: *mut u32,
|
||||||
|
ovl: *mut c_void,
|
||||||
|
cr: *mut c_void,
|
||||||
|
) -> i32 {
|
||||||
|
let orig: WsaRecvFn = core::mem::transmute(ORIG_WSARECV.load(Ordering::SeqCst));
|
||||||
|
let ret = orig(s, bufs, count, recvd, flags, ovl, cr);
|
||||||
|
// Only the synchronous case (no overlapped) has data ready on return.
|
||||||
|
if ret == 0 && ovl.is_null() && !recvd.is_null() && !bufs.is_null() && count > 0 {
|
||||||
|
let n = *recvd as usize;
|
||||||
|
let b0 = &*bufs;
|
||||||
|
if n > 0 && !b0.buf.is_null() {
|
||||||
|
let cap = n.min(b0.len as usize);
|
||||||
|
let head = core::slice::from_raw_parts(b0.buf, cap.min(64));
|
||||||
|
if looks_like_lsx(head) {
|
||||||
|
let show = core::slice::from_raw_parts(b0.buf, cap.min(800));
|
||||||
|
log(&format!("LSX WSARecv sock={s} {n}B: {}", ascii_render(show)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detour ws2_32 send/recv (sync) and WSASend/WSARecv (async) to capture LSX XML.
|
||||||
|
unsafe fn hook_winsock_data() {
|
||||||
|
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("ERROR: load ws2_32 for send/recv: {e:?}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
install_detour(ws2, b"send\0", hooked_send as *const (), &ORIG_SEND, "send");
|
||||||
|
install_detour(ws2, b"recv\0", hooked_recv as *const (), &ORIG_RECV, "recv");
|
||||||
|
install_detour(ws2, b"WSASend\0", hooked_wsasend as *const (), &ORIG_WSASEND, "WSASend");
|
||||||
|
install_detour(ws2, b"WSARecv\0", hooked_wsarecv as *const (), &ORIG_WSARECV, "WSARecv");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve and detour ws2_32 `connect`.
|
||||||
|
unsafe fn hook_connect() {
|
||||||
|
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("ERROR: could not load ws2_32.dll: {e:?}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let addr = match GetProcAddress(ws2, PCSTR(b"connect\0".as_ptr())) {
|
||||||
|
Some(f) => f as usize,
|
||||||
|
None => {
|
||||||
|
log("ERROR: connect not found in ws2_32.dll");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let detour = match RawDetour::new(addr as *const (), hooked_connect as *const ()) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("ERROR: could not create connect detour: {e:?}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = detour.enable() {
|
||||||
|
log(&format!("ERROR: could not enable connect detour: {e:?}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let detour: &'static RawDetour = Box::leak(Box::new(detour));
|
||||||
|
ORIG_CONNECT.store(detour.trampoline() as *const () as usize, Ordering::SeqCst);
|
||||||
|
log("connect hook installed (external IPv4 -> 127.0.0.1:7777)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a TCP listener on `127.0.0.1:port`, tagging logged traffic with `tag`.
|
||||||
|
/// Accepts connections and logs what the client sends — as readable text (for
|
||||||
|
/// the text-based LSX protocol) plus a hex prefix (for binary TLS/Blaze).
|
||||||
|
fn start_listener(port: u16, tag: &'static str) {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("ERROR: listener[{tag}] bind {port} failed: {e}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let bound = listener
|
||||||
|
.local_addr()
|
||||||
|
.map(|a| a.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
log(&format!("listener[{tag}] up, bound {bound}, accepting"));
|
||||||
|
|
||||||
|
// Explicit accept loop so accept errors are visible (not swallowed).
|
||||||
|
loop {
|
||||||
|
match listener.accept() {
|
||||||
|
Ok((stream, peer)) => {
|
||||||
|
log(&format!("ACCEPT[{tag}] from {peer}"));
|
||||||
|
std::thread::spawn(move || handle_conn(stream, tag, peer.to_string()));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("ACCEPT[{tag}] error: {e}"));
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_conn(mut stream: std::net::TcpStream, tag: &'static str, peer: String) {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut buf = [0u8; 2048];
|
||||||
|
let mut total = 0usize;
|
||||||
|
loop {
|
||||||
|
match stream.read(&mut buf) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
total += n;
|
||||||
|
let ascii = ascii_render(&buf[..n.min(400)]);
|
||||||
|
let hexp = hex(&buf[..n.min(24)]);
|
||||||
|
log(&format!("RECV[{tag}] {n}B | hex: {hexp} | text: {ascii}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(&format!("CLOSE[{tag}] from {peer} after {total}B total"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render bytes as printable ASCII (non-printable -> '.'), for text protocols.
|
||||||
|
fn ascii_render(bytes: &[u8]) -> String {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|&b| {
|
||||||
|
if (0x20..=0x7e).contains(&b) || b == b'\n' || b == b'\r' || b == b'\t' {
|
||||||
|
b as char
|
||||||
|
} else {
|
||||||
|
'.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logging
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn log_frame(content_type: u8, buf_a: *const u8, len_a: i32, buf_b: *const u8, len_b: i32) {
|
||||||
|
let label = match content_type {
|
||||||
|
0x14 => "ccs",
|
||||||
|
0x15 => "alert",
|
||||||
|
0x16 => "handshake",
|
||||||
|
0x17 => "appdata",
|
||||||
|
_ => "?",
|
||||||
|
};
|
||||||
|
let mut line = format!("SEND type=0x{content_type:02X}({label}) lenA={len_a} lenB={len_b}");
|
||||||
|
if !buf_a.is_null() && len_a > 0 {
|
||||||
|
let n = (len_a as usize).min(48);
|
||||||
|
let bytes = unsafe { core::slice::from_raw_parts(buf_a, n) };
|
||||||
|
line.push_str(&format!(" | A: {}", hex(bytes)));
|
||||||
|
}
|
||||||
|
if !buf_b.is_null() && len_b > 0 {
|
||||||
|
let n = (len_b as usize).min(16);
|
||||||
|
let bytes = unsafe { core::slice::from_raw_parts(buf_b, n) };
|
||||||
|
line.push_str(&format!(" | B: {}", hex(bytes)));
|
||||||
|
}
|
||||||
|
log(&line);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02X}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes log writes so concurrent threads don't corrupt each other's lines.
|
||||||
|
static LOG_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// Append a timestamped line to `C:\openfut\hook.log`.
|
||||||
|
fn log(msg: &str) {
|
||||||
|
use std::io::Write;
|
||||||
|
let _guard = LOG_LOCK.lock();
|
||||||
|
let _ = std::fs::create_dir_all("C:\\openfut");
|
||||||
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open("C:\\openfut\\hook.log")
|
||||||
|
{
|
||||||
|
let _ = writeln!(f, "[{}] {}", now(), msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now() -> String {
|
||||||
|
let st = unsafe { GetLocalTime() };
|
||||||
|
format!(
|
||||||
|
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
|
||||||
|
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers + entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn wide(s: &str) -> Vec<u16> {
|
||||||
|
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FIFA23.exe base address (the main module), for pretty logging.
|
||||||
|
fn module_base() -> usize {
|
||||||
|
unsafe {
|
||||||
|
GetModuleHandleW(PCWSTR::null())
|
||||||
|
.map(|h| h.0 as usize)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn DllMain(module: HMODULE, reason: u32, _reserved: *mut c_void) -> BOOL {
|
||||||
|
if reason == DLL_PROCESS_ATTACH {
|
||||||
|
unsafe {
|
||||||
|
let _ = DisableThreadLibraryCalls(module);
|
||||||
|
|
||||||
|
let host = std::env::current_exe()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
log(&format!(
|
||||||
|
"openfut-hook loaded | pid {} | host {host}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
|
||||||
|
// Forward exports synchronously (must be ready before any call)...
|
||||||
|
resolve_exports();
|
||||||
|
|
||||||
|
// ...then do the pattern scan + hook on a background thread (never
|
||||||
|
// do real work directly in DllMain — loader lock).
|
||||||
|
let _ = CreateThread(
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
Some(init_thread),
|
||||||
|
None,
|
||||||
|
THREAD_CREATION_FLAGS(0),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BOOL(1)
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>OpenFUT Bridge — Admin</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.5; }
|
||||||
|
header { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 24px; display: flex; align-items: center; gap: 16px; }
|
||||||
|
header h1 { font-size: 1.1rem; color: #58a6ff; }
|
||||||
|
header .badge { font-size: 0.75rem; background: #238636; color: #fff; padding: 2px 8px; border-radius: 12px; }
|
||||||
|
main { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||||
|
section { margin-bottom: 32px; }
|
||||||
|
h2 { font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.05em; color: #8b949e; border-bottom: 1px solid #30363d; padding-bottom: 8px; margin-bottom: 16px; }
|
||||||
|
.stats { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||||
|
.stat { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 16px 24px; min-width: 140px; }
|
||||||
|
.stat .num { font-size: 2rem; font-weight: 700; color: #58a6ff; }
|
||||||
|
.stat .label { font-size: 0.8rem; color: #8b949e; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
th { text-align: left; padding: 8px 12px; background: #161b22; color: #8b949e; border-bottom: 1px solid #30363d; white-space: nowrap; }
|
||||||
|
td { padding: 8px 12px; border-bottom: 1px solid #21262d; vertical-align: top; word-break: break-all; max-width: 400px; }
|
||||||
|
tr:hover td { background: #161b22; }
|
||||||
|
.badge-method { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.GET { background: #1f6feb; color: #fff; }
|
||||||
|
.POST { background: #2ea043; color: #fff; }
|
||||||
|
.PUT { background: #9e6a03; color: #fff; }
|
||||||
|
.DELETE { background: #b91c1c; color: #fff; }
|
||||||
|
.status-mapped { color: #3fb950; }
|
||||||
|
.status-unknown { color: #f85149; }
|
||||||
|
.btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
|
||||||
|
.btn:hover { background: #30363d; }
|
||||||
|
.btn-danger { border-color: #b91c1c; color: #f85149; }
|
||||||
|
.btn-danger:hover { background: #b91c1c22; }
|
||||||
|
#live-log { height: 220px; overflow-y: auto; background: #010409; border: 1px solid #30363d; border-radius: 6px; padding: 12px; font-family: monospace; font-size: 0.78rem; color: #7ee787; }
|
||||||
|
#live-log .entry { margin-bottom: 4px; }
|
||||||
|
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.toolbar .spacer { flex: 1; }
|
||||||
|
.empty { color: #8b949e; font-style: italic; font-size: 0.85rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>OpenFUT Bridge</h1>
|
||||||
|
<span class="badge">Admin Dashboard</span>
|
||||||
|
<span id="status-dot" style="margin-left:auto;font-size:0.8rem;color:#8b949e">connecting…</span>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<div class="stats" id="stats">
|
||||||
|
<div class="stat"><div class="num" id="s-total">—</div><div class="label">Total captures</div></div>
|
||||||
|
<div class="stat"><div class="num" id="s-unknown">—</div><div class="label">Unknown endpoints</div></div>
|
||||||
|
<div class="stat"><div class="num" id="s-endpoints">—</div><div class="label">Unique endpoints</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Live capture stream</h2>
|
||||||
|
<div id="live-log"><span class="empty">Waiting for traffic…</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Endpoint status</h2>
|
||||||
|
<div id="endpoints-table"><span class="empty">Loading…</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Recent captures</h2>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn" onclick="loadCaptures()">Refresh</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn btn-danger" onclick="clearCaptures()">Delete all captures</button>
|
||||||
|
</div>
|
||||||
|
<div id="captures-table"><span class="empty">Loading…</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
async function api(path) {
|
||||||
|
const r = await fetch(path);
|
||||||
|
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodBadge(m) {
|
||||||
|
return `<span class="badge-method ${m}">${m}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const d = await api('/_bridge/captures');
|
||||||
|
$('s-total').textContent = d.total;
|
||||||
|
$('s-unknown').textContent = d.unknown_endpoints;
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEndpoints() {
|
||||||
|
try {
|
||||||
|
const d = await api('/_bridge/status');
|
||||||
|
$('s-endpoints').textContent = d.total_unique_endpoints;
|
||||||
|
if (!d.endpoints.length) {
|
||||||
|
$('endpoints-table').innerHTML = '<span class="empty">No endpoints seen yet.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = d.endpoints.map(ep => `
|
||||||
|
<tr>
|
||||||
|
<td>${methodBadge(ep.method)}</td>
|
||||||
|
<td><code>${ep.path}</code></td>
|
||||||
|
<td class="status-${ep.status}">${ep.status}</td>
|
||||||
|
<td>${ep.mapped_to || '—'}</td>
|
||||||
|
<td>${ep.first_seen ? ep.first_seen.slice(0,19).replace('T',' ') : '—'}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
$('endpoints-table').innerHTML = `
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Method</th><th>Path</th><th>Status</th><th>Maps to</th><th>First seen</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
} catch (e) { $('endpoints-table').innerHTML = `<span class="empty">Error: ${e.message}</span>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCaptures() {
|
||||||
|
try {
|
||||||
|
const d = await api('/_bridge/captures');
|
||||||
|
if (!d.captures.length) {
|
||||||
|
$('captures-table').innerHTML = '<span class="empty">No captures yet.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = d.captures.slice(-50).reverse().map(c => `
|
||||||
|
<tr>
|
||||||
|
<td>${c.timestamp ? c.timestamp.slice(0,19).replace('T',' ') : ''}</td>
|
||||||
|
<td>${methodBadge(c.method)}</td>
|
||||||
|
<td><code>${c.path}${c.query ? '?' + c.query : ''}</code></td>
|
||||||
|
<td>${c.response_status || '—'}</td>
|
||||||
|
<td class="status-${c.mapped_to_core ? 'mapped' : 'unknown'}">${c.mapped_to_core || 'unknown'}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
$('captures-table').innerHTML = `
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Time</th><th>Method</th><th>Path</th><th>Status</th><th>Core mapping</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
} catch (e) { $('captures-table').innerHTML = `<span class="empty">Error: ${e.message}</span>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCaptures() {
|
||||||
|
if (!confirm('Delete all capture files from disk?')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/_bridge/captures', { method: 'DELETE' });
|
||||||
|
const d = await r.json();
|
||||||
|
alert(`Deleted ${d.deleted} capture(s).`);
|
||||||
|
loadCaptures(); loadStats();
|
||||||
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSSE() {
|
||||||
|
const es = new EventSource('/_bridge/captures/stream');
|
||||||
|
const log = $('live-log');
|
||||||
|
|
||||||
|
es.onopen = () => { $('status-dot').textContent = '● live'; $('status-dot').style.color = '#3fb950'; };
|
||||||
|
es.onerror = () => { $('status-dot').textContent = '○ disconnected'; $('status-dot').style.color = '#f85149'; };
|
||||||
|
|
||||||
|
es.addEventListener('capture', e => {
|
||||||
|
try {
|
||||||
|
const c = JSON.parse(e.data);
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'entry';
|
||||||
|
entry.textContent = `${c.timestamp?.slice(11,19) || ''} ${c.method} ${c.path} → ${c.response_status || '?'} [${c.mapped_to_core || 'unknown'}]`;
|
||||||
|
if (log.firstChild?.className === 'empty') log.innerHTML = '';
|
||||||
|
log.prepend(entry);
|
||||||
|
if (log.children.length > 100) log.lastChild?.remove();
|
||||||
|
loadStats();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadStats();
|
||||||
|
loadEndpoints();
|
||||||
|
loadCaptures();
|
||||||
|
connectSSE();
|
||||||
|
setInterval(() => { loadEndpoints(); loadCaptures(); }, 10_000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! openfut-bridge-replay — replay a captured request file against the bridge.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! openfut-bridge-replay \<capture.json\> [bridge-url]
|
||||||
|
//! openfut-bridge-replay captures/ (replay all captures in a dir)
|
||||||
|
//!
|
||||||
|
//! The bridge URL defaults to `http://127.0.0.1:8443`.
|
||||||
|
//! Self-signed TLS certificates are accepted automatically.
|
||||||
|
|
||||||
|
use openfut_bridge::capture::CapturedRequest;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> ExitCode {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: openfut-bridge-replay <capture.json|dir> [bridge-url]");
|
||||||
|
eprintln!(" bridge-url defaults to http://127.0.0.1:8443");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_arg = &args[1];
|
||||||
|
let bridge_url = args
|
||||||
|
.get(2)
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("http://127.0.0.1:8443");
|
||||||
|
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to build HTTP client: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let captures = match collect_captures(path_arg) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error loading captures: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if captures.is_empty() {
|
||||||
|
eprintln!("No capture files found at {path_arg}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Replaying {} capture(s) against {bridge_url}",
|
||||||
|
captures.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut failures = 0u32;
|
||||||
|
for capture in &captures {
|
||||||
|
let result = replay_one(&client, bridge_url, capture).await;
|
||||||
|
match result {
|
||||||
|
Ok(status) => println!(" [{}] {} {} → {status}", capture.id, capture.method, capture.path),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" [{}] {} {} → ERROR: {e}", capture.id, capture.method, capture.path);
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failures > 0 {
|
||||||
|
eprintln!("{failures} replay(s) failed.");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
} else {
|
||||||
|
println!("All replays succeeded.");
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_captures(path_arg: &str) -> anyhow::Result<Vec<CapturedRequest>> {
|
||||||
|
let path = std::path::Path::new(path_arg);
|
||||||
|
if path.is_dir() {
|
||||||
|
openfut_bridge::capture::load_all_captures(path_arg)
|
||||||
|
} else {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let capture: CapturedRequest = serde_json::from_str(&content)?;
|
||||||
|
Ok(vec![capture])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replay_one(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
bridge_url: &str,
|
||||||
|
capture: &CapturedRequest,
|
||||||
|
) -> anyhow::Result<u16> {
|
||||||
|
let url = format!("{}{}", bridge_url, capture.path);
|
||||||
|
let builder = match capture.method.to_uppercase().as_str() {
|
||||||
|
"POST" => client.post(&url),
|
||||||
|
"PUT" => client.put(&url),
|
||||||
|
"DELETE" => client.delete(&url),
|
||||||
|
_ => client.get(&url),
|
||||||
|
};
|
||||||
|
|
||||||
|
let builder = if let Some(body) = &capture.body {
|
||||||
|
builder
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(body.clone())
|
||||||
|
} else {
|
||||||
|
builder
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = builder.send().await?;
|
||||||
|
Ok(resp.status().as_u16())
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ pub struct Config {
|
|||||||
pub captures_dir: String,
|
pub captures_dir: String,
|
||||||
/// If true, return placeholder 200 responses for unknown routes
|
/// If true, return placeholder 200 responses for unknown routes
|
||||||
pub placeholder_mode: bool,
|
pub placeholder_mode: bool,
|
||||||
|
/// If true, serve over TLS with a generated self-signed certificate
|
||||||
|
pub tls_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -22,6 +24,9 @@ impl Config {
|
|||||||
placeholder_mode: std::env::var("PLACEHOLDER_MODE")
|
placeholder_mode: std::env::var("PLACEHOLDER_MODE")
|
||||||
.map(|v| v == "true" || v == "1")
|
.map(|v| v == "true" || v == "1")
|
||||||
.unwrap_or(true),
|
.unwrap_or(true),
|
||||||
|
tls_enabled: std::env::var("TLS_ENABLED")
|
||||||
|
.map(|v| v == "true" || v == "1")
|
||||||
|
.unwrap_or(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2947
File diff suppressed because it is too large
Load Diff
@@ -4,3 +4,5 @@ pub mod error;
|
|||||||
pub mod mapper;
|
pub mod mapper;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
pub mod shaper;
|
||||||
|
pub mod tls;
|
||||||
|
|||||||
+92
-10
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{any, get},
|
routing::{any, delete, get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use openfut_bridge::{config::Config, proxy::ProxyState, routes};
|
use openfut_bridge::{config::Config, proxy::ProxyState, routes};
|
||||||
@@ -22,31 +22,113 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let cfg = Config::from_env()?;
|
let cfg = Config::from_env()?;
|
||||||
let listen_addr = cfg.listen_addr.clone();
|
let listen_addr = cfg.listen_addr.clone();
|
||||||
|
let tls_enabled = cfg.tls_enabled;
|
||||||
|
|
||||||
info!("OpenFUT Bridge starting on {listen_addr}");
|
info!("OpenFUT Bridge starting on {listen_addr}");
|
||||||
info!("Core URL: {}", cfg.core_url);
|
info!("Core URL: {}", cfg.core_url);
|
||||||
info!("Placeholder mode: {}", cfg.placeholder_mode);
|
info!("Placeholder mode: {}", cfg.placeholder_mode);
|
||||||
info!("Captures dir: {}", cfg.captures_dir);
|
info!("Captures dir: {}", cfg.captures_dir);
|
||||||
|
info!("TLS enabled: {tls_enabled}");
|
||||||
|
|
||||||
std::fs::create_dir_all(&cfg.captures_dir)?;
|
std::fs::create_dir_all(&cfg.captures_dir)?;
|
||||||
|
|
||||||
let state = ProxyState::new(cfg);
|
let addr: std::net::SocketAddr = listen_addr.parse()?;
|
||||||
|
|
||||||
let app = Router::new()
|
if tls_enabled {
|
||||||
|
// Generate cert/key before building state so the cert can be served via /_bridge/cert.pem
|
||||||
|
let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert()?;
|
||||||
|
let state = ProxyState::new(cfg).with_cert(cert_pem.clone());
|
||||||
|
let app = build_router(state);
|
||||||
|
serve_tls(app, cert_pem, key_pem, addr).await
|
||||||
|
} else {
|
||||||
|
let state = ProxyState::new(cfg);
|
||||||
|
let app = build_router(state);
|
||||||
|
info!("Bridge listening on http://{addr}");
|
||||||
|
info!("Set TLS_ENABLED=true to serve over HTTPS");
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_router(state: ProxyState) -> Router {
|
||||||
|
Router::new()
|
||||||
.route("/_bridge/health", get(routes::health::get_health))
|
.route("/_bridge/health", get(routes::health::get_health))
|
||||||
|
.route("/_bridge/cert.pem", get(routes::health::get_cert))
|
||||||
|
.route("/_bridge/guide", get(routes::health::get_guide))
|
||||||
|
.route("/_bridge/dashboard", get(routes::health::get_dashboard))
|
||||||
|
.route("/_bridge/admin", get(routes::admin::get_admin_dashboard))
|
||||||
.route("/_bridge/captures", get(routes::admin::get_captures))
|
.route("/_bridge/captures", get(routes::admin::get_captures))
|
||||||
|
.route("/_bridge/captures", delete(routes::admin::delete_captures))
|
||||||
|
.route(
|
||||||
|
"/_bridge/captures/stream",
|
||||||
|
get(routes::admin::get_captures_stream),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/_bridge/unknown",
|
"/_bridge/unknown",
|
||||||
get(routes::admin::get_unknown_endpoints),
|
get(routes::admin::get_unknown_endpoints),
|
||||||
)
|
)
|
||||||
|
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
|
||||||
|
.route(
|
||||||
|
"/_bridge/captures/diff",
|
||||||
|
get(routes::admin::get_capture_diff),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/_bridge/captures/:id/replay",
|
||||||
|
post(routes::admin::post_replay_capture),
|
||||||
|
)
|
||||||
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.with_state(state);
|
.with_state(state)
|
||||||
|
}
|
||||||
let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
|
|
||||||
info!("Bridge listening on http://{listen_addr}");
|
async fn serve_tls(
|
||||||
axum::serve(listener, app).await?;
|
app: Router,
|
||||||
|
cert_pem: Vec<u8>,
|
||||||
Ok(())
|
key_pem: Vec<u8>,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
) -> Result<()> {
|
||||||
|
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||||
|
use openfut_bridge::tls::make_tls_acceptor;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
let acceptor = make_tls_acceptor(&cert_pem, &key_pem)?;
|
||||||
|
|
||||||
|
info!("Bridge listening on https://{addr} (self-signed TLS)");
|
||||||
|
info!("Download the CA cert from /_bridge/cert.pem and install it as trusted");
|
||||||
|
info!("Setup guide: https://{addr}/_bridge/guide");
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (tcp, _peer) = listener.accept().await?;
|
||||||
|
let acceptor = acceptor.clone();
|
||||||
|
let app = app.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let tls_stream = match acceptor.accept(tcp).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("TLS handshake failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let io = TokioIo::new(tls_stream);
|
||||||
|
let svc = hyper::service::service_fn(
|
||||||
|
move |req: hyper::Request<hyper::body::Incoming>| {
|
||||||
|
let app = app.clone();
|
||||||
|
async move { app.oneshot(req.map(axum::body::Body::new)).await }
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
|
||||||
|
.serve_connection(io, svc)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::debug!("TLS connection closed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+774
-68
@@ -4,84 +4,569 @@
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CoreMapping {
|
pub struct CoreMapping {
|
||||||
pub method: &'static str,
|
pub method: &'static str,
|
||||||
pub core_path: &'static str,
|
pub core_path: String,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub notes: &'static str,
|
pub notes: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Exact mappings (static path, no dynamic segments) ─────────────────────────
|
||||||
|
|
||||||
|
struct ExactRoute {
|
||||||
|
ea_method: &'static str,
|
||||||
|
ea_path: &'static str,
|
||||||
|
core_method: &'static str,
|
||||||
|
core_path: &'static str,
|
||||||
|
notes: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXACT: &[ExactRoute] = &[
|
||||||
|
// ── Auth ─────────────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/auth",
|
||||||
|
core_method: "POST", core_path: "/auth/local",
|
||||||
|
notes: "FUT login → Core local auth",
|
||||||
|
},
|
||||||
|
// ── Profile / Settings ───────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/user/settings",
|
||||||
|
core_method: "GET", core_path: "/profile",
|
||||||
|
notes: "FUT user settings → Core profile",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "PUT", ea_path: "/ut/game/fut/user/settings",
|
||||||
|
core_method: "PUT", core_path: "/settings",
|
||||||
|
notes: "FUT update settings → Core settings",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/user/accountinfo",
|
||||||
|
core_method: "GET", core_path: "/profile",
|
||||||
|
notes: "FUT account info → Core profile",
|
||||||
|
},
|
||||||
|
// ── Club / Mass info ──────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/usermassinfo",
|
||||||
|
core_method: "GET", core_path: "/club",
|
||||||
|
notes: "FUT mass info (club + coins) → Core club",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/club",
|
||||||
|
core_method: "GET", core_path: "/club",
|
||||||
|
notes: "FUT club info → Core club",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "PUT", ea_path: "/ut/game/fut/club",
|
||||||
|
core_method: "PUT", core_path: "/club",
|
||||||
|
notes: "FUT update club → Core update club",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/club/stats",
|
||||||
|
core_method: "GET", core_path: "/statistics",
|
||||||
|
notes: "FUT club stats → Core statistics",
|
||||||
|
},
|
||||||
|
// ── Cards / Collection ────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/item",
|
||||||
|
core_method: "GET", core_path: "/collection",
|
||||||
|
notes: "FUT collection → Core owned cards",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/item/search",
|
||||||
|
core_method: "GET", core_path: "/cards",
|
||||||
|
notes: "FUT item search → Core card catalogue (query params forwarded)",
|
||||||
|
},
|
||||||
|
// ── Squad ─────────────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/squad/active",
|
||||||
|
core_method: "GET", core_path: "/squad",
|
||||||
|
notes: "FUT active squad → Core squad",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/squad/0",
|
||||||
|
core_method: "GET", core_path: "/squad",
|
||||||
|
notes: "FUT squad by slot 0 → Core squad (first squad)",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "PUT", ea_path: "/ut/game/fut/squad/active",
|
||||||
|
core_method: "POST", core_path: "/squad",
|
||||||
|
notes: "FUT save squad → Core save squad",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/squad/chemistry",
|
||||||
|
core_method: "GET", core_path: "/squad",
|
||||||
|
notes: "FUT squad chemistry → Core squad (chemistry included in response)",
|
||||||
|
},
|
||||||
|
// ── Packs ─────────────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/store/packdetails",
|
||||||
|
core_method: "GET", core_path: "/packs",
|
||||||
|
notes: "FUT pack store → Core pack list",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/store/purchase",
|
||||||
|
core_method: "POST", core_path: "/packs/buy",
|
||||||
|
notes: "FUT pack purchase → Core pack buy",
|
||||||
|
},
|
||||||
|
// ── Transfer Market ───────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/transfermarket",
|
||||||
|
core_method: "GET", core_path: "/market",
|
||||||
|
notes: "FUT transfer market search → Core NPC market",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/trade/bid",
|
||||||
|
core_method: "POST", core_path: "/market/buy",
|
||||||
|
notes: "FUT bid/buy now → Core market buy",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/trade/watchlist",
|
||||||
|
core_method: "GET", core_path: "/market",
|
||||||
|
notes: "FUT watchlist → Core market (approximation)",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/trade/tradepile",
|
||||||
|
core_method: "GET", core_path: "/market/my-listings",
|
||||||
|
notes: "FUT trade pile (my listings) → Core my-listings",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/auctionhouse",
|
||||||
|
core_method: "POST", core_path: "/market/sell",
|
||||||
|
notes: "FUT list card on AH → Core sell card",
|
||||||
|
},
|
||||||
|
// ── Objectives ────────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/objectives",
|
||||||
|
core_method: "GET", core_path: "/objectives",
|
||||||
|
notes: "FUT objectives → Core objectives list",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/objectives/claim",
|
||||||
|
core_method: "POST", core_path: "/objectives/claim",
|
||||||
|
notes: "FUT claim objective → Core claim",
|
||||||
|
},
|
||||||
|
// ── Events ────────────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/events",
|
||||||
|
core_method: "GET", core_path: "/events",
|
||||||
|
notes: "FUT events → Core events list",
|
||||||
|
},
|
||||||
|
// ── Matches / Squad Battles ───────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/squadbattle/opponent",
|
||||||
|
core_method: "GET", core_path: "/matches/opponent",
|
||||||
|
notes: "Squad battles opponent → Core match opponent generator",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/result",
|
||||||
|
core_method: "POST", core_path: "/matches/complete",
|
||||||
|
notes: "FUT match result submit → Core exactly-once match completion. \
|
||||||
|
Core requires a match_identity on the body; /matches/result was \
|
||||||
|
removed because it had no transaction and no idempotency key.",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/matches",
|
||||||
|
core_method: "GET", core_path: "/matches",
|
||||||
|
notes: "FUT match history → Core match list",
|
||||||
|
},
|
||||||
|
// ── SBC ──────────────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/sbc",
|
||||||
|
core_method: "GET", core_path: "/sbc",
|
||||||
|
notes: "FUT SBC list → Core SBC list",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/sbc/challenges",
|
||||||
|
core_method: "GET", core_path: "/sbc",
|
||||||
|
notes: "FUT SBC challenges → Core SBC list",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/sbc/submission",
|
||||||
|
core_method: "POST", core_path: "/sbc/submit",
|
||||||
|
notes: "FUT SBC submission → Core SBC submit",
|
||||||
|
},
|
||||||
|
// ── Draft ─────────────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/draft",
|
||||||
|
core_method: "GET", core_path: "/draft/squad",
|
||||||
|
notes: "FUT draft view → Core draft squad",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/draft/new",
|
||||||
|
core_method: "POST", core_path: "/draft/start",
|
||||||
|
notes: "FUT new draft → Core draft start",
|
||||||
|
},
|
||||||
|
// ── Division / Season ─────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/division/rivals",
|
||||||
|
core_method: "GET", core_path: "/division",
|
||||||
|
notes: "FUT Division Rivals status → Core division",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/division/rivals/claim",
|
||||||
|
core_method: "POST", core_path: "/rivals/claim-weekly",
|
||||||
|
notes: "FUT rivals weekly claim → Core rivals claim",
|
||||||
|
},
|
||||||
|
// ── FUT Champions ─────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/champs",
|
||||||
|
core_method: "GET", core_path: "/fut-champs",
|
||||||
|
notes: "FUT Champions status → Core fut-champs",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/champs/start",
|
||||||
|
core_method: "POST", core_path: "/fut-champs/start",
|
||||||
|
notes: "FUT Champions start week → Core fut-champs start",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/champs/history",
|
||||||
|
core_method: "GET", core_path: "/fut-champs/history",
|
||||||
|
notes: "FUT Champions history → Core history",
|
||||||
|
},
|
||||||
|
// ── Card Upgrades ─────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/chemistry",
|
||||||
|
core_method: "GET", core_path: "/chemistry-styles",
|
||||||
|
notes: "FUT chemistry styles → Core chemistry styles list",
|
||||||
|
},
|
||||||
|
// ── Notifications ─────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/notification",
|
||||||
|
core_method: "GET", core_path: "/notifications",
|
||||||
|
notes: "FUT notifications → Core notifications",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/notifications",
|
||||||
|
core_method: "GET", core_path: "/notifications",
|
||||||
|
notes: "FUT notifications (plural form) → Core notifications",
|
||||||
|
},
|
||||||
|
// ── Squad list ────────────────────────────────────────────────────────────
|
||||||
|
// ── Division / Season history / Leaderboard ───────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/division/history",
|
||||||
|
core_method: "GET", core_path: "/division/history",
|
||||||
|
notes: "FUT division history → Core season history",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/division/leaderboard",
|
||||||
|
core_method: "GET", core_path: "/division/leaderboard",
|
||||||
|
notes: "FUT division leaderboard → Core seeded NPC leaderboard",
|
||||||
|
},
|
||||||
|
// ── Market trade history ──────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/trade/history",
|
||||||
|
core_method: "GET", core_path: "/market/trade-history",
|
||||||
|
notes: "FUT trade history → Core market trade history",
|
||||||
|
},
|
||||||
|
// ── Daily check-in ────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/dailyObjective",
|
||||||
|
core_method: "GET", core_path: "/club/checkin",
|
||||||
|
notes: "FUT daily objective status → Core check-in status",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/dailyObjective/claim",
|
||||||
|
core_method: "POST", core_path: "/club/checkin",
|
||||||
|
notes: "FUT daily objective claim → Core check-in claim",
|
||||||
|
},
|
||||||
|
// ── Club milestones ───────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/milestones",
|
||||||
|
core_method: "GET", core_path: "/club/milestones",
|
||||||
|
notes: "FUT milestones → Core club milestones",
|
||||||
|
},
|
||||||
|
// ── Squad list ────────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/squad/list",
|
||||||
|
core_method: "GET", core_path: "/squads",
|
||||||
|
notes: "FUT squad list → Core all squads",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "DELETE", ea_path: "/ut/game/fut/squad/active",
|
||||||
|
core_method: "DELETE", core_path: "/squad",
|
||||||
|
notes: "FUT delete active squad → Core delete squad (best-effort)",
|
||||||
|
},
|
||||||
|
// ── Achievements / Trophies ───────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/trophies",
|
||||||
|
core_method: "GET", core_path: "/achievements",
|
||||||
|
notes: "FUT trophies → Core achievements",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/trophy",
|
||||||
|
core_method: "GET", core_path: "/achievements",
|
||||||
|
notes: "FUT trophy (singular) → Core achievements",
|
||||||
|
},
|
||||||
|
// ── Rivals extra endpoints ────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/rivals/rank",
|
||||||
|
core_method: "GET", core_path: "/division",
|
||||||
|
notes: "FUT rivals rank → Core division",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/rivals/result",
|
||||||
|
core_method: "POST", core_path: "/matches/complete",
|
||||||
|
notes: "FUT rivals match result → Core exactly-once match completion \
|
||||||
|
(body must carry a match_identity).",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/rivals/leaderboard",
|
||||||
|
core_method: "GET", core_path: "/statistics",
|
||||||
|
notes: "FUT rivals leaderboard → Core statistics (offline approximation)",
|
||||||
|
},
|
||||||
|
// ── Objectives sub-groups ─────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/objectives/group",
|
||||||
|
core_method: "GET", core_path: "/objectives",
|
||||||
|
notes: "FUT objectives group → Core objectives",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/objectives/daily",
|
||||||
|
core_method: "GET", core_path: "/objectives",
|
||||||
|
notes: "FUT daily objectives → Core objectives",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/objectives/weekly",
|
||||||
|
core_method: "GET", core_path: "/objectives",
|
||||||
|
notes: "FUT weekly objectives → Core objectives",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/objectives/group/claim",
|
||||||
|
core_method: "POST", core_path: "/objectives/claim",
|
||||||
|
notes: "FUT objectives group claim → Core objectives claim",
|
||||||
|
},
|
||||||
|
// ── Catalogue / Card search ───────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/catalogue/item",
|
||||||
|
core_method: "GET", core_path: "/cards",
|
||||||
|
notes: "FUT catalogue items → Core card catalogue",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/catalogue",
|
||||||
|
core_method: "GET", core_path: "/cards",
|
||||||
|
notes: "FUT catalogue → Core card catalogue",
|
||||||
|
},
|
||||||
|
// ── Store / Pricing ───────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/store/pricetiers",
|
||||||
|
core_method: "GET", core_path: "/packs/store",
|
||||||
|
notes: "FUT price tiers → Core pack store definitions",
|
||||||
|
},
|
||||||
|
// ── Consumables / Chemistry / Fitness ─────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/consumables",
|
||||||
|
core_method: "GET", core_path: "/chemistry-styles",
|
||||||
|
notes: "FUT consumables → Core chemistry styles (approximation)",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/fitness",
|
||||||
|
core_method: "GET", core_path: "/club",
|
||||||
|
notes: "FUT fitness apply → Core club (placeholder, fitness not tracked)",
|
||||||
|
},
|
||||||
|
// ── Loan items ────────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/loanitems",
|
||||||
|
core_method: "GET", core_path: "/collection",
|
||||||
|
notes: "FUT loan items → Core collection (client filters is_loan)",
|
||||||
|
},
|
||||||
|
// ── Customization / Kit ───────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/customization",
|
||||||
|
core_method: "GET", core_path: "/settings",
|
||||||
|
notes: "FUT customization → Core settings",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "PUT", ea_path: "/ut/game/fut/customization",
|
||||||
|
core_method: "PUT", core_path: "/settings",
|
||||||
|
notes: "FUT save customization → Core save settings",
|
||||||
|
},
|
||||||
|
// ── Active messages / MOTD ────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/activeMessage",
|
||||||
|
core_method: "GET", core_path: "/notifications",
|
||||||
|
notes: "FUT active messages → Core notifications (mapped to nearest equivalent)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Prefix mappings (dynamic path segments after a known prefix) ───────────────
|
||||||
|
|
||||||
|
struct PrefixRoute {
|
||||||
|
ea_method: &'static str,
|
||||||
|
ea_prefix: &'static str,
|
||||||
|
/// Generates the core path given the path suffix after ea_prefix.
|
||||||
|
core_path_fn: fn(&str) -> String,
|
||||||
|
notes: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix_routes() -> &'static [PrefixRoute] {
|
||||||
|
&[
|
||||||
|
// /ut/game/fut/draft/{session_id} → GET /draft/sessions/{session_id}
|
||||||
|
PrefixRoute {
|
||||||
|
ea_method: "GET",
|
||||||
|
ea_prefix: "/ut/game/fut/draft/",
|
||||||
|
core_path_fn: |suffix| format!("/draft/sessions/{suffix}"),
|
||||||
|
notes: "FUT draft session → Core draft session",
|
||||||
|
},
|
||||||
|
// /ut/game/fut/draft/{id}/pick → POST /draft/sessions/{id}/pick
|
||||||
|
PrefixRoute {
|
||||||
|
ea_method: "POST",
|
||||||
|
ea_prefix: "/ut/game/fut/draft/",
|
||||||
|
core_path_fn: |suffix| {
|
||||||
|
// suffix is "{id}/pick" or "{id}/abandon"
|
||||||
|
let parts: Vec<&str> = suffix.splitn(2, '/').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
format!("/draft/sessions/{}/{}", parts[0], parts[1])
|
||||||
|
} else {
|
||||||
|
format!("/draft/sessions/{suffix}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notes: "FUT draft pick/abandon → Core draft action",
|
||||||
|
},
|
||||||
|
// /ut/game/fut/champs/{session_id}/result → POST /fut-champs/{session_id}/result
|
||||||
|
PrefixRoute {
|
||||||
|
ea_method: "POST",
|
||||||
|
ea_prefix: "/ut/game/fut/champs/",
|
||||||
|
core_path_fn: |suffix| format!("/fut-champs/{suffix}"),
|
||||||
|
notes: "FUT Champions match result / claim → Core fut-champs action",
|
||||||
|
},
|
||||||
|
// /ut/game/fut/sbc/set/{set_id}/submission → POST /sbc/submit
|
||||||
|
PrefixRoute {
|
||||||
|
ea_method: "POST",
|
||||||
|
ea_prefix: "/ut/game/fut/sbc/set/",
|
||||||
|
core_path_fn: |_| "/sbc/submit".to_string(),
|
||||||
|
notes: "FUT SBC set submission → Core SBC submit",
|
||||||
|
},
|
||||||
|
// /ut/game/fut/trade/{trade_id} (DELETE) → DELETE /market/listings/{trade_id}
|
||||||
|
PrefixRoute {
|
||||||
|
ea_method: "DELETE",
|
||||||
|
ea_prefix: "/ut/game/fut/trade/",
|
||||||
|
core_path_fn: |suffix| format!("/market/listings/{suffix}"),
|
||||||
|
notes: "FUT cancel trade listing → Core delete market listing",
|
||||||
|
},
|
||||||
|
// /ut/game/fut/item/{owned_id} (DELETE) → DELETE /collection/{owned_id}
|
||||||
|
PrefixRoute {
|
||||||
|
ea_method: "DELETE",
|
||||||
|
ea_prefix: "/ut/game/fut/item/",
|
||||||
|
core_path_fn: |suffix| format!("/collection/{suffix}"),
|
||||||
|
notes: "FUT quick-sell item → Core delete owned card",
|
||||||
|
},
|
||||||
|
// /ut/game/fut/item/{owned_id} (PUT) → PUT /collection/{owned_id}/chemistry
|
||||||
|
PrefixRoute {
|
||||||
|
ea_method: "PUT",
|
||||||
|
ea_prefix: "/ut/game/fut/item/",
|
||||||
|
core_path_fn: |suffix| {
|
||||||
|
// suffix is just the owned_card_id; game sends chemistry style in body
|
||||||
|
format!("/collection/{suffix}/chemistry")
|
||||||
|
},
|
||||||
|
notes: "FUT apply chemistry style → Core update card chemistry",
|
||||||
|
},
|
||||||
|
// /ut/game/fut/squad/{id} (GET) → GET /squads/{id}
|
||||||
|
// Note: must come after exact matches for /squad/active, /squad/0, etc.
|
||||||
|
PrefixRoute {
|
||||||
|
ea_method: "GET",
|
||||||
|
ea_prefix: "/ut/game/fut/squad/",
|
||||||
|
core_path_fn: |suffix| {
|
||||||
|
// Skip known non-ID suffixes already handled as exact routes
|
||||||
|
match suffix {
|
||||||
|
"active" | "chemistry" | "list" => "/squad".to_string(),
|
||||||
|
_ => format!("/squads/{suffix}"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notes: "FUT squad by ID → Core squad by ID",
|
||||||
|
},
|
||||||
|
// /ut/game/fut/squad/{id} (DELETE) → DELETE /squads/{id}
|
||||||
|
PrefixRoute {
|
||||||
|
ea_method: "DELETE",
|
||||||
|
ea_prefix: "/ut/game/fut/squad/",
|
||||||
|
core_path_fn: |suffix| format!("/squads/{suffix}"),
|
||||||
|
notes: "FUT delete squad by ID → Core delete squad",
|
||||||
|
},
|
||||||
|
// /ut/game/fut/sbc/set/{set_id} (GET) → GET /sbc/{set_id}
|
||||||
|
PrefixRoute {
|
||||||
|
ea_method: "GET",
|
||||||
|
ea_prefix: "/ut/game/fut/sbc/set/",
|
||||||
|
core_path_fn: |suffix| format!("/sbc/{suffix}"),
|
||||||
|
notes: "FUT SBC set details → Core individual SBC",
|
||||||
|
},
|
||||||
|
// /ut/game/fut/store/pack/{pack_id}/open → POST /packs/open/{pack_id}
|
||||||
|
PrefixRoute {
|
||||||
|
ea_method: "POST",
|
||||||
|
ea_prefix: "/ut/game/fut/store/pack/",
|
||||||
|
core_path_fn: |suffix| {
|
||||||
|
// suffix: "{pack_id}/open" or just "{pack_id}"
|
||||||
|
let id = suffix.trim_end_matches("/open");
|
||||||
|
format!("/packs/open/{id}")
|
||||||
|
},
|
||||||
|
notes: "FUT open pack → Core open pack",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call.
|
/// Attempt to map an incoming FIFA 23 request to an OpenFUT Core call.
|
||||||
/// Returns Some(mapping) if the route is known, None otherwise.
|
/// Returns Some(mapping) if the route is known, None otherwise.
|
||||||
pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
|
pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
|
||||||
let m = method.to_uppercase();
|
let m = method.to_uppercase();
|
||||||
let p = path.trim_end_matches('/');
|
let p = path.trim_end_matches('/');
|
||||||
|
|
||||||
// These are speculative mappings based on common FUT API patterns.
|
// 1. Exact match
|
||||||
// They will be refined as reverse engineering progresses.
|
for r in EXACT {
|
||||||
let known: &[(&str, &str, CoreMapping)] = &[
|
if r.ea_method == m && r.ea_path == p {
|
||||||
// Auth
|
return Some(CoreMapping {
|
||||||
(
|
method: r.core_method,
|
||||||
"POST",
|
core_path: r.core_path.to_string(),
|
||||||
"/ut/auth",
|
notes: r.notes,
|
||||||
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 {
|
// 2. Prefix match — first prefix that fits wins
|
||||||
if *km == m && *kp == p {
|
for r in prefix_routes() {
|
||||||
return Some(mapping.clone());
|
if r.ea_method == m {
|
||||||
|
if let Some(suffix) = p.strip_prefix(r.ea_prefix) {
|
||||||
|
if !suffix.is_empty() {
|
||||||
|
let core_path = (r.core_path_fn)(suffix);
|
||||||
|
return Some(CoreMapping {
|
||||||
|
method: "GET", // overridden per-route below
|
||||||
|
core_path,
|
||||||
|
notes: r.notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as `map_to_core` but preserves the original HTTP method for prefix routes.
|
||||||
|
///
|
||||||
|
/// The simple `map_to_core` hardcodes "GET" for prefix routes because the method
|
||||||
|
/// is already known from the match condition. This helper re-derives it.
|
||||||
|
pub fn map_to_core_with_method(method: &str, path: &str) -> Option<CoreMapping> {
|
||||||
|
let m = method.to_uppercase();
|
||||||
|
let p = path.trim_end_matches('/');
|
||||||
|
|
||||||
|
for r in EXACT {
|
||||||
|
if r.ea_method == m && r.ea_path == p {
|
||||||
|
return Some(CoreMapping {
|
||||||
|
method: r.core_method,
|
||||||
|
core_path: r.core_path.to_string(),
|
||||||
|
notes: r.notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for r in prefix_routes() {
|
||||||
|
if r.ea_method == m {
|
||||||
|
if let Some(suffix) = p.strip_prefix(r.ea_prefix) {
|
||||||
|
if !suffix.is_empty() {
|
||||||
|
return Some(CoreMapping {
|
||||||
|
method: r.ea_method, // use the matched method
|
||||||
|
core_path: (r.core_path_fn)(suffix),
|
||||||
|
notes: r.notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +574,6 @@ pub fn map_to_core(method: &str, path: &str) -> Option<CoreMapping> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return a safe placeholder response for unknown endpoints.
|
/// 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 {
|
pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"UNKNOWN ENDPOINT: {} {} — returning placeholder",
|
"UNKNOWN ENDPOINT: {} {} — returning placeholder",
|
||||||
@@ -103,3 +587,225 @@ pub fn placeholder_response(method: &str, path: &str) -> serde_json::Value {
|
|||||||
"path": path,
|
"path": path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_known_auth_maps() {
|
||||||
|
let m = map_to_core("POST", "/ut/auth");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/auth/local");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_known_market_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/transfermarket");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/market");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_events_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/events");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/events");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_squad_put_maps() {
|
||||||
|
let m = map_to_core("PUT", "/ut/game/fut/squad/active");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().method, "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_returns_none() {
|
||||||
|
assert!(map_to_core("GET", "/ut/game/fut/some/unknown/path").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trailing_slash_trimmed() {
|
||||||
|
let m = map_to_core("POST", "/ut/auth/");
|
||||||
|
assert!(m.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── New exact mappings ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sbc_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/sbc");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/sbc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_division_rivals_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/division/rivals");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/division");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fut_champs_start_maps() {
|
||||||
|
let m = map_to_core("POST", "/ut/game/fut/champs/start");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/fut-champs/start");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_notifications_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/notification");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_put_maps() {
|
||||||
|
let m = map_to_core("PUT", "/ut/game/fut/user/settings");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_item_search_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/item/search");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/cards");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tradepile_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/trade/tradepile");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/market/my-listings");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Prefix-based mappings ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_draft_session_get_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/draft/abc-123");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_draft_pick_maps() {
|
||||||
|
let m = map_to_core("POST", "/ut/game/fut/draft/abc-123/pick");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123/pick");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_draft_abandon_maps() {
|
||||||
|
let m = map_to_core("POST", "/ut/game/fut/draft/abc-123/abandon");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/draft/sessions/abc-123/abandon");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_champs_result_maps() {
|
||||||
|
let m = map_to_core("POST", "/ut/game/fut/champs/sess-456/result");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/fut-champs/sess-456/result");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pack_open_maps() {
|
||||||
|
let m = map_to_core("POST", "/ut/game/fut/store/pack/pack-789/open");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/packs/open/pack-789");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quick_sell_maps() {
|
||||||
|
let m = map_to_core("DELETE", "/ut/game/fut/item/owned-111");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/collection/owned-111");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cancel_listing_maps() {
|
||||||
|
let m = map_to_core("DELETE", "/ut/game/fut/trade/trade-222");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/market/listings/trade-222");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_total_exact_routes_count() {
|
||||||
|
assert!(EXACT.len() >= 61, "expected at least 61 exact mappings, got {}", EXACT.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 22 new mappings ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_squad_list_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/squad/list");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/squads");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_squad_by_id_get_maps() {
|
||||||
|
let m = map_to_core_with_method("GET", "/ut/game/fut/squad/abc-squad-id");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/squads/abc-squad-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_squad_by_id_delete_maps() {
|
||||||
|
let m = map_to_core_with_method("DELETE", "/ut/game/fut/squad/abc-squad-id");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/squads/abc-squad-id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_item_put_chemistry_maps() {
|
||||||
|
let m = map_to_core_with_method("PUT", "/ut/game/fut/item/owned-999");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/collection/owned-999/chemistry");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sbc_set_get_maps() {
|
||||||
|
let m = map_to_core_with_method("GET", "/ut/game/fut/sbc/set/sbc-123");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/sbc/sbc-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trophies_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/trophies");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/achievements");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rivals_result_maps() {
|
||||||
|
let m = map_to_core("POST", "/ut/game/fut/rivals/result");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/matches/complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_catalogue_item_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/catalogue/item");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/cards");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_objectives_daily_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/objectives/daily");
|
||||||
|
assert!(m.is_some());
|
||||||
|
assert_eq!(m.unwrap().core_path, "/objectives");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_active_message_maps() {
|
||||||
|
let m = map_to_core("GET", "/ut/game/fut/activeMessage");
|
||||||
|
assert!(m.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+82
-14
@@ -6,31 +6,69 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
capture::{save_capture, CapturedRequest},
|
capture::{save_capture, CapturedRequest},
|
||||||
config::Config,
|
config::Config,
|
||||||
error::BridgeResult,
|
error::BridgeResult,
|
||||||
mapper::{map_to_core, placeholder_response},
|
mapper::{map_to_core, placeholder_response},
|
||||||
|
shaper::shape_response,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CAPTURE_DEDUP_SECS: u64 = 1;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ProxyState {
|
pub struct ProxyState {
|
||||||
pub config: Arc<Config>,
|
pub config: Arc<Config>,
|
||||||
pub http_client: reqwest::Client,
|
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 {
|
impl ProxyState {
|
||||||
pub fn new(config: Config) -> Self {
|
pub fn new(config: Config) -> Self {
|
||||||
|
let (capture_tx, _) = broadcast::channel(256);
|
||||||
Self {
|
Self {
|
||||||
config: Arc::new(config),
|
config: Arc::new(config),
|
||||||
http_client: reqwest::Client::builder()
|
http_client: reqwest::Client::builder()
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()
|
.build()
|
||||||
.expect("failed to build HTTP client"),
|
.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(
|
pub async fn catch_all_handler(
|
||||||
@@ -48,7 +86,6 @@ pub async fn catch_all_handler(
|
|||||||
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string()))
|
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Extract body via axum's built-in mechanism
|
|
||||||
let (_parts, body) = req.into_parts();
|
let (_parts, body) = req.into_parts();
|
||||||
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024)
|
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024)
|
||||||
.await
|
.await
|
||||||
@@ -71,7 +108,7 @@ pub async fn catch_all_handler(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut capture =
|
let mut capture =
|
||||||
CapturedRequest::new(&method, &path, query.as_deref(), headers, body_str.clone());
|
CapturedRequest::new(&method, &path, query.as_deref(), headers.clone(), body_str.clone());
|
||||||
|
|
||||||
let (response_body, status_code): (Value, u16) =
|
let (response_body, status_code): (Value, u16) =
|
||||||
if let Some(mapping) = map_to_core(&method, &path) {
|
if let Some(mapping) = map_to_core(&method, &path) {
|
||||||
@@ -85,12 +122,13 @@ pub async fn catch_all_handler(
|
|||||||
match forward_to_core(
|
match forward_to_core(
|
||||||
&state,
|
&state,
|
||||||
mapping.method,
|
mapping.method,
|
||||||
mapping.core_path,
|
&mapping.core_path,
|
||||||
body_str.as_deref(),
|
body_str.as_deref(),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((body, status)) => (body, status),
|
Ok((body, status)) => (shape_response(&mapping.core_path, body), status),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Core request failed: {e}");
|
tracing::error!("Core request failed: {e}");
|
||||||
(serde_json::json!({ "error": e.to_string() }), 502)
|
(serde_json::json!({ "error": e.to_string() }), 502)
|
||||||
@@ -104,13 +142,22 @@ pub async fn catch_all_handler(
|
|||||||
|
|
||||||
capture = capture.with_response(status_code, Some(response_body.to_string()));
|
capture = capture.with_response(status_code, Some(response_body.to_string()));
|
||||||
|
|
||||||
let captures_dir = state.config.captures_dir.clone();
|
// Deduplicate: skip saving if same method+path was saved within 1 second
|
||||||
let capture_clone = capture.clone();
|
let should_save = !is_duplicate(&state.dedup, &method, &path);
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = save_capture(&captures_dir, &capture_clone) {
|
if should_save {
|
||||||
tracing::warn!("Failed to save capture: {e}");
|
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 status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK);
|
||||||
let json_bytes =
|
let json_bytes =
|
||||||
@@ -125,21 +172,42 @@ pub async fn catch_all_handler(
|
|||||||
Ok(response)
|
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(
|
async fn forward_to_core(
|
||||||
state: &ProxyState,
|
state: &ProxyState,
|
||||||
method: &str,
|
method: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
body: Option<&str>,
|
body: Option<&str>,
|
||||||
|
incoming_headers: &[(String, String)],
|
||||||
) -> anyhow::Result<(Value, u16)> {
|
) -> anyhow::Result<(Value, u16)> {
|
||||||
let url = format!("{}{}", state.config.core_url, path);
|
let url = format!("{}{}", state.config.core_url, path);
|
||||||
let builder = match method {
|
let mut builder = match method {
|
||||||
"POST" => state.http_client.post(&url),
|
"POST" => state.http_client.post(&url),
|
||||||
"PUT" => state.http_client.put(&url),
|
"PUT" => state.http_client.put(&url),
|
||||||
"DELETE" => state.http_client.delete(&url),
|
"DELETE" => state.http_client.delete(&url),
|
||||||
_ => state.http_client.get(&url),
|
_ => state.http_client.get(&url),
|
||||||
};
|
};
|
||||||
|
|
||||||
let builder = if let Some(b) = body {
|
// 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
|
builder
|
||||||
.header("content-type", "application/json")
|
.header("content-type", "application/json")
|
||||||
.body(b.to_string())
|
.body(b.to_string())
|
||||||
|
|||||||
+315
-7
@@ -1,12 +1,26 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{
|
||||||
|
sse::{Event, KeepAlive, Sse},
|
||||||
|
IntoResponse, Json, Response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use tokio_stream::{wrappers::BroadcastStream, StreamExt};
|
||||||
|
|
||||||
use crate::{capture::load_all_captures, error::BridgeResult, proxy::ProxyState};
|
use crate::{
|
||||||
|
capture::load_all_captures,
|
||||||
|
error::{BridgeError, BridgeResult},
|
||||||
|
mapper::map_to_core_with_method,
|
||||||
|
proxy::ProxyState,
|
||||||
|
};
|
||||||
|
|
||||||
/// List all captured requests.
|
/// List all captured requests.
|
||||||
pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
let captures = load_all_captures(&state.config.captures_dir)
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
.map_err(crate::error::BridgeError::Internal)?;
|
|
||||||
|
|
||||||
let unknown: Vec<_> = captures
|
let unknown: Vec<_> = captures
|
||||||
.iter()
|
.iter()
|
||||||
@@ -20,10 +34,9 @@ pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List only unknown (unmapped) endpoints.
|
/// List only unknown (unmapped) endpoints, deduplicated by method+path.
|
||||||
pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
let captures = load_all_captures(&state.config.captures_dir)
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
.map_err(crate::error::BridgeError::Internal)?;
|
|
||||||
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
let unknown: Vec<Value> = captures
|
let unknown: Vec<Value> = captures
|
||||||
@@ -49,3 +62,298 @@ pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeRes
|
|||||||
"endpoints": unknown,
|
"endpoints": unknown,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wipe all capture files from disk.
|
||||||
|
pub async fn delete_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
|
let dir = std::path::Path::new(&state.config.captures_dir);
|
||||||
|
let mut deleted = 0u64;
|
||||||
|
if dir.exists() {
|
||||||
|
for entry in std::fs::read_dir(dir).map_err(BridgeError::Io)? {
|
||||||
|
let entry = entry.map_err(BridgeError::Io)?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||||
|
std::fs::remove_file(&path).map_err(BridgeError::Io)?;
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(
|
||||||
|
json!({ "deleted": deleted, "message": "capture folder cleared" }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-sent events stream: pushes each new capture to subscribed clients.
|
||||||
|
pub async fn get_captures_stream(
|
||||||
|
State(state): State<ProxyState>,
|
||||||
|
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||||
|
let rx = state.capture_tx.subscribe();
|
||||||
|
let stream = BroadcastStream::new(rx).filter_map(|result| {
|
||||||
|
result.ok().and_then(|capture| {
|
||||||
|
serde_json::to_string(&capture)
|
||||||
|
.ok()
|
||||||
|
.map(|json| Ok(Event::default().event("capture").data(json)))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Endpoint status summary: known/mapped/unknown across all captures.
|
||||||
|
pub async fn get_endpoint_status(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
|
use crate::mapper::map_to_core;
|
||||||
|
|
||||||
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
|
|
||||||
|
let mut seen: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
||||||
|
for c in &captures {
|
||||||
|
let key = format!("{} {}", c.method, c.path);
|
||||||
|
seen.entry(key).or_insert_with(|| {
|
||||||
|
let status = if c.mapped_to_core.is_some() {
|
||||||
|
"mapped"
|
||||||
|
} else if map_to_core(&c.method, &c.path).is_some() {
|
||||||
|
"known"
|
||||||
|
} else {
|
||||||
|
"unknown"
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"method": c.method,
|
||||||
|
"path": c.path,
|
||||||
|
"status": status,
|
||||||
|
"mapped_to": c.mapped_to_core,
|
||||||
|
"first_seen": c.timestamp,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut endpoints: Vec<Value> = seen.into_values().collect();
|
||||||
|
endpoints.sort_by(|a, b| {
|
||||||
|
a["path"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.cmp(b["path"].as_str().unwrap_or(""))
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"total_unique_endpoints": endpoints.len(),
|
||||||
|
"endpoints": endpoints,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query params for the diff endpoint.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DiffQuery {
|
||||||
|
pub a: String,
|
||||||
|
pub b: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff two captures by ID: shows what changed between them.
|
||||||
|
///
|
||||||
|
/// Usage: GET /_bridge/captures/diff?a=<uuid>&b=<uuid>
|
||||||
|
pub async fn get_capture_diff(
|
||||||
|
State(state): State<ProxyState>,
|
||||||
|
Query(params): Query<DiffQuery>,
|
||||||
|
) -> BridgeResult<Json<Value>> {
|
||||||
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
|
|
||||||
|
let find = |id: &str| -> Option<&crate::capture::CapturedRequest> {
|
||||||
|
captures.iter().find(|c| c.id == id)
|
||||||
|
};
|
||||||
|
|
||||||
|
let cap_a = find(¶ms.a)
|
||||||
|
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.a)))?;
|
||||||
|
let cap_b = find(¶ms.b)
|
||||||
|
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.b)))?;
|
||||||
|
|
||||||
|
// Structural JSON diff of request bodies
|
||||||
|
let body_diff = diff_json_strings(cap_a.body.as_deref(), cap_b.body.as_deref());
|
||||||
|
let resp_diff = diff_json_strings(
|
||||||
|
cap_a.response_body.as_deref(),
|
||||||
|
cap_b.response_body.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Header diff: keys present in one but not the other
|
||||||
|
let headers_a: std::collections::HashSet<String> =
|
||||||
|
cap_a.headers.iter().map(|(k, _)| k.clone()).collect();
|
||||||
|
let headers_b: std::collections::HashSet<String> =
|
||||||
|
cap_b.headers.iter().map(|(k, _)| k.clone()).collect();
|
||||||
|
|
||||||
|
let headers_only_in_a: Vec<_> = headers_a.difference(&headers_b).collect();
|
||||||
|
let headers_only_in_b: Vec<_> = headers_b.difference(&headers_a).collect();
|
||||||
|
|
||||||
|
// Headers present in both but with different values
|
||||||
|
let headers_changed: Vec<Value> = cap_a
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(k, va)| {
|
||||||
|
cap_b.headers.iter().find(|(kb, _)| kb == k).and_then(|(_, vb)| {
|
||||||
|
if va != vb {
|
||||||
|
Some(json!({ "header": k, "a": va, "b": vb }))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"a": { "id": cap_a.id, "timestamp": cap_a.timestamp, "method": cap_a.method, "path": cap_a.path },
|
||||||
|
"b": { "id": cap_b.id, "timestamp": cap_b.timestamp, "method": cap_b.method, "path": cap_b.path },
|
||||||
|
"method_changed": cap_a.method != cap_b.method,
|
||||||
|
"path_changed": cap_a.path != cap_b.path,
|
||||||
|
"status_a": cap_a.response_status,
|
||||||
|
"status_b": cap_b.response_status,
|
||||||
|
"status_changed": cap_a.response_status != cap_b.response_status,
|
||||||
|
"headers": {
|
||||||
|
"only_in_a": headers_only_in_a,
|
||||||
|
"only_in_b": headers_only_in_b,
|
||||||
|
"changed": headers_changed,
|
||||||
|
},
|
||||||
|
"body_diff": body_diff,
|
||||||
|
"response_diff": resp_diff,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shallow JSON diff of two optional JSON strings.
|
||||||
|
///
|
||||||
|
/// If both parse as JSON objects, lists keys that differ or are absent.
|
||||||
|
/// Falls back to string comparison if either is not valid JSON.
|
||||||
|
fn diff_json_strings(a: Option<&str>, b: Option<&str>) -> Value {
|
||||||
|
match (a, b) {
|
||||||
|
(None, None) => json!({ "same": true, "note": "both empty" }),
|
||||||
|
(Some(_), None) => json!({ "same": false, "note": "b has no body" }),
|
||||||
|
(None, Some(_)) => json!({ "same": false, "note": "a has no body" }),
|
||||||
|
(Some(a_str), Some(b_str)) => {
|
||||||
|
if a_str == b_str {
|
||||||
|
return json!({ "same": true });
|
||||||
|
}
|
||||||
|
// Try to parse as JSON objects for a structured diff
|
||||||
|
let va: Option<serde_json::Map<String, Value>> =
|
||||||
|
serde_json::from_str(a_str).ok();
|
||||||
|
let vb: Option<serde_json::Map<String, Value>> =
|
||||||
|
serde_json::from_str(b_str).ok();
|
||||||
|
|
||||||
|
match (va, vb) {
|
||||||
|
(Some(ma), Some(mb)) => {
|
||||||
|
let keys_a: std::collections::HashSet<&str> =
|
||||||
|
ma.keys().map(|s| s.as_str()).collect();
|
||||||
|
let keys_b: std::collections::HashSet<&str> =
|
||||||
|
mb.keys().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
let only_in_a: Vec<&str> = keys_a.difference(&keys_b).copied().collect();
|
||||||
|
let only_in_b: Vec<&str> = keys_b.difference(&keys_a).copied().collect();
|
||||||
|
let changed: Vec<Value> = keys_a
|
||||||
|
.intersection(&keys_b)
|
||||||
|
.filter_map(|k| {
|
||||||
|
let va = ma.get(*k)?;
|
||||||
|
let vb = mb.get(*k)?;
|
||||||
|
if va != vb {
|
||||||
|
Some(json!({ "key": k, "a": va, "b": vb }))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"same": false,
|
||||||
|
"type": "json_object_diff",
|
||||||
|
"keys_only_in_a": only_in_a,
|
||||||
|
"keys_only_in_b": only_in_b,
|
||||||
|
"keys_changed": changed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => json!({
|
||||||
|
"same": false,
|
||||||
|
"type": "string_diff",
|
||||||
|
"a_len": a_str.len(),
|
||||||
|
"b_len": b_str.len(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay a captured request against Core and return the result alongside the original response.
|
||||||
|
///
|
||||||
|
/// Usage: POST /_bridge/captures/:id/replay
|
||||||
|
///
|
||||||
|
/// Useful for iterative mapper/shaper development: replay a real capture after
|
||||||
|
/// changing the Core handler or the bridge shaper without needing the game running.
|
||||||
|
pub async fn post_replay_capture(
|
||||||
|
State(state): State<ProxyState>,
|
||||||
|
Path(capture_id): Path<String>,
|
||||||
|
) -> BridgeResult<Json<Value>> {
|
||||||
|
let captures =
|
||||||
|
load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
|
|
||||||
|
let capture = captures
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == capture_id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", capture_id))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mapping = map_to_core_with_method(&capture.method, &capture.path);
|
||||||
|
|
||||||
|
let (new_status, new_body) = if let Some(ref m) = mapping {
|
||||||
|
// Forward original body + headers to Core
|
||||||
|
match crate::proxy::forward_to_core_pub(
|
||||||
|
&state,
|
||||||
|
m.method,
|
||||||
|
&m.core_path,
|
||||||
|
capture.body.as_deref(),
|
||||||
|
&capture.headers,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((body, status)) => (
|
||||||
|
status,
|
||||||
|
crate::shaper::shape_response(&m.core_path, body),
|
||||||
|
),
|
||||||
|
Err(e) => (
|
||||||
|
502u16,
|
||||||
|
json!({ "error": format!("core forwarding failed: {e}") }),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
404,
|
||||||
|
json!({ "error": "no mapping found for this endpoint", "method": capture.method, "path": capture.path }),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse original response body for display
|
||||||
|
let original_body: Value = capture
|
||||||
|
.response_body
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"capture_id": capture_id,
|
||||||
|
"method": capture.method,
|
||||||
|
"path": capture.path,
|
||||||
|
"mapped_to": mapping.as_ref().map(|m| format!("{} {}", m.method, m.core_path)),
|
||||||
|
"original": {
|
||||||
|
"status": capture.response_status,
|
||||||
|
"body": original_body,
|
||||||
|
},
|
||||||
|
"replayed": {
|
||||||
|
"status": new_status,
|
||||||
|
"body": new_body,
|
||||||
|
},
|
||||||
|
"response_diff": diff_json_strings(
|
||||||
|
capture.response_body.as_deref(),
|
||||||
|
Some(&new_body.to_string()),
|
||||||
|
),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple HTML admin dashboard.
|
||||||
|
pub async fn get_admin_dashboard() -> impl IntoResponse {
|
||||||
|
let html = include_str!("../admin.html");
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||||
|
.body(axum::body::Body::from(html))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|||||||
+149
-1
@@ -1,6 +1,13 @@
|
|||||||
use axum::{http::StatusCode, Json};
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::proxy::ProxyState;
|
||||||
|
|
||||||
pub async fn get_health() -> (StatusCode, Json<Value>) {
|
pub async fn get_health() -> (StatusCode, Json<Value>) {
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
@@ -12,3 +19,144 @@ pub async fn get_health() -> (StatusCode, Json<Value>) {
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serve the TLS certificate as a downloadable PEM file.
|
||||||
|
///
|
||||||
|
/// Users navigate to `https://<bridge-ip>:8443/_bridge/cert.pem` in a browser,
|
||||||
|
/// download the file, and install it as a trusted CA in their OS or game console.
|
||||||
|
pub async fn get_cert(State(state): State<ProxyState>) -> Response {
|
||||||
|
match &state.cert_pem {
|
||||||
|
Some(pem) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/x-pem-file"),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"openfut-ca.pem\"",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
pem.as_ref().clone(),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
None => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({ "error": "TLS is not enabled; no certificate to download" })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Club management dashboard served at `/_bridge/dashboard`.
|
||||||
|
///
|
||||||
|
/// Injects the Core URL so client-side JS can call Core directly without going through the proxy.
|
||||||
|
pub async fn get_dashboard(State(state): State<ProxyState>) -> Response {
|
||||||
|
let template = include_str!("../dashboard.html");
|
||||||
|
let html = template.replace("{{CORE_URL}}", &state.config.core_url);
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||||
|
.body(axum::body::Body::from(html))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTML setup guide for connecting FIFA 23 to OpenFUT Bridge.
|
||||||
|
pub async fn get_guide(State(state): State<ProxyState>) -> Html<String> {
|
||||||
|
let host = state
|
||||||
|
.config
|
||||||
|
.listen_addr
|
||||||
|
.split(':')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("localhost");
|
||||||
|
let tls = state.config.tls_enabled;
|
||||||
|
let scheme = if tls { "https" } else { "http" };
|
||||||
|
let port = state.config.listen_addr.split(':').next_back().unwrap_or("8080");
|
||||||
|
let bridge_url = format!("{scheme}://{host}:{port}");
|
||||||
|
let core_url = &state.config.core_url;
|
||||||
|
|
||||||
|
let guide = format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>OpenFUT Bridge — Setup Guide</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: system-ui, sans-serif; max-width: 780px; margin: 0 auto; padding: 2rem; background: #0a0a0a; color: #e0e0e0; }}
|
||||||
|
h1 {{ color: #f5a623; }} h2 {{ color: #a0c4ff; border-bottom: 1px solid #333; padding-bottom: 4px; }}
|
||||||
|
code {{ background: #1c1c1c; padding: 2px 6px; border-radius: 3px; font-family: monospace; }}
|
||||||
|
pre {{ background: #1c1c1c; padding: 1rem; border-radius: 6px; overflow-x: auto; }}
|
||||||
|
.ok {{ color: #5fbb5f; }} .warn {{ color: #f5a623; }}
|
||||||
|
a {{ color: #a0c4ff; }}
|
||||||
|
.step {{ margin: 1.2rem 0; padding: 1rem; background: #141414; border-left: 3px solid #f5a623; border-radius: 0 6px 6px 0; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>OpenFUT Bridge — Setup Guide</h1>
|
||||||
|
<p><span class="ok">✓</span> Bridge is running at <code>{bridge_url}</code></p>
|
||||||
|
<p>Core API: <code>{core_url}</code></p>
|
||||||
|
|
||||||
|
<h2>How it works</h2>
|
||||||
|
<p>OpenFUT Bridge sits between FIFA 23 and EA's servers. It intercepts FUT API calls and
|
||||||
|
routes supported endpoints to OpenFUT Core (your local offline backend). Unsupported
|
||||||
|
endpoints return placeholder responses so the game stays stable.</p>
|
||||||
|
|
||||||
|
<h2>Step 1 — Redirect FIFA 23 traffic</h2>
|
||||||
|
<div class="step">
|
||||||
|
<p>Add these entries to your <strong>hosts file</strong> (requires admin/root):</p>
|
||||||
|
<pre>
|
||||||
|
# Point FIFA 23 FUT traffic to OpenFUT Bridge
|
||||||
|
127.0.0.1 fut.ea.com
|
||||||
|
127.0.0.1 utas.mob.v4.fut.ea.com
|
||||||
|
</pre>
|
||||||
|
<ul>
|
||||||
|
<li>Windows: <code>C:\Windows\System32\drivers\etc\hosts</code></li>
|
||||||
|
<li>macOS / Linux: <code>/etc/hosts</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Step 2 — Install the TLS certificate (HTTPS only)</h2>
|
||||||
|
{cert_section}
|
||||||
|
|
||||||
|
<h2>Step 3 — Launch FIFA 23</h2>
|
||||||
|
<div class="step">
|
||||||
|
<p>Start FIFA 23 and navigate to <strong>Ultimate Team</strong>. The bridge will capture
|
||||||
|
and proxy FUT API calls. Open <code>{bridge_url}/_bridge/admin</code> in your browser to
|
||||||
|
watch traffic in real time.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Supported endpoints</h2>
|
||||||
|
<p>See <code><a href="/_bridge/status">/_bridge/status</a></code> for a full list of mapped
|
||||||
|
and unknown endpoints seen so far.</p>
|
||||||
|
|
||||||
|
<h2>Troubleshooting</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Game shows "FUT servers unavailable":</strong> make sure the hosts file is saved and DNS is flushed (<code>ipconfig /flushdns</code> on Windows).</li>
|
||||||
|
<li><strong>Certificate error in browser:</strong> the self-signed cert must be trusted by the OS or the game's certificate store.</li>
|
||||||
|
<li><strong>Bridge not reaching Core:</strong> check that <code>openfut-core</code> is running and <code>CORE_URL</code> is set correctly (default <code>http://127.0.0.1:3000</code>).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="color:#555; font-size:0.85em; margin-top:3rem;">
|
||||||
|
OpenFUT is an <strong>offline</strong> replacement backend for legitimate FIFA 23 owners.
|
||||||
|
It does not connect to EA servers or enable online multiplayer.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
cert_section = if tls {
|
||||||
|
format!(
|
||||||
|
r#"<div class="step">
|
||||||
|
<p>Download and install the bridge's self-signed CA certificate so FIFA 23 trusts HTTPS connections:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Open <a href="/_bridge/cert.pem"><code>{bridge_url}/_bridge/cert.pem</code></a> and save it.</li>
|
||||||
|
<li><strong>Windows:</strong> double-click the file → "Install Certificate" → "Local Machine" → "Trusted Root Certification Authorities".</li>
|
||||||
|
<li><strong>macOS:</strong> double-click the file → Keychain Access → trust "Always".</li>
|
||||||
|
<li><strong>Linux:</strong> copy to <code>/usr/local/share/ca-certificates/</code> and run <code>update-ca-certificates</code>.</li>
|
||||||
|
</ol>
|
||||||
|
</div>"#
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"<div class=\"step\"><p class=\"warn\">TLS is disabled — set <code>TLS_ENABLED=true</code> to enable HTTPS (required for FIFA 23 interception).</p></div>".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Html(guide)
|
||||||
|
}
|
||||||
|
|||||||
+395
@@ -0,0 +1,395 @@
|
|||||||
|
//! Response shaper: transforms OpenFUT Core responses into the envelope format
|
||||||
|
//! FIFA 23 expects, based on the Core path that was called.
|
||||||
|
//!
|
||||||
|
//! These are best-guess wrappers derived from typical EA FUT API patterns.
|
||||||
|
//! They will be refined as real traffic captures come in.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Shape a Core JSON response into the format FIFA 23 expects for this path.
|
||||||
|
/// Returns the shaped response or the original unchanged if no shaping is defined.
|
||||||
|
pub fn shape_response(core_path: &str, core_response: Value) -> Value {
|
||||||
|
// Exact matches
|
||||||
|
match core_path {
|
||||||
|
"/auth/local" => return shape_auth_response(core_response),
|
||||||
|
"/profile" => return shape_profile_response(core_response),
|
||||||
|
"/club" => return shape_club_response(core_response),
|
||||||
|
"/squad" => return shape_squad_response(core_response),
|
||||||
|
"/market" => return shape_market_response(core_response),
|
||||||
|
"/market/my-listings" => return shape_my_listings_response(core_response),
|
||||||
|
"/packs" => return shape_packs_response(core_response),
|
||||||
|
"/collection" => return shape_collection_response(core_response),
|
||||||
|
"/cards" => return shape_cards_response(core_response),
|
||||||
|
"/objectives" => return shape_objectives_response(core_response),
|
||||||
|
"/notifications" => return shape_notifications_response(core_response),
|
||||||
|
"/division" => return shape_division_response(core_response),
|
||||||
|
"/statistics" => return shape_statistics_response(core_response),
|
||||||
|
"/sbc" => return shape_sbc_response(core_response),
|
||||||
|
"/fut-champs" => return shape_fut_champs_response(core_response),
|
||||||
|
"/chemistry-styles" => return shape_chemistry_styles_response(core_response),
|
||||||
|
"/achievements" => return shape_achievements_response(core_response),
|
||||||
|
"/squads" => return shape_squads_list_response(core_response),
|
||||||
|
"/packs/store" => return shape_pack_store_response(core_response),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix matches for parameterised paths
|
||||||
|
if core_path.starts_with("/packs/open/") {
|
||||||
|
return shape_pack_open_response(core_response);
|
||||||
|
}
|
||||||
|
if core_path.starts_with("/draft/sessions/") {
|
||||||
|
return shape_draft_response(core_response);
|
||||||
|
}
|
||||||
|
if core_path.starts_with("/fut-champs/") {
|
||||||
|
return shape_fut_champs_response(core_response);
|
||||||
|
}
|
||||||
|
if core_path.starts_with("/squads/") {
|
||||||
|
return shape_squads_list_response(core_response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown — pass through unchanged
|
||||||
|
core_response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core auth response in the FUT auth envelope format.
|
||||||
|
///
|
||||||
|
/// EA's `/ut/auth` returns a top-level object with a SID, PID, and persona.
|
||||||
|
/// The `sid` is used as a session token in subsequent `X-UT-SID` headers.
|
||||||
|
fn shape_auth_response(core: Value) -> Value {
|
||||||
|
let profile_id = core
|
||||||
|
.get("profile")
|
||||||
|
.and_then(|p| p.get("id"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
|
||||||
|
let display_name = core
|
||||||
|
.get("profile")
|
||||||
|
.and_then(|p| p.get("username"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("FUT Player");
|
||||||
|
|
||||||
|
let fake_sid = format!("ofut-sid-{}", Uuid::new_v4().simple());
|
||||||
|
let fake_pid = format!("1{}", &Uuid::new_v4().simple().to_string()[..9]);
|
||||||
|
let phishing_token = format!("ptkn-{}", Uuid::new_v4().simple());
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"sid": fake_sid,
|
||||||
|
"pid": {
|
||||||
|
"pidId": fake_pid,
|
||||||
|
"email": format!("{}@openfut.local", display_name.to_lowercase().replace(' ', "_")),
|
||||||
|
"emailStatus": "VERIFIED",
|
||||||
|
"strength": "STRONG",
|
||||||
|
"dob": "1990-01-01",
|
||||||
|
"country": "US",
|
||||||
|
},
|
||||||
|
"phishingToken": phishing_token,
|
||||||
|
"persona": {
|
||||||
|
"personaId": profile_id,
|
||||||
|
"displayName": display_name,
|
||||||
|
"isEmailVerified": true,
|
||||||
|
},
|
||||||
|
"userAccountInfo": {
|
||||||
|
"userAccountInfoSummary": [{
|
||||||
|
"personaId": profile_id,
|
||||||
|
"displayName": display_name,
|
||||||
|
"clubs": [{ "clubType": "FUT", "supported": true }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core profile response in the FUT user settings envelope.
|
||||||
|
fn shape_profile_response(core: Value) -> Value {
|
||||||
|
let profile = core.get("profile").cloned().unwrap_or(core.clone());
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"settings": {
|
||||||
|
"personaId": profile.get("id").cloned().unwrap_or(json!("unknown")),
|
||||||
|
"displayName": profile.get("username").cloned().unwrap_or(json!("FUT Player")),
|
||||||
|
"level": profile.get("level").cloned().unwrap_or(json!(1)),
|
||||||
|
"xp": profile.get("xp").cloned().unwrap_or(json!(0)),
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
"_raw": profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core club response in the FUT usermassinfo envelope.
|
||||||
|
fn shape_club_response(core: Value) -> Value {
|
||||||
|
let club = core.get("club").cloned().unwrap_or(core.clone());
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"pilluserinfo": {
|
||||||
|
"clubId": club.get("id").cloned().unwrap_or(json!("unknown")),
|
||||||
|
"credits": club.get("coins").cloned().unwrap_or(json!(0)),
|
||||||
|
"purchased": 0,
|
||||||
|
"sold": 0,
|
||||||
|
},
|
||||||
|
"userInfo": {
|
||||||
|
"duplicationInfo": { "isDuplicate": false },
|
||||||
|
"purchasedPacksInfo": { "premium": 0, "standard": 0 },
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
"_raw": club,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core squad response in the FUT squad envelope.
|
||||||
|
fn shape_squad_response(core: Value) -> Value {
|
||||||
|
let squad = core.get("squad").cloned().unwrap_or(core.clone());
|
||||||
|
let players = core.get("players").cloned().unwrap_or(json!([]));
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"squad": {
|
||||||
|
"squadId": squad.get("id").cloned().unwrap_or(json!("unknown")),
|
||||||
|
"name": squad.get("name").cloned().unwrap_or(json!("My Squad")),
|
||||||
|
"formation": squad.get("formation").cloned().unwrap_or(json!("4-4-2")),
|
||||||
|
"players": players,
|
||||||
|
"rating": 0,
|
||||||
|
"chemistry": 0,
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core market response in the FUT transfer market envelope.
|
||||||
|
fn shape_market_response(core: Value) -> Value {
|
||||||
|
let listings = core.get("listings").cloned().unwrap_or(json!([]));
|
||||||
|
let total = core
|
||||||
|
.get("total")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"auctionInfo": listings,
|
||||||
|
"bidTokens": {},
|
||||||
|
"credits": 0,
|
||||||
|
"currencies": [
|
||||||
|
{ "name": "COINS", "funds": 0, "finalFunds": 0 },
|
||||||
|
],
|
||||||
|
"duplicateItemIdList": [],
|
||||||
|
"itemData": [],
|
||||||
|
"total": total,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core packs response in the FUT pack store envelope.
|
||||||
|
fn shape_packs_response(core: Value) -> Value {
|
||||||
|
let packs = core.get("packs").cloned().unwrap_or(json!([]));
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"purchasedPacks": {
|
||||||
|
"packs": packs,
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_my_listings_response(core: Value) -> Value {
|
||||||
|
let listings = core.get("listings").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"tradepile": listings,
|
||||||
|
"credits": 0,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_collection_response(core: Value) -> Value {
|
||||||
|
let items = core.get("collection").cloned().unwrap_or(json!([]));
|
||||||
|
let total = core.get("total").cloned().unwrap_or(json!(0));
|
||||||
|
json!({
|
||||||
|
"itemData": items,
|
||||||
|
"total": total,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_cards_response(core: Value) -> Value {
|
||||||
|
let cards = core.get("cards").cloned().unwrap_or(json!([]));
|
||||||
|
let total = core.get("total").cloned().unwrap_or(json!(0));
|
||||||
|
json!({
|
||||||
|
"items": cards,
|
||||||
|
"total": total,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_objectives_response(core: Value) -> Value {
|
||||||
|
let objectives = core.get("objectives").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"objectives": objectives,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_notifications_response(core: Value) -> Value {
|
||||||
|
let notifications = core.get("notifications").cloned().unwrap_or(json!([]));
|
||||||
|
let count = core.get("count").cloned().unwrap_or(json!(0));
|
||||||
|
json!({
|
||||||
|
"notifications": notifications,
|
||||||
|
"count": count,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_division_response(core: Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"divisionRivalsInfo": {
|
||||||
|
"division": core.get("division").cloned().unwrap_or(json!(5)),
|
||||||
|
"seasonNumber": core.get("season_number").cloned().unwrap_or(json!(1)),
|
||||||
|
"points": core.get("season_points").cloned().unwrap_or(json!(0)),
|
||||||
|
"matchesPlayed": core.get("matches_played").cloned().unwrap_or(json!(0)),
|
||||||
|
"matchesRemaining": core.get("matches_remaining").cloned().unwrap_or(json!(10)),
|
||||||
|
"record": core.get("record").cloned().unwrap_or(json!({})),
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
"_raw": core,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_statistics_response(core: Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"stats": core,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_sbc_response(core: Value) -> Value {
|
||||||
|
let sbcs = core.get("sbcs").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"challenges": sbcs,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_pack_open_response(core: Value) -> Value {
|
||||||
|
let cards = core.get("cards").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"itemData": cards,
|
||||||
|
"duplicateItemIdList": [],
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_draft_response(core: Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"draft": core,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_fut_champs_response(core: Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"futChampions": core,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_chemistry_styles_response(core: Value) -> Value {
|
||||||
|
let styles = core.get("chemistry_styles").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"chemistryStyles": styles,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_achievements_response(core: Value) -> Value {
|
||||||
|
// FIFA 23 uses a "trophies" envelope with "trophyData" array
|
||||||
|
let achievements = core.get("achievements").cloned().unwrap_or(json!([]));
|
||||||
|
let earned = core.get("earned").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let total = core.get("total").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
json!({
|
||||||
|
"trophyData": achievements,
|
||||||
|
"totalEarned": earned,
|
||||||
|
"totalAvailable": total,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_squads_list_response(core: Value) -> Value {
|
||||||
|
// Wrap squads in EA's squad list envelope
|
||||||
|
let squads = if core.is_array() {
|
||||||
|
core
|
||||||
|
} else {
|
||||||
|
core.get("squads").cloned().unwrap_or(json!([]))
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"squads": squads,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_pack_store_response(core: Value) -> Value {
|
||||||
|
let packs = core.get("packs").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"purchasablePacks": packs,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_auth_response_has_required_fields() {
|
||||||
|
let core = json!({
|
||||||
|
"profile": { "id": "prof_001", "username": "TestPlayer" }
|
||||||
|
});
|
||||||
|
let shaped = shape_auth_response(core);
|
||||||
|
assert!(shaped.get("sid").is_some());
|
||||||
|
assert!(shaped.get("phishingToken").is_some());
|
||||||
|
assert!(shaped.get("persona").is_some());
|
||||||
|
assert_eq!(shaped["persona"]["displayName"], "TestPlayer");
|
||||||
|
assert_eq!(shaped["openfut"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_response_dispatches_correctly() {
|
||||||
|
let core = json!({ "profile": { "id": "x", "username": "u" } });
|
||||||
|
let shaped = shape_response("/auth/local", core);
|
||||||
|
assert!(shaped.get("sid").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_response_passthrough_for_unknown_path() {
|
||||||
|
let core = json!({ "foo": "bar" });
|
||||||
|
let shaped = shape_response("/some/unknown/path", core.clone());
|
||||||
|
assert_eq!(shaped, core);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_market_response_has_auction_info() {
|
||||||
|
let core = json!({ "listings": [{ "id": "l1" }], "total": 1 });
|
||||||
|
let shaped = shape_market_response(core);
|
||||||
|
assert!(shaped["auctionInfo"].as_array().is_some());
|
||||||
|
assert_eq!(shaped["total"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_achievements_response() {
|
||||||
|
let core = json!({ "achievements": [{ "id": "first_match" }], "earned": 1, "total": 18 });
|
||||||
|
let shaped = shape_response("/achievements", core);
|
||||||
|
assert!(shaped["trophyData"].is_array());
|
||||||
|
assert_eq!(shaped["totalEarned"], 1);
|
||||||
|
assert_eq!(shaped["totalAvailable"], 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_squads_list_response() {
|
||||||
|
let core = json!({ "squads": [{ "id": "sq-1", "name": "My Squad" }] });
|
||||||
|
let shaped = shape_response("/squads", core);
|
||||||
|
assert!(shaped["squads"].is_array());
|
||||||
|
assert_eq!(shaped["squads"].as_array().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_pack_store_response() {
|
||||||
|
let core = json!({ "packs": [{ "id": "gold-pack" }] });
|
||||||
|
let shaped = shape_response("/packs/store", core);
|
||||||
|
assert!(shaped["purchasablePacks"].is_array());
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tokio_rustls::TlsAcceptor;
|
||||||
|
|
||||||
|
/// Generate a self-signed certificate covering localhost and EA FUT hostnames.
|
||||||
|
/// Returns (cert_pem, key_pem) as byte vectors.
|
||||||
|
///
|
||||||
|
/// For FIFA 23 to connect, the cert must be installed as a trusted CA in the OS
|
||||||
|
/// trust store, OR certificate validation must be disabled in the game binary.
|
||||||
|
pub fn generate_self_signed_cert() -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
|
||||||
|
let subject_alt_names = vec![
|
||||||
|
"localhost".to_string(),
|
||||||
|
"127.0.0.1".to_string(),
|
||||||
|
"fut.ea.com".to_string(),
|
||||||
|
"utas.mob.v4.fut.ea.com".to_string(),
|
||||||
|
];
|
||||||
|
let cert = rcgen::generate_simple_self_signed(subject_alt_names)?;
|
||||||
|
let cert_pem = cert.serialize_pem()?.into_bytes();
|
||||||
|
let key_pem = cert.serialize_private_key_pem().into_bytes();
|
||||||
|
Ok((cert_pem, key_pem))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a TlsAcceptor from PEM-encoded certificate and private key bytes.
|
||||||
|
pub fn make_tls_acceptor(cert_pem: &[u8], key_pem: &[u8]) -> anyhow::Result<TlsAcceptor> {
|
||||||
|
use rustls::{Certificate, PrivateKey, ServerConfig};
|
||||||
|
|
||||||
|
let certs: Vec<Certificate> = rustls_pemfile::certs(&mut std::io::Cursor::new(cert_pem))?
|
||||||
|
.into_iter()
|
||||||
|
.map(Certificate)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut keys = rustls_pemfile::pkcs8_private_keys(&mut std::io::Cursor::new(key_pem))?;
|
||||||
|
if keys.is_empty() {
|
||||||
|
anyhow::bail!("no PKCS8 private keys found in key PEM");
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Arc::new(
|
||||||
|
ServerConfig::builder()
|
||||||
|
.with_safe_defaults()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(certs, PrivateKey(keys.remove(0)))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(TlsAcceptor::from(config))
|
||||||
|
}
|
||||||
+229
-1
@@ -1,4 +1,18 @@
|
|||||||
use openfut_bridge::{capture::CapturedRequest, mapper::map_to_core};
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
};
|
||||||
|
use openfut_bridge::{
|
||||||
|
capture::CapturedRequest,
|
||||||
|
config::Config,
|
||||||
|
mapper::{map_to_core, placeholder_response},
|
||||||
|
proxy::ProxyState,
|
||||||
|
routes,
|
||||||
|
};
|
||||||
|
use axum::routing::{any, delete, get, post};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
// ── Unit tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_known_endpoint_maps_to_core() {
|
fn test_known_endpoint_maps_to_core() {
|
||||||
@@ -46,3 +60,217 @@ fn test_capture_with_response() {
|
|||||||
assert_eq!(capture.response_status, Some(200));
|
assert_eq!(capture.response_status, Some(200));
|
||||||
assert!(capture.response_body.is_some());
|
assert!(capture.response_body.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── #24 Placeholder response format ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_placeholder_response_has_required_fields() {
|
||||||
|
let resp = placeholder_response("GET", "/ut/game/fut/unknown");
|
||||||
|
assert_eq!(resp["status"], "ok");
|
||||||
|
assert!(resp["openfut_note"].is_string());
|
||||||
|
assert_eq!(resp["method"], "GET");
|
||||||
|
assert_eq!(resp["path"], "/ut/game/fut/unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_placeholder_response_for_post() {
|
||||||
|
let resp = placeholder_response("POST", "/ut/auth/fifa");
|
||||||
|
assert_eq!(resp["status"], "ok");
|
||||||
|
assert_eq!(resp["method"], "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #25 Full HTTP integration test against bridge ────────────────────────────
|
||||||
|
|
||||||
|
fn build_test_app() -> axum::Router {
|
||||||
|
let cfg = Config {
|
||||||
|
listen_addr: "127.0.0.1:0".into(),
|
||||||
|
core_url: "http://127.0.0.1:9999".into(), // won't be reached in placeholder mode
|
||||||
|
captures_dir: "/tmp/openfut-test-captures".into(),
|
||||||
|
placeholder_mode: true,
|
||||||
|
tls_enabled: false,
|
||||||
|
};
|
||||||
|
let state = ProxyState::new(cfg);
|
||||||
|
|
||||||
|
axum::Router::new()
|
||||||
|
.route("/_bridge/health", get(routes::health::get_health))
|
||||||
|
.route("/_bridge/dashboard", get(routes::health::get_dashboard))
|
||||||
|
.route("/_bridge/captures", get(routes::admin::get_captures))
|
||||||
|
.route("/_bridge/captures", delete(routes::admin::delete_captures))
|
||||||
|
.route("/_bridge/unknown", get(routes::admin::get_unknown_endpoints))
|
||||||
|
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
|
||||||
|
.route("/_bridge/captures/:id/replay", post(routes::admin::post_replay_capture))
|
||||||
|
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bridge_health_endpoint() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(Request::builder().uri("/_bridge/health").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(json["status"], "ok");
|
||||||
|
assert_eq!(json["service"], "openfut-bridge");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bridge_placeholder_mode_returns_ok() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/ut/game/fut/completely/unknown/endpoint")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(json["status"], "ok");
|
||||||
|
assert!(json["openfut_note"].is_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bridge_captures_endpoint_returns_list() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/_bridge/captures")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["captures"].is_array());
|
||||||
|
assert!(json["total"].is_number());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bridge_delete_captures() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("DELETE")
|
||||||
|
.uri("/_bridge/captures")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["deleted"].is_number());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bridge_status_endpoint() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/_bridge/status")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["endpoints"].is_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 13 — Dashboard ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dashboard_returns_html() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/_bridge/dashboard")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let ct = resp.headers().get("content-type").unwrap().to_str().unwrap();
|
||||||
|
assert!(ct.contains("text/html"), "expected text/html, got {ct}");
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(html.contains("OpenFUT Dashboard"), "title missing");
|
||||||
|
assert!(html.contains("const CORE ="), "core URL injection missing");
|
||||||
|
// Placeholder URL injected in test mode
|
||||||
|
assert!(html.contains("127.0.0.1:9999"), "core URL not injected");
|
||||||
|
assert!(!html.contains("{{CORE_URL}}"), "template placeholder was not replaced");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dashboard_contains_key_sections() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/_bridge/dashboard")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
// Verify all major tab sections are present
|
||||||
|
assert!(html.contains("tab-club"), "club tab missing");
|
||||||
|
assert!(html.contains("tab-collection"), "collection tab missing");
|
||||||
|
assert!(html.contains("tab-packs"), "packs tab missing");
|
||||||
|
assert!(html.contains("tab-objectives"), "objectives tab missing");
|
||||||
|
assert!(html.contains("tab-division"), "division tab missing");
|
||||||
|
assert!(html.contains("tab-champs"), "fut champs tab missing");
|
||||||
|
assert!(html.contains("tab-squad"), "squad tab missing");
|
||||||
|
assert!(html.contains("tab-draft"), "draft tab missing");
|
||||||
|
assert!(html.contains("tab-market"), "market tab missing");
|
||||||
|
assert!(html.contains("tab-events"), "events tab missing");
|
||||||
|
assert!(html.contains("tab-matches"), "matches tab missing");
|
||||||
|
assert!(html.contains("tab-store"), "pack store tab missing");
|
||||||
|
assert!(html.contains("tab-sbc"), "sbc tab missing");
|
||||||
|
assert!(html.contains("tab-statistics"), "statistics tab missing");
|
||||||
|
assert!(html.contains("tab-catalog"), "card catalog tab missing");
|
||||||
|
assert!(html.contains("tab-settings"), "settings tab missing");
|
||||||
|
assert!(html.contains("tab-notifications"), "notifications tab missing");
|
||||||
|
assert!(html.contains("tab-achievements"), "achievements tab missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tls_cert_generation() {
|
||||||
|
let result = openfut_bridge::tls::generate_self_signed_cert();
|
||||||
|
assert!(result.is_ok(), "cert generation failed: {:?}", result.err());
|
||||||
|
let (cert_pem, key_pem) = result.unwrap();
|
||||||
|
assert!(!cert_pem.is_empty());
|
||||||
|
assert!(!key_pem.is_empty());
|
||||||
|
// Verify the PEM blocks are well-formed
|
||||||
|
let cert_str = String::from_utf8(cert_pem).unwrap();
|
||||||
|
let key_str = String::from_utf8(key_pem).unwrap();
|
||||||
|
assert!(cert_str.contains("BEGIN CERTIFICATE"));
|
||||||
|
assert!(key_str.contains("PRIVATE KEY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tls_acceptor_construction() {
|
||||||
|
let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert().unwrap();
|
||||||
|
let acceptor = openfut_bridge::tls::make_tls_acceptor(&cert_pem, &key_pem);
|
||||||
|
assert!(acceptor.is_ok(), "acceptor construction failed: {:?}", acceptor.err());
|
||||||
|
}
|
||||||
|
|||||||
Generated
+196
@@ -0,0 +1,196 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iced-x86"
|
||||||
|
version = "1.21.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c447cff8c7f384a7d4f741cfcff32f75f3ad02b406432e8d6c878d56b1edf6b"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "protossl-scan"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"iced-x86",
|
||||||
|
"memchr",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.118"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||||
|
dependencies = [
|
||||||
|
"windows-result",
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[package]
|
||||||
|
name = "protossl-scan"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Task 1: read-only memory scan to confirm FIFA 23 uses EA DirtySDK / ProtoSSL"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "protossl-scan"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# SIMD-accelerated substring search. Orders of magnitude faster than a naive
|
||||||
|
# byte-by-byte scan when sweeping the game's multi-GB address space.
|
||||||
|
memchr = "2"
|
||||||
|
|
||||||
|
# Pure-Rust x86/x64 disassembler. Lets us read the game's own code around a
|
||||||
|
# code anchor (clean-room: we only disassemble the binary the user owns).
|
||||||
|
iced-x86 = "1"
|
||||||
|
|
||||||
|
# The official Microsoft `windows` crate gives us safe-ish bindings to the
|
||||||
|
# Win32 APIs we need. We only turn on the few feature modules we use, to keep
|
||||||
|
# build times and binary size down.
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_System_Memory",
|
||||||
|
"Win32_System_Diagnostics_ToolHelp",
|
||||||
|
"Win32_System_Diagnostics_Debug",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# This tool lives inside the openfut-bridge repo but is its OWN crate. Declaring
|
||||||
|
# an empty [workspace] here makes Cargo treat this directory as a standalone
|
||||||
|
# workspace root, so it does not try to attach to the openfut-bridge package
|
||||||
|
# in the parent directory (which would error).
|
||||||
|
[workspace]
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user