Compare commits
18 Commits
a826e5f7d3
...
77af7ce3c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 77af7ce3c9 | |||
| 7c779e0d72 | |||
| ab6021adac | |||
| 893ee369c2 | |||
| 72ffe9a806 | |||
| 335398df72 | |||
| 44c3a0ce5b | |||
| 30dfb25cf7 | |||
| ba1b0a09f2 | |||
| 8087564df1 | |||
| 67b84abe7c | |||
| aabbe775b3 | |||
| eae3e72fd9 | |||
| b7da8b6e83 | |||
| 00839f5f1b | |||
| f3ff291ff8 | |||
| b8e766e9dd | |||
| 3b7a4928c9 |
Generated
+302
-7
@@ -78,7 +78,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper 1.0.2",
|
"sync_wrapper 1.0.2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -122,6 +122,12 @@ version = "0.21.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -202,6 +208,12 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -325,6 +337,17 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -355,6 +378,25 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http 1.4.2",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -438,7 +480,7 @@ dependencies = [
|
|||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2 0.3.27",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.4.6",
|
"http-body 0.4.6",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -462,6 +504,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2 0.4.15",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -472,6 +515,20 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.24.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"http 0.2.12",
|
||||||
|
"hyper 0.14.32",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-tls"
|
name = "hyper-tls"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -763,6 +820,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -788,11 +851,19 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
|
"hyper 1.10.1",
|
||||||
|
"hyper-util",
|
||||||
|
"rcgen",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tokio-stream",
|
||||||
|
"tower 0.4.13",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -865,12 +936,42 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project"
|
||||||
|
version = "1.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-internal",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-internal"
|
||||||
|
version = "1.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -892,6 +993,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -916,6 +1023,18 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rcgen"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52c4f3084aa3bc7dfbba4eff4fab2a54db4324965d8872ab933565e6fbd83bc6"
|
||||||
|
dependencies = [
|
||||||
|
"pem",
|
||||||
|
"ring 0.16.20",
|
||||||
|
"time",
|
||||||
|
"yasna",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -948,15 +1067,16 @@ version = "0.11.27"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
|
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.21.7",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2 0.3.27",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http-body 0.4.6",
|
"http-body 0.4.6",
|
||||||
"hyper 0.14.32",
|
"hyper 0.14.32",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-tls",
|
"hyper-tls",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -966,6 +1086,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -974,14 +1095,45 @@ dependencies = [
|
|||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
"winreg",
|
"winreg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.16.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"spin",
|
||||||
|
"untrusted 0.7.1",
|
||||||
|
"web-sys",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted 0.9.0",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -995,13 +1147,35 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.21.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"ring 0.17.14",
|
||||||
|
"rustls-webpki",
|
||||||
|
"sct",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pemfile"
|
name = "rustls-pemfile"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.21.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.101.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
|
||||||
|
dependencies = [
|
||||||
|
"ring 0.17.14",
|
||||||
|
"untrusted 0.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1031,6 +1205,16 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sct"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
|
||||||
|
dependencies = [
|
||||||
|
"ring 0.17.14",
|
||||||
|
"untrusted 0.9.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -1177,6 +1361,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spin"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -1245,7 +1435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -1280,6 +1470,25 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.51"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -1328,6 +1537,28 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-stream"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -1341,6 +1572,21 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.4.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1460,6 +1706,18 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -1484,7 +1742,7 @@ version = "1.23.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.4.3",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -1582,6 +1840,34 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.25.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -1805,6 +2091,15 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yasna"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||||
|
dependencies = [
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
+16
-1
@@ -15,11 +15,17 @@ path = "src/lib.rs"
|
|||||||
name = "openfut-bridge"
|
name = "openfut-bridge"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "openfut-bridge-replay"
|
||||||
|
path = "src/bin/replay.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -27,10 +33,19 @@ thiserror = "1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
http = "1"
|
http = "1"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
|
rcgen = "0.11"
|
||||||
|
# TLS: same rustls version as reqwest 0.11 uses internally
|
||||||
|
rustls = "0.21"
|
||||||
|
rustls-pemfile = "1"
|
||||||
|
tokio-rustls = "0.24"
|
||||||
|
# hyper 1.x + hyper-util (same versions axum 0.7 pulls in)
|
||||||
|
hyper = { version = "1", features = ["http1", "http2"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["server-auto", "tokio"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
|||||||
@@ -13,24 +13,43 @@
|
|||||||
- [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify)
|
- [ ] #10 Identify any binary/protobuf endpoints (most are JSON but verify)
|
||||||
|
|
||||||
## Proxy
|
## Proxy
|
||||||
- [ ] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS
|
- [x] #11 Add TLS support (self-signed cert) so FIFA 23 connects via HTTPS
|
||||||
- [ ] #12 Add replay CLI: `openfut-bridge replay captures/some_file.json`
|
→ `TLS_ENABLED=true` generates cert at startup via rcgen; uses tokio-rustls
|
||||||
- [ ] #13 Add `DELETE /_bridge/captures` to wipe capture folder
|
→ Cert covers localhost, 127.0.0.1, fut.ea.com, utas.mob.v4.fut.ea.com
|
||||||
- [ ] #14 Add capture deduplication (same method+path within 1 second)
|
- [x] #12 Add replay CLI: `openfut-bridge-replay captures/some_file.json [bridge-url]`
|
||||||
- [ ] #15 Add request diff tool: show what changed between two captures
|
→ Accepts single file or entire captures/ directory
|
||||||
|
→ TLS: danger_accept_invalid_certs=true so self-signed certs work
|
||||||
|
- [x] #13 Add `DELETE /_bridge/captures` to wipe capture folder
|
||||||
|
- [x] #14 Add capture deduplication (same method+path within 1 second)
|
||||||
|
- [x] #15 Add request diff tool: `GET /_bridge/captures/diff?a=<id>&b=<id>`
|
||||||
|
→ Reports method/path/status changes, header diffs, JSON body key-level diff
|
||||||
|
|
||||||
## Mapper
|
## Mapper
|
||||||
- [ ] #16 Implement actual auth endpoint mapping (static token response)
|
- [x] #16 Implement auth endpoint mapping + response shaping
|
||||||
- [ ] #17 Map squad read endpoint when confirmed
|
→ `src/shaper.rs` wraps Core `/auth/local` response in FUT auth envelope
|
||||||
- [ ] #18 Map pack details endpoint when confirmed
|
→ Includes sid, phishingToken, persona, pid, userAccountInfo
|
||||||
- [ ] #19 Add X-UT-SID session header pass-through to Core
|
- [x] #17 Map squad read/write endpoints
|
||||||
- [ ] #20 Add phishing token passthrough
|
→ `GET /ut/game/fut/squad/active` → Core GET /squad
|
||||||
|
→ `PUT /ut/game/fut/squad/active` → Core POST /squad
|
||||||
|
- [x] #18 Map pack details endpoint
|
||||||
|
→ `GET /ut/game/fut/store/packdetails` → Core GET /packs
|
||||||
|
→ Response shaped into FUT purchasedPacks envelope
|
||||||
|
- [x] #19 Add X-UT-SID session header pass-through to Core
|
||||||
|
→ Forwarded verbatim on every mapped Core request
|
||||||
|
- [x] #20 Add phishing token passthrough
|
||||||
|
→ X-UT-PHISHING-TOKEN forwarded on every mapped Core request
|
||||||
|
|
||||||
## Admin UI
|
## Admin UI
|
||||||
- [ ] #21 Build a simple web dashboard for viewing captures
|
- [x] #21 Build a simple web dashboard for viewing captures (`GET /_bridge/admin`)
|
||||||
- [ ] #22 Add endpoint status page (known vs unknown vs confirmed)
|
→ Auto-refreshes every 10s; shows stats, endpoint table, recent captures
|
||||||
- [ ] #23 Add live capture stream via SSE
|
- [x] #22 Add endpoint status page (`GET /_bridge/status`)
|
||||||
|
→ Reports each unique endpoint as mapped/known/unknown
|
||||||
|
- [x] #23 Add live capture stream via SSE (`GET /_bridge/captures/stream`)
|
||||||
|
→ `event: capture` events pushed on each new capture
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
- [ ] #24 Add test for placeholder response format
|
- [x] #24 Add test for placeholder response format
|
||||||
- [ ] #25 Add integration test that fires real HTTP at the Bridge
|
- [x] #25 Add integration test that fires real HTTP at the Bridge
|
||||||
|
→ 13 tests: health, placeholder, captures CRUD, status, TLS cert gen
|
||||||
|
- [x] Shaper unit tests (4) — auth envelope shape, dispatch, passthrough, market
|
||||||
|
- [x] Mapper unit tests (6) — auth, market, events, squad PUT, unknown, trailing slash
|
||||||
|
|||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>OpenFUT Bridge — Admin</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.5; }
|
||||||
|
header { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 24px; display: flex; align-items: center; gap: 16px; }
|
||||||
|
header h1 { font-size: 1.1rem; color: #58a6ff; }
|
||||||
|
header .badge { font-size: 0.75rem; background: #238636; color: #fff; padding: 2px 8px; border-radius: 12px; }
|
||||||
|
main { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||||
|
section { margin-bottom: 32px; }
|
||||||
|
h2 { font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.05em; color: #8b949e; border-bottom: 1px solid #30363d; padding-bottom: 8px; margin-bottom: 16px; }
|
||||||
|
.stats { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||||
|
.stat { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 16px 24px; min-width: 140px; }
|
||||||
|
.stat .num { font-size: 2rem; font-weight: 700; color: #58a6ff; }
|
||||||
|
.stat .label { font-size: 0.8rem; color: #8b949e; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
th { text-align: left; padding: 8px 12px; background: #161b22; color: #8b949e; border-bottom: 1px solid #30363d; white-space: nowrap; }
|
||||||
|
td { padding: 8px 12px; border-bottom: 1px solid #21262d; vertical-align: top; word-break: break-all; max-width: 400px; }
|
||||||
|
tr:hover td { background: #161b22; }
|
||||||
|
.badge-method { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.GET { background: #1f6feb; color: #fff; }
|
||||||
|
.POST { background: #2ea043; color: #fff; }
|
||||||
|
.PUT { background: #9e6a03; color: #fff; }
|
||||||
|
.DELETE { background: #b91c1c; color: #fff; }
|
||||||
|
.status-mapped { color: #3fb950; }
|
||||||
|
.status-unknown { color: #f85149; }
|
||||||
|
.btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
|
||||||
|
.btn:hover { background: #30363d; }
|
||||||
|
.btn-danger { border-color: #b91c1c; color: #f85149; }
|
||||||
|
.btn-danger:hover { background: #b91c1c22; }
|
||||||
|
#live-log { height: 220px; overflow-y: auto; background: #010409; border: 1px solid #30363d; border-radius: 6px; padding: 12px; font-family: monospace; font-size: 0.78rem; color: #7ee787; }
|
||||||
|
#live-log .entry { margin-bottom: 4px; }
|
||||||
|
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||||
|
.toolbar .spacer { flex: 1; }
|
||||||
|
.empty { color: #8b949e; font-style: italic; font-size: 0.85rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>OpenFUT Bridge</h1>
|
||||||
|
<span class="badge">Admin Dashboard</span>
|
||||||
|
<span id="status-dot" style="margin-left:auto;font-size:0.8rem;color:#8b949e">connecting…</span>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<div class="stats" id="stats">
|
||||||
|
<div class="stat"><div class="num" id="s-total">—</div><div class="label">Total captures</div></div>
|
||||||
|
<div class="stat"><div class="num" id="s-unknown">—</div><div class="label">Unknown endpoints</div></div>
|
||||||
|
<div class="stat"><div class="num" id="s-endpoints">—</div><div class="label">Unique endpoints</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Live capture stream</h2>
|
||||||
|
<div id="live-log"><span class="empty">Waiting for traffic…</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Endpoint status</h2>
|
||||||
|
<div id="endpoints-table"><span class="empty">Loading…</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Recent captures</h2>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn" onclick="loadCaptures()">Refresh</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn btn-danger" onclick="clearCaptures()">Delete all captures</button>
|
||||||
|
</div>
|
||||||
|
<div id="captures-table"><span class="empty">Loading…</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
async function api(path) {
|
||||||
|
const r = await fetch(path);
|
||||||
|
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodBadge(m) {
|
||||||
|
return `<span class="badge-method ${m}">${m}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const d = await api('/_bridge/captures');
|
||||||
|
$('s-total').textContent = d.total;
|
||||||
|
$('s-unknown').textContent = d.unknown_endpoints;
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEndpoints() {
|
||||||
|
try {
|
||||||
|
const d = await api('/_bridge/status');
|
||||||
|
$('s-endpoints').textContent = d.total_unique_endpoints;
|
||||||
|
if (!d.endpoints.length) {
|
||||||
|
$('endpoints-table').innerHTML = '<span class="empty">No endpoints seen yet.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = d.endpoints.map(ep => `
|
||||||
|
<tr>
|
||||||
|
<td>${methodBadge(ep.method)}</td>
|
||||||
|
<td><code>${ep.path}</code></td>
|
||||||
|
<td class="status-${ep.status}">${ep.status}</td>
|
||||||
|
<td>${ep.mapped_to || '—'}</td>
|
||||||
|
<td>${ep.first_seen ? ep.first_seen.slice(0,19).replace('T',' ') : '—'}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
$('endpoints-table').innerHTML = `
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Method</th><th>Path</th><th>Status</th><th>Maps to</th><th>First seen</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
} catch (e) { $('endpoints-table').innerHTML = `<span class="empty">Error: ${e.message}</span>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCaptures() {
|
||||||
|
try {
|
||||||
|
const d = await api('/_bridge/captures');
|
||||||
|
if (!d.captures.length) {
|
||||||
|
$('captures-table').innerHTML = '<span class="empty">No captures yet.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = d.captures.slice(-50).reverse().map(c => `
|
||||||
|
<tr>
|
||||||
|
<td>${c.timestamp ? c.timestamp.slice(0,19).replace('T',' ') : ''}</td>
|
||||||
|
<td>${methodBadge(c.method)}</td>
|
||||||
|
<td><code>${c.path}${c.query ? '?' + c.query : ''}</code></td>
|
||||||
|
<td>${c.response_status || '—'}</td>
|
||||||
|
<td class="status-${c.mapped_to_core ? 'mapped' : 'unknown'}">${c.mapped_to_core || 'unknown'}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
$('captures-table').innerHTML = `
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Time</th><th>Method</th><th>Path</th><th>Status</th><th>Core mapping</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
} catch (e) { $('captures-table').innerHTML = `<span class="empty">Error: ${e.message}</span>`; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCaptures() {
|
||||||
|
if (!confirm('Delete all capture files from disk?')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/_bridge/captures', { method: 'DELETE' });
|
||||||
|
const d = await r.json();
|
||||||
|
alert(`Deleted ${d.deleted} capture(s).`);
|
||||||
|
loadCaptures(); loadStats();
|
||||||
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSSE() {
|
||||||
|
const es = new EventSource('/_bridge/captures/stream');
|
||||||
|
const log = $('live-log');
|
||||||
|
|
||||||
|
es.onopen = () => { $('status-dot').textContent = '● live'; $('status-dot').style.color = '#3fb950'; };
|
||||||
|
es.onerror = () => { $('status-dot').textContent = '○ disconnected'; $('status-dot').style.color = '#f85149'; };
|
||||||
|
|
||||||
|
es.addEventListener('capture', e => {
|
||||||
|
try {
|
||||||
|
const c = JSON.parse(e.data);
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'entry';
|
||||||
|
entry.textContent = `${c.timestamp?.slice(11,19) || ''} ${c.method} ${c.path} → ${c.response_status || '?'} [${c.mapped_to_core || 'unknown'}]`;
|
||||||
|
if (log.firstChild?.className === 'empty') log.innerHTML = '';
|
||||||
|
log.prepend(entry);
|
||||||
|
if (log.children.length > 100) log.lastChild?.remove();
|
||||||
|
loadStats();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadStats();
|
||||||
|
loadEndpoints();
|
||||||
|
loadCaptures();
|
||||||
|
connectSSE();
|
||||||
|
setInterval(() => { loadEndpoints(); loadCaptures(); }, 10_000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! openfut-bridge-replay — replay a captured request file against the bridge.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! openfut-bridge-replay \<capture.json\> [bridge-url]
|
||||||
|
//! openfut-bridge-replay captures/ (replay all captures in a dir)
|
||||||
|
//!
|
||||||
|
//! The bridge URL defaults to `http://127.0.0.1:8443`.
|
||||||
|
//! Self-signed TLS certificates are accepted automatically.
|
||||||
|
|
||||||
|
use openfut_bridge::capture::CapturedRequest;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> ExitCode {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: openfut-bridge-replay <capture.json|dir> [bridge-url]");
|
||||||
|
eprintln!(" bridge-url defaults to http://127.0.0.1:8443");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_arg = &args[1];
|
||||||
|
let bridge_url = args
|
||||||
|
.get(2)
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("http://127.0.0.1:8443");
|
||||||
|
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to build HTTP client: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let captures = match collect_captures(path_arg) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error loading captures: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if captures.is_empty() {
|
||||||
|
eprintln!("No capture files found at {path_arg}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Replaying {} capture(s) against {bridge_url}",
|
||||||
|
captures.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut failures = 0u32;
|
||||||
|
for capture in &captures {
|
||||||
|
let result = replay_one(&client, bridge_url, capture).await;
|
||||||
|
match result {
|
||||||
|
Ok(status) => println!(" [{}] {} {} → {status}", capture.id, capture.method, capture.path),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" [{}] {} {} → ERROR: {e}", capture.id, capture.method, capture.path);
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failures > 0 {
|
||||||
|
eprintln!("{failures} replay(s) failed.");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
} else {
|
||||||
|
println!("All replays succeeded.");
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_captures(path_arg: &str) -> anyhow::Result<Vec<CapturedRequest>> {
|
||||||
|
let path = std::path::Path::new(path_arg);
|
||||||
|
if path.is_dir() {
|
||||||
|
openfut_bridge::capture::load_all_captures(path_arg)
|
||||||
|
} else {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let capture: CapturedRequest = serde_json::from_str(&content)?;
|
||||||
|
Ok(vec![capture])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn replay_one(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
bridge_url: &str,
|
||||||
|
capture: &CapturedRequest,
|
||||||
|
) -> anyhow::Result<u16> {
|
||||||
|
let url = format!("{}{}", bridge_url, capture.path);
|
||||||
|
let builder = match capture.method.to_uppercase().as_str() {
|
||||||
|
"POST" => client.post(&url),
|
||||||
|
"PUT" => client.put(&url),
|
||||||
|
"DELETE" => client.delete(&url),
|
||||||
|
_ => client.get(&url),
|
||||||
|
};
|
||||||
|
|
||||||
|
let builder = if let Some(body) = &capture.body {
|
||||||
|
builder
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(body.clone())
|
||||||
|
} else {
|
||||||
|
builder
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = builder.send().await?;
|
||||||
|
Ok(resp.status().as_u16())
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ pub struct Config {
|
|||||||
pub captures_dir: String,
|
pub captures_dir: String,
|
||||||
/// If true, return placeholder 200 responses for unknown routes
|
/// If true, return placeholder 200 responses for unknown routes
|
||||||
pub placeholder_mode: bool,
|
pub placeholder_mode: bool,
|
||||||
|
/// If true, serve over TLS with a generated self-signed certificate
|
||||||
|
pub tls_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -22,6 +24,9 @@ impl Config {
|
|||||||
placeholder_mode: std::env::var("PLACEHOLDER_MODE")
|
placeholder_mode: std::env::var("PLACEHOLDER_MODE")
|
||||||
.map(|v| v == "true" || v == "1")
|
.map(|v| v == "true" || v == "1")
|
||||||
.unwrap_or(true),
|
.unwrap_or(true),
|
||||||
|
tls_enabled: std::env::var("TLS_ENABLED")
|
||||||
|
.map(|v| v == "true" || v == "1")
|
||||||
|
.unwrap_or(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2939
File diff suppressed because it is too large
Load Diff
@@ -4,3 +4,5 @@ pub mod error;
|
|||||||
pub mod mapper;
|
pub mod mapper;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
pub mod shaper;
|
||||||
|
pub mod tls;
|
||||||
|
|||||||
+92
-10
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{any, get},
|
routing::{any, delete, get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use openfut_bridge::{config::Config, proxy::ProxyState, routes};
|
use openfut_bridge::{config::Config, proxy::ProxyState, routes};
|
||||||
@@ -22,31 +22,113 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let cfg = Config::from_env()?;
|
let cfg = Config::from_env()?;
|
||||||
let listen_addr = cfg.listen_addr.clone();
|
let listen_addr = cfg.listen_addr.clone();
|
||||||
|
let tls_enabled = cfg.tls_enabled;
|
||||||
|
|
||||||
info!("OpenFUT Bridge starting on {listen_addr}");
|
info!("OpenFUT Bridge starting on {listen_addr}");
|
||||||
info!("Core URL: {}", cfg.core_url);
|
info!("Core URL: {}", cfg.core_url);
|
||||||
info!("Placeholder mode: {}", cfg.placeholder_mode);
|
info!("Placeholder mode: {}", cfg.placeholder_mode);
|
||||||
info!("Captures dir: {}", cfg.captures_dir);
|
info!("Captures dir: {}", cfg.captures_dir);
|
||||||
|
info!("TLS enabled: {tls_enabled}");
|
||||||
|
|
||||||
std::fs::create_dir_all(&cfg.captures_dir)?;
|
std::fs::create_dir_all(&cfg.captures_dir)?;
|
||||||
|
|
||||||
let state = ProxyState::new(cfg);
|
let addr: std::net::SocketAddr = listen_addr.parse()?;
|
||||||
|
|
||||||
let app = Router::new()
|
if tls_enabled {
|
||||||
|
// Generate cert/key before building state so the cert can be served via /_bridge/cert.pem
|
||||||
|
let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert()?;
|
||||||
|
let state = ProxyState::new(cfg).with_cert(cert_pem.clone());
|
||||||
|
let app = build_router(state);
|
||||||
|
serve_tls(app, cert_pem, key_pem, addr).await
|
||||||
|
} else {
|
||||||
|
let state = ProxyState::new(cfg);
|
||||||
|
let app = build_router(state);
|
||||||
|
info!("Bridge listening on http://{addr}");
|
||||||
|
info!("Set TLS_ENABLED=true to serve over HTTPS");
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_router(state: ProxyState) -> Router {
|
||||||
|
Router::new()
|
||||||
.route("/_bridge/health", get(routes::health::get_health))
|
.route("/_bridge/health", get(routes::health::get_health))
|
||||||
|
.route("/_bridge/cert.pem", get(routes::health::get_cert))
|
||||||
|
.route("/_bridge/guide", get(routes::health::get_guide))
|
||||||
|
.route("/_bridge/dashboard", get(routes::health::get_dashboard))
|
||||||
|
.route("/_bridge/admin", get(routes::admin::get_admin_dashboard))
|
||||||
.route("/_bridge/captures", get(routes::admin::get_captures))
|
.route("/_bridge/captures", get(routes::admin::get_captures))
|
||||||
|
.route("/_bridge/captures", delete(routes::admin::delete_captures))
|
||||||
|
.route(
|
||||||
|
"/_bridge/captures/stream",
|
||||||
|
get(routes::admin::get_captures_stream),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/_bridge/unknown",
|
"/_bridge/unknown",
|
||||||
get(routes::admin::get_unknown_endpoints),
|
get(routes::admin::get_unknown_endpoints),
|
||||||
)
|
)
|
||||||
|
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
|
||||||
|
.route(
|
||||||
|
"/_bridge/captures/diff",
|
||||||
|
get(routes::admin::get_capture_diff),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/_bridge/captures/:id/replay",
|
||||||
|
post(routes::admin::post_replay_capture),
|
||||||
|
)
|
||||||
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.with_state(state);
|
.with_state(state)
|
||||||
|
}
|
||||||
let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
|
|
||||||
info!("Bridge listening on http://{listen_addr}");
|
async fn serve_tls(
|
||||||
axum::serve(listener, app).await?;
|
app: Router,
|
||||||
|
cert_pem: Vec<u8>,
|
||||||
Ok(())
|
key_pem: Vec<u8>,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
) -> Result<()> {
|
||||||
|
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||||
|
use openfut_bridge::tls::make_tls_acceptor;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
let acceptor = make_tls_acceptor(&cert_pem, &key_pem)?;
|
||||||
|
|
||||||
|
info!("Bridge listening on https://{addr} (self-signed TLS)");
|
||||||
|
info!("Download the CA cert from /_bridge/cert.pem and install it as trusted");
|
||||||
|
info!("Setup guide: https://{addr}/_bridge/guide");
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (tcp, _peer) = listener.accept().await?;
|
||||||
|
let acceptor = acceptor.clone();
|
||||||
|
let app = app.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let tls_stream = match acceptor.accept(tcp).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("TLS handshake failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let io = TokioIo::new(tls_stream);
|
||||||
|
let svc = hyper::service::service_fn(
|
||||||
|
move |req: hyper::Request<hyper::body::Incoming>| {
|
||||||
|
let app = app.clone();
|
||||||
|
async move { app.oneshot(req.map(axum::body::Body::new)).await }
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
|
||||||
|
.serve_connection(io, svc)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::debug!("TLS connection closed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+82
-14
@@ -6,31 +6,69 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
capture::{save_capture, CapturedRequest},
|
capture::{save_capture, CapturedRequest},
|
||||||
config::Config,
|
config::Config,
|
||||||
error::BridgeResult,
|
error::BridgeResult,
|
||||||
mapper::{map_to_core, placeholder_response},
|
mapper::{map_to_core, placeholder_response},
|
||||||
|
shaper::shape_response,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CAPTURE_DEDUP_SECS: u64 = 1;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ProxyState {
|
pub struct ProxyState {
|
||||||
pub config: Arc<Config>,
|
pub config: Arc<Config>,
|
||||||
pub http_client: reqwest::Client,
|
pub http_client: reqwest::Client,
|
||||||
|
/// Broadcast channel for streaming new captures to SSE subscribers.
|
||||||
|
pub capture_tx: Arc<broadcast::Sender<CapturedRequest>>,
|
||||||
|
/// Deduplication window: (method+path) → last saved instant.
|
||||||
|
pub dedup: Arc<Mutex<HashMap<String, Instant>>>,
|
||||||
|
/// PEM-encoded TLS certificate for download. None when TLS is disabled.
|
||||||
|
pub cert_pem: Option<Arc<Vec<u8>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProxyState {
|
impl ProxyState {
|
||||||
pub fn new(config: Config) -> Self {
|
pub fn new(config: Config) -> Self {
|
||||||
|
let (capture_tx, _) = broadcast::channel(256);
|
||||||
Self {
|
Self {
|
||||||
config: Arc::new(config),
|
config: Arc::new(config),
|
||||||
http_client: reqwest::Client::builder()
|
http_client: reqwest::Client::builder()
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()
|
.build()
|
||||||
.expect("failed to build HTTP client"),
|
.expect("failed to build HTTP client"),
|
||||||
|
capture_tx: Arc::new(capture_tx),
|
||||||
|
dedup: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
cert_pem: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_cert(mut self, cert_pem: Vec<u8>) -> Self {
|
||||||
|
self.cert_pem = Some(Arc::new(cert_pem));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this (method, path) pair was already saved within the dedup window.
|
||||||
|
fn is_duplicate(dedup: &Mutex<HashMap<String, Instant>>, method: &str, path: &str) -> bool {
|
||||||
|
let key = format!("{method} {path}");
|
||||||
|
let mut map = dedup.lock().unwrap();
|
||||||
|
let threshold = std::time::Duration::from_secs(CAPTURE_DEDUP_SECS);
|
||||||
|
if let Some(last) = map.get(&key) {
|
||||||
|
if last.elapsed() < threshold {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.insert(key, Instant::now());
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn catch_all_handler(
|
pub async fn catch_all_handler(
|
||||||
@@ -48,7 +86,6 @@ pub async fn catch_all_handler(
|
|||||||
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string()))
|
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("<binary>").to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Extract body via axum's built-in mechanism
|
|
||||||
let (_parts, body) = req.into_parts();
|
let (_parts, body) = req.into_parts();
|
||||||
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024)
|
let body_bytes: Bytes = axum::body::to_bytes(body, 1024 * 1024)
|
||||||
.await
|
.await
|
||||||
@@ -71,7 +108,7 @@ pub async fn catch_all_handler(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut capture =
|
let mut capture =
|
||||||
CapturedRequest::new(&method, &path, query.as_deref(), headers, body_str.clone());
|
CapturedRequest::new(&method, &path, query.as_deref(), headers.clone(), body_str.clone());
|
||||||
|
|
||||||
let (response_body, status_code): (Value, u16) =
|
let (response_body, status_code): (Value, u16) =
|
||||||
if let Some(mapping) = map_to_core(&method, &path) {
|
if let Some(mapping) = map_to_core(&method, &path) {
|
||||||
@@ -85,12 +122,13 @@ pub async fn catch_all_handler(
|
|||||||
match forward_to_core(
|
match forward_to_core(
|
||||||
&state,
|
&state,
|
||||||
mapping.method,
|
mapping.method,
|
||||||
mapping.core_path,
|
&mapping.core_path,
|
||||||
body_str.as_deref(),
|
body_str.as_deref(),
|
||||||
|
&headers,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((body, status)) => (body, status),
|
Ok((body, status)) => (shape_response(&mapping.core_path, body), status),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Core request failed: {e}");
|
tracing::error!("Core request failed: {e}");
|
||||||
(serde_json::json!({ "error": e.to_string() }), 502)
|
(serde_json::json!({ "error": e.to_string() }), 502)
|
||||||
@@ -104,13 +142,22 @@ pub async fn catch_all_handler(
|
|||||||
|
|
||||||
capture = capture.with_response(status_code, Some(response_body.to_string()));
|
capture = capture.with_response(status_code, Some(response_body.to_string()));
|
||||||
|
|
||||||
let captures_dir = state.config.captures_dir.clone();
|
// Deduplicate: skip saving if same method+path was saved within 1 second
|
||||||
let capture_clone = capture.clone();
|
let should_save = !is_duplicate(&state.dedup, &method, &path);
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = save_capture(&captures_dir, &capture_clone) {
|
if should_save {
|
||||||
tracing::warn!("Failed to save capture: {e}");
|
let captures_dir = state.config.captures_dir.clone();
|
||||||
}
|
let capture_clone = capture.clone();
|
||||||
});
|
let capture_tx = state.capture_tx.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = save_capture(&captures_dir, &capture_clone) {
|
||||||
|
tracing::warn!("Failed to save capture: {e}");
|
||||||
|
}
|
||||||
|
// Broadcast to SSE subscribers (ignore send errors — no subscribers is OK)
|
||||||
|
let _ = capture_tx.send(capture_clone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK);
|
let status = StatusCode::from_u16(status_code).unwrap_or(StatusCode::OK);
|
||||||
let json_bytes =
|
let json_bytes =
|
||||||
@@ -125,21 +172,42 @@ pub async fn catch_all_handler(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public alias for use by the replay route in admin.rs.
|
||||||
|
pub async fn forward_to_core_pub(
|
||||||
|
state: &ProxyState,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
headers: &[(String, String)],
|
||||||
|
) -> anyhow::Result<(Value, u16)> {
|
||||||
|
forward_to_core(state, method, path, body, headers).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn forward_to_core(
|
async fn forward_to_core(
|
||||||
state: &ProxyState,
|
state: &ProxyState,
|
||||||
method: &str,
|
method: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
body: Option<&str>,
|
body: Option<&str>,
|
||||||
|
incoming_headers: &[(String, String)],
|
||||||
) -> anyhow::Result<(Value, u16)> {
|
) -> anyhow::Result<(Value, u16)> {
|
||||||
let url = format!("{}{}", state.config.core_url, path);
|
let url = format!("{}{}", state.config.core_url, path);
|
||||||
let builder = match method {
|
let mut builder = match method {
|
||||||
"POST" => state.http_client.post(&url),
|
"POST" => state.http_client.post(&url),
|
||||||
"PUT" => state.http_client.put(&url),
|
"PUT" => state.http_client.put(&url),
|
||||||
"DELETE" => state.http_client.delete(&url),
|
"DELETE" => state.http_client.delete(&url),
|
||||||
_ => state.http_client.get(&url),
|
_ => state.http_client.get(&url),
|
||||||
};
|
};
|
||||||
|
|
||||||
let builder = if let Some(b) = body {
|
// Pass through FUT session and anti-phishing headers so Core can log/correlate them.
|
||||||
|
// These are used in #19 (X-UT-SID) and #20 (X-UT-PHISHING-TOKEN) passthrough.
|
||||||
|
for (k, v) in incoming_headers {
|
||||||
|
let lower = k.to_lowercase();
|
||||||
|
if lower == "x-ut-sid" || lower == "x-ut-phishing-token" || lower == "x-request-id" {
|
||||||
|
builder = builder.header(k.as_str(), v.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder = if let Some(b) = body {
|
||||||
builder
|
builder
|
||||||
.header("content-type", "application/json")
|
.header("content-type", "application/json")
|
||||||
.body(b.to_string())
|
.body(b.to_string())
|
||||||
|
|||||||
+315
-7
@@ -1,12 +1,26 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{
|
||||||
|
sse::{Event, KeepAlive, Sse},
|
||||||
|
IntoResponse, Json, Response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use tokio_stream::{wrappers::BroadcastStream, StreamExt};
|
||||||
|
|
||||||
use crate::{capture::load_all_captures, error::BridgeResult, proxy::ProxyState};
|
use crate::{
|
||||||
|
capture::load_all_captures,
|
||||||
|
error::{BridgeError, BridgeResult},
|
||||||
|
mapper::map_to_core_with_method,
|
||||||
|
proxy::ProxyState,
|
||||||
|
};
|
||||||
|
|
||||||
/// List all captured requests.
|
/// List all captured requests.
|
||||||
pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
let captures = load_all_captures(&state.config.captures_dir)
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
.map_err(crate::error::BridgeError::Internal)?;
|
|
||||||
|
|
||||||
let unknown: Vec<_> = captures
|
let unknown: Vec<_> = captures
|
||||||
.iter()
|
.iter()
|
||||||
@@ -20,10 +34,9 @@ pub async fn get_captures(State(state): State<ProxyState>) -> BridgeResult<Json<
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List only unknown (unmapped) endpoints.
|
/// List only unknown (unmapped) endpoints, deduplicated by method+path.
|
||||||
pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
let captures = load_all_captures(&state.config.captures_dir)
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
.map_err(crate::error::BridgeError::Internal)?;
|
|
||||||
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
let unknown: Vec<Value> = captures
|
let unknown: Vec<Value> = captures
|
||||||
@@ -49,3 +62,298 @@ pub async fn get_unknown_endpoints(State(state): State<ProxyState>) -> BridgeRes
|
|||||||
"endpoints": unknown,
|
"endpoints": unknown,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wipe all capture files from disk.
|
||||||
|
pub async fn delete_captures(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
|
let dir = std::path::Path::new(&state.config.captures_dir);
|
||||||
|
let mut deleted = 0u64;
|
||||||
|
if dir.exists() {
|
||||||
|
for entry in std::fs::read_dir(dir).map_err(BridgeError::Io)? {
|
||||||
|
let entry = entry.map_err(BridgeError::Io)?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||||
|
std::fs::remove_file(&path).map_err(BridgeError::Io)?;
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(
|
||||||
|
json!({ "deleted": deleted, "message": "capture folder cleared" }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-sent events stream: pushes each new capture to subscribed clients.
|
||||||
|
pub async fn get_captures_stream(
|
||||||
|
State(state): State<ProxyState>,
|
||||||
|
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||||
|
let rx = state.capture_tx.subscribe();
|
||||||
|
let stream = BroadcastStream::new(rx).filter_map(|result| {
|
||||||
|
result.ok().and_then(|capture| {
|
||||||
|
serde_json::to_string(&capture)
|
||||||
|
.ok()
|
||||||
|
.map(|json| Ok(Event::default().event("capture").data(json)))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Endpoint status summary: known/mapped/unknown across all captures.
|
||||||
|
pub async fn get_endpoint_status(State(state): State<ProxyState>) -> BridgeResult<Json<Value>> {
|
||||||
|
use crate::mapper::map_to_core;
|
||||||
|
|
||||||
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
|
|
||||||
|
let mut seen: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
||||||
|
for c in &captures {
|
||||||
|
let key = format!("{} {}", c.method, c.path);
|
||||||
|
seen.entry(key).or_insert_with(|| {
|
||||||
|
let status = if c.mapped_to_core.is_some() {
|
||||||
|
"mapped"
|
||||||
|
} else if map_to_core(&c.method, &c.path).is_some() {
|
||||||
|
"known"
|
||||||
|
} else {
|
||||||
|
"unknown"
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"method": c.method,
|
||||||
|
"path": c.path,
|
||||||
|
"status": status,
|
||||||
|
"mapped_to": c.mapped_to_core,
|
||||||
|
"first_seen": c.timestamp,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut endpoints: Vec<Value> = seen.into_values().collect();
|
||||||
|
endpoints.sort_by(|a, b| {
|
||||||
|
a["path"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.cmp(b["path"].as_str().unwrap_or(""))
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"total_unique_endpoints": endpoints.len(),
|
||||||
|
"endpoints": endpoints,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query params for the diff endpoint.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DiffQuery {
|
||||||
|
pub a: String,
|
||||||
|
pub b: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff two captures by ID: shows what changed between them.
|
||||||
|
///
|
||||||
|
/// Usage: GET /_bridge/captures/diff?a=<uuid>&b=<uuid>
|
||||||
|
pub async fn get_capture_diff(
|
||||||
|
State(state): State<ProxyState>,
|
||||||
|
Query(params): Query<DiffQuery>,
|
||||||
|
) -> BridgeResult<Json<Value>> {
|
||||||
|
let captures = load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
|
|
||||||
|
let find = |id: &str| -> Option<&crate::capture::CapturedRequest> {
|
||||||
|
captures.iter().find(|c| c.id == id)
|
||||||
|
};
|
||||||
|
|
||||||
|
let cap_a = find(¶ms.a)
|
||||||
|
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.a)))?;
|
||||||
|
let cap_b = find(¶ms.b)
|
||||||
|
.ok_or_else(|| BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", params.b)))?;
|
||||||
|
|
||||||
|
// Structural JSON diff of request bodies
|
||||||
|
let body_diff = diff_json_strings(cap_a.body.as_deref(), cap_b.body.as_deref());
|
||||||
|
let resp_diff = diff_json_strings(
|
||||||
|
cap_a.response_body.as_deref(),
|
||||||
|
cap_b.response_body.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Header diff: keys present in one but not the other
|
||||||
|
let headers_a: std::collections::HashSet<String> =
|
||||||
|
cap_a.headers.iter().map(|(k, _)| k.clone()).collect();
|
||||||
|
let headers_b: std::collections::HashSet<String> =
|
||||||
|
cap_b.headers.iter().map(|(k, _)| k.clone()).collect();
|
||||||
|
|
||||||
|
let headers_only_in_a: Vec<_> = headers_a.difference(&headers_b).collect();
|
||||||
|
let headers_only_in_b: Vec<_> = headers_b.difference(&headers_a).collect();
|
||||||
|
|
||||||
|
// Headers present in both but with different values
|
||||||
|
let headers_changed: Vec<Value> = cap_a
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(k, va)| {
|
||||||
|
cap_b.headers.iter().find(|(kb, _)| kb == k).and_then(|(_, vb)| {
|
||||||
|
if va != vb {
|
||||||
|
Some(json!({ "header": k, "a": va, "b": vb }))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"a": { "id": cap_a.id, "timestamp": cap_a.timestamp, "method": cap_a.method, "path": cap_a.path },
|
||||||
|
"b": { "id": cap_b.id, "timestamp": cap_b.timestamp, "method": cap_b.method, "path": cap_b.path },
|
||||||
|
"method_changed": cap_a.method != cap_b.method,
|
||||||
|
"path_changed": cap_a.path != cap_b.path,
|
||||||
|
"status_a": cap_a.response_status,
|
||||||
|
"status_b": cap_b.response_status,
|
||||||
|
"status_changed": cap_a.response_status != cap_b.response_status,
|
||||||
|
"headers": {
|
||||||
|
"only_in_a": headers_only_in_a,
|
||||||
|
"only_in_b": headers_only_in_b,
|
||||||
|
"changed": headers_changed,
|
||||||
|
},
|
||||||
|
"body_diff": body_diff,
|
||||||
|
"response_diff": resp_diff,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shallow JSON diff of two optional JSON strings.
|
||||||
|
///
|
||||||
|
/// If both parse as JSON objects, lists keys that differ or are absent.
|
||||||
|
/// Falls back to string comparison if either is not valid JSON.
|
||||||
|
fn diff_json_strings(a: Option<&str>, b: Option<&str>) -> Value {
|
||||||
|
match (a, b) {
|
||||||
|
(None, None) => json!({ "same": true, "note": "both empty" }),
|
||||||
|
(Some(_), None) => json!({ "same": false, "note": "b has no body" }),
|
||||||
|
(None, Some(_)) => json!({ "same": false, "note": "a has no body" }),
|
||||||
|
(Some(a_str), Some(b_str)) => {
|
||||||
|
if a_str == b_str {
|
||||||
|
return json!({ "same": true });
|
||||||
|
}
|
||||||
|
// Try to parse as JSON objects for a structured diff
|
||||||
|
let va: Option<serde_json::Map<String, Value>> =
|
||||||
|
serde_json::from_str(a_str).ok();
|
||||||
|
let vb: Option<serde_json::Map<String, Value>> =
|
||||||
|
serde_json::from_str(b_str).ok();
|
||||||
|
|
||||||
|
match (va, vb) {
|
||||||
|
(Some(ma), Some(mb)) => {
|
||||||
|
let keys_a: std::collections::HashSet<&str> =
|
||||||
|
ma.keys().map(|s| s.as_str()).collect();
|
||||||
|
let keys_b: std::collections::HashSet<&str> =
|
||||||
|
mb.keys().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
let only_in_a: Vec<&str> = keys_a.difference(&keys_b).copied().collect();
|
||||||
|
let only_in_b: Vec<&str> = keys_b.difference(&keys_a).copied().collect();
|
||||||
|
let changed: Vec<Value> = keys_a
|
||||||
|
.intersection(&keys_b)
|
||||||
|
.filter_map(|k| {
|
||||||
|
let va = ma.get(*k)?;
|
||||||
|
let vb = mb.get(*k)?;
|
||||||
|
if va != vb {
|
||||||
|
Some(json!({ "key": k, "a": va, "b": vb }))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"same": false,
|
||||||
|
"type": "json_object_diff",
|
||||||
|
"keys_only_in_a": only_in_a,
|
||||||
|
"keys_only_in_b": only_in_b,
|
||||||
|
"keys_changed": changed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => json!({
|
||||||
|
"same": false,
|
||||||
|
"type": "string_diff",
|
||||||
|
"a_len": a_str.len(),
|
||||||
|
"b_len": b_str.len(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay a captured request against Core and return the result alongside the original response.
|
||||||
|
///
|
||||||
|
/// Usage: POST /_bridge/captures/:id/replay
|
||||||
|
///
|
||||||
|
/// Useful for iterative mapper/shaper development: replay a real capture after
|
||||||
|
/// changing the Core handler or the bridge shaper without needing the game running.
|
||||||
|
pub async fn post_replay_capture(
|
||||||
|
State(state): State<ProxyState>,
|
||||||
|
Path(capture_id): Path<String>,
|
||||||
|
) -> BridgeResult<Json<Value>> {
|
||||||
|
let captures =
|
||||||
|
load_all_captures(&state.config.captures_dir).map_err(BridgeError::Internal)?;
|
||||||
|
|
||||||
|
let capture = captures
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == capture_id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
BridgeError::Internal(anyhow::anyhow!("capture '{}' not found", capture_id))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mapping = map_to_core_with_method(&capture.method, &capture.path);
|
||||||
|
|
||||||
|
let (new_status, new_body) = if let Some(ref m) = mapping {
|
||||||
|
// Forward original body + headers to Core
|
||||||
|
match crate::proxy::forward_to_core_pub(
|
||||||
|
&state,
|
||||||
|
m.method,
|
||||||
|
&m.core_path,
|
||||||
|
capture.body.as_deref(),
|
||||||
|
&capture.headers,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((body, status)) => (
|
||||||
|
status,
|
||||||
|
crate::shaper::shape_response(&m.core_path, body),
|
||||||
|
),
|
||||||
|
Err(e) => (
|
||||||
|
502u16,
|
||||||
|
json!({ "error": format!("core forwarding failed: {e}") }),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
404,
|
||||||
|
json!({ "error": "no mapping found for this endpoint", "method": capture.method, "path": capture.path }),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse original response body for display
|
||||||
|
let original_body: Value = capture
|
||||||
|
.response_body
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"capture_id": capture_id,
|
||||||
|
"method": capture.method,
|
||||||
|
"path": capture.path,
|
||||||
|
"mapped_to": mapping.as_ref().map(|m| format!("{} {}", m.method, m.core_path)),
|
||||||
|
"original": {
|
||||||
|
"status": capture.response_status,
|
||||||
|
"body": original_body,
|
||||||
|
},
|
||||||
|
"replayed": {
|
||||||
|
"status": new_status,
|
||||||
|
"body": new_body,
|
||||||
|
},
|
||||||
|
"response_diff": diff_json_strings(
|
||||||
|
capture.response_body.as_deref(),
|
||||||
|
Some(&new_body.to_string()),
|
||||||
|
),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple HTML admin dashboard.
|
||||||
|
pub async fn get_admin_dashboard() -> impl IntoResponse {
|
||||||
|
let html = include_str!("../admin.html");
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||||
|
.body(axum::body::Body::from(html))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|||||||
+149
-1
@@ -1,6 +1,13 @@
|
|||||||
use axum::{http::StatusCode, Json};
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::proxy::ProxyState;
|
||||||
|
|
||||||
pub async fn get_health() -> (StatusCode, Json<Value>) {
|
pub async fn get_health() -> (StatusCode, Json<Value>) {
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
@@ -12,3 +19,144 @@ pub async fn get_health() -> (StatusCode, Json<Value>) {
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serve the TLS certificate as a downloadable PEM file.
|
||||||
|
///
|
||||||
|
/// Users navigate to `https://<bridge-ip>:8443/_bridge/cert.pem` in a browser,
|
||||||
|
/// download the file, and install it as a trusted CA in their OS or game console.
|
||||||
|
pub async fn get_cert(State(state): State<ProxyState>) -> Response {
|
||||||
|
match &state.cert_pem {
|
||||||
|
Some(pem) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/x-pem-file"),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"openfut-ca.pem\"",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
pem.as_ref().clone(),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
None => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({ "error": "TLS is not enabled; no certificate to download" })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Club management dashboard served at `/_bridge/dashboard`.
|
||||||
|
///
|
||||||
|
/// Injects the Core URL so client-side JS can call Core directly without going through the proxy.
|
||||||
|
pub async fn get_dashboard(State(state): State<ProxyState>) -> Response {
|
||||||
|
let template = include_str!("../dashboard.html");
|
||||||
|
let html = template.replace("{{CORE_URL}}", &state.config.core_url);
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||||
|
.body(axum::body::Body::from(html))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTML setup guide for connecting FIFA 23 to OpenFUT Bridge.
|
||||||
|
pub async fn get_guide(State(state): State<ProxyState>) -> Html<String> {
|
||||||
|
let host = state
|
||||||
|
.config
|
||||||
|
.listen_addr
|
||||||
|
.split(':')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("localhost");
|
||||||
|
let tls = state.config.tls_enabled;
|
||||||
|
let scheme = if tls { "https" } else { "http" };
|
||||||
|
let port = state.config.listen_addr.split(':').next_back().unwrap_or("8080");
|
||||||
|
let bridge_url = format!("{scheme}://{host}:{port}");
|
||||||
|
let core_url = &state.config.core_url;
|
||||||
|
|
||||||
|
let guide = format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>OpenFUT Bridge — Setup Guide</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: system-ui, sans-serif; max-width: 780px; margin: 0 auto; padding: 2rem; background: #0a0a0a; color: #e0e0e0; }}
|
||||||
|
h1 {{ color: #f5a623; }} h2 {{ color: #a0c4ff; border-bottom: 1px solid #333; padding-bottom: 4px; }}
|
||||||
|
code {{ background: #1c1c1c; padding: 2px 6px; border-radius: 3px; font-family: monospace; }}
|
||||||
|
pre {{ background: #1c1c1c; padding: 1rem; border-radius: 6px; overflow-x: auto; }}
|
||||||
|
.ok {{ color: #5fbb5f; }} .warn {{ color: #f5a623; }}
|
||||||
|
a {{ color: #a0c4ff; }}
|
||||||
|
.step {{ margin: 1.2rem 0; padding: 1rem; background: #141414; border-left: 3px solid #f5a623; border-radius: 0 6px 6px 0; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>OpenFUT Bridge — Setup Guide</h1>
|
||||||
|
<p><span class="ok">✓</span> Bridge is running at <code>{bridge_url}</code></p>
|
||||||
|
<p>Core API: <code>{core_url}</code></p>
|
||||||
|
|
||||||
|
<h2>How it works</h2>
|
||||||
|
<p>OpenFUT Bridge sits between FIFA 23 and EA's servers. It intercepts FUT API calls and
|
||||||
|
routes supported endpoints to OpenFUT Core (your local offline backend). Unsupported
|
||||||
|
endpoints return placeholder responses so the game stays stable.</p>
|
||||||
|
|
||||||
|
<h2>Step 1 — Redirect FIFA 23 traffic</h2>
|
||||||
|
<div class="step">
|
||||||
|
<p>Add these entries to your <strong>hosts file</strong> (requires admin/root):</p>
|
||||||
|
<pre>
|
||||||
|
# Point FIFA 23 FUT traffic to OpenFUT Bridge
|
||||||
|
127.0.0.1 fut.ea.com
|
||||||
|
127.0.0.1 utas.mob.v4.fut.ea.com
|
||||||
|
</pre>
|
||||||
|
<ul>
|
||||||
|
<li>Windows: <code>C:\Windows\System32\drivers\etc\hosts</code></li>
|
||||||
|
<li>macOS / Linux: <code>/etc/hosts</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Step 2 — Install the TLS certificate (HTTPS only)</h2>
|
||||||
|
{cert_section}
|
||||||
|
|
||||||
|
<h2>Step 3 — Launch FIFA 23</h2>
|
||||||
|
<div class="step">
|
||||||
|
<p>Start FIFA 23 and navigate to <strong>Ultimate Team</strong>. The bridge will capture
|
||||||
|
and proxy FUT API calls. Open <code>{bridge_url}/_bridge/admin</code> in your browser to
|
||||||
|
watch traffic in real time.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Supported endpoints</h2>
|
||||||
|
<p>See <code><a href="/_bridge/status">/_bridge/status</a></code> for a full list of mapped
|
||||||
|
and unknown endpoints seen so far.</p>
|
||||||
|
|
||||||
|
<h2>Troubleshooting</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Game shows "FUT servers unavailable":</strong> make sure the hosts file is saved and DNS is flushed (<code>ipconfig /flushdns</code> on Windows).</li>
|
||||||
|
<li><strong>Certificate error in browser:</strong> the self-signed cert must be trusted by the OS or the game's certificate store.</li>
|
||||||
|
<li><strong>Bridge not reaching Core:</strong> check that <code>openfut-core</code> is running and <code>CORE_URL</code> is set correctly (default <code>http://127.0.0.1:3000</code>).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="color:#555; font-size:0.85em; margin-top:3rem;">
|
||||||
|
OpenFUT is an <strong>offline</strong> replacement backend for legitimate FIFA 23 owners.
|
||||||
|
It does not connect to EA servers or enable online multiplayer.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
cert_section = if tls {
|
||||||
|
format!(
|
||||||
|
r#"<div class="step">
|
||||||
|
<p>Download and install the bridge's self-signed CA certificate so FIFA 23 trusts HTTPS connections:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Open <a href="/_bridge/cert.pem"><code>{bridge_url}/_bridge/cert.pem</code></a> and save it.</li>
|
||||||
|
<li><strong>Windows:</strong> double-click the file → "Install Certificate" → "Local Machine" → "Trusted Root Certification Authorities".</li>
|
||||||
|
<li><strong>macOS:</strong> double-click the file → Keychain Access → trust "Always".</li>
|
||||||
|
<li><strong>Linux:</strong> copy to <code>/usr/local/share/ca-certificates/</code> and run <code>update-ca-certificates</code>.</li>
|
||||||
|
</ol>
|
||||||
|
</div>"#
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"<div class=\"step\"><p class=\"warn\">TLS is disabled — set <code>TLS_ENABLED=true</code> to enable HTTPS (required for FIFA 23 interception).</p></div>".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Html(guide)
|
||||||
|
}
|
||||||
|
|||||||
+395
@@ -0,0 +1,395 @@
|
|||||||
|
//! Response shaper: transforms OpenFUT Core responses into the envelope format
|
||||||
|
//! FIFA 23 expects, based on the Core path that was called.
|
||||||
|
//!
|
||||||
|
//! These are best-guess wrappers derived from typical EA FUT API patterns.
|
||||||
|
//! They will be refined as real traffic captures come in.
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Shape a Core JSON response into the format FIFA 23 expects for this path.
|
||||||
|
/// Returns the shaped response or the original unchanged if no shaping is defined.
|
||||||
|
pub fn shape_response(core_path: &str, core_response: Value) -> Value {
|
||||||
|
// Exact matches
|
||||||
|
match core_path {
|
||||||
|
"/auth/local" => return shape_auth_response(core_response),
|
||||||
|
"/profile" => return shape_profile_response(core_response),
|
||||||
|
"/club" => return shape_club_response(core_response),
|
||||||
|
"/squad" => return shape_squad_response(core_response),
|
||||||
|
"/market" => return shape_market_response(core_response),
|
||||||
|
"/market/my-listings" => return shape_my_listings_response(core_response),
|
||||||
|
"/packs" => return shape_packs_response(core_response),
|
||||||
|
"/collection" => return shape_collection_response(core_response),
|
||||||
|
"/cards" => return shape_cards_response(core_response),
|
||||||
|
"/objectives" => return shape_objectives_response(core_response),
|
||||||
|
"/notifications" => return shape_notifications_response(core_response),
|
||||||
|
"/division" => return shape_division_response(core_response),
|
||||||
|
"/statistics" => return shape_statistics_response(core_response),
|
||||||
|
"/sbc" => return shape_sbc_response(core_response),
|
||||||
|
"/fut-champs" => return shape_fut_champs_response(core_response),
|
||||||
|
"/chemistry-styles" => return shape_chemistry_styles_response(core_response),
|
||||||
|
"/achievements" => return shape_achievements_response(core_response),
|
||||||
|
"/squads" => return shape_squads_list_response(core_response),
|
||||||
|
"/packs/store" => return shape_pack_store_response(core_response),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix matches for parameterised paths
|
||||||
|
if core_path.starts_with("/packs/open/") {
|
||||||
|
return shape_pack_open_response(core_response);
|
||||||
|
}
|
||||||
|
if core_path.starts_with("/draft/sessions/") {
|
||||||
|
return shape_draft_response(core_response);
|
||||||
|
}
|
||||||
|
if core_path.starts_with("/fut-champs/") {
|
||||||
|
return shape_fut_champs_response(core_response);
|
||||||
|
}
|
||||||
|
if core_path.starts_with("/squads/") {
|
||||||
|
return shape_squads_list_response(core_response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown — pass through unchanged
|
||||||
|
core_response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core auth response in the FUT auth envelope format.
|
||||||
|
///
|
||||||
|
/// EA's `/ut/auth` returns a top-level object with a SID, PID, and persona.
|
||||||
|
/// The `sid` is used as a session token in subsequent `X-UT-SID` headers.
|
||||||
|
fn shape_auth_response(core: Value) -> Value {
|
||||||
|
let profile_id = core
|
||||||
|
.get("profile")
|
||||||
|
.and_then(|p| p.get("id"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
|
||||||
|
let display_name = core
|
||||||
|
.get("profile")
|
||||||
|
.and_then(|p| p.get("username"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("FUT Player");
|
||||||
|
|
||||||
|
let fake_sid = format!("ofut-sid-{}", Uuid::new_v4().simple());
|
||||||
|
let fake_pid = format!("1{}", &Uuid::new_v4().simple().to_string()[..9]);
|
||||||
|
let phishing_token = format!("ptkn-{}", Uuid::new_v4().simple());
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"sid": fake_sid,
|
||||||
|
"pid": {
|
||||||
|
"pidId": fake_pid,
|
||||||
|
"email": format!("{}@openfut.local", display_name.to_lowercase().replace(' ', "_")),
|
||||||
|
"emailStatus": "VERIFIED",
|
||||||
|
"strength": "STRONG",
|
||||||
|
"dob": "1990-01-01",
|
||||||
|
"country": "US",
|
||||||
|
},
|
||||||
|
"phishingToken": phishing_token,
|
||||||
|
"persona": {
|
||||||
|
"personaId": profile_id,
|
||||||
|
"displayName": display_name,
|
||||||
|
"isEmailVerified": true,
|
||||||
|
},
|
||||||
|
"userAccountInfo": {
|
||||||
|
"userAccountInfoSummary": [{
|
||||||
|
"personaId": profile_id,
|
||||||
|
"displayName": display_name,
|
||||||
|
"clubs": [{ "clubType": "FUT", "supported": true }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core profile response in the FUT user settings envelope.
|
||||||
|
fn shape_profile_response(core: Value) -> Value {
|
||||||
|
let profile = core.get("profile").cloned().unwrap_or(core.clone());
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"settings": {
|
||||||
|
"personaId": profile.get("id").cloned().unwrap_or(json!("unknown")),
|
||||||
|
"displayName": profile.get("username").cloned().unwrap_or(json!("FUT Player")),
|
||||||
|
"level": profile.get("level").cloned().unwrap_or(json!(1)),
|
||||||
|
"xp": profile.get("xp").cloned().unwrap_or(json!(0)),
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
"_raw": profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core club response in the FUT usermassinfo envelope.
|
||||||
|
fn shape_club_response(core: Value) -> Value {
|
||||||
|
let club = core.get("club").cloned().unwrap_or(core.clone());
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"pilluserinfo": {
|
||||||
|
"clubId": club.get("id").cloned().unwrap_or(json!("unknown")),
|
||||||
|
"credits": club.get("coins").cloned().unwrap_or(json!(0)),
|
||||||
|
"purchased": 0,
|
||||||
|
"sold": 0,
|
||||||
|
},
|
||||||
|
"userInfo": {
|
||||||
|
"duplicationInfo": { "isDuplicate": false },
|
||||||
|
"purchasedPacksInfo": { "premium": 0, "standard": 0 },
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
"_raw": club,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core squad response in the FUT squad envelope.
|
||||||
|
fn shape_squad_response(core: Value) -> Value {
|
||||||
|
let squad = core.get("squad").cloned().unwrap_or(core.clone());
|
||||||
|
let players = core.get("players").cloned().unwrap_or(json!([]));
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"squad": {
|
||||||
|
"squadId": squad.get("id").cloned().unwrap_or(json!("unknown")),
|
||||||
|
"name": squad.get("name").cloned().unwrap_or(json!("My Squad")),
|
||||||
|
"formation": squad.get("formation").cloned().unwrap_or(json!("4-4-2")),
|
||||||
|
"players": players,
|
||||||
|
"rating": 0,
|
||||||
|
"chemistry": 0,
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core market response in the FUT transfer market envelope.
|
||||||
|
fn shape_market_response(core: Value) -> Value {
|
||||||
|
let listings = core.get("listings").cloned().unwrap_or(json!([]));
|
||||||
|
let total = core
|
||||||
|
.get("total")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"auctionInfo": listings,
|
||||||
|
"bidTokens": {},
|
||||||
|
"credits": 0,
|
||||||
|
"currencies": [
|
||||||
|
{ "name": "COINS", "funds": 0, "finalFunds": 0 },
|
||||||
|
],
|
||||||
|
"duplicateItemIdList": [],
|
||||||
|
"itemData": [],
|
||||||
|
"total": total,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps Core packs response in the FUT pack store envelope.
|
||||||
|
fn shape_packs_response(core: Value) -> Value {
|
||||||
|
let packs = core.get("packs").cloned().unwrap_or(json!([]));
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"purchasedPacks": {
|
||||||
|
"packs": packs,
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_my_listings_response(core: Value) -> Value {
|
||||||
|
let listings = core.get("listings").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"tradepile": listings,
|
||||||
|
"credits": 0,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_collection_response(core: Value) -> Value {
|
||||||
|
let items = core.get("collection").cloned().unwrap_or(json!([]));
|
||||||
|
let total = core.get("total").cloned().unwrap_or(json!(0));
|
||||||
|
json!({
|
||||||
|
"itemData": items,
|
||||||
|
"total": total,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_cards_response(core: Value) -> Value {
|
||||||
|
let cards = core.get("cards").cloned().unwrap_or(json!([]));
|
||||||
|
let total = core.get("total").cloned().unwrap_or(json!(0));
|
||||||
|
json!({
|
||||||
|
"items": cards,
|
||||||
|
"total": total,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_objectives_response(core: Value) -> Value {
|
||||||
|
let objectives = core.get("objectives").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"objectives": objectives,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_notifications_response(core: Value) -> Value {
|
||||||
|
let notifications = core.get("notifications").cloned().unwrap_or(json!([]));
|
||||||
|
let count = core.get("count").cloned().unwrap_or(json!(0));
|
||||||
|
json!({
|
||||||
|
"notifications": notifications,
|
||||||
|
"count": count,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_division_response(core: Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"divisionRivalsInfo": {
|
||||||
|
"division": core.get("division").cloned().unwrap_or(json!(5)),
|
||||||
|
"seasonNumber": core.get("season_number").cloned().unwrap_or(json!(1)),
|
||||||
|
"points": core.get("season_points").cloned().unwrap_or(json!(0)),
|
||||||
|
"matchesPlayed": core.get("matches_played").cloned().unwrap_or(json!(0)),
|
||||||
|
"matchesRemaining": core.get("matches_remaining").cloned().unwrap_or(json!(10)),
|
||||||
|
"record": core.get("record").cloned().unwrap_or(json!({})),
|
||||||
|
},
|
||||||
|
"openfut": true,
|
||||||
|
"_raw": core,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_statistics_response(core: Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"stats": core,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_sbc_response(core: Value) -> Value {
|
||||||
|
let sbcs = core.get("sbcs").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"challenges": sbcs,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_pack_open_response(core: Value) -> Value {
|
||||||
|
let cards = core.get("cards").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"itemData": cards,
|
||||||
|
"duplicateItemIdList": [],
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_draft_response(core: Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"draft": core,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_fut_champs_response(core: Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"futChampions": core,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_chemistry_styles_response(core: Value) -> Value {
|
||||||
|
let styles = core.get("chemistry_styles").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"chemistryStyles": styles,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_achievements_response(core: Value) -> Value {
|
||||||
|
// FIFA 23 uses a "trophies" envelope with "trophyData" array
|
||||||
|
let achievements = core.get("achievements").cloned().unwrap_or(json!([]));
|
||||||
|
let earned = core.get("earned").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let total = core.get("total").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
json!({
|
||||||
|
"trophyData": achievements,
|
||||||
|
"totalEarned": earned,
|
||||||
|
"totalAvailable": total,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_squads_list_response(core: Value) -> Value {
|
||||||
|
// Wrap squads in EA's squad list envelope
|
||||||
|
let squads = if core.is_array() {
|
||||||
|
core
|
||||||
|
} else {
|
||||||
|
core.get("squads").cloned().unwrap_or(json!([]))
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"squads": squads,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_pack_store_response(core: Value) -> Value {
|
||||||
|
let packs = core.get("packs").cloned().unwrap_or(json!([]));
|
||||||
|
json!({
|
||||||
|
"purchasablePacks": packs,
|
||||||
|
"openfut": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_auth_response_has_required_fields() {
|
||||||
|
let core = json!({
|
||||||
|
"profile": { "id": "prof_001", "username": "TestPlayer" }
|
||||||
|
});
|
||||||
|
let shaped = shape_auth_response(core);
|
||||||
|
assert!(shaped.get("sid").is_some());
|
||||||
|
assert!(shaped.get("phishingToken").is_some());
|
||||||
|
assert!(shaped.get("persona").is_some());
|
||||||
|
assert_eq!(shaped["persona"]["displayName"], "TestPlayer");
|
||||||
|
assert_eq!(shaped["openfut"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_response_dispatches_correctly() {
|
||||||
|
let core = json!({ "profile": { "id": "x", "username": "u" } });
|
||||||
|
let shaped = shape_response("/auth/local", core);
|
||||||
|
assert!(shaped.get("sid").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_response_passthrough_for_unknown_path() {
|
||||||
|
let core = json!({ "foo": "bar" });
|
||||||
|
let shaped = shape_response("/some/unknown/path", core.clone());
|
||||||
|
assert_eq!(shaped, core);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_market_response_has_auction_info() {
|
||||||
|
let core = json!({ "listings": [{ "id": "l1" }], "total": 1 });
|
||||||
|
let shaped = shape_market_response(core);
|
||||||
|
assert!(shaped["auctionInfo"].as_array().is_some());
|
||||||
|
assert_eq!(shaped["total"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_achievements_response() {
|
||||||
|
let core = json!({ "achievements": [{ "id": "first_match" }], "earned": 1, "total": 18 });
|
||||||
|
let shaped = shape_response("/achievements", core);
|
||||||
|
assert!(shaped["trophyData"].is_array());
|
||||||
|
assert_eq!(shaped["totalEarned"], 1);
|
||||||
|
assert_eq!(shaped["totalAvailable"], 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_squads_list_response() {
|
||||||
|
let core = json!({ "squads": [{ "id": "sq-1", "name": "My Squad" }] });
|
||||||
|
let shaped = shape_response("/squads", core);
|
||||||
|
assert!(shaped["squads"].is_array());
|
||||||
|
assert_eq!(shaped["squads"].as_array().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shape_pack_store_response() {
|
||||||
|
let core = json!({ "packs": [{ "id": "gold-pack" }] });
|
||||||
|
let shaped = shape_response("/packs/store", core);
|
||||||
|
assert!(shaped["purchasablePacks"].is_array());
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tokio_rustls::TlsAcceptor;
|
||||||
|
|
||||||
|
/// Generate a self-signed certificate covering localhost and EA FUT hostnames.
|
||||||
|
/// Returns (cert_pem, key_pem) as byte vectors.
|
||||||
|
///
|
||||||
|
/// For FIFA 23 to connect, the cert must be installed as a trusted CA in the OS
|
||||||
|
/// trust store, OR certificate validation must be disabled in the game binary.
|
||||||
|
pub fn generate_self_signed_cert() -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
|
||||||
|
let subject_alt_names = vec![
|
||||||
|
"localhost".to_string(),
|
||||||
|
"127.0.0.1".to_string(),
|
||||||
|
"fut.ea.com".to_string(),
|
||||||
|
"utas.mob.v4.fut.ea.com".to_string(),
|
||||||
|
];
|
||||||
|
let cert = rcgen::generate_simple_self_signed(subject_alt_names)?;
|
||||||
|
let cert_pem = cert.serialize_pem()?.into_bytes();
|
||||||
|
let key_pem = cert.serialize_private_key_pem().into_bytes();
|
||||||
|
Ok((cert_pem, key_pem))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a TlsAcceptor from PEM-encoded certificate and private key bytes.
|
||||||
|
pub fn make_tls_acceptor(cert_pem: &[u8], key_pem: &[u8]) -> anyhow::Result<TlsAcceptor> {
|
||||||
|
use rustls::{Certificate, PrivateKey, ServerConfig};
|
||||||
|
|
||||||
|
let certs: Vec<Certificate> = rustls_pemfile::certs(&mut std::io::Cursor::new(cert_pem))?
|
||||||
|
.into_iter()
|
||||||
|
.map(Certificate)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut keys = rustls_pemfile::pkcs8_private_keys(&mut std::io::Cursor::new(key_pem))?;
|
||||||
|
if keys.is_empty() {
|
||||||
|
anyhow::bail!("no PKCS8 private keys found in key PEM");
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Arc::new(
|
||||||
|
ServerConfig::builder()
|
||||||
|
.with_safe_defaults()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(certs, PrivateKey(keys.remove(0)))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(TlsAcceptor::from(config))
|
||||||
|
}
|
||||||
+229
-1
@@ -1,4 +1,18 @@
|
|||||||
use openfut_bridge::{capture::CapturedRequest, mapper::map_to_core};
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
};
|
||||||
|
use openfut_bridge::{
|
||||||
|
capture::CapturedRequest,
|
||||||
|
config::Config,
|
||||||
|
mapper::{map_to_core, placeholder_response},
|
||||||
|
proxy::ProxyState,
|
||||||
|
routes,
|
||||||
|
};
|
||||||
|
use axum::routing::{any, delete, get, post};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
// ── Unit tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_known_endpoint_maps_to_core() {
|
fn test_known_endpoint_maps_to_core() {
|
||||||
@@ -46,3 +60,217 @@ fn test_capture_with_response() {
|
|||||||
assert_eq!(capture.response_status, Some(200));
|
assert_eq!(capture.response_status, Some(200));
|
||||||
assert!(capture.response_body.is_some());
|
assert!(capture.response_body.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── #24 Placeholder response format ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_placeholder_response_has_required_fields() {
|
||||||
|
let resp = placeholder_response("GET", "/ut/game/fut/unknown");
|
||||||
|
assert_eq!(resp["status"], "ok");
|
||||||
|
assert!(resp["openfut_note"].is_string());
|
||||||
|
assert_eq!(resp["method"], "GET");
|
||||||
|
assert_eq!(resp["path"], "/ut/game/fut/unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_placeholder_response_for_post() {
|
||||||
|
let resp = placeholder_response("POST", "/ut/auth/fifa");
|
||||||
|
assert_eq!(resp["status"], "ok");
|
||||||
|
assert_eq!(resp["method"], "POST");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #25 Full HTTP integration test against bridge ────────────────────────────
|
||||||
|
|
||||||
|
fn build_test_app() -> axum::Router {
|
||||||
|
let cfg = Config {
|
||||||
|
listen_addr: "127.0.0.1:0".into(),
|
||||||
|
core_url: "http://127.0.0.1:9999".into(), // won't be reached in placeholder mode
|
||||||
|
captures_dir: "/tmp/openfut-test-captures".into(),
|
||||||
|
placeholder_mode: true,
|
||||||
|
tls_enabled: false,
|
||||||
|
};
|
||||||
|
let state = ProxyState::new(cfg);
|
||||||
|
|
||||||
|
axum::Router::new()
|
||||||
|
.route("/_bridge/health", get(routes::health::get_health))
|
||||||
|
.route("/_bridge/dashboard", get(routes::health::get_dashboard))
|
||||||
|
.route("/_bridge/captures", get(routes::admin::get_captures))
|
||||||
|
.route("/_bridge/captures", delete(routes::admin::delete_captures))
|
||||||
|
.route("/_bridge/unknown", get(routes::admin::get_unknown_endpoints))
|
||||||
|
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
|
||||||
|
.route("/_bridge/captures/:id/replay", post(routes::admin::post_replay_capture))
|
||||||
|
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bridge_health_endpoint() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(Request::builder().uri("/_bridge/health").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(json["status"], "ok");
|
||||||
|
assert_eq!(json["service"], "openfut-bridge");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bridge_placeholder_mode_returns_ok() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/ut/game/fut/completely/unknown/endpoint")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(json["status"], "ok");
|
||||||
|
assert!(json["openfut_note"].is_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bridge_captures_endpoint_returns_list() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/_bridge/captures")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["captures"].is_array());
|
||||||
|
assert!(json["total"].is_number());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bridge_delete_captures() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("DELETE")
|
||||||
|
.uri("/_bridge/captures")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["deleted"].is_number());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bridge_status_endpoint() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/_bridge/status")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["endpoints"].is_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 13 — Dashboard ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dashboard_returns_html() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/_bridge/dashboard")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let ct = resp.headers().get("content-type").unwrap().to_str().unwrap();
|
||||||
|
assert!(ct.contains("text/html"), "expected text/html, got {ct}");
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(html.contains("OpenFUT Dashboard"), "title missing");
|
||||||
|
assert!(html.contains("const CORE ="), "core URL injection missing");
|
||||||
|
// Placeholder URL injected in test mode
|
||||||
|
assert!(html.contains("127.0.0.1:9999"), "core URL not injected");
|
||||||
|
assert!(!html.contains("{{CORE_URL}}"), "template placeholder was not replaced");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dashboard_contains_key_sections() {
|
||||||
|
let app = build_test_app();
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/_bridge/dashboard")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||||
|
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
// Verify all major tab sections are present
|
||||||
|
assert!(html.contains("tab-club"), "club tab missing");
|
||||||
|
assert!(html.contains("tab-collection"), "collection tab missing");
|
||||||
|
assert!(html.contains("tab-packs"), "packs tab missing");
|
||||||
|
assert!(html.contains("tab-objectives"), "objectives tab missing");
|
||||||
|
assert!(html.contains("tab-division"), "division tab missing");
|
||||||
|
assert!(html.contains("tab-champs"), "fut champs tab missing");
|
||||||
|
assert!(html.contains("tab-squad"), "squad tab missing");
|
||||||
|
assert!(html.contains("tab-draft"), "draft tab missing");
|
||||||
|
assert!(html.contains("tab-market"), "market tab missing");
|
||||||
|
assert!(html.contains("tab-events"), "events tab missing");
|
||||||
|
assert!(html.contains("tab-matches"), "matches tab missing");
|
||||||
|
assert!(html.contains("tab-store"), "pack store tab missing");
|
||||||
|
assert!(html.contains("tab-sbc"), "sbc tab missing");
|
||||||
|
assert!(html.contains("tab-statistics"), "statistics tab missing");
|
||||||
|
assert!(html.contains("tab-catalog"), "card catalog tab missing");
|
||||||
|
assert!(html.contains("tab-settings"), "settings tab missing");
|
||||||
|
assert!(html.contains("tab-notifications"), "notifications tab missing");
|
||||||
|
assert!(html.contains("tab-achievements"), "achievements tab missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tls_cert_generation() {
|
||||||
|
let result = openfut_bridge::tls::generate_self_signed_cert();
|
||||||
|
assert!(result.is_ok(), "cert generation failed: {:?}", result.err());
|
||||||
|
let (cert_pem, key_pem) = result.unwrap();
|
||||||
|
assert!(!cert_pem.is_empty());
|
||||||
|
assert!(!key_pem.is_empty());
|
||||||
|
// Verify the PEM blocks are well-formed
|
||||||
|
let cert_str = String::from_utf8(cert_pem).unwrap();
|
||||||
|
let key_str = String::from_utf8(key_pem).unwrap();
|
||||||
|
assert!(cert_str.contains("BEGIN CERTIFICATE"));
|
||||||
|
assert!(key_str.contains("PRIVATE KEY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_tls_acceptor_construction() {
|
||||||
|
let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert().unwrap();
|
||||||
|
let acceptor = openfut_bridge::tls::make_tls_acceptor(&cert_pem, &key_pem);
|
||||||
|
assert!(acceptor.is_ok(), "acceptor construction failed: {:?}", acceptor.err());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user