Compare commits
41 Commits
a826e5f7d3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3649c3d6f0 | |||
| 61b138db89 | |||
| 80107b7800 | |||
| e4108fcff8 | |||
| 8f5b6b1d59 | |||
| 105cca1cbe | |||
| 404064f0bb | |||
| 6bd1241bdf | |||
| ba7bb4f4be | |||
| c8f6adfb70 | |||
| 24c1925b5f | |||
| feb03fec78 | |||
| 0138a39f3e | |||
| 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
+374
-7
@@ -2,6 +2,17 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -78,7 +89,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 +133,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"
|
||||||
@@ -176,6 +193,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -202,6 +229,31 @@ 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 = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 +377,27 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 +428,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 +530,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 +554,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 +565,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"
|
||||||
@@ -637,6 +744,15 @@ dependencies = [
|
|||||||
"hashbrown",
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -763,6 +879,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"
|
||||||
@@ -782,17 +904,26 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|||||||
name = "openfut-bridge"
|
name = "openfut-bridge"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"bytes",
|
"bytes",
|
||||||
"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 +996,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 +1053,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 +1083,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 +1127,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 +1146,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 +1155,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 +1207,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 +1265,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 +1421,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 +1495,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 +1530,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 +1597,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 +1632,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"
|
||||||
@@ -1454,12 +1760,30 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
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 +1808,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",
|
||||||
@@ -1502,6 +1826,12 @@ version = "0.2.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1582,6 +1912,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 +2163,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"
|
||||||
|
|||||||
+25
-1
@@ -15,11 +15,25 @@ 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"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "openfut-bridge-xref"
|
||||||
|
path = "src/bin/xref.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "openfut-poke"
|
||||||
|
path = "src/bin/openfut-poke.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 +41,20 @@ 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"] }
|
||||||
|
aes = "0.8"
|
||||||
|
|
||||||
[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,139 @@
|
|||||||
|
# Handoff — LSX handshake complete end-to-end
|
||||||
|
|
||||||
|
**Date:** 2026-07-02
|
||||||
|
**Scope of the session:** `openfut-bridge` (the delivery test) that expanded into
|
||||||
|
`openfut-launcher/openfut-hook` (the fix). Nothing was committed — everything below is
|
||||||
|
uncommitted working-tree state.
|
||||||
|
|
||||||
|
Read `docs/connection-gate-findings.md` (bottom two sections) for the full technical
|
||||||
|
narrative. This file is the short "where we are / what to do next" version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
FIFA 23 now completes the **LSX handshake and holds a sustained encrypted session
|
||||||
|
against our own bridge**, live — the first EA-side step our code services end-to-end.
|
||||||
|
Three changes made it work:
|
||||||
|
|
||||||
|
1. **Hook redirect** — FIFA's `connect(127.0.0.1:3216)` is rewritten to `:3217` by our
|
||||||
|
hook. The existing in-process offline shim keys on **port 3216 specifically**, so a
|
||||||
|
different port slips past it and lands on our host bridge (loopback is shared with
|
||||||
|
the Wine/Proton prefix — same netns).
|
||||||
|
2. **AES key** = `000102030405060708090a0b0c0d0e0f`. The `[0,1,2,…,15]` sequence that
|
||||||
|
looked like a placeholder is the actual session key; confirmed by round-tripping
|
||||||
|
against a captured live session.
|
||||||
|
3. **Session-seed fix** — `compute_seed()` must take the **raw ASCII bytes** of the
|
||||||
|
first two chars of our response hex, not parse them as a hex pair.
|
||||||
|
|
||||||
|
Last live run: FIFA held one `ESTAB` connection to the bridge and drove 20 EbisuSDK
|
||||||
|
requests with **no crash**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current deployment state (IMPORTANT)
|
||||||
|
|
||||||
|
- **The redirect hook is currently deployed** as `/mnt/games/FIFA 23/version.dll`
|
||||||
|
(md5 `dab3952809c24122af228a3201b9357b`). While this is in place, FIFA routes LSX to
|
||||||
|
our bridge and **requires the bridge running on 3217** or LSX will not complete.
|
||||||
|
- **Backup of the previous hook:**
|
||||||
|
`/mnt/games/FIFA 23/version.dll.pre-lsx-redirect.bak` (md5 `fb7a5aae…`).
|
||||||
|
- **To restore the previous behavior for normal offline play:**
|
||||||
|
```bash
|
||||||
|
cp "/mnt/games/FIFA 23/version.dll.pre-lsx-redirect.bak" "/mnt/games/FIFA 23/version.dll"
|
||||||
|
```
|
||||||
|
- The bridge process from this session may or may not still be running. Check:
|
||||||
|
`pgrep -a openfut-bridge` / `ss -tlnp | grep 3217`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Uncommitted changes (nothing is committed)
|
||||||
|
|
||||||
|
### `openfut-bridge`
|
||||||
|
- `src/lsx.rs`:
|
||||||
|
- **Seed fix** in `compute_seed()` (the load-bearing change).
|
||||||
|
- AES-128 hand-roll replaced with the `aes` crate (+ a FIPS-197 unit test
|
||||||
|
`aes128_matches_fips197_and_round_trips`).
|
||||||
|
- A debug log line dumping raw session ciphertext (used during verification against
|
||||||
|
the capture; harmless to keep, or remove).
|
||||||
|
- `Cargo.toml` / `Cargo.lock`: added `aes = "0.8"`.
|
||||||
|
- `docs/connection-gate-findings.md`: corrected classification + new "LSX complete"
|
||||||
|
section.
|
||||||
|
- `docs/HANDOFF-lsx-2026-07-02.md`: this file.
|
||||||
|
- `captures/lsx-handshake-capture-2026-07-02.log`: a captured live handshake plus the
|
||||||
|
raw session ciphertext, kept as a reference sample for verifying the implementation.
|
||||||
|
|
||||||
|
### `openfut-launcher/openfut-hook`
|
||||||
|
- `src/connect_hook.rs`: added a `PORT_LSX_NBO (3216) → PORT_LSX_TARGET_NBO (3217)` arm
|
||||||
|
in `redirect_if_ea`, plus result logging on the redirect path.
|
||||||
|
|
||||||
|
> There is **other** pre-existing uncommitted work in these trees from before this
|
||||||
|
> session (e.g. bridge TLS/proxy changes, core changes). Do not sweep it into a commit
|
||||||
|
> without checking with the user. Confirm scope before committing anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to reproduce the working LSX session
|
||||||
|
|
||||||
|
1. **Build the hook** (redirect):
|
||||||
|
```bash
|
||||||
|
cd openfut-launcher/openfut-hook
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu
|
||||||
|
```
|
||||||
|
2. **Deploy it** (back up first — already backed up once):
|
||||||
|
```bash
|
||||||
|
SRC=openfut-launcher/openfut-hook/target/x86_64-pc-windows-gnu/release/openfut_hook.dll
|
||||||
|
cp -f "$SRC" "/mnt/games/FIFA 23/version.dll"
|
||||||
|
```
|
||||||
|
3. **Build + run the bridge on 3217:**
|
||||||
|
```bash
|
||||||
|
cd openfut-bridge && cargo build --release
|
||||||
|
LSX_ADDR=127.0.0.1:3217 RUST_LOG=openfut_bridge=debug \
|
||||||
|
./target/release/openfut-bridge > /tmp/bridge-lsx.log 2>&1 &
|
||||||
|
```
|
||||||
|
4. **Launch FIFA** (user does this — umu/Steam via
|
||||||
|
`/home/alex/Desktop/launch-fifa23-live-editor.sh`).
|
||||||
|
5. **Verify:** `ss -tnp | grep 3217` should show `ESTAB FIFA23.exe ↔ openfut-bridge`,
|
||||||
|
and `/tmp/bridge-lsx.log` should show multiple `LSX: dispatch …` lines (not one
|
||||||
|
garbage message).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key facts / constants
|
||||||
|
|
||||||
|
| Thing | Value |
|
||||||
|
|---|---|
|
||||||
|
| EA App LSX port (FIFA connects here) | `127.0.0.1:3216` |
|
||||||
|
| Redirect target (our bridge LSX) | `127.0.0.1:3217` (`LSX_ADDR`) |
|
||||||
|
| LSX AES-128-ECB key | `000102030405060708090a0b0c0d0e0f` |
|
||||||
|
| Our greeting key (challenge plaintext) | `cacf897a20b6d612ad0c05e011df52bb` |
|
||||||
|
| Session seed rule | first **two ASCII chars** of our `ChallengeAccepted` response hex, as raw bytes (e.g. `"0f…"` → `0x3066`), NOT the parsed hex pair |
|
||||||
|
| Existing shim interception | in-process, keyed on **port 3216**; loopback is shared (FIFA netns == host netns) |
|
||||||
|
|
||||||
|
Small verification helpers (`keyscan.py`, `seedscan.py`) live in the session
|
||||||
|
scratchpad — trivial to recreate from `connection-gate-findings.md` if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next steps (in priority order)
|
||||||
|
|
||||||
|
1. **Add a `GetGameInfo GameInfoId=…` handler** to the bridge dispatcher (`src/lsx.rs`
|
||||||
|
`dispatch()`). It currently falls back to the generic `<Ok/>` ("unknown request
|
||||||
|
type"). FIFA tolerated that, but may need real responses to progress. This is the
|
||||||
|
immediate iteration surface now that we can see FIFA's real requests.
|
||||||
|
2. **Watch how far FIFA advances** with the LSX session satisfied — the next step is
|
||||||
|
the Blaze/online layer (the old M2 `ONLINE_STATUS_EVENT` blocker). With LSX now
|
||||||
|
handled by our bridge, that picture may change; re-evaluate M2 with FIFA actually
|
||||||
|
reaching it.
|
||||||
|
3. **Port the seed fix into the hook's own `src/lsx.rs`** if the in-process LSX path is
|
||||||
|
ever revived — it has the same `compute_seed` bug.
|
||||||
|
4. **Decide on the redirect's permanence** — right now it's a manual DLL swap. If
|
||||||
|
LSX-via-bridge becomes the intended path, the launcher's setup/deploy flow should
|
||||||
|
own it.
|
||||||
|
|
||||||
|
## Open question for the user
|
||||||
|
|
||||||
|
We never captured what FIFA showed **on screen** during the successful run (advanced to
|
||||||
|
a menu? sat at "connecting"? waited on `GetGameInfo`?). Ask, or observe on the next
|
||||||
|
launch — it tells you whether LSX is fully satisfied or whether `GetGameInfo` is the
|
||||||
|
next thing to address.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Blaze Handshake — Reference & Milestone Map
|
||||||
|
|
||||||
|
This is the reference for the **blaze_brain** arc: emulating the EA Blaze backend
|
||||||
|
well enough for FIFA 23 FUT to get past *"FUT is connecting to the EA Servers…"*.
|
||||||
|
It replaces the earlier in-process *forcing* arc, which concluded that the game's
|
||||||
|
online dispatch scaffold cannot be filled by forcing local state — it fills only when
|
||||||
|
a real Blaze exchange happens.
|
||||||
|
|
||||||
|
Every claim below is tagged so that on a re-read you can see instantly what is
|
||||||
|
grounded in our own reverse engineering versus what is general public knowledge of
|
||||||
|
Blaze's protocol shape versus what still needs a capture:
|
||||||
|
|
||||||
|
- **CONFIRMED** — established by our own RE in this project (live memory reads,
|
||||||
|
objdump, hook logs).
|
||||||
|
- **SHAPE** — the general shape of EA's Blaze protocol from public community
|
||||||
|
knowledge. The *ordering* and *purpose* are reliable; exact numeric IDs and field
|
||||||
|
tags for FIFA 23's specific Blaze version are **not** implied by a SHAPE tag.
|
||||||
|
- **UNKNOWN** — must be captured from the live client before it can be implemented.
|
||||||
|
|
||||||
|
> Clean-room discipline: nothing here derives from leaked EA source. The Blaze
|
||||||
|
> protocol shape is reconstructed from public community reverse-engineering of EA
|
||||||
|
> titles and our own observations of this client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three layers that all say "connecting to EA"
|
||||||
|
|
||||||
|
"Connecting to EA" is not one thing. Three distinct systems wear that label, stacked
|
||||||
|
on top of each other, and it is easy to confuse a stall in one for a stall in
|
||||||
|
another. Understanding which layer the FUT spinner belongs to is the whole point of
|
||||||
|
this document.
|
||||||
|
|
||||||
|
**Layer 0 — LSX (Local Socket eXchange).** `CONFIRMED working.` This is the
|
||||||
|
Origin/EA-App desktop-client local API: entitlements, the local login handshake, and
|
||||||
|
`GetAuthCode`. It is *not* Blaze — it is a localhost protocol the game uses to talk to
|
||||||
|
whatever is standing in for the EA App. Our native bridge answers it on ports
|
||||||
|
3216/3217, and answering it is what got the client to the main menu with a working
|
||||||
|
first-party login. Layer 0's output that matters to Blaze is a **Nucleus auth code /
|
||||||
|
access token**: the credential the client will later present to the Blaze
|
||||||
|
authentication component.
|
||||||
|
|
||||||
|
**Layer 1 — the Blaze core session.** *This is the wall.* Blaze is EA's online backend
|
||||||
|
framework. Reaching it is a multi-step handshake (detailed below) that starts by
|
||||||
|
asking a *redirector* where the real server is, connecting there over TLS, then
|
||||||
|
negotiating configuration, authenticating with the Layer-0 auth code, and finally
|
||||||
|
bringing the session fully online. Until this completes, nothing above it can work.
|
||||||
|
Everything we have observed says the client currently never even *starts* this — see
|
||||||
|
the transport finding below.
|
||||||
|
|
||||||
|
**Layer 2 — FUT / UTAS.** `SHAPE.` FIFA Ultimate Team specifically is served by the
|
||||||
|
EASFC Blaze component *and* by UTAS, a separate HTTPS REST service
|
||||||
|
(`utas.*.fut.ea.com`). Once a Blaze session exists, the FUT client authenticates to
|
||||||
|
UTAS (`POST /ut/auth`) with the session credential to obtain a FUT session id, and
|
||||||
|
only then does it load squad/club data. The FUT spinner in the screenshot lives at the
|
||||||
|
*top* of this stack, but it cannot clear until Layer 1 is answered — so "minimal Blaze
|
||||||
|
handshake" means **Layer 1**, and Layer 2 is a second, HTTP-shaped project that comes
|
||||||
|
after.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where the client currently stalls — the transport finding
|
||||||
|
|
||||||
|
`CONFIRMED.` Across a full menu-plus-FUT-attempt session we observed **zero
|
||||||
|
`getaddrinfo` calls and zero outbound sockets**, and — via the `ELEM_WATCH` probe — the
|
||||||
|
game's per-connection message-handler dispatch container at `element[0]+0x40` stayed a
|
||||||
|
clean, empty, default-constructed vector the entire time, including while the FUT
|
||||||
|
spinner was on screen. `connectState.ctor` fired (the client builds connect-state
|
||||||
|
objects) but registered nothing into that container.
|
||||||
|
|
||||||
|
The mechanical reading: **that container is populated by an actual Blaze message
|
||||||
|
exchange** — handlers register as component notifications arrive during session
|
||||||
|
bring-up (Layer-1 step 6 below). No Blaze reply → no handler registration → empty
|
||||||
|
container → spinner spins forever. The spinner *is* the Layer-1 wall.
|
||||||
|
|
||||||
|
This leaves two possible transport situations, and **Milestone 0 exists solely to
|
||||||
|
decide which one we are in** (see the Milestones section):
|
||||||
|
|
||||||
|
- **(a)** the client expects Blaze on a hardcoded local endpoint and would dial it if a
|
||||||
|
listener were there, or
|
||||||
|
- **(b)** the client only dials the Blaze redirector after an in-process bootstrap (the
|
||||||
|
dial handler `0x144f4d360` `CONFIRMED`) fires — in which case the transport wall and
|
||||||
|
the bootstrap wall are the same wall.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer 1 — the minimal Blaze handshake ordering
|
||||||
|
|
||||||
|
Blaze is a framed binary protocol. Each message is a header — length, component id,
|
||||||
|
command id, message type (request / response / notification / error), error code, and
|
||||||
|
a message/sequence id — followed by a **TDF**-encoded body (EA's tag/type/value binary
|
||||||
|
serialization). `SHAPE.` The exact numeric component/command ids and TDF field tags for
|
||||||
|
FIFA 23's Blaze version are **UNKNOWN** until captured.
|
||||||
|
|
||||||
|
These are the six steps a responder must satisfy, **in order**, to bring a session
|
||||||
|
online:
|
||||||
|
|
||||||
|
1. **`Redirector::getServerInstance`** `SHAPE` — the client asks the redirector for the
|
||||||
|
address of the real Blaze server, naming the FIFA 23 service/SKU. The responder
|
||||||
|
returns a `ServerInstanceInfo` TDF containing a **host:port** (point it at
|
||||||
|
ourselves) plus an SSL flag. Without a valid address the client has nowhere to go.
|
||||||
|
2. **connect + TLS** `CONFIRMED (bypass)` — the client opens a TLS connection to the
|
||||||
|
returned address. Our ProtoSSL/cert-verify bypass is already in place, so our
|
||||||
|
listener can terminate TLS.
|
||||||
|
3. **`Util::preAuth`** `SHAPE` — the client sends its config/version/locale; the
|
||||||
|
responder returns server config: the **component list**, ping-site list, telemetry
|
||||||
|
settings (disable), and a misc config bundle. The client uses the component list to
|
||||||
|
know what exists; an empty or malformed response here stalls it.
|
||||||
|
4. **`Authentication::login`** `CONFIRMED (GetAuthCode feeds here)` + `SHAPE` — the
|
||||||
|
client authenticates, presenting the **Nucleus auth code from Layer 0** (SSO). The
|
||||||
|
responder returns the session: **BlazeId, session key, persona, entitlements**. A
|
||||||
|
failure here is what surfaces as *"EA Servers are down."*
|
||||||
|
5. **`Util::postAuth`** `SHAPE` — after auth, the client finalizes the session; the
|
||||||
|
responder returns post-auth config (UserManager, association lists, telemetry,
|
||||||
|
ticker/PIN).
|
||||||
|
6. **`UserSessions::updateNetworkInfo` / `updateHardwareFlags`, then a
|
||||||
|
`UserSessionExtendedDataUpdate` notification** `CONFIRMED (container is
|
||||||
|
Blaze-downstream)` + `SHAPE (which command)` — the client publishes its network/
|
||||||
|
hardware info and the server pushes extended session data. **The client flips to
|
||||||
|
"online" on receipt of the extended-data notification** — and this is the exact
|
||||||
|
moment the `element[0]+0x40` container we watched would begin filling, as handlers
|
||||||
|
register for the arriving component notifications.
|
||||||
|
|
||||||
|
At step 6 the top-level "connected to EA" clears, and Layer 2 (UTAS) can begin.
|
||||||
|
|
||||||
|
### Why step 6 is the Milestone-1 success signal
|
||||||
|
|
||||||
|
The container-fill and the "online" flip are the *same event*: handlers register
|
||||||
|
because Blaze notifications arrived. That gives blaze_brain a precise, already-built
|
||||||
|
success detector. The first real win is **not** "full FUT works" — it is a single line
|
||||||
|
in the hook log:
|
||||||
|
|
||||||
|
```
|
||||||
|
ELEM_WATCH: [elem+0x40] CHANGED! …
|
||||||
|
```
|
||||||
|
|
||||||
|
That line means the client accepted our Blaze replies and started registering
|
||||||
|
handlers — i.e. steps 1–6 were convincing enough to bring the session online. The
|
||||||
|
`ELEM_WATCH` probe that emits it is already written and deployed; it is our
|
||||||
|
instrumentation for blaze_brain regardless of anything else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
- **M0 — transport reachability (this task).** Read-only. Confirm whether the client
|
||||||
|
attempts *any* Blaze-flavored transport (situation a) or none at all (situation b).
|
||||||
|
Gates every decision below. See the M0 section in the hook docs / launch notes.
|
||||||
|
- **M1 — minimal session bring-up.** Stand up a listener at the M0-observed endpoint;
|
||||||
|
answer `getServerInstance` (redirect to ourselves), then `preAuth` / `login` /
|
||||||
|
`postAuth` with minimal valid TDFs and a single hardcoded persona. **Success signal:
|
||||||
|
`ELEM_WATCH: CHANGED` fires and the spinner advances.**
|
||||||
|
- **M2 — session online.** Handle the step-6 session notifications until the top-level
|
||||||
|
"connected to EA" state is reached and stays.
|
||||||
|
- **M3 — FUT / UTAS.** Implement the UTAS REST layer (`POST /ut/auth` and the
|
||||||
|
`/ut/game/...` endpoints already sketched in `endpoint-map.md`) so FUT itself loads.
|
||||||
|
|
||||||
|
M1 is only reachable if M0 says the client actually dials a listener. If M0 comes back
|
||||||
|
"no traffic" (situation b), M1 in its "answer over a wire" shape is blocked until the
|
||||||
|
dial trigger is solved, and the strategic options are re-opened rather than a next task
|
||||||
|
being obvious.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Honest UNKNOWNs (capture before implementing)
|
||||||
|
|
||||||
|
- **Numeric component and command ids** for FIFA 23's Blaze version. The *names* and
|
||||||
|
*ordering* above are `SHAPE`-reliable; the wire ids are `UNKNOWN`.
|
||||||
|
- **TDF field tags and body layouts** for each request/response. `UNKNOWN` — every
|
||||||
|
response body must be shaped from a real capture (or careful trial against the
|
||||||
|
client's parser).
|
||||||
|
- **The redirector/Blaze transport itself** — host, port, and whether it is remote,
|
||||||
|
loopback, or a pipe. This is exactly M0's question and is `UNKNOWN` until M0 runs.
|
||||||
|
- **Whether the client dials at all** without the in-process bootstrap firing — the
|
||||||
|
situation (a) vs (b) question. `UNKNOWN` until M0.
|
||||||
|
|
||||||
|
Until M0 answers the transport question, the ordering above is designable but
|
||||||
|
untestable: there is nothing to answer *to*.
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# OpenFUT — Closure & Preservation Record
|
||||||
|
|
||||||
|
*A clean-room reverse-engineering effort to restore FIFA 23 Ultimate Team offline after
|
||||||
|
EA's server shutdown (October 2025). This document records what was built, what was
|
||||||
|
achieved, and the precise technical wall at which the effort concludes.*
|
||||||
|
|
||||||
|
**Status: the online/FUT route is closed at a characterized wall. The offline-menu /
|
||||||
|
EA-App-emulation layer works and is preserved.**
|
||||||
|
|
||||||
|
**Provenance:** everything here derives from observing the running client's own behaviour —
|
||||||
|
live memory reads, static disassembly of the shipped binary, and the game's responses to
|
||||||
|
our synthesized inputs. No leaked EA source was used or referenced at any point. That
|
||||||
|
clean-room discipline is the legal foundation of the work and is the reason this record can
|
||||||
|
exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What OpenFUT set out to do
|
||||||
|
|
||||||
|
FIFA 23's online services were permanently shut down in October 2025, which removed FIFA
|
||||||
|
Ultimate Team — the mode is backed by EA's online infrastructure and simply cannot start
|
||||||
|
without it. OpenFUT's goal was game preservation: let a legitimately-owned copy run FUT
|
||||||
|
against a **local, emulated backend** instead of EA's dead servers.
|
||||||
|
|
||||||
|
The intended shape was three cooperating pieces:
|
||||||
|
|
||||||
|
```
|
||||||
|
FIFA 23 client (offline, under Proton/Wine or native Windows)
|
||||||
|
│ EA-App / Blaze / FUT protocols
|
||||||
|
openfut_hook.dll — injected; redirects EA traffic, bypasses cert pinning, observes
|
||||||
|
│ localhost
|
||||||
|
openfut-bridge — answers the EA-App (LSX) protocol; was to answer Blaze + FUT
|
||||||
|
│
|
||||||
|
openfut-core — the game-independent FUT economy backend (cards, packs, SBCs…)
|
||||||
|
```
|
||||||
|
|
||||||
|
The entry sequence the client runs, in order, each gating the next:
|
||||||
|
|
||||||
|
1. **LSX** — the EA App ↔ game local handshake (login, entitlements, config).
|
||||||
|
2. **Blaze redirector** — "where is my game server?"
|
||||||
|
3. **Blaze preauth / login / postauth** — establish the online session.
|
||||||
|
4. **FUT entry** — eligibility, then the FUT hub loads over REST.
|
||||||
|
|
||||||
|
The plan was to walk these gates one at a time, synthesizing each response and verifying it
|
||||||
|
against the live client (the client is the oracle — a gate is "done" when the game advances).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. What was achieved
|
||||||
|
|
||||||
|
This is preservation documentation, so the wins are recorded first and plainly. They are
|
||||||
|
real, and they stand independent of the wall reached later.
|
||||||
|
|
||||||
|
### 2.1 Clean-room injection and TLS neutralization
|
||||||
|
|
||||||
|
- A `version.dll` / FLE-loaded hook that injects into FIFA 23 under Proton/Wine without
|
||||||
|
tripping the (neutralized) anti-cheat, writing a durable log for observation.
|
||||||
|
- ProtoSSL / cert-verify bypass sufficient to let the game accept our substituted
|
||||||
|
endpoints at the layers we terminate.
|
||||||
|
- Winsock interception (`getaddrinfo`, `connect`, `WSAConnect`, `ConnectEx`) with EA-host
|
||||||
|
and EA-port redirection to the local bridge — including, by the end, **IPv6 (IPv4-mapped)
|
||||||
|
redirection**, which closed a real leak the earlier IPv4-only path had missed.
|
||||||
|
|
||||||
|
### 2.2 The LSX / EA-App layer works — the game reaches a fully-authenticated menu offline
|
||||||
|
|
||||||
|
This is the substantive achievement. The bridge's native LSX server (port 3217) emulates
|
||||||
|
the EA App / EbisuSDK local protocol completely enough that FIFA 23, with EA's servers gone,
|
||||||
|
**boots to its main menu believing it is logged in and online.** Confirmed working, from the
|
||||||
|
bridge's own logs of a live session:
|
||||||
|
|
||||||
|
- `EALS ChallengeResponse` — the EA login-service challenge/response handshake completes
|
||||||
|
(`handshake complete`).
|
||||||
|
- `EbisuSDK GetConfig / GetProfile / GetGameInfo / GetSetting` — all answered; the game gets
|
||||||
|
its service list, profile, and configuration.
|
||||||
|
- `Utility GetInternetConnectedState → connected=1` — the connectivity check passes.
|
||||||
|
- `XMPP SetPresence → INGAME` — presence is set.
|
||||||
|
- `Login IsLoggedIn=true` and `OnlineStatusEvent isOnline=true` — pushed continuously; the
|
||||||
|
game's own online-status state flips to "online."
|
||||||
|
|
||||||
|
Everything the EA-App layer is asked for, it receives. This is a genuine, reusable
|
||||||
|
clean-room EA-App/EbisuSDK emulator.
|
||||||
|
|
||||||
|
### 2.3 Reverse-engineering infrastructure
|
||||||
|
|
||||||
|
The effort produced durable tooling and knowledge, all clean-room:
|
||||||
|
|
||||||
|
- Live memory inspection via `/proc/<pid>/mem` (Wine maps `FIFA23.exe` flat at ImageBase
|
||||||
|
`0x140000000`), including a stable algorithm to resolve the live Blaze connection manager
|
||||||
|
(`G = *[0x14acd02c0]` → `M = *[G+0x360]` → `ctx = *[M+0x778]`; then a heap scan for the
|
||||||
|
object `P` with `[P+0] == base+0x80200b8` and `[P+8] == M`).
|
||||||
|
- Read-only in-process probes (menu-time snapshots, a write-watchpoint on the dispatch
|
||||||
|
container, transport observation) — all env-gated, none altering game state.
|
||||||
|
- Handshake-independent TLS **SNI capture** on the bridge (peek the ClientHello, parse SNI
|
||||||
|
before the handshake, so the hostname is learned even when the client rejects our cert).
|
||||||
|
- A documented **Blaze handshake reference** (`docs/blaze-handshake.md`) mapping the
|
||||||
|
three-layer LSX / Blaze-core / UTAS model and the six-step Blaze session ordering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The wall — reached from three independent directions
|
||||||
|
|
||||||
|
FUT never loaded. The reason is a single wall, and the strongest evidence for it is that
|
||||||
|
three unrelated lines of investigation arrived at the same place.
|
||||||
|
|
||||||
|
### 3.1 The forcing arc (memory side)
|
||||||
|
|
||||||
|
We confirmed the live Blaze connection manager and the in-process "dial" handler
|
||||||
|
(`0x144f4d360`) that begins the Blaze connection when the network comes online. Both exist at
|
||||||
|
the menu. But:
|
||||||
|
|
||||||
|
- **Forcing the dial with the connection scaffold empty crashes** in normal `.text` (not the
|
||||||
|
VM) at `0x144fd6b6c`: a binary search over a per-connection message-handler table whose
|
||||||
|
`begin` pointer is an uninitialized sentinel (`1`). The table is empty because nothing has
|
||||||
|
populated it.
|
||||||
|
- **Forcing the online state machine to "fully online" (state 2) crashes the anti-tamper
|
||||||
|
VM** deterministically — the game's own protected online code runs against a forced-but-
|
||||||
|
absent session and faults. A forced state cannot substitute for a real session.
|
||||||
|
|
||||||
|
Conclusion of the arc: the dial and the online state are *primed but unpumped* — real
|
||||||
|
infrastructure that only a genuine connection flow drives, and that cannot be safely forced.
|
||||||
|
|
||||||
|
### 3.2 The network / transport arc
|
||||||
|
|
||||||
|
With the entire EA-App layer satisfied (§2.2) and the network path fully instrumented
|
||||||
|
(including IPv6 and SNI capture), we watched a complete FUT "connecting to EA Servers"
|
||||||
|
attempt. The complete outbound inventory:
|
||||||
|
|
||||||
|
| Traffic | Identity | Fate |
|
||||||
|
|---|---|---|
|
||||||
|
| 2× IPv6 `:443` → `rl.data.ea.com`, `pin-river.data.ea.com` | EA **PIN/River telemetry** (fire-and-forget) | reached the bridge, **cert-rejected by ProtoSSL** |
|
||||||
|
| 6× IPv6 UPnP (`:1900`, `:80`, LAN) | NAT traversal | — |
|
||||||
|
| LSX (`:3217`) | EA-App layer | fully answered |
|
||||||
|
|
||||||
|
**No `gosredirector` / `redirector.ea.com`. No Blaze-port (`:42127` / `:10041`) connect. On
|
||||||
|
any transport.** Starting FUT added *zero* new dials. The game is satisfied enough by the
|
||||||
|
EA-App layer that it never attempts a Blaze connection at all. (This also corrected an
|
||||||
|
earlier false belief — "zero outbound sockets" — which turned out to be undecoded IPv6
|
||||||
|
traffic.)
|
||||||
|
|
||||||
|
Conclusion of the arc: the wall is **not** transport, **not** an unanswered LSX request,
|
||||||
|
**not** the cert. The game simply never initiates Blaze.
|
||||||
|
|
||||||
|
### 3.3 The two arcs meet
|
||||||
|
|
||||||
|
The forcing arc found, from memory, that the connection scaffold is never populated. The
|
||||||
|
network arc found, from the wire, that no connection is ever attempted. Same wall, two sides.
|
||||||
|
The final chapter explains *why*, from the binary itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Final chapter — the dial-initiation gate (why it is circular)
|
||||||
|
|
||||||
|
This is the concluding technical result: a static reverse-engineering pass answering the one
|
||||||
|
question both arcs left open — *beyond "online," what does the code check before it fires the
|
||||||
|
Blaze dial, and is that condition reachable offline?*
|
||||||
|
|
||||||
|
### 4.1 Method
|
||||||
|
|
||||||
|
Read-only static analysis of the shipped `FIFA23.exe` (`objdump`), two cross-reference
|
||||||
|
techniques: an 8-byte absolute-address search across the whole file (finds function pointers
|
||||||
|
stored in data — vtables, tables) and a disassembly grep for `call`/`jmp`/`lea` targets
|
||||||
|
(finds code references; a `lea reg,[…] # 0x…` is an address being *taken* for registration, a
|
||||||
|
`call 0x…` is a direct invocation). Addresses below are `FIFA23.exe` virtual addresses
|
||||||
|
(ImageBase `0x140000000`). The anti-tamper VM region `[0x14c1cd000, 0x160eb1000)` is
|
||||||
|
unreadable; everything traced here is in normal `.text`.
|
||||||
|
|
||||||
|
### 4.2 The caller graph
|
||||||
|
|
||||||
|
```
|
||||||
|
dial handler 0x144f4d360
|
||||||
|
▲ address-taken only (lea r9,[dial]) — NEVER called directly, NEVER in a vtable
|
||||||
|
│ inserted into the container [connMgr+0xc38] by:
|
||||||
|
F1 registrar 0x144f4b7b0 (exactly ONE caller)
|
||||||
|
▲
|
||||||
|
F2 0x144f4b960 (builds a callback, calls F1 with rcx = connMgr)
|
||||||
|
▲ TWO callers, identical guard: cmp bpl,[this+0x42] ; cmp bp,[this+0x142]
|
||||||
|
├── F3a 0x144f44770
|
||||||
|
└── F3b 0x144f4bbf0
|
||||||
|
```
|
||||||
|
|
||||||
|
Every reference is a normal-`.text` `lea` — the dial handler is *registered as a callback*,
|
||||||
|
never called by address. The registrar `F1` is a "get-or-create into the message-handler
|
||||||
|
container": on a miss it allocates a handler and copies a caller-supplied callback into it.
|
||||||
|
Its sole caller `F2` supplies the callback and resolves the connection manager
|
||||||
|
(`rcx = [[this+8]+0xca0]`).
|
||||||
|
|
||||||
|
### 4.3 The decisive structural fact
|
||||||
|
|
||||||
|
`F3a` and `F3b` — the two functions that actually decide to register the dial — are, from the
|
||||||
|
whole-binary sweep, **in zero vtables and never directly called.** They are `lea`'d at four
|
||||||
|
sites and **registered into `[connMgr+0xc38]`, each keyed by a `.rdata` message-type
|
||||||
|
descriptor** (`0x1483fcf98`, `0x14808d7d0`). In other words they are themselves **message
|
||||||
|
handlers**, dispatched indirectly by the connection manager when a matching Blaze message
|
||||||
|
arrives — not methods invoked by a state machine you can drive locally. (In Rust terms: not
|
||||||
|
methods on a trait object, but closures inserted into a `HashMap<MessageType, Handler>` and
|
||||||
|
called by a dispatcher.) The functions that register *them* (`G_a 0x144f4a350`,
|
||||||
|
`G_b 0x144f45220`) are ordinary helpers inside the same cluster, and the entire cluster
|
||||||
|
`F1…G_b` is vtable-free.
|
||||||
|
|
||||||
|
So the whole thing is one **self-registering, message-driven state machine on
|
||||||
|
`[connMgr+0xc38]`:** each handler fires when its Blaze message arrives, checks connection
|
||||||
|
state, and registers the next handler — until `F3a`/`F3b` register the dial. The state each
|
||||||
|
step checks (`[this+0x42]`, `[this+0x142]`, …) is connection-lifecycle state, advanced only
|
||||||
|
as messages are processed.
|
||||||
|
|
||||||
|
### 4.4 Reachability, and why it is circular
|
||||||
|
|
||||||
|
Offline, the container `[connMgr+0xc38]` is **empty** (measured directly: the crashing table's
|
||||||
|
`begin` is `0`/garbage; the network arc saw zero Blaze messages). Nothing ever dispatches
|
||||||
|
`F3a`/`F3b`, so their guards never even execute — the wall sits *upstream* of them. The one
|
||||||
|
thing that does fire offline is the connection-state constructor (on the online-status event),
|
||||||
|
so the machine's entrance is *partially* reached and then stalls immediately, because no
|
||||||
|
Blaze message follows to drive the first dispatch.
|
||||||
|
|
||||||
|
**Outcome: circular gate.** The trigger is *found* — we can see precisely how the dial gets
|
||||||
|
registered and fired. But its precondition is *prior Blaze connection-lifecycle messages
|
||||||
|
having populated and dispatched the handler container* — i.e. the very network exchange the
|
||||||
|
connection is meant to produce. The state that gates the dial requires the dial's own earlier
|
||||||
|
connection steps to have already run. It is not a settable flag (there is none between
|
||||||
|
"online" and "dial"); it is an entire message exchange that never begins.
|
||||||
|
|
||||||
|
This is the exact mechanical explanation of both arcs: the container is empty because no
|
||||||
|
messages flow (network arc), and forcing the dial into that empty container crashes (memory
|
||||||
|
arc).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What this means — the boundary, stated plainly
|
||||||
|
|
||||||
|
**OpenFUT can take FIFA 23 to a fully-authenticated main menu, offline, believing it is
|
||||||
|
logged in and online — but it cannot take it into FUT.** FUT requires a Blaze session, and on
|
||||||
|
this build the Blaze connection is never initiated. The bootstrap is circular: the client
|
||||||
|
will only walk its connection state machine as real Blaze messages arrive, and no messages
|
||||||
|
arrive because no connection is begun. "Online," at the EA-App level, is genuinely
|
||||||
|
disconnected from "reach out to Blaze," and the gap between them is not a flag we failed to
|
||||||
|
set — it is a live protocol exchange that has no counterpart to talk to.
|
||||||
|
|
||||||
|
Two secondary walls sit behind the first, and would matter only if it were solved:
|
||||||
|
|
||||||
|
- **ProtoSSL cert pinning** (`CertificateUnknown`) rejects our self-signed cert on the
|
||||||
|
DirtySDK path — so even redirected EA HTTPS cannot complete a handshake as-is.
|
||||||
|
- **The anti-tamper VM** faults deterministically whenever forced state is used by the game's
|
||||||
|
own protected online code — so "force the state and let the game run" is not viable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Paths not taken, scoped honestly
|
||||||
|
|
||||||
|
Only one route could still reach FUT, and this record scopes it so the decision is clear-eyed
|
||||||
|
rather than reopened casually.
|
||||||
|
|
||||||
|
**In-process Blaze state-machine injection.** Rather than answer Blaze over a wire (there is
|
||||||
|
no wire — the game never dials), blaze_brain would **drive the game's in-process connection
|
||||||
|
state machine by injecting synthesized Blaze response frames into its dispatch layer.** From
|
||||||
|
the final-chapter RE, that requires:
|
||||||
|
|
||||||
|
1. Making the connection appear *initiated*, so the game registers its first response handler.
|
||||||
|
2. Feeding synthesized Blaze responses **in order and keyed to the same `.rdata` message-type
|
||||||
|
descriptors**, so each registered handler fires, advances the connection-state flags, and
|
||||||
|
registers its successor — walking the machine forward until the dial is registered and
|
||||||
|
fires.
|
||||||
|
|
||||||
|
This is a weeks-scale build requiring per-message protocol RE done from the *receive* side,
|
||||||
|
against a client that is the only available oracle (the servers are dead, so every frame must
|
||||||
|
be synthesized and validated by whether the client advances). It also inherits an unsolved
|
||||||
|
sub-problem: the initial "connection initiated" state must itself be synthesized or forced,
|
||||||
|
since the game does not produce it offline. It is possible in principle; it is not a small
|
||||||
|
task, and its scope should be understood before it is begun.
|
||||||
|
|
||||||
|
The alternative — the one this document records — is to treat the fully-authenticated offline
|
||||||
|
menu as the achieved preservation state and close the online/FUT route here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Preservation value
|
||||||
|
|
||||||
|
Even without FUT, the effort produced things worth keeping:
|
||||||
|
|
||||||
|
- **A working clean-room EA-App / EbisuSDK (LSX) emulator** that boots FIFA 23 to an
|
||||||
|
authenticated main menu with no live EA servers — a genuine preservation artifact for the
|
||||||
|
offline single-player state.
|
||||||
|
- **A documented map of exactly where and why online play is unreachable** on this build,
|
||||||
|
grounded in the binary: the dial caller graph, the message-driven container mechanism, and
|
||||||
|
the circular bootstrap. Future work (on this title or the engine family) starts from a
|
||||||
|
known wall, not a blank page.
|
||||||
|
- **Reusable RE tooling and references** — live-memory resolution of the connection manager,
|
||||||
|
the transport/SNI instrumentation, and the Blaze handshake reference doc.
|
||||||
|
- **A clean provenance record** — nothing derived from leaked source, so the work remains
|
||||||
|
usable and shareable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Closing note
|
||||||
|
|
||||||
|
OpenFUT reached the last gate it could reach without a live server to talk to, and then
|
||||||
|
proved — from memory, from the wire, and from the binary — that the next gate is not a lock
|
||||||
|
we failed to pick but a door that only opens from the far side. That is a complete and honest
|
||||||
|
result. The offline menu stands; the map is drawn; the wall is named.
|
||||||
|
|
||||||
|
*This record is the concluding chapter of the OpenFUT reverse-engineering arc.*
|
||||||
File diff suppressed because it is too large
Load Diff
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)
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
+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,389 @@
|
|||||||
|
//! openfut-poke — live inspector / gate-forcer for FIFA 23's "go online" decision.
|
||||||
|
//!
|
||||||
|
//! Reconstructed to the project spec + the resolver layout established earlier in the RE
|
||||||
|
//! (fn 0x144f46650 reads its config from `[this+8]` = M, with host buffer at M+0x560, flag at
|
||||||
|
//! M+0x660, port WORD at M+0x662; the "go online" gate is the `je` at 0x144f4d47c).
|
||||||
|
//!
|
||||||
|
//! It operates over `/proc/<pid>/mem` directly (pread/pwrite via FileExt) — no ptrace attach,
|
||||||
|
//! matching how the earlier data pokes worked. Three subcommands:
|
||||||
|
//!
|
||||||
|
//! inspect (READ-ONLY, safe): resolve M, print env / override-host / flag / port and the live
|
||||||
|
//! bytes at the gate.
|
||||||
|
//! arm (LIVE WRITE): write an override redirector host into M+0x560 (+ optional port at
|
||||||
|
//! M+0x662), then patch the gate `0f 84 → 90 e9` (je → nop; jmp). The 4-byte rel32 is
|
||||||
|
//! left untouched, so the branch target stays 0x144f46590... (0x144f4d590). Saves the
|
||||||
|
//! original bytes to a restore file first.
|
||||||
|
//! restore (LIVE WRITE): write the saved original bytes back and clear the override.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! openfut-poke inspect --pid <PID>
|
||||||
|
//! openfut-poke arm --pid <PID> --host <HOST> [--port <PORT>] [--flag <BYTE>]
|
||||||
|
//! openfut-poke restore --pid <PID>
|
||||||
|
//!
|
||||||
|
//! The `#[repr]`-free, dependency-light design uses only std + anyhow.
|
||||||
|
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::os::unix::fs::FileExt; // gives read_exact_at / write_all_at (pread/pwrite semantics)
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Established RE constants — DO NOT change. Validated by prior analysis + live probe.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Fixed global that holds pointer X. Chain: M = *(*(SINGLETON_PTR) + X_TO_M).
|
||||||
|
const SINGLETON_PTR: u64 = 0x1_4acd_02c0;
|
||||||
|
const X_TO_M: u64 = 0x360;
|
||||||
|
|
||||||
|
/// Fields inside the config object M (all relative to M):
|
||||||
|
const ENV_OFF: u64 = 0x54c; // u32 environment enum: 0=sdev 1=stest 2=scert 3=prod
|
||||||
|
const HOST_OFF: u64 = 0x560; // override redirector host: NUL-terminated buffer, 0x100 bytes
|
||||||
|
const HOST_LEN: usize = 0x100; // resolver copies up to 0x100 bytes from here
|
||||||
|
const FLAG_OFF: u64 = 0x660; // u8 flag (semantics UNCONFIRMED — do not touch unless asked)
|
||||||
|
const PORT_OFF: u64 = 0x662; // u16 override port (resolver loads this into r10w)
|
||||||
|
|
||||||
|
/// The "go online" gate: `je 0x144f4d590` at this VA. Taken only when the state code == "+onl".
|
||||||
|
const GATE_VA: u64 = 0x1_44f4_d47c;
|
||||||
|
/// Original 6 bytes: 0F 84 = je rel32, rel32 = 0x0000010E (→ 0x144f4d590).
|
||||||
|
const GATE_ORIG: [u8; 6] = [0x0f, 0x84, 0x0e, 0x01, 0x00, 0x00];
|
||||||
|
/// Armed 6 bytes: 90 = nop, E9 = jmp rel32, SAME rel32 0x0000010E. Because the jmp's rel32 is
|
||||||
|
/// measured from the end of the instruction (identical to the je's end), the target is unchanged.
|
||||||
|
const GATE_ARMED: [u8; 6] = [0x90, 0xe9, 0x0e, 0x01, 0x00, 0x00];
|
||||||
|
/// The (unconditional) target both encodings resolve to — printed for the operator's sanity check.
|
||||||
|
const GATE_TARGET: u64 = 0x1_44f4_d590;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// /proc/<pid>/mem helpers — pread/pwrite at a virtual address (Wine maps FIFA flat at 0x140000000)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn open_mem_ro(pid: u32) -> Result<File> {
|
||||||
|
File::open(format!("/proc/{pid}/mem"))
|
||||||
|
.with_context(|| format!("open /proc/{pid}/mem for reading (is the PID right? permissions?)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_mem_rw(pid: u32) -> Result<File> {
|
||||||
|
OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(format!("/proc/{pid}/mem"))
|
||||||
|
.with_context(|| format!("open /proc/{pid}/mem read-write (ptrace_scope? try sudo)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bytes(f: &File, va: u64, buf: &mut [u8]) -> Result<()> {
|
||||||
|
f.read_exact_at(buf, va)
|
||||||
|
.with_context(|| format!("read {} bytes at {va:#x}", buf.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_bytes(f: &File, va: u64, buf: &[u8]) -> Result<()> {
|
||||||
|
f.write_all_at(buf, va)
|
||||||
|
.with_context(|| format!("write {} bytes at {va:#x} (EPERM here usually means ptrace_scope=1 blocks the write — sudo or lower it)", buf.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u16(f: &File, va: u64) -> Result<u16> {
|
||||||
|
let mut b = [0u8; 2];
|
||||||
|
read_bytes(f, va, &mut b)?;
|
||||||
|
Ok(u16::from_le_bytes(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(f: &File, va: u64) -> Result<u32> {
|
||||||
|
let mut b = [0u8; 4];
|
||||||
|
read_bytes(f, va, &mut b)?;
|
||||||
|
Ok(u32::from_le_bytes(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u64(f: &File, va: u64) -> Result<u64> {
|
||||||
|
let mut b = [0u8; 8];
|
||||||
|
read_bytes(f, va, &mut b)?;
|
||||||
|
Ok(u64::from_le_bytes(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the config object M via the validated chain: M = *(*(SINGLETON_PTR) + X_TO_M).
|
||||||
|
fn resolve_m(f: &File) -> Result<u64> {
|
||||||
|
let x = read_u64(f, SINGLETON_PTR)?;
|
||||||
|
if x == 0 {
|
||||||
|
bail!(
|
||||||
|
"singleton at {SINGLETON_PTR:#x} is NULL — the game isn't initialized yet.\n\
|
||||||
|
Get FIFA to the main menu and retry."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let m = read_u64(f, x + X_TO_M)?;
|
||||||
|
if m == 0 {
|
||||||
|
bail!(
|
||||||
|
"M (*({x:#x}+{X_TO_M:#x})) is NULL — the online subsystem hasn't populated the config yet.\n\
|
||||||
|
Wait a few seconds on the menu (or step toward an online menu) and re-inspect."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the NUL-terminated override host string out of the M+0x560 buffer.
|
||||||
|
fn read_host(f: &File, m: u64) -> Result<String> {
|
||||||
|
let mut buf = [0u8; HOST_LEN];
|
||||||
|
read_bytes(f, m + HOST_OFF, &mut buf)?;
|
||||||
|
let end = buf.iter().position(|&b| b == 0).unwrap_or(HOST_LEN);
|
||||||
|
Ok(String::from_utf8_lossy(&buf[..end]).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_label(env: u32) -> &'static str {
|
||||||
|
match env {
|
||||||
|
0 => "sdev",
|
||||||
|
1 => "stest",
|
||||||
|
2 => "scert",
|
||||||
|
3 => "prod",
|
||||||
|
_ => "??? (out of 0-3 range)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_path(pid: u32) -> String {
|
||||||
|
format!("/tmp/openfut-poke-restore-{pid}.bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Subcommands
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cmd_inspect(pid: u32) -> Result<()> {
|
||||||
|
let f = open_mem_ro(pid)?;
|
||||||
|
|
||||||
|
println!("== openfut-poke inspect (read-only) — pid {pid} ==\n");
|
||||||
|
|
||||||
|
// Chain resolution.
|
||||||
|
let x = read_u64(&f, SINGLETON_PTR)?;
|
||||||
|
let m = resolve_m(&f)?;
|
||||||
|
println!("chain:");
|
||||||
|
println!(" *({SINGLETON_PTR:#x}) = X = {x:#x}");
|
||||||
|
println!(" *(X + {X_TO_M:#x}) = M = {m:#x} (config object)");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Config fields.
|
||||||
|
let env = read_u32(&f, m + ENV_OFF)?;
|
||||||
|
let host = read_host(&f, m)?;
|
||||||
|
let flag = {
|
||||||
|
let mut b = [0u8; 1];
|
||||||
|
read_bytes(&f, m + FLAG_OFF, &mut b)?;
|
||||||
|
b[0]
|
||||||
|
};
|
||||||
|
let port = read_u16(&f, m + PORT_OFF)?;
|
||||||
|
println!("config @ M:");
|
||||||
|
println!(" env [M+{ENV_OFF:#x}] = {env} ({})", env_label(env));
|
||||||
|
println!(
|
||||||
|
" host [M+{HOST_OFF:#x}] = {}",
|
||||||
|
if host.is_empty() { "(empty)".to_string() } else { format!("{host:?}") }
|
||||||
|
);
|
||||||
|
println!(" flag [M+{FLAG_OFF:#x}] = {flag:#04x} (semantics unconfirmed)");
|
||||||
|
println!(" port [M+{PORT_OFF:#x}] = {port}");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Gate bytes.
|
||||||
|
let mut gate = [0u8; 6];
|
||||||
|
read_bytes(&f, GATE_VA, &mut gate)?;
|
||||||
|
let gate_state = if gate == GATE_ORIG {
|
||||||
|
"RECOGNISED original je (not armed)"
|
||||||
|
} else if gate == GATE_ARMED {
|
||||||
|
"ARMED (nop; jmp — already forced)"
|
||||||
|
} else {
|
||||||
|
"UNRECOGNISED"
|
||||||
|
};
|
||||||
|
println!("gate @ {GATE_VA:#x}: {} → {gate_state}", hex(&gate));
|
||||||
|
println!(" (original je target = {GATE_TARGET:#x}; armed keeps the same target)");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// ---- operator-facing verdict ----
|
||||||
|
println!("== read ==");
|
||||||
|
println!(" • singleton non-null, M resolved: YES ({m:#x})");
|
||||||
|
match gate_state.chars().next() {
|
||||||
|
Some('R') => println!(" • gate reads the recognised original je: YES → safe to arm"),
|
||||||
|
Some('A') => println!(" • gate is ALREADY ARMED — run `restore` before arming again"),
|
||||||
|
_ => println!(
|
||||||
|
" • gate is UNRECOGNISED ({}) → WRONG PROCESS or DIFFERENT BUILD. Do NOT arm.",
|
||||||
|
hex(&gate)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if (0..=3).contains(&env) {
|
||||||
|
println!(" • env is sane ({env}={}) → real green light", env_label(env));
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" • env={env} is out of 0-3. If host/flag/port are also empty/garbage, the menu is up\n\
|
||||||
|
\x20 but the online subsystem hasn't populated config yet — wait a few seconds and re-inspect."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" • override host currently {}",
|
||||||
|
if host.is_empty() { "EMPTY (expected)".to_string() } else { format!("NON-EMPTY: {host:?} (already overridden?)") }
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_arm(pid: u32, host: &str, port: Option<u16>, flag: Option<u8>) -> Result<()> {
|
||||||
|
if host.is_empty() {
|
||||||
|
bail!("--host must be a non-empty redirector hostname");
|
||||||
|
}
|
||||||
|
if host.len() + 1 > HOST_LEN {
|
||||||
|
bail!("--host too long ({} bytes); buffer is {HOST_LEN} bytes incl. NUL", host.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let f = open_mem_rw(pid)?;
|
||||||
|
let m = resolve_m(&f)?;
|
||||||
|
println!("== openfut-poke arm — pid {pid}, M={m:#x} ==\n");
|
||||||
|
|
||||||
|
// 1) Verify the gate is exactly the original je before we save/patch. This prevents clobbering
|
||||||
|
// the restore file with already-armed (or foreign) bytes.
|
||||||
|
let mut gate = [0u8; 6];
|
||||||
|
read_bytes(&f, GATE_VA, &mut gate)?;
|
||||||
|
if gate == GATE_ARMED {
|
||||||
|
bail!("gate at {GATE_VA:#x} is ALREADY armed ({}). Run `restore` first.", hex(&gate));
|
||||||
|
}
|
||||||
|
if gate != GATE_ORIG {
|
||||||
|
bail!(
|
||||||
|
"gate at {GATE_VA:#x} is UNRECOGNISED ({}). Expected {} — wrong process/build. Refusing to write.",
|
||||||
|
hex(&gate),
|
||||||
|
hex(&GATE_ORIG)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Save the ORIGINAL bytes we're about to touch (gate + the whole override region) so
|
||||||
|
// `restore` can put everything back even across separate invocations.
|
||||||
|
let mut orig_region = [0u8; 0x104]; // host 0x100 + flag/pad 0x2 + port 0x2, i.e. M+0x560..M+0x664
|
||||||
|
read_bytes(&f, m + HOST_OFF, &mut orig_region)?;
|
||||||
|
let mut save = Vec::with_capacity(6 + orig_region.len());
|
||||||
|
save.extend_from_slice(&gate);
|
||||||
|
save.extend_from_slice(&orig_region);
|
||||||
|
std::fs::write(restore_path(pid), &save)
|
||||||
|
.with_context(|| format!("write restore file {}", restore_path(pid)))?;
|
||||||
|
println!("saved restore file: {} ({} bytes)", restore_path(pid), save.len());
|
||||||
|
|
||||||
|
// 3) Write the override host (bytes + a single NUL terminator) into M+0x560.
|
||||||
|
let mut host_bytes = host.as_bytes().to_vec();
|
||||||
|
host_bytes.push(0);
|
||||||
|
write_bytes(&f, m + HOST_OFF, &host_bytes)?;
|
||||||
|
println!("wrote override host [M+{HOST_OFF:#x}] = {host:?}");
|
||||||
|
|
||||||
|
// 4) Optionally write the override port (WORD) at M+0x662.
|
||||||
|
if let Some(p) = port {
|
||||||
|
write_bytes(&f, m + PORT_OFF, &p.to_le_bytes())?;
|
||||||
|
println!("wrote override port [M+{PORT_OFF:#x}] = {p}");
|
||||||
|
} else {
|
||||||
|
println!("port left unchanged (no --port)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Optionally write the flag (only if explicitly requested — semantics unconfirmed).
|
||||||
|
if let Some(fl) = flag {
|
||||||
|
write_bytes(&f, m + FLAG_OFF, &[fl])?;
|
||||||
|
println!("wrote flag [M+{FLAG_OFF:#x}] = {fl:#04x}");
|
||||||
|
} else {
|
||||||
|
println!("flag left unchanged (no --flag)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Patch the gate: je → nop; jmp (same rel32, same target).
|
||||||
|
write_bytes(&f, GATE_VA, &GATE_ARMED)?;
|
||||||
|
let mut check = [0u8; 6];
|
||||||
|
read_bytes(&f, GATE_VA, &mut check)?;
|
||||||
|
if check != GATE_ARMED {
|
||||||
|
bail!("gate write-back verify FAILED: read {} after patch", hex(&check));
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"patched gate @ {GATE_VA:#x}: {} → {} (je → nop; jmp {GATE_TARGET:#x})",
|
||||||
|
hex(&GATE_ORIG),
|
||||||
|
hex(&GATE_ARMED)
|
||||||
|
);
|
||||||
|
println!("\nARMED. Run `restore --pid {pid}` to reverse.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_restore(pid: u32) -> Result<()> {
|
||||||
|
let path = restore_path(pid);
|
||||||
|
let save = std::fs::read(&path)
|
||||||
|
.with_context(|| format!("read restore file {path} (was `arm` run for this pid?)"))?;
|
||||||
|
if save.len() != 6 + 0x104 {
|
||||||
|
bail!("restore file {path} has unexpected size {} (corrupt?)", save.len());
|
||||||
|
}
|
||||||
|
let gate_orig = &save[0..6];
|
||||||
|
let region_orig = &save[6..];
|
||||||
|
|
||||||
|
let f = open_mem_rw(pid)?;
|
||||||
|
let m = resolve_m(&f)?;
|
||||||
|
println!("== openfut-poke restore — pid {pid}, M={m:#x} ==\n");
|
||||||
|
|
||||||
|
// Put the override region back (this also clears the injected host, since the saved region
|
||||||
|
// was captured before arm).
|
||||||
|
write_bytes(&f, m + HOST_OFF, region_orig)?;
|
||||||
|
println!("restored override region [M+{HOST_OFF:#x}..+0x104]");
|
||||||
|
|
||||||
|
// Put the gate back and verify.
|
||||||
|
write_bytes(&f, GATE_VA, gate_orig)?;
|
||||||
|
let mut check = [0u8; 6];
|
||||||
|
read_bytes(&f, GATE_VA, &mut check)?;
|
||||||
|
println!("restored gate @ {GATE_VA:#x}: {}", hex(&check));
|
||||||
|
if check == GATE_ORIG {
|
||||||
|
println!("verify: gate is back to the original je (0f 84 0e 01 00 00). ✔");
|
||||||
|
} else {
|
||||||
|
bail!("verify FAILED: gate reads {} (expected {})", hex(&check), hex(&GATE_ORIG));
|
||||||
|
}
|
||||||
|
// Best-effort: remove the restore file now that we've applied it.
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tiny helpers + CLI
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Format bytes as space-separated lowercase hex, e.g. "0f 84 0e 01 00 00".
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an integer that may be 0x-prefixed hex or decimal.
|
||||||
|
fn parse_int(s: &str) -> Result<u64> {
|
||||||
|
let t = s.trim();
|
||||||
|
if let Some(h) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
|
||||||
|
u64::from_str_radix(h, 16).with_context(|| format!("bad hex: {s:?}"))
|
||||||
|
} else {
|
||||||
|
t.parse::<u64>().with_context(|| format!("bad number: {s:?}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() -> ! {
|
||||||
|
eprintln!(
|
||||||
|
"openfut-poke — inspect/force FIFA 23's go-online gate via /proc/<pid>/mem\n\
|
||||||
|
\n\
|
||||||
|
USAGE:\n\
|
||||||
|
\x20 openfut-poke inspect --pid <PID>\n\
|
||||||
|
\x20 openfut-poke arm --pid <PID> --host <HOST> [--port <PORT>] [--flag <BYTE>]\n\
|
||||||
|
\x20 openfut-poke restore --pid <PID>\n"
|
||||||
|
);
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let sub = args.next().unwrap_or_default();
|
||||||
|
|
||||||
|
let mut pid: Option<u32> = None;
|
||||||
|
let mut host: Option<String> = None;
|
||||||
|
let mut port: Option<u16> = None;
|
||||||
|
let mut flag: Option<u8> = None;
|
||||||
|
|
||||||
|
while let Some(a) = args.next() {
|
||||||
|
match a.as_str() {
|
||||||
|
"--pid" => pid = Some(parse_int(&args.next().context("--pid needs a value")?)? as u32),
|
||||||
|
"--host" => host = Some(args.next().context("--host needs a value")?),
|
||||||
|
"--port" => port = Some(parse_int(&args.next().context("--port needs a value")?)? as u16),
|
||||||
|
"--flag" => flag = Some(parse_int(&args.next().context("--flag needs a value")?)? as u8),
|
||||||
|
"-h" | "--help" => usage(),
|
||||||
|
other => bail!("unknown argument: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pid = match pid {
|
||||||
|
Some(p) => p,
|
||||||
|
None => usage(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match sub.as_str() {
|
||||||
|
"inspect" => cmd_inspect(pid),
|
||||||
|
"arm" => cmd_arm(pid, &host.context("arm needs --host")?, port, flag),
|
||||||
|
"restore" => cmd_restore(pid),
|
||||||
|
_ => usage(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
+1179
File diff suppressed because it is too large
Load Diff
+12
-4
@@ -48,19 +48,27 @@ impl CapturedRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Persist a capture to disk as JSON.
|
/// Persist a capture to disk as JSON.
|
||||||
|
/// Sensitive auth headers (`X-UT-SID`, `X-UT-PHISHING-TOKEN`) are stripped
|
||||||
|
/// before writing so they are never stored on disk.
|
||||||
pub fn save_capture(captures_dir: &str, capture: &CapturedRequest) -> anyhow::Result<()> {
|
pub fn save_capture(captures_dir: &str, capture: &CapturedRequest) -> anyhow::Result<()> {
|
||||||
let dir = Path::new(captures_dir);
|
let dir = Path::new(captures_dir);
|
||||||
std::fs::create_dir_all(dir)?;
|
std::fs::create_dir_all(dir)?;
|
||||||
|
|
||||||
|
let mut sanitized = capture.clone();
|
||||||
|
sanitized.headers.retain(|(k, _)| {
|
||||||
|
let lower = k.to_lowercase();
|
||||||
|
lower != "x-ut-sid" && lower != "x-ut-phishing-token"
|
||||||
|
});
|
||||||
|
|
||||||
let filename = format!(
|
let filename = format!(
|
||||||
"{}_{}_{}.json",
|
"{}_{}_{}.json",
|
||||||
capture.timestamp.replace(':', "-"),
|
sanitized.timestamp.replace(':', "-"),
|
||||||
capture.method,
|
sanitized.method,
|
||||||
capture.id
|
sanitized.id
|
||||||
);
|
);
|
||||||
let path = dir.join(filename);
|
let path = dir.join(filename);
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(capture)?;
|
let json = serde_json::to_string_pretty(&sanitized)?;
|
||||||
std::fs::write(&path, json)?;
|
std::fs::write(&path, json)?;
|
||||||
|
|
||||||
tracing::debug!("Capture saved: {:?}", path);
|
tracing::debug!("Capture saved: {:?}", path);
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2939
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
|||||||
pub mod capture;
|
pub mod capture;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod lsx;
|
||||||
pub mod mapper;
|
pub mod mapper;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
pub mod shaper;
|
||||||
|
pub mod tls;
|
||||||
|
|||||||
+667
@@ -0,0 +1,667 @@
|
|||||||
|
/// EA App LSX server (TCP port 3216).
|
||||||
|
///
|
||||||
|
/// FIFA 23 opens two concurrent connections to this port at startup.
|
||||||
|
/// Protocol: server speaks first — sends an XML greeting with a challenge
|
||||||
|
/// key; client replies with ChallengeResponse; server replies with
|
||||||
|
/// ChallengeAccepted; then an AES-128-ECB encrypted session loop follows.
|
||||||
|
use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit};
|
||||||
|
use aes::Aes128;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb";
|
||||||
|
const AES_KEY: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||||
|
|
||||||
|
/// The async `ONLINE_STATUS_EVENT` push FIFA waits for after `GoOnline`.
|
||||||
|
///
|
||||||
|
/// FIFA calls GoOnline (handled in-process by EbisuSDK/anadius), gets success,
|
||||||
|
/// then blocks waiting for an unsolicited `OnlineStatusEvent` push on the LSX
|
||||||
|
/// socket — the M2 wall. Now that our bridge owns FIFA's LSX socket we can send
|
||||||
|
/// it. Format RE'd from FIFA23.exe's OriginSDK deserializer (authoritative):
|
||||||
|
/// element `OnlineStatusEvent` (RTTI `Origin::EventHandler<lsx::OnlineStatusEventT,
|
||||||
|
/// bool>`, parser FIFA23.exe+0x274d4ae), single bool attribute `isOnline`
|
||||||
|
/// (deserializer FIFA23.exe+0x28a4d0). See docs/connection-gate-findings.md.
|
||||||
|
///
|
||||||
|
/// The value is the string literal `true`/`false` (not `1`/`0`): FIFA's bool
|
||||||
|
/// parser (FIFA23.exe+0x28fb50) string-compares the attribute against `"false"`,
|
||||||
|
/// matching the OriginSDK convention every other bool in our LSX responses uses.
|
||||||
|
/// An initial `isOnline="1"` push was delivered but parsed as not-online (FIFA
|
||||||
|
/// kept polling), which pinned the encoding. Re-push cadence below still
|
||||||
|
/// TODO/CONFIRM (single push may suffice once value is correct).
|
||||||
|
const ONLINE_STATUS_EVENT: &str =
|
||||||
|
r#"<LSX><Event sender="EbisuSDK"><OnlineStatusEvent isOnline="true"/></Event></LSX>"#;
|
||||||
|
|
||||||
|
/// The authenticated-session `Login` event. OnlineStatusEvent alone set FIFA's
|
||||||
|
/// *connectivity* (presence went INGAME) but not an authenticated session, so FIFA
|
||||||
|
/// never started the GetAuthCode→Nucleus→Blaze chain. FIFA's Origin event set has a
|
||||||
|
/// separate `Login` event (`Origin::EventHandler<lsx::LoginT,OriginLoginT>`) that
|
||||||
|
/// carries the login state. Format RE'd from FIFA23.exe's LoginT deserializer
|
||||||
|
/// (0x142787a30): fields `UserIndex` (int, primary user 0), `IsLoggedIn` (bool,
|
||||||
|
/// parsed by the same true/false helper as isOnline), `LoginReasonCode` (int/enum).
|
||||||
|
///
|
||||||
|
/// Pushed BEFORE OnlineStatusEvent each cycle (log in, then go online).
|
||||||
|
/// TODO/CONFIRM `LoginReasonCode` — 0 is the natural "no error / normal" value; if
|
||||||
|
/// FIFA switches on a specific success code, RE the consumer and adjust.
|
||||||
|
const LOGIN_EVENT: &str =
|
||||||
|
r#"<LSX><Event sender="EbisuSDK"><Login UserIndex="0" IsLoggedIn="true" LoginReasonCode="0"/></Event></LSX>"#;
|
||||||
|
|
||||||
|
/// Events pushed on the LSX socket each interval, in order, to drive FIFA online.
|
||||||
|
const ONLINE_PUSH_EVENTS: &[&str] = &[LOGIN_EVENT, ONLINE_STATUS_EVENT];
|
||||||
|
|
||||||
|
/// Delay before the first `OnlineStatusEvent` push (and the re-push interval).
|
||||||
|
/// Long enough that FIFA has finished LSX bootstrap and subscribed its event
|
||||||
|
/// listeners + issued GoOnline (GetInternetConnectedState was seen ~2s in), short
|
||||||
|
/// enough to fire while FIFA is still polling and waiting.
|
||||||
|
const ONLINE_PUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2000);
|
||||||
|
|
||||||
|
// Account identity + locale that anadius's in-process LSX emu reports (recovered
|
||||||
|
// from anadius.cfg — the config the emu reads). FIFA carries these values into
|
||||||
|
// its online/loading state, so our bridge must report the SAME ones anadius does;
|
||||||
|
// fabricated values crash FIFA before the loading screen. See the LSX-regression
|
||||||
|
// section in docs/connection-gate-findings.md.
|
||||||
|
const PERSONA_ID: &str = "1144668899";
|
||||||
|
const USER_ID: &str = "1000200030000";
|
||||||
|
const USERNAME: &str = "fun";
|
||||||
|
const INSTALLED_LANGUAGE: &str = "en_US";
|
||||||
|
const LANGUAGES: &str =
|
||||||
|
"pt_PT,tr_TR,ko_KR,cs_CZ,zh_CN,zh_HK,da_DK,no_NO,sv_SE,en_US,pt_BR,de_DE,\
|
||||||
|
es_ES,fr_FR,it_IT,ja_JP,es_MX,nl_NL,pl_PL,ru_RU,ar_SA";
|
||||||
|
|
||||||
|
/// The full game-info attribute set anadius's `GetAllGameInfo` handler emits,
|
||||||
|
/// as (GameInfoId key, response attribute name, value). Recovered by
|
||||||
|
/// disassembling anadius64.dll's builder (attribute names + order) plus
|
||||||
|
/// anadius.cfg (version/language values). Our old `<GetAllGameInfoResponse />`
|
||||||
|
/// was empty — FIFA reads these fields during load and crashes without them.
|
||||||
|
/// TODO/CONFIRM the exact values for the entitlement/group fields (the names are
|
||||||
|
/// certain from the disassembly; a few values are best-effort defaults).
|
||||||
|
const GAME_INFO: &[(&str, &str, &str)] = &[
|
||||||
|
("DISPLAY_NAME", "DisplayName", "FIFA 23"),
|
||||||
|
("INSTALLED_VERSION", "InstalledVersion", "1.0.82.43747"),
|
||||||
|
("AVAILABLE_VERSION", "AvailableVersion", "1.0.82.43747"),
|
||||||
|
("UPTODATE", "UpToDate", "true"),
|
||||||
|
("FULLGAME_IS_RELEASED", "FullGameReleased", "true"),
|
||||||
|
("FULLGAME_RELEASE_DATE", "FullGameReleaseDate", "2022-09-30T00:00:00"),
|
||||||
|
("FULLGAME_PURCHASED", "FullGamePurchased", "true"),
|
||||||
|
("FREETRIAL", "FreeTrial", "false"),
|
||||||
|
("EXPIRATION", "Expiration", "0000-00-00T00:00:00"),
|
||||||
|
("ENTITLEMENT_SOURCE", "EntitlementSource", "NORMAL"),
|
||||||
|
("MAX_GROUP_SIZE", "MaxGroupSize", "0"),
|
||||||
|
("LANGUAGES", "Languages", LANGUAGES),
|
||||||
|
("INSTALLED_LANGUAGE", "InstalledLanguage", INSTALLED_LANGUAGE),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub async fn start_server(addr: &str) -> anyhow::Result<()> {
|
||||||
|
let listener = TcpListener::bind(addr).await?;
|
||||||
|
info!("LSX server listening on {addr}");
|
||||||
|
loop {
|
||||||
|
let (stream, peer) = listener.accept().await?;
|
||||||
|
debug!("LSX: connection from {peer}");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handle(stream).await {
|
||||||
|
warn!("LSX: connection error: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(mut stream: TcpStream) -> anyhow::Result<()> {
|
||||||
|
// 1. Server speaks first — send greeting
|
||||||
|
let greeting = format!(
|
||||||
|
"<LSX>\r\n <Event sender=\"EALS\">\r\n <Challenge build=\"release\" key=\"{GREETING_KEY}\" version=\"10,5,30,15625\" />\r\n </Event>\r\n</LSX>\0"
|
||||||
|
);
|
||||||
|
debug!("LSX: sending greeting");
|
||||||
|
stream.write_all(greeting.as_bytes()).await?;
|
||||||
|
|
||||||
|
// 2. Receive ChallengeResponse
|
||||||
|
let raw = read_msg(&mut stream).await?;
|
||||||
|
let text = String::from_utf8_lossy(&raw);
|
||||||
|
debug!("LSX: got ChallengeResponse: {}", &text[..text.len().min(300)]);
|
||||||
|
|
||||||
|
let parts: Vec<&str> = text.split('"').collect();
|
||||||
|
let id = parts.get(3).copied().unwrap_or("1");
|
||||||
|
let key = parts.get(7).copied().unwrap_or("");
|
||||||
|
debug!("LSX: challenge id={id} key={key}");
|
||||||
|
|
||||||
|
let our_response = make_challenge_response(key);
|
||||||
|
let seed = compute_seed(&our_response);
|
||||||
|
debug!("LSX: response={our_response} seed={seed}");
|
||||||
|
|
||||||
|
// 3. Send ChallengeAccepted
|
||||||
|
let accepted = format!(
|
||||||
|
"<LSX>\r\n <Response id=\"{id}\" sender=\"EALS\">\r\n <ChallengeAccepted response=\"{our_response}\" />\r\n </Response>\r\n</LSX>\0"
|
||||||
|
);
|
||||||
|
stream.write_all(accepted.as_bytes()).await?;
|
||||||
|
info!("LSX: handshake complete");
|
||||||
|
|
||||||
|
// 4. Session loop — multiplexes request handling with an unsolicited
|
||||||
|
// OnlineStatusEvent push. A single task keeps owning the stream; the
|
||||||
|
// `select!` services reads and lets a timer fire the push, so no socket
|
||||||
|
// split / shared writer is needed.
|
||||||
|
let mut push_timer = tokio::time::interval(ONLINE_PUSH_INTERVAL);
|
||||||
|
// interval fires immediately on the first tick — consume it so the first
|
||||||
|
// push lands after ONLINE_PUSH_INTERVAL (giving FIFA time to subscribe).
|
||||||
|
push_timer.tick().await;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
read = read_msg(&mut stream) => {
|
||||||
|
let raw = match read {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
// Dump the exact wire bytes so the session seed can be brute-forced
|
||||||
|
// offline against this ciphertext (see docs/connection-gate-findings.md).
|
||||||
|
debug!("LSX: session raw ({} bytes): {}", raw.len(), bytes_to_hex(&raw));
|
||||||
|
|
||||||
|
let out = process_frame(&raw, seed);
|
||||||
|
if out.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if stream.write_all(&out).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = push_timer.tick() => {
|
||||||
|
// Push the Login (authenticated session) then OnlineStatusEvent
|
||||||
|
// (connectivity) so FIFA leaves the "connecting" wait and starts the
|
||||||
|
// GetAuthCode→Nucleus→Blaze chain. Each is encrypted + null-terminated
|
||||||
|
// like any session frame. Re-pushed each interval to cover the
|
||||||
|
// subscribe-timing race until FIFA advances (or the socket closes).
|
||||||
|
let mut push_err = false;
|
||||||
|
for ev in ONLINE_PUSH_EVENTS {
|
||||||
|
let mut frame = lsx_encrypt(ev, seed).into_bytes();
|
||||||
|
frame.push(0);
|
||||||
|
debug!("LSX: pushing event: {ev}");
|
||||||
|
if stream.write_all(&frame).await.is_err() {
|
||||||
|
push_err = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if push_err {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("LSX: client disconnected");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one raw socket read, which may contain SEVERAL pipelined LSX messages.
|
||||||
|
///
|
||||||
|
/// FIFA batches multiple LSX messages into a single TCP segment — each a
|
||||||
|
/// null-terminated block of AES-ciphertext-as-ASCII-hex (e.g. `SetPresence`
|
||||||
|
/// immediately followed by a `GetGameInfo GameInfoId="LANGUAGES"` query). The
|
||||||
|
/// old loop decrypted the whole read as one blob and dispatched only the FIRST
|
||||||
|
/// message, silently dropping the rest. A dropped request leaves FIFA waiting on
|
||||||
|
/// a response that never arrives; it times out ~15s later and crashes before the
|
||||||
|
/// loading screen. So we split on the null terminators and answer EVERY message,
|
||||||
|
/// returning the concatenated null-terminated encrypted responses (one per
|
||||||
|
/// request), preserving order.
|
||||||
|
fn process_frame(raw: &[u8], seed: u16) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for chunk in raw.split(|&b| b == 0) {
|
||||||
|
let hex = String::from_utf8_lossy(chunk);
|
||||||
|
let hex = hex.trim();
|
||||||
|
if hex.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let decrypted = lsx_decrypt(hex, seed);
|
||||||
|
let trimmed = decrypted.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
debug!("LSX: request: {}", &trimmed[..trimmed.len().min(300)]);
|
||||||
|
let response_xml = dispatch(trimmed);
|
||||||
|
debug!("LSX: response: {}", &response_xml[..response_xml.len().min(300)]);
|
||||||
|
let encrypted = lsx_encrypt(&response_xml, seed);
|
||||||
|
out.extend_from_slice(encrypted.as_bytes());
|
||||||
|
out.push(0);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a null-terminated or fixed-size LSX message.
|
||||||
|
async fn read_msg(stream: &mut TcpStream) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let mut buf = vec![0u8; 65536];
|
||||||
|
let n = stream.read(&mut buf).await?;
|
||||||
|
if n == 0 { return Err(anyhow::anyhow!("connection closed")); }
|
||||||
|
buf.truncate(n);
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── dispatcher ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn dispatch(xml: &str) -> String {
|
||||||
|
let parts: Vec<&str> = xml.split('"').collect();
|
||||||
|
let id = parts.get(3).copied().unwrap_or("1");
|
||||||
|
let req_type = parts.get(4).copied().unwrap_or("");
|
||||||
|
debug!("LSX: dispatch id={id} type={req_type}");
|
||||||
|
|
||||||
|
match req_type {
|
||||||
|
"><GetConfig version=" => get_config(id),
|
||||||
|
"><GetAuthCode ClientId=" | "><GetAuthCode UserId=" => get_auth_code(id),
|
||||||
|
"><GetInternetConnectedState version=" => get_internet_state(id),
|
||||||
|
"><GetProfile index=" => get_profile(id),
|
||||||
|
"><GetSetting SettingId=" => {
|
||||||
|
let setting = parts.get(5).copied().unwrap_or("");
|
||||||
|
get_setting(id, setting)
|
||||||
|
}
|
||||||
|
"><QueryEntitlements UserId=" => query_entitlements(id),
|
||||||
|
"><RequestLicense UserId=" => request_license(id),
|
||||||
|
"><QueryContent UserId=" => query_content(id),
|
||||||
|
"><GetBlockList version=" => get_block_list(id),
|
||||||
|
"><QueryFriends UserId=" => query_friends(id),
|
||||||
|
"><QueryPresence UserId=" => query_presence(id),
|
||||||
|
"><SetPresence UserId=" => set_presence(id),
|
||||||
|
"><GetPresenceVisibility UserId=" => get_presence_visibility(id),
|
||||||
|
"><GetWalletBalance UserId=" => get_wallet_balance(id),
|
||||||
|
"><GetGameInfo GameInfoId=" => {
|
||||||
|
let game_info_id = parts.get(5).copied().unwrap_or("");
|
||||||
|
get_game_info(id, game_info_id)
|
||||||
|
}
|
||||||
|
"><GetAllGameInfo version=" => get_all_game_info(id),
|
||||||
|
"><IsProgressiveInstallationAvailable ItemId=" => is_progressive_installation_available(id),
|
||||||
|
_ => {
|
||||||
|
warn!("LSX: unknown request type: {req_type}");
|
||||||
|
format!("<LSX><Response id=\"{id}\" sender=\"EbisuSDK\"><Ok /></Response></LSX>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_config(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="EbisuSDK">
|
||||||
|
<GetConfigResponse>
|
||||||
|
<Service Facility="SDK" Name="EbisuSDK" />
|
||||||
|
<Service Facility="PROFILE" Name="EbisuSDK" />
|
||||||
|
<Service Facility="PRESENCE" Name="XMPP" />
|
||||||
|
<Service Facility="FRIENDS" Name="XMPP" />
|
||||||
|
<Service Facility="COMMERCE" Name="Commerce" />
|
||||||
|
<Service Facility="LOGIN" Name="EALS" />
|
||||||
|
<Service Facility="UTILITY" Name="Utility" />
|
||||||
|
<Service Facility="XMPP" Name="XMPP" />
|
||||||
|
<Service Facility="CHAT" Name="XMPP" />
|
||||||
|
<Service Facility="IGO" Name="EbisuSDK" />
|
||||||
|
<Service Facility="MISC" Name="EbisuSDK" />
|
||||||
|
<Service Facility="IGO_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="EALS_EVENTS" Name="EALS" />
|
||||||
|
<Service Facility="LOGIN_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="INVITE_EVENT" Name="XMPP" />
|
||||||
|
<Service Facility="PROFILE_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="PRESENCE_EVENT" Name="XMPP" />
|
||||||
|
<Service Facility="FRIENDS_EVENT" Name="XMPP" />
|
||||||
|
<Service Facility="COMMERCE_EVENT" Name="Commerce" />
|
||||||
|
<Service Facility="CHAT_EVENT" Name="XMPP" />
|
||||||
|
<Service Facility="DOWNLOAD_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="PERMISSION" Name="EbisuSDK" />
|
||||||
|
<Service Facility="RESOURCES" Name="EbisuSDK" />
|
||||||
|
<Service Facility="BLOCKED_USERS" Name="EbisuSDK" />
|
||||||
|
<Service Facility="BLOCKED_USER_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="GET_USERID" Name="EbisuSDK" />
|
||||||
|
<Service Facility="ONLINE_STATUS_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="ACHIEVEMENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="ACHIEVEMENT_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="BROADCAST_EVENT" Name="EbisuSDK" />
|
||||||
|
<Service Facility="RECENTPLAYER" Name="EbisuSDK" />
|
||||||
|
<Service Facility="PROGRESSIVE_INSTALLATION" Name="PI" />
|
||||||
|
<Service Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI" />
|
||||||
|
<Service Facility="CONTENT" Name="EbisuSDK" />
|
||||||
|
</GetConfigResponse>
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_auth_code(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="Utility">
|
||||||
|
<AuthCode value="OpenFUT_fake_auth_code_v1" />
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_internet_state(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="Utility">
|
||||||
|
<InternetConnectedState connected="1" />
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_profile(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="EbisuSDK">
|
||||||
|
<GetProfileResponse PersonaId="{PERSONA_ID}" Persona="{USERNAME}" Country="US" GeoCountry="US"
|
||||||
|
UserIndex="0" IsTrialSubscriber="false" AvatarId="1"
|
||||||
|
IsUnderAge="false" IsSubscriber="false" IsSteamSubscriber="false" SubscriberLevel="2"
|
||||||
|
CommerceCurrency="USD" UserId="{USER_ID}" CommerceCountry="US" />
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_setting(id: &str, setting: &str) -> String {
|
||||||
|
let value = match setting { "ENVIRONMENT" => "production", _ => "false" };
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="EbisuSDK">
|
||||||
|
<GetSettingResponse Setting="{value}" />
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_entitlements(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="Commerce">
|
||||||
|
<QueryEntitlementsResponse>
|
||||||
|
<Entitlements ItemId="Origin.OFR.50.0004658" Type="ONLINE_ACCESS"
|
||||||
|
EntitlementId="1021747550001" EntitlementTag="ONLINE_ACCESS"
|
||||||
|
Group="FIFA23PC" ResourceId="" UseCount="0"
|
||||||
|
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
|
||||||
|
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
|
||||||
|
<Entitlements ItemId="Origin.OFR.50.0004658" Type="DEFAULT"
|
||||||
|
EntitlementId="1021747550002" EntitlementTag="ONLINE_ACCESS"
|
||||||
|
Group="FIFA23PC" ResourceId="" UseCount="0"
|
||||||
|
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
|
||||||
|
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
|
||||||
|
</QueryEntitlementsResponse>
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_license(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response sender="EbisuSDK" id="{id}">
|
||||||
|
<RequestLicenseResponse License="OpenFUT_fake_license_v1" />
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_content(id: &str) -> String {
|
||||||
|
format!(r#"<LSX>
|
||||||
|
<Response id="{id}" sender="EbisuSDK">
|
||||||
|
<QueryContentResponse>
|
||||||
|
<Content Gamestate="READY_TO_PLAY" progressValue="0"
|
||||||
|
contentID="Origin.OFR.50.0004658"
|
||||||
|
installedVersion="1.0.0.0" availableVersion="1.0.0.0"
|
||||||
|
displayName="FIFA 23" />
|
||||||
|
</QueryContentResponse>
|
||||||
|
</Response>
|
||||||
|
</LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_block_list(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetBlockListResponse /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn query_friends(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryFriendsResponse /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn query_presence(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryPresenceResponse UserId="{USER_ID}" PersonaId="{PERSONA_ID}" /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn set_presence(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="XMPP"><SetPresenceResponse /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn get_presence_visibility(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetPresenceVisibilityResponse Visibility="FRIENDS" /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn get_wallet_balance(id: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{id}" sender="Commerce"><GetWalletBalanceResponse Balance="0" Currency="USD" /></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
fn get_all_game_info(id: &str) -> String {
|
||||||
|
// Populated with the full attribute set anadius emits (see GAME_INFO), plus
|
||||||
|
// the literal `HasExpiration="false"` the disassembly showed. Empty responses
|
||||||
|
// crash FIFA during load.
|
||||||
|
let mut attrs = String::new();
|
||||||
|
for (_key, attr, value) in GAME_INFO {
|
||||||
|
attrs.push_str(&format!(r#"{attr}="{value}" "#));
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetAllGameInfoResponse {attrs}HasExpiration="false" /></Response></LSX>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GetGameInfo — FIFA queries per-key EbisuSDK "game info" values during the LSX
|
||||||
|
/// session (observed keys: `FREETRIAL`, `LANGUAGES`, `INSTALLED_LANGUAGE`).
|
||||||
|
///
|
||||||
|
/// The response element is `GetGameInfoResponse` with a **single fixed attribute
|
||||||
|
/// literally named `GameInfo`** holding the requested value — NOT a per-key
|
||||||
|
/// attribute (`InstalledLanguage`, `Languages`, …) and NOT a generic `Value`.
|
||||||
|
/// Recovered by disassembling the response builder in `anadius64.dll`: it writes
|
||||||
|
/// `<` + `GetGameInfoResponse` + ` ` + `GameInfo` + `="` + <value> + `"` + ` />`,
|
||||||
|
/// with `GameInfo` a fixed 8-byte string constant regardless of the GameInfoId.
|
||||||
|
/// The value is looked up per key (from `anadius.cfg`); the attribute name never
|
||||||
|
/// changes.
|
||||||
|
///
|
||||||
|
/// This was the loading-screen crash: we were sending an attribute FIFA never
|
||||||
|
/// reads (first `Value=`, then a per-key name), so it always saw a null
|
||||||
|
/// installed-language, re-queried `INSTALLED_LANGUAGE` three times, and crashed.
|
||||||
|
/// That also explains why changing the value string (empty → `en_US`) had zero
|
||||||
|
/// effect — FIFA wasn't reading our attribute at all.
|
||||||
|
fn get_game_info(id: &str, game_info_id: &str) -> String {
|
||||||
|
// Strip surrounding quotes the positional parser can leave on the value.
|
||||||
|
let key = game_info_id.trim_matches('"');
|
||||||
|
debug!("LSX: GetGameInfo GameInfoId={key}");
|
||||||
|
// The value is per-key; the attribute name is always the literal `GameInfo`.
|
||||||
|
let value = GAME_INFO
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _, _)| *k == key)
|
||||||
|
.map(|(_, _, v)| *v)
|
||||||
|
.unwrap_or("");
|
||||||
|
format!(
|
||||||
|
r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetGameInfoResponse GameInfo="{value}" /></Response></LSX>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IsProgressiveInstallationAvailable — FIFA asks this early in the LSX session.
|
||||||
|
/// anadius emits `IsProgressiveInstallationAvailableResponse` with an `Available`
|
||||||
|
/// attribute (recovered from `anadius64.dll`); we previously fell through to the
|
||||||
|
/// generic `<Ok/>`. The game is fully installed, so progressive/streaming install
|
||||||
|
/// is not available.
|
||||||
|
///
|
||||||
|
/// The handling service is `PROGRESSIVE_INSTALLATION`, whose Name in anadius's
|
||||||
|
/// service registry is `PI` (see `get_config`), so the response is sent from
|
||||||
|
/// `sender="PI"`. This service MUST also be declared in the `GetConfig` response:
|
||||||
|
/// FIFA builds its service registry from GetConfig and resolves the
|
||||||
|
/// `PROGRESSIVE_INSTALLATION` handle from it. Omitting the service left FIFA with a
|
||||||
|
/// null handle it dereferenced during online-init, crashing at FIFA23.exe+0x133dfc5
|
||||||
|
/// (`mov rax,[r13+0x18]`, r13=NULL) ~2s after LSX — the invariant loading-screen
|
||||||
|
/// crash across every prior run.
|
||||||
|
fn is_progressive_installation_available(id: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<LSX><Response id="{id}" sender="PI"><IsProgressiveInstallationAvailableResponse Available="false" /></Response></LSX>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── crypto ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// AES-128-ECB encrypt with PKCS#7 padding, backed by the `aes` crate.
|
||||||
|
/// ECB has no per-message state, so we drive the block cipher over each
|
||||||
|
/// 16-byte chunk ourselves.
|
||||||
|
fn aes_ecb_pkcs7_encrypt(key: &[u8; 16], plaintext: &[u8]) -> Vec<u8> {
|
||||||
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||||
|
let pad = 16 - (plaintext.len() % 16);
|
||||||
|
let mut padded = plaintext.to_vec();
|
||||||
|
padded.resize(plaintext.len() + pad, pad as u8);
|
||||||
|
for chunk in padded.chunks_mut(16) {
|
||||||
|
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
|
||||||
|
}
|
||||||
|
padded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AES-128-ECB decrypt of whole blocks, then a *lenient* PKCS#7 strip.
|
||||||
|
/// Kept lenient (won't error on invalid padding) on purpose: a wrong session
|
||||||
|
/// key yields garbage we want to log and move past, not panic on. This is why
|
||||||
|
/// we don't use the crate's strict `Pkcs7` unpadder.
|
||||||
|
fn aes_ecb_decrypt_nopad(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
|
||||||
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||||
|
let mut out = Vec::with_capacity(data.len());
|
||||||
|
for chunk in data.chunks(16) {
|
||||||
|
if chunk.len() < 16 { break; }
|
||||||
|
let mut block = *GenericArray::from_slice(chunk);
|
||||||
|
cipher.decrypt_block(&mut block);
|
||||||
|
out.extend_from_slice(&block);
|
||||||
|
}
|
||||||
|
if let Some(&pad) = out.last() {
|
||||||
|
let pad = pad as usize;
|
||||||
|
if pad <= 16 && out.len() >= pad { out.truncate(out.len() - pad); }
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CRandom { seed: u32 }
|
||||||
|
impl CRandom {
|
||||||
|
fn new() -> Self { Self { seed: 0 } }
|
||||||
|
fn seed_with(&mut self, s: u32) { self.seed = s; }
|
||||||
|
fn rand(&mut self) -> u32 { self.seed=self.seed.wrapping_mul(214013).wrapping_add(2531011); (self.seed>>16)&0xFFFF }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_lsx_key(seed: u16) -> [u8; 16] {
|
||||||
|
let mut rng=CRandom::new();
|
||||||
|
rng.seed_with(7);
|
||||||
|
let next=rng.rand();
|
||||||
|
rng.seed_with(next.wrapping_add(seed as u32));
|
||||||
|
let mut k=[0u8;16]; for b in &mut k { *b=rng.rand() as u8; }
|
||||||
|
k
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_to_bytes(s: &str) -> Vec<u8> {
|
||||||
|
let s: String=s.chars().filter(|c|c.is_ascii_hexdigit()).collect();
|
||||||
|
if s.len()%2!=0 { return Vec::new(); }
|
||||||
|
(0..s.len()/2).filter_map(|i|u8::from_str_radix(&s[2*i..2*i+2],16).ok()).collect()
|
||||||
|
}
|
||||||
|
fn bytes_to_hex(b: &[u8]) -> String { b.iter().map(|x|format!("{x:02x}")).collect() }
|
||||||
|
|
||||||
|
fn compute_seed(hex: &str) -> u16 {
|
||||||
|
// The session seed is the first TWO ASCII characters of the ChallengeAccepted
|
||||||
|
// response hex string, taken as their raw byte values — NOT the leading hex
|
||||||
|
// pair parsed into a byte. Confirmed 2026-07-02 by brute-forcing the seed
|
||||||
|
// against a captured FIFA session message (see docs/connection-gate-findings.md):
|
||||||
|
// response "0f73…" → seed 0x3066 ('0','f'), not 0x0F73.
|
||||||
|
let b = hex.as_bytes();
|
||||||
|
let b0 = b.first().copied().unwrap_or(0);
|
||||||
|
let b1 = b.get(1).copied().unwrap_or(0);
|
||||||
|
((b0 as u16) << 8) | (b1 as u16)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_challenge_response(key: &str) -> String {
|
||||||
|
bytes_to_hex(&aes_ecb_pkcs7_encrypt(&AES_KEY, key.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lsx_decrypt(hex_data: &str, seed: u16) -> String {
|
||||||
|
let key=get_lsx_key(seed);
|
||||||
|
let ct=hex_to_bytes(hex_data);
|
||||||
|
if ct.is_empty() { return String::new(); }
|
||||||
|
String::from_utf8_lossy(&aes_ecb_decrypt_nopad(&key, &ct)).trim_matches('\0').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lsx_encrypt(text: &str, seed: u16) -> String {
|
||||||
|
let key=get_lsx_key(seed);
|
||||||
|
bytes_to_hex(&aes_ecb_pkcs7_encrypt(&key, text.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// FIPS-197 known-answer vector. The placeholder `AES_KEY` (00 01 .. 0f) is
|
||||||
|
/// exactly the FIPS-197 example key, so encrypting the FIPS example block
|
||||||
|
/// must yield the published ciphertext. This proves the `aes`-crate swap is
|
||||||
|
/// standard, interop-compatible AES-128 — not merely self-consistent.
|
||||||
|
#[test]
|
||||||
|
fn aes128_matches_fips197_and_round_trips() {
|
||||||
|
// FIPS-197 §C.1: plaintext 00112233445566778899aabbccddeeff
|
||||||
|
let plaintext = [
|
||||||
|
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
||||||
|
0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
|
||||||
|
];
|
||||||
|
// ...under key 000102..0f gives ciphertext 69c4e0d86a7b0430d8cdb78070b4c55a
|
||||||
|
let expected_ct = [
|
||||||
|
0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30,
|
||||||
|
0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5, 0x5a,
|
||||||
|
];
|
||||||
|
|
||||||
|
// aes_ecb_pkcs7_encrypt pads a full extra block, so only assert block 0.
|
||||||
|
let out = aes_ecb_pkcs7_encrypt(&AES_KEY, &plaintext);
|
||||||
|
assert_eq!(&out[..16], &expected_ct, "AES-128-ECB block 0 must match FIPS-197");
|
||||||
|
|
||||||
|
// Encrypt→decrypt round-trips back to the exact plaintext (padding stripped).
|
||||||
|
let round_tripped = aes_ecb_decrypt_nopad(&AES_KEY, &out);
|
||||||
|
assert_eq!(round_tripped, plaintext, "decrypt must invert encrypt");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The load-bearing regression test: when FIFA pipelines two LSX messages into
|
||||||
|
/// one frame (as it does with SetPresence + a GetGameInfo LANGUAGES query),
|
||||||
|
/// BOTH must be answered. Dropping the second hangs FIFA into a ~15s timeout
|
||||||
|
/// and a crash before the loading screen (the 2026-07-02 root cause).
|
||||||
|
#[test]
|
||||||
|
fn process_frame_answers_every_pipelined_message() {
|
||||||
|
let seed: u16 = 0x3066;
|
||||||
|
let m1 = lsx_encrypt(
|
||||||
|
r#"<LSX><Request recipient="XMPP" id="1"><SetPresence UserId="1" Presence="INGAME" version="3"/></Request></LSX>"#,
|
||||||
|
seed,
|
||||||
|
);
|
||||||
|
let m2 = lsx_encrypt(
|
||||||
|
r#"<LSX><Request recipient="EbisuSDK" id="2"><GetGameInfo GameInfoId="LANGUAGES" version="3"/></Request></LSX>"#,
|
||||||
|
seed,
|
||||||
|
);
|
||||||
|
// Two null-terminated ciphertext messages in a single read, as FIFA sends.
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
raw.extend_from_slice(m1.as_bytes());
|
||||||
|
raw.push(0);
|
||||||
|
raw.extend_from_slice(m2.as_bytes());
|
||||||
|
raw.push(0);
|
||||||
|
|
||||||
|
let out = process_frame(&raw, seed);
|
||||||
|
let responses: Vec<&[u8]> = out.split(|&b| b == 0).filter(|c| !c.is_empty()).collect();
|
||||||
|
assert_eq!(responses.len(), 2, "both pipelined requests must be answered");
|
||||||
|
|
||||||
|
let r1 = lsx_decrypt(&String::from_utf8_lossy(responses[0]), seed);
|
||||||
|
let r2 = lsx_decrypt(&String::from_utf8_lossy(responses[1]), seed);
|
||||||
|
assert!(r1.contains(r#"id="1""#), "first response is for request id 1, got: {r1}");
|
||||||
|
assert!(r2.contains(r#"id="2""#), "second response is for request id 2, got: {r2}");
|
||||||
|
assert!(r2.contains("GetGameInfoResponse"), "the piggybacked LANGUAGES query must be answered, got: {r2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pins the `split('"')` index parsing that routes GetGameInfo, so the fragile
|
||||||
|
/// positional extraction doesn't silently regress to the generic `<Ok/>` path.
|
||||||
|
/// GetConfig must declare the PROGRESSIVE_INSTALLATION service (Name="PI"),
|
||||||
|
/// recovered from anadius's service-registration table in anadius64.dll. FIFA
|
||||||
|
/// builds its service registry from GetConfig and resolves this handle during
|
||||||
|
/// online-init; omitting it left a null handle that crashed FIFA at
|
||||||
|
/// FIFA23.exe+0x133dfc5 ~2s after LSX in every run (2026-07-02).
|
||||||
|
#[test]
|
||||||
|
fn get_config_declares_progressive_installation_service() {
|
||||||
|
let resp = dispatch(r#"<LSX><Request recipient="EbisuSDK" id="1"><GetConfig version="3"/></Request></LSX>"#);
|
||||||
|
assert!(
|
||||||
|
resp.contains(r#"Facility="PROGRESSIVE_INSTALLATION" Name="PI""#),
|
||||||
|
"GetConfig must declare PROGRESSIVE_INSTALLATION→PI, got: {resp}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resp.contains(r#"Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI""#),
|
||||||
|
"GetConfig must declare PROGRESSIVE_INSTALLATION_EVENT→PI, got: {resp}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_routes_get_game_info() {
|
||||||
|
let req = r#"<LSX><Request recipient="EbisuSDK" id="42"><GetGameInfo GameInfoId="FREETRIAL" /></Request></LSX>"#;
|
||||||
|
let resp = dispatch(req);
|
||||||
|
assert!(resp.contains("<GetGameInfoResponse"), "should hit the GetGameInfo handler, got: {resp}");
|
||||||
|
assert!(resp.contains(r#"id="42""#), "should echo the request id, got: {resp}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GetGameInfo must use anadius's fixed `GameInfo="<value>"` attribute (RE'd
|
||||||
|
/// from the response builder in anadius64.dll), NOT a per-key attribute name
|
||||||
|
/// and NOT a generic `Value` — FIFA reads the value out of the `GameInfo`
|
||||||
|
/// attribute, and the wrong attribute name crashed it before the loading
|
||||||
|
/// screen (2026-07-02).
|
||||||
|
#[test]
|
||||||
|
fn dispatch_get_game_info_uses_fixed_gameinfo_attribute() {
|
||||||
|
let installed = dispatch(r#"<LSX><Request recipient="EbisuSDK" id="7"><GetGameInfo GameInfoId="INSTALLED_LANGUAGE" version="3"/></Request></LSX>"#);
|
||||||
|
assert!(installed.contains(r#"GameInfo="en_US""#), "got: {installed}");
|
||||||
|
|
||||||
|
let languages = dispatch(r#"<LSX><Request recipient="EbisuSDK" id="8"><GetGameInfo GameInfoId="LANGUAGES" version="3"/></Request></LSX>"#);
|
||||||
|
assert!(languages.contains(r#"GameInfo=""#), "LANGUAGES must use the fixed GameInfo attribute, got: {languages}");
|
||||||
|
assert!(languages.contains("en_US") && languages.contains("fr_FR"), "LANGUAGES must be the full locale list, got: {languages}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
-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,201 @@ 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);
|
// LSX server (EA App port 3216) — must be a native Linux process so that
|
||||||
|
// accept() is delivered correctly (same-process Wine loopback never fires).
|
||||||
|
let lsx_addr = std::env::var("LSX_ADDR").unwrap_or_else(|_| "127.0.0.1:3216".into());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = openfut_bridge::lsx::start_server(&lsx_addr).await {
|
||||||
|
tracing::error!("LSX server failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let addr: std::net::SocketAddr = listen_addr.parse()?;
|
||||||
|
|
||||||
|
if tls_enabled {
|
||||||
|
// Reuse a persisted cert so clients only have to trust it once.
|
||||||
|
let cert_path = std::path::Path::new(&cfg.captures_dir).join("bridge_cert.pem");
|
||||||
|
let key_path = std::path::Path::new(&cfg.captures_dir).join("bridge_key.pem");
|
||||||
|
let (cert_pem, key_pem) = openfut_bridge::tls::load_or_generate_cert(&cert_path, &key_path)?;
|
||||||
|
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}");
|
/// Extract the SNI host_name from a raw TLS ClientHello, if present.
|
||||||
axum::serve(listener, app).await?;
|
///
|
||||||
|
/// This is handshake-independent: we parse the bytes the client sends first, so we
|
||||||
Ok(())
|
/// learn the hostname even when the TLS handshake later fails (e.g. the client's
|
||||||
|
/// ProtoSSL rejects our self-signed cert). All indexing is bounds-checked; on any
|
||||||
|
/// malformed/short input we return None rather than panic.
|
||||||
|
///
|
||||||
|
/// ClientHello layout walked here: TLS record header (type=0x16, version, length) →
|
||||||
|
/// handshake header (type=0x01, length) → client_version, 32-byte random,
|
||||||
|
/// session_id, cipher_suites, compression_methods → extensions. The SNI extension
|
||||||
|
/// (type 0x0000) holds a server_name_list whose first entry (name_type 0x00 =
|
||||||
|
/// host_name) is the hostname.
|
||||||
|
fn parse_sni(buf: &[u8]) -> Option<String> {
|
||||||
|
if buf.len() < 5 || buf[0] != 0x16 {
|
||||||
|
return None; // not a TLS handshake record
|
||||||
|
}
|
||||||
|
let mut p = 5;
|
||||||
|
if buf.len() < p + 4 || buf[p] != 0x01 {
|
||||||
|
return None; // not a ClientHello
|
||||||
|
}
|
||||||
|
p += 4; // handshake msg_type(1) + length(3)
|
||||||
|
p += 2 + 32; // client_version(2) + random(32)
|
||||||
|
let sid_len = *buf.get(p)? as usize;
|
||||||
|
p += 1 + sid_len;
|
||||||
|
let cs_len = u16::from_be_bytes([*buf.get(p)?, *buf.get(p + 1)?]) as usize;
|
||||||
|
p += 2 + cs_len;
|
||||||
|
let cm_len = *buf.get(p)? as usize;
|
||||||
|
p += 1 + cm_len;
|
||||||
|
let ext_total = u16::from_be_bytes([*buf.get(p)?, *buf.get(p + 1)?]) as usize;
|
||||||
|
p += 2;
|
||||||
|
let ext_end = (p + ext_total).min(buf.len());
|
||||||
|
while p + 4 <= ext_end {
|
||||||
|
let etype = u16::from_be_bytes([buf[p], buf[p + 1]]);
|
||||||
|
let elen = u16::from_be_bytes([buf[p + 2], buf[p + 3]]) as usize;
|
||||||
|
p += 4;
|
||||||
|
if p + elen > buf.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if etype == 0x0000 {
|
||||||
|
// SNI ext: server_name_list_len(2), name_type(1), name_len(2), name
|
||||||
|
let d = &buf[p..p + elen];
|
||||||
|
if d.len() >= 5 && d[2] == 0x00 {
|
||||||
|
let nlen = u16::from_be_bytes([d[3], d[4]]) as usize;
|
||||||
|
if 5 + nlen <= d.len() {
|
||||||
|
return String::from_utf8(d[5..5 + nlen].to_vec()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p += elen;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_tls(
|
||||||
|
app: Router,
|
||||||
|
cert_pem: Vec<u8>,
|
||||||
|
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 {
|
||||||
|
// Peek the ClientHello (without consuming it) and log the SNI up front, so
|
||||||
|
// we capture the hostname even if the handshake below fails on cert pinning.
|
||||||
|
{
|
||||||
|
let mut peek = [0u8; 2048];
|
||||||
|
match tcp.peek(&mut peek).await {
|
||||||
|
Ok(n) if n > 0 => match parse_sni(&peek[..n]) {
|
||||||
|
Some(sni) => tracing::info!("ClientHello SNI (peek): {sni}"),
|
||||||
|
None => tracing::info!(
|
||||||
|
"ClientHello peek: no SNI ({n} bytes, first=0x{:02x})",
|
||||||
|
peek[0]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Ok(_) => tracing::info!("ClientHello peek: 0 bytes"),
|
||||||
|
Err(e) => tracing::warn!("ClientHello peek failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tls_stream = match acceptor.accept(tcp).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("TLS handshake failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Post-handshake SNI (only fires on success; the peek above is the reliable one).
|
||||||
|
{
|
||||||
|
let (_, server_conn) = tls_stream.get_ref();
|
||||||
|
let sni = server_conn.server_name().unwrap_or("(none)");
|
||||||
|
tracing::info!("TLS accepted — SNI: {sni}");
|
||||||
|
}
|
||||||
|
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+771
-68
@@ -4,84 +4,566 @@
|
|||||||
#[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/result",
|
||||||
|
notes: "FUT match result submit → Core match result",
|
||||||
|
},
|
||||||
|
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/result",
|
||||||
|
notes: "FUT rivals match result → Core match result",
|
||||||
|
},
|
||||||
|
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 +571,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 +584,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/result");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+96
-17
@@ -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_or_else(|e| e.into_inner());
|
||||||
|
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,11 +86,21 @@ 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 = match axum::body::to_bytes(body, 1024 * 1024).await {
|
||||||
.await
|
Ok(b) => b,
|
||||||
.unwrap_or_default();
|
Err(_) => {
|
||||||
|
let json = serde_json::to_vec(
|
||||||
|
&serde_json::json!({ "error": "request body too large (limit: 1MB)" }),
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::PAYLOAD_TOO_LARGE)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(json))
|
||||||
|
.map_err(|e| anyhow::anyhow!("response build error: {e}"))?);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let body_str = if body_bytes.is_empty() {
|
let body_str = if body_bytes.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -71,7 +119,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 +133,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 +153,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 +183,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
use std::{path::Path, sync::Arc};
|
||||||
|
use tokio_rustls::TlsAcceptor;
|
||||||
|
|
||||||
|
/// Load a previously persisted cert/key pair, or generate and save a new one.
|
||||||
|
///
|
||||||
|
/// Reusing the same cert across restarts means clients only need to trust it once.
|
||||||
|
pub fn load_or_generate_cert(cert_path: &Path, key_path: &Path) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
|
||||||
|
if cert_path.exists() && key_path.exists() {
|
||||||
|
let cert_pem = std::fs::read(cert_path)?;
|
||||||
|
let key_pem = std::fs::read(key_path)?;
|
||||||
|
tracing::info!("Loaded existing TLS cert from {:?}", cert_path);
|
||||||
|
return Ok((cert_pem, key_pem));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (cert_pem, key_pem) = generate_self_signed_cert()?;
|
||||||
|
if let Some(parent) = cert_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(cert_path, &cert_pem)?;
|
||||||
|
std::fs::write(key_path, &key_pem)?;
|
||||||
|
tracing::info!("Generated new TLS cert, saved to {:?}", cert_path);
|
||||||
|
Ok((cert_pem, key_pem))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a self-signed certificate covering EA FUT hostnames.
|
||||||
|
/// CN is set to `fut.ea.com` so ProtoSSL's CN hostname check passes.
|
||||||
|
/// SANs include wildcard entries for all `*.ea.com` and `*.easports.com` subdomains
|
||||||
|
/// so connections to any EA subdomain are covered.
|
||||||
|
pub fn generate_self_signed_cert() -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
|
||||||
|
use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, SanType};
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
let mut params = CertificateParams::new(vec![
|
||||||
|
"fut.ea.com".to_string(),
|
||||||
|
"utas.mob.v4.fut.ea.com".to_string(),
|
||||||
|
"accounts.ea.com".to_string(),
|
||||||
|
"pin.data.ea.com".to_string(),
|
||||||
|
"gateway.ea.com".to_string(),
|
||||||
|
"localhost".to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wildcard SANs cover every EA subdomain the game might contact
|
||||||
|
params.subject_alt_names.push(SanType::DnsName("*.ea.com".to_string()));
|
||||||
|
params.subject_alt_names.push(SanType::DnsName("*.easports.com".to_string()));
|
||||||
|
params.subject_alt_names.push(SanType::DnsName("*.ugc.footapi.com".to_string()));
|
||||||
|
params.subject_alt_names.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||||
|
|
||||||
|
// CN must match the primary hostname so ProtoSSL's CN check passes
|
||||||
|
let mut dn = DistinguishedName::new();
|
||||||
|
dn.push(DnType::CommonName, "fut.ea.com");
|
||||||
|
params.distinguished_name = dn;
|
||||||
|
|
||||||
|
let cert = Certificate::from_params(params)?;
|
||||||
|
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))
|
||||||
|
}
|
||||||
+232
-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,220 @@ 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: std::env::temp_dir()
|
||||||
|
.join(format!("openfut-bridge-test-{}", uuid::Uuid::new_v4()))
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
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