Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2a9a01ec9 |
Generated
+380
-226
@@ -33,26 +33,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.17.0"
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "blaze-proto"
|
||||
@@ -66,6 +50,19 @@ dependencies = [
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blaze-ssl-async"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fec08f35919613bda0b3eb3bc772c2f793b3634133923b931874b18e1ac55de"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"num_enum",
|
||||
"rsa",
|
||||
"tokio",
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -85,8 +82,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -111,13 +106,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
@@ -125,6 +117,16 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@@ -161,10 +163,38 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"der_derive",
|
||||
"flagset",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der_derive"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
@@ -178,18 +208,18 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flagset"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
@@ -233,6 +263,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -244,18 +284,6 @@ dependencies = [
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
@@ -314,16 +342,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.103"
|
||||
@@ -340,6 +358,9 @@ name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
@@ -347,6 +368,12 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
@@ -376,7 +403,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -385,7 +412,42 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -395,6 +457,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
|
||||
dependencies = [
|
||||
"num_enum_derive",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum_derive"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -403,12 +488,60 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||
dependencies = [
|
||||
"der",
|
||||
"pkcs8",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.13+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -428,10 +561,33 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
name = "rand"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
@@ -451,65 +607,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"const-oid",
|
||||
"digest",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -574,17 +690,15 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blaze-proto",
|
||||
"blaze-ssl-async",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tdf",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"tracing",
|
||||
@@ -606,6 +720,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -625,7 +749,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -702,6 +842,27 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tls_codec"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
|
||||
dependencies = [
|
||||
"tls_codec_derive",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tls_codec_derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
@@ -714,7 +875,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -728,16 +889,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -759,8 +910,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -772,6 +923,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
@@ -781,9 +941,30 @@ dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.13+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -853,39 +1034,36 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.126"
|
||||
@@ -990,15 +1168,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -1008,70 +1177,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
@@ -1082,16 +1187,65 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x509-cert"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"der",
|
||||
"spki",
|
||||
"tls_codec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
|
||||
@@ -1,112 +1,198 @@
|
||||
# fifa-blaze
|
||||
|
||||
EA Blaze protocol server emulator for FIFA 23 offline FUT.
|
||||
EA Blaze protocol server emulator — **FIFA 17 target**.
|
||||
|
||||
## Why FIFA 17 and not FIFA 23
|
||||
|
||||
FIFA 23 was the original target. It does not work, for a structural reason documented in
|
||||
`../openfut-bridge/docs/closure-and-preservation.md`: **the FIFA 23 client never dials.**
|
||||
The functions that register the redirector dial are Blaze *message handlers*, dispatched
|
||||
only once Blaze messages are already flowing — so the dial requires the connection it is
|
||||
supposed to create. Offline that handler container is empty and nothing can break the
|
||||
circle. Two further walls sit behind it (ProtoSSL cert pinning, and an anti-tamper VM that
|
||||
faults on forced state).
|
||||
|
||||
FIFA 17 differs categorically, not by degree:
|
||||
|
||||
| | FIFA 17 (2016) | FIFA 23 (2022) |
|
||||
|---|---|---|
|
||||
| Online entry | Client dials `gosredirector` directly | Gated behind an EA-App handshake with no counterpart |
|
||||
| Anti-tamper | None | VM faults deterministically on forced state |
|
||||
| Transport | ProtoSSL — SSLv3 + RC4 | Pinned modern TLS (`CertificateUnknown`) |
|
||||
| Feedback loop | **You get a wire on day one** | Never dials → nothing to iterate against |
|
||||
|
||||
That last row is the one that matters. Redirect the hostname, and the client connects and
|
||||
starts talking — which restores the oracle the whole method depends on: send a frame,
|
||||
watch the client react, correct, repeat.
|
||||
|
||||
`openfut-core` is unaffected by any of this. It is game-independent and already complete;
|
||||
only the bridge layer is title-specific.
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone 1 — capture stub.**
|
||||
Two TLS listeners start and log every Blaze packet. No FIFA 23 component/command IDs are known yet — the capture log is how we discover them.
|
||||
**Milestone 1 — capture stub.** Two SSLv3 listeners start and log every byte. No FIFA 17
|
||||
component/command IDs are known yet — the capture log is how they are discovered.
|
||||
|
||||
**Milestone 1 goal: one real frame in `captures/`.** The FIFA 23 effort produced zero in a
|
||||
month.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
FIFA 23 (via openfut-hook DLL / /etc/hosts)
|
||||
FIFA 17 (hosts file, or the openfut-hook DLL under Proton)
|
||||
│
|
||||
▼ gosredirector.ea.com → 127.0.0.1:42127
|
||||
blaze-server redirector listener (TLS, Fire2 framing)
|
||||
blaze-server redirector listener (SSLv3, Fire2 framing)
|
||||
│ replies: "connect to 127.0.0.1:10041"
|
||||
▼
|
||||
blaze-server Blaze listener (TLS, Fire2 framing)
|
||||
│ every packet → JSONL capture + pretty-print
|
||||
└─ captures/<timestamp>.jsonl
|
||||
blaze-server Blaze listener (SSLv3, Fire2 framing)
|
||||
│
|
||||
├─ raw byte tee → captures/<label>-<port>.raw ← always, before framing
|
||||
└─ framed packet → captures/<timestamp>.jsonl ← only if framing is right
|
||||
```
|
||||
|
||||
## Framing note
|
||||
## Transport: SSLv3, not TLS
|
||||
|
||||
Two Blaze wire framings exist: **Fire** and **Fire2**. FIFA 23's exact variant is unknown — Fire2 is attempted first because it is used by all post-2012 EA titles (ME3, BF3, etc.). If captures show malformed frame sizes, switch the `FramingVariant` in `main.rs` to `Raw` to bypass framing and capture raw bytes for manual analysis.
|
||||
EA's DirtySDK speaks **ProtoSSL**, a homegrown SSLv3 restricted to `RC4-SHA` / `RC4-MD5`.
|
||||
A modern TLS stack cannot negotiate with it at all — no shared version, let alone a shared
|
||||
cipher. This server uses [`blaze-ssl-async`](https://github.com/jacobtread/blaze-ssl-async),
|
||||
which implements that dialect and ships a certificate built to satisfy old ProtoSSL
|
||||
verification.
|
||||
|
||||
**There are no certificates to generate.** The `certs/` directory and the old `openssl`
|
||||
step are obsolete; the `[tls]` config section is gone.
|
||||
|
||||
## The raw tee
|
||||
|
||||
Every byte is mirrored to `captures/<label>-<port>.raw` *before* framing is attempted:
|
||||
|
||||
```
|
||||
IN 12 0000000900000001...
|
||||
OUT 30 0000001e00090001...
|
||||
```
|
||||
|
||||
This exists because the FIFA 23 run ended with six capture files containing zero bytes —
|
||||
captures were only written after a packet decoded, so a wrong framing guess destroyed the
|
||||
very evidence needed to fix the guess. With the tee, a bad guess still yields bytes.
|
||||
|
||||
Disable with `raw_tee = false` in `[capture]`. Don't.
|
||||
|
||||
## Framing
|
||||
|
||||
`Fire` and `Fire2` both exist. FIFA 17 is post-2012 so **Fire2 is the likely variant, but
|
||||
this is UNCONFIRMED**. It is a config value, not a constant — flip it without rebuilding:
|
||||
|
||||
```toml
|
||||
[blaze]
|
||||
framing = "raw" # bypass framing entirely, capture byte streams
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### 1. Generate a self-signed TLS cert (RSA-2048)
|
||||
|
||||
DirtySDK (FIFA's network library) rejects ECDSA — RSA-2048 is required.
|
||||
|
||||
```bash
|
||||
mkdir certs
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout certs/key.pem -out certs/cert.pem \
|
||||
-days 3650 -nodes \
|
||||
-subj "/CN=gosredirector.ea.com"
|
||||
cp config.example.toml config.toml # edit if needed
|
||||
cargo run --release --bin blaze-server -- config.toml
|
||||
```
|
||||
|
||||
### 2. Configure
|
||||
|
||||
```bash
|
||||
cp config.example.toml config.toml
|
||||
# edit if needed
|
||||
```
|
||||
|
||||
### 3. /etc/hosts redirect
|
||||
Then redirect the hostname:
|
||||
|
||||
```
|
||||
127.0.0.1 gosredirector.ea.com
|
||||
```
|
||||
|
||||
Or deploy `openfut-hook` DLL which redirects at the DNS level inside Proton.
|
||||
…or deploy `openfut-hook` to redirect inside Proton, and launch FIFA 17 and go online.
|
||||
|
||||
### 4. Run
|
||||
### Verifying without FIFA
|
||||
|
||||
The SSLv3 path is covered by a test that completes a real handshake and round-trips
|
||||
application bytes through the tee — so the server can be validated before touching the
|
||||
game:
|
||||
|
||||
```bash
|
||||
cd fifa-blaze
|
||||
cargo run --release --bin blaze-server -- config.toml
|
||||
cargo test
|
||||
```
|
||||
|
||||
### 5. Start FIFA 23 and go to FUT
|
||||
|
||||
The capture log at `captures/<timestamp>.jsonl` will contain every packet.
|
||||
Inspect with jq:
|
||||
## Reading captures
|
||||
|
||||
```bash
|
||||
# Raw bytes (always present)
|
||||
cat captures/blaze-*.raw
|
||||
|
||||
# Framed packets (only if the framing guess is right)
|
||||
jq '.' captures/capture-*.jsonl | less
|
||||
# Find all unique component/command pairs:
|
||||
jq -r '[.component, .command] | @tsv' captures/*.jsonl | sort -u
|
||||
```
|
||||
|
||||
## Capture log format
|
||||
### Replaying a capture offline
|
||||
|
||||
Each line is a JSON object:
|
||||
`blaze-replay` feeds a recorded `.raw` back through the codec, so a framing hypothesis is
|
||||
tested against real bytes in a second — no game, no rebuild:
|
||||
|
||||
```json
|
||||
{
|
||||
"n": 1,
|
||||
"ts": "2026-06-26T12:00:00.000Z",
|
||||
"peer": "127.0.0.1:54321",
|
||||
"dir": "IN",
|
||||
"component": "0x0001",
|
||||
"command": "0x0001",
|
||||
"type": "REQUEST",
|
||||
"seq": 1,
|
||||
"error": 0,
|
||||
"body_len": 42,
|
||||
"raw_hex": "deadbeef...",
|
||||
"tdf": "{\n \"VRSN\": 0,\n ...}"
|
||||
}
|
||||
```bash
|
||||
blaze-replay captures/blaze-54321.raw # test fire2
|
||||
blaze-replay captures/blaze-54321.raw --framing raw # dump unparsed, read the header by hand
|
||||
blaze-replay captures/blaze-54321.raw --dir out # decode our replies instead
|
||||
```
|
||||
|
||||
It concatenates records before decoding (TCP does not preserve message boundaries) and
|
||||
ends with a verdict:
|
||||
|
||||
```
|
||||
#1 component=0x0009 command=0x0007 type=REQUEST seq=1 error=0 body=0B
|
||||
2 packet(s) decoded, 0 byte(s) undecoded
|
||||
VERDICT: fire2 is a clean fit — every byte accounted for.
|
||||
```
|
||||
|
||||
## What the server answers today
|
||||
|
||||
`src/components.rs` carries the Blaze component/command tables and turns capture lines
|
||||
from raw hex into names like `Util.preAuth`, so an *unknown* ID stands out as unknown.
|
||||
|
||||
`src/routes.rs` holds the response bodies. Three commands are answered; everything else
|
||||
falls through to an empty response so the client keeps talking and the log keeps growing.
|
||||
|
||||
| Command | Status |
|
||||
|---|---|
|
||||
| `Redirector.getServerInstance` | `ADDR` union + `VALU{HOST,IP,PORT}`, `SECU`, `XDNS` |
|
||||
| `Util.ping` | `STIM` server clock |
|
||||
| `Util.preAuth` | minimal scaffold — the gate that must be right before auth is attempted |
|
||||
|
||||
**All of it is `TODO/CONFIRM`.** The tags and structure come from open-source emulators for
|
||||
*other* EA titles (chiefly [PocketRelay](https://github.com/PocketRelay/Server), ME3, MIT);
|
||||
no EA source is involved. Component IDs are framework-level and stable across titles;
|
||||
**command IDs are per-title and may well differ for FIFA 17.** When a capture disagrees
|
||||
with anything in these files, the capture wins.
|
||||
|
||||
The point of answering at all is that an empty body is guaranteed wrong and teaches
|
||||
nothing, whereas a structurally plausible one either advances the client — a result — or is
|
||||
rejected with an error code that is itself information.
|
||||
|
||||
### A note on TDF tags
|
||||
|
||||
Tags are not ASCII on the wire. TDF packs each character into 6 bits across 3 bytes, so
|
||||
`ADDR` never appears literally in a capture. Grepping bytes for tag names will always fail;
|
||||
parse instead (`blaze-replay`, or `TdfStringifier` as the tests in `routes.rs` do).
|
||||
|
||||
## Next steps (Milestone 2)
|
||||
|
||||
Once captures reveal component/command IDs:
|
||||
1. Identify the redirector request/response tag layout
|
||||
2. Identify Util `preAuth` / `postAuth` / `ping` commands
|
||||
3. Identify Authentication `login` command
|
||||
4. Implement handlers and test with FIFA 23
|
||||
|
||||
1. Confirm the framing variant from the raw bytes
|
||||
2. Identify the redirector request/response tag layout
|
||||
3. Identify Util `preAuth` / `postAuth` / `ping`
|
||||
4. Identify Authentication `login`
|
||||
5. Implement handlers via `Dispatcher::register` and test against the client
|
||||
|
||||
Prior art worth reading before guessing: [`PocketRelay/Server`](https://github.com/PocketRelay/Server)
|
||||
(ME3, Rust), [`openBlase`](https://github.com/openBlase/openBlase), and
|
||||
[`Tratos/New-Blaze-Emulator`](https://github.com/Tratos/New-Blaze-Emulator) (BF3). For the
|
||||
FUT REST layer that follows, [`futapi/fut`](https://github.com/futapi/fut) documents real
|
||||
`fut.ea.com` endpoint shapes.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cargo build
|
||||
|
||||
# Run with verbose logging
|
||||
cargo test
|
||||
RUST_LOG=debug cargo run --bin blaze-server -- config.toml
|
||||
```
|
||||
|
||||
+12
-6
@@ -7,14 +7,20 @@ listen = "0.0.0.0:42127"
|
||||
listen = "0.0.0.0:10041"
|
||||
advertise_host = "127.0.0.1" # IP/hostname we tell the client to connect to
|
||||
advertise_port = 10041
|
||||
# Wire framing: "fire2" (default) or "raw".
|
||||
# FIFA 17 is post-2012 so Fire2 is the likely variant, but this is UNCONFIRMED until
|
||||
# real bytes arrive. If decode fails or frame sizes look absurd, set "raw" and rerun —
|
||||
# no rebuild needed. The raw tee below captures usable bytes either way.
|
||||
framing = "fire2"
|
||||
|
||||
[tls]
|
||||
# Generate with: openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem
|
||||
# -days 3650 -nodes -subj "/CN=gosredirector.ea.com"
|
||||
# DirtySDK rejects ECDSA — RSA-2048 required.
|
||||
cert = "certs/cert.pem"
|
||||
key = "certs/key.pem"
|
||||
# No [tls] section: the server uses blaze-ssl-async's built-in certificate, which is
|
||||
# built to satisfy older EA ProtoSSL verification. Nothing to generate.
|
||||
|
||||
[capture]
|
||||
dir = "captures"
|
||||
pretty_print = true
|
||||
# Mirror every byte to captures/<label>-<port>.raw BEFORE framing is attempted.
|
||||
# Leave this on. It is the difference between "the framing guess was wrong" and
|
||||
# "the framing guess was wrong AND we have nothing to fix it with" — the exact
|
||||
# failure that left the FIFA 23 effort with six zero-byte capture files.
|
||||
raw_tee = true
|
||||
|
||||
@@ -13,9 +13,6 @@ tdf = { version = "0.1", features = ["serde"] }
|
||||
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "macros", "sync", "time", "fs"] }
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
tokio-rustls = "0.26"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
rustls-pemfile = "2"
|
||||
futures-util = { version = "0.3", features = ["sink"] }
|
||||
|
||||
bytes = "1"
|
||||
@@ -29,3 +26,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
anyhow = "1"
|
||||
hex = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
blaze-ssl-async = "0.4.0"
|
||||
|
||||
[[bin]]
|
||||
name = "blaze-replay"
|
||||
path = "src/bin/replay.rs"
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! blaze-replay — decode a recorded `.raw` byte log offline.
|
||||
//!
|
||||
//! The raw tee writes every byte of a session before framing is attempted, precisely so a
|
||||
//! wrong framing guess is recoverable. This tool is the other half of that bargain: it
|
||||
//! feeds a recorded log back through the codec so a framing hypothesis can be tested in a
|
||||
//! second, against real bytes, without the game.
|
||||
//!
|
||||
//! That inverts the FIFA 23 iteration loop. There, testing a guess meant
|
||||
//! edit → build → deploy → relaunch FIFA → hope. Here it means rerunning a binary against
|
||||
//! a file, so wrong guesses cost nothing and can be tried exhaustively.
|
||||
//!
|
||||
//! ```bash
|
||||
//! blaze-replay captures/blaze-54321.raw # default: try fire2
|
||||
//! blaze-replay captures/blaze-54321.raw --framing raw
|
||||
//! blaze-replay captures/blaze-54321.raw --dir out # decode outbound instead
|
||||
//! ```
|
||||
//!
|
||||
//! Input format, one record per line, as written by `tee.rs`:
|
||||
//!
|
||||
//! ```text
|
||||
//! IN 12 0000000900000001...
|
||||
//! OUT 30 0000001e00090001...
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use blaze_proto::{FramingVariant, PacketCodec};
|
||||
use bytes::BytesMut;
|
||||
use tokio_util::codec::Decoder;
|
||||
|
||||
/// One parsed line of a `.raw` log.
|
||||
struct Record {
|
||||
inbound: bool,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let mut path: Option<PathBuf> = None;
|
||||
let mut framing = FramingVariant::Fire2;
|
||||
let mut want_inbound = true;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--framing" => {
|
||||
let v = args.next().unwrap_or_default();
|
||||
framing = match v.trim() {
|
||||
"raw" | "Raw" | "RAW" => FramingVariant::Raw,
|
||||
"fire2" | "Fire2" | "FIRE2" => FramingVariant::Fire2,
|
||||
other => anyhow::bail!("unknown framing {other:?} (expected fire2 or raw)"),
|
||||
};
|
||||
}
|
||||
"--dir" => {
|
||||
let v = args.next().unwrap_or_default();
|
||||
want_inbound = match v.trim() {
|
||||
"in" | "IN" => true,
|
||||
"out" | "OUT" => false,
|
||||
other => anyhow::bail!("unknown direction {other:?} (expected in or out)"),
|
||||
};
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
eprintln!("usage: blaze-replay <file.raw> [--framing fire2|raw] [--dir in|out]");
|
||||
return Ok(());
|
||||
}
|
||||
other => path = Some(PathBuf::from(other)),
|
||||
}
|
||||
}
|
||||
|
||||
let path = path.ok_or_else(|| {
|
||||
anyhow::anyhow!("usage: blaze-replay <file.raw> [--framing fire2|raw] [--dir in|out]")
|
||||
})?;
|
||||
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?;
|
||||
|
||||
let records = parse_log(&text);
|
||||
let (kept, skipped): (Vec<_>, Vec<_>) = records.iter().partition(|r| r.inbound == want_inbound);
|
||||
|
||||
println!("file : {}", path.display());
|
||||
println!("framing : {framing:?}");
|
||||
println!("direction : {}", if want_inbound { "IN" } else { "OUT" });
|
||||
println!(
|
||||
"records : {} matching, {} in the other direction",
|
||||
kept.len(),
|
||||
skipped.len()
|
||||
);
|
||||
|
||||
if kept.is_empty() {
|
||||
println!("\nNothing to decode. If the file is non-empty, try --dir out.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Concatenate into one stream: TCP does not preserve message boundaries, so a single
|
||||
// Blaze packet may span several reads and one read may hold several packets. Decoding
|
||||
// each record in isolation would misparse exactly the cases that matter.
|
||||
let mut buf = BytesMut::new();
|
||||
for r in &kept {
|
||||
buf.extend_from_slice(&r.bytes);
|
||||
}
|
||||
let total = buf.len();
|
||||
|
||||
let mut codec = PacketCodec::new(framing);
|
||||
let mut count = 0usize;
|
||||
|
||||
println!("\n{total} bytes\n");
|
||||
|
||||
loop {
|
||||
match codec.decode(&mut buf) {
|
||||
Ok(Some(pkt)) => {
|
||||
count += 1;
|
||||
println!(
|
||||
"#{count} component=0x{:04X} command=0x{:04X} type={} seq={} error={} body={}B",
|
||||
pkt.frame.component,
|
||||
pkt.frame.command,
|
||||
pkt.frame.ty,
|
||||
pkt.frame.seq,
|
||||
pkt.frame.error,
|
||||
pkt.body.len()
|
||||
);
|
||||
if !pkt.body.is_empty() {
|
||||
println!(" {}", hex::encode(&pkt.body));
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
println!("decode error after {count} packet(s): {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let leftover = buf.len();
|
||||
println!("\n{count} packet(s) decoded, {leftover} byte(s) undecoded");
|
||||
|
||||
// The verdict line. A framing guess that leaves a large tail undecoded is wrong, and
|
||||
// saying so plainly is more useful than a pile of packets that happen to parse.
|
||||
match framing {
|
||||
// Raw is a passthrough: it swallows the whole buffer unconditionally, so it can
|
||||
// never fail and a "clean fit" here would mean nothing. Say so, rather than
|
||||
// reporting a success that isn't evidence of anything.
|
||||
FramingVariant::Raw => {
|
||||
println!("VERDICT: raw passthrough — no framing was tested.");
|
||||
println!(" The hex above is the unparsed stream; read the header by hand,");
|
||||
println!(" then rerun with --framing fire2 to test the hypothesis.");
|
||||
}
|
||||
FramingVariant::Fire2 if count == 0 => {
|
||||
println!("VERDICT: fire2 does not fit — nothing decoded. Inspect with --framing raw.");
|
||||
}
|
||||
FramingVariant::Fire2 if leftover > 0 => {
|
||||
println!("VERDICT: partial fit — {count} decoded but {leftover} bytes left over.");
|
||||
println!(" Either the stream is truncated, or the framing is wrong.");
|
||||
}
|
||||
FramingVariant::Fire2 => {
|
||||
println!("VERDICT: fire2 is a clean fit — every byte accounted for.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse `.raw` log lines into records, ignoring anything malformed.
|
||||
///
|
||||
/// Malformed lines are skipped rather than fatal: a log truncated mid-write by a crashing
|
||||
/// game is a normal and valuable artifact, and refusing to read the good 99% of it because
|
||||
/// the last line is half-written would defeat the purpose.
|
||||
fn parse_log(text: &str) -> Vec<Record> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split_whitespace();
|
||||
let dir = parts.next()?;
|
||||
let inbound = match dir {
|
||||
"IN" => true,
|
||||
"OUT" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let _len = parts.next()?;
|
||||
let hex_str = parts.next()?;
|
||||
let bytes = hex::decode(hex_str).ok()?;
|
||||
Some(Record { inbound, bytes })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_directions_and_hex() {
|
||||
let log = "IN 2 dead\nOUT 2 beef\n";
|
||||
let recs = parse_log(log);
|
||||
assert_eq!(recs.len(), 2);
|
||||
assert!(recs[0].inbound);
|
||||
assert_eq!(recs[0].bytes, vec![0xDE, 0xAD]);
|
||||
assert!(!recs[1].inbound);
|
||||
assert_eq!(recs[1].bytes, vec![0xBE, 0xEF]);
|
||||
}
|
||||
|
||||
/// A log truncated by a crashing game must still yield its complete records.
|
||||
#[test]
|
||||
fn skips_malformed_lines_without_losing_good_ones() {
|
||||
let log = "IN 2 dead\ngarbage line\nIN 1 zz\nIN 2 beef\nIN 4\n";
|
||||
let recs = parse_log(log);
|
||||
assert_eq!(recs.len(), 2, "both well-formed records must survive");
|
||||
assert_eq!(recs[1].bytes, vec![0xBE, 0xEF]);
|
||||
}
|
||||
|
||||
/// A Fire2 frame written by the codec must survive a round trip through the log
|
||||
/// format — this is what makes a replayed capture trustworthy.
|
||||
#[test]
|
||||
fn fire2_frame_round_trips_through_the_log_format() {
|
||||
use blaze_proto::{FireFrame, FrameType, Packet, PacketOptions};
|
||||
use tokio_util::codec::Encoder;
|
||||
|
||||
let pkt = Packet {
|
||||
frame: FireFrame {
|
||||
component: 0x0009,
|
||||
command: 0x0007,
|
||||
error: 0,
|
||||
ty: FrameType::Request,
|
||||
options: PacketOptions::NONE,
|
||||
seq: 42,
|
||||
},
|
||||
body: bytes::Bytes::from_static(&[1, 2, 3, 4]),
|
||||
};
|
||||
|
||||
let mut encoded = BytesMut::new();
|
||||
PacketCodec::new(FramingVariant::Fire2)
|
||||
.encode(pkt, &mut encoded)
|
||||
.unwrap();
|
||||
|
||||
// Through the log representation and back.
|
||||
let line = format!("IN {} {}\n", encoded.len(), hex::encode(&encoded));
|
||||
let recs = parse_log(&line);
|
||||
let mut buf = BytesMut::from(&recs[0].bytes[..]);
|
||||
|
||||
let decoded = PacketCodec::new(FramingVariant::Fire2)
|
||||
.decode(&mut buf)
|
||||
.unwrap()
|
||||
.expect("frame must decode");
|
||||
|
||||
assert_eq!(decoded.frame.component, 0x0009);
|
||||
assert_eq!(decoded.frame.command, 0x0007);
|
||||
assert_eq!(decoded.frame.seq, 42);
|
||||
assert_eq!(&decoded.body[..], &[1, 2, 3, 4]);
|
||||
assert!(buf.is_empty(), "a clean fit must consume every byte");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Blaze component and command identifiers.
|
||||
//!
|
||||
//! ## Provenance and confidence
|
||||
//!
|
||||
//! These are **not FIFA 17 captures.** They are the Blaze framework's component numbering
|
||||
//! as published by open-source emulators for other EA titles — chiefly
|
||||
//! [`PocketRelay/Server`](https://github.com/PocketRelay/Server) (Mass Effect 3, MIT) with
|
||||
//! corroboration from the BF3 emulators. No EA source is involved; those projects derived
|
||||
//! the numbering by observing their own clients, the same clean-room basis this project
|
||||
//! uses.
|
||||
//!
|
||||
//! Confidence splits cleanly, and the split matters:
|
||||
//!
|
||||
//! * **Component IDs are framework-level** and have been stable across EA titles for a
|
||||
//! decade — Util is 0x9 in every title anyone has published numbers for. Treat these as
|
||||
//! likely-correct starting points.
|
||||
//! * **Command IDs are per-title.** Blaze lets each game define its own command set inside
|
||||
//! a component, so FIFA 17's may diverge from ME3's. Treat every command constant here as
|
||||
//! **TODO/CONFIRM** until a capture agrees with it.
|
||||
//!
|
||||
//! The purpose of this table is not to be right. It is to turn the capture log from a wall
|
||||
//! of hex into named requests, so an unknown ID stands out as unknown instead of hiding
|
||||
//! among the ones we could already have identified.
|
||||
|
||||
/// Blaze component identifiers.
|
||||
///
|
||||
/// Framework-level and stable across titles — the higher-confidence half of this module.
|
||||
pub mod component {
|
||||
pub const AUTHENTICATION: u16 = 0x1;
|
||||
pub const GAME_MANAGER: u16 = 0x4;
|
||||
pub const REDIRECTOR: u16 = 0x5;
|
||||
pub const STATS: u16 = 0x7;
|
||||
pub const UTIL: u16 = 0x9;
|
||||
pub const MESSAGING: u16 = 0xF;
|
||||
pub const ASSOCIATION_LISTS: u16 = 0x19;
|
||||
pub const GAME_REPORTING: u16 = 0x1C;
|
||||
pub const USER_SESSIONS: u16 = 0x7802;
|
||||
}
|
||||
|
||||
/// Redirector commands — the first exchange a Blaze client makes.
|
||||
pub mod redirector {
|
||||
/// "Where is the Blaze server?" The only command the redirector listener should see.
|
||||
pub const GET_SERVER_INSTANCE: u16 = 0x1;
|
||||
}
|
||||
|
||||
/// Util commands. `PRE_AUTH`, `PING` and `POST_AUTH` are the three that gate a session:
|
||||
/// the client will not proceed to authentication until preAuth is answered.
|
||||
pub mod util {
|
||||
pub const FETCH_CLIENT_CONFIG: u16 = 0x1;
|
||||
pub const PING: u16 = 0x2;
|
||||
pub const SET_CLIENT_DATA: u16 = 0x3;
|
||||
pub const LOCALIZE_STRINGS: u16 = 0x4;
|
||||
pub const GET_TELEMETRY_SERVER: u16 = 0x5;
|
||||
pub const GET_TICKER_SERVER: u16 = 0x6;
|
||||
pub const PRE_AUTH: u16 = 0x7;
|
||||
pub const POST_AUTH: u16 = 0x8;
|
||||
pub const USER_SETTINGS_LOAD: u16 = 0xA;
|
||||
pub const USER_SETTINGS_SAVE: u16 = 0xB;
|
||||
pub const USER_SETTINGS_LOAD_ALL: u16 = 0xC;
|
||||
pub const DELETE_USER_SETTINGS: u16 = 0xE;
|
||||
pub const FILTER_FOR_PROFANITY: u16 = 0x14;
|
||||
pub const FETCH_QOS_CONFIG: u16 = 0x15;
|
||||
pub const SET_CLIENT_METRICS: u16 = 0x16;
|
||||
pub const SET_CONNECTION_STATE: u16 = 0x17;
|
||||
pub const GET_PSS_CONFIG: u16 = 0x18;
|
||||
pub const GET_USER_OPTIONS: u16 = 0x19;
|
||||
pub const SET_USER_OPTIONS: u16 = 0x1A;
|
||||
pub const SUSPEND_USER_PING: u16 = 0x1B;
|
||||
}
|
||||
|
||||
/// Authentication commands.
|
||||
pub mod authentication {
|
||||
pub const LIST_USER_ENTITLEMENTS_2: u16 = 0x1D;
|
||||
pub const GET_AUTH_TOKEN: u16 = 0x24;
|
||||
pub const LOGIN: u16 = 0x28;
|
||||
pub const SILENT_LOGIN: u16 = 0x32;
|
||||
pub const LOGIN_PERSONA: u16 = 0x6E;
|
||||
pub const LOGOUT: u16 = 0x46;
|
||||
}
|
||||
|
||||
/// Human-readable name for a `(component, command)` pair, or `None` if unrecognised.
|
||||
///
|
||||
/// Returning `None` rather than a generic placeholder is deliberate: the capture log should
|
||||
/// make an unknown pair conspicuous, because unknown pairs are the entire point of
|
||||
/// Milestone 1.
|
||||
pub fn command_name(component: u16, command: u16) -> Option<&'static str> {
|
||||
Some(match (component, command) {
|
||||
(component::REDIRECTOR, redirector::GET_SERVER_INSTANCE) => "Redirector.getServerInstance",
|
||||
|
||||
(component::UTIL, util::FETCH_CLIENT_CONFIG) => "Util.fetchClientConfig",
|
||||
(component::UTIL, util::PING) => "Util.ping",
|
||||
(component::UTIL, util::SET_CLIENT_DATA) => "Util.setClientData",
|
||||
(component::UTIL, util::LOCALIZE_STRINGS) => "Util.localizeStrings",
|
||||
(component::UTIL, util::GET_TELEMETRY_SERVER) => "Util.getTelemetryServer",
|
||||
(component::UTIL, util::GET_TICKER_SERVER) => "Util.getTickerServer",
|
||||
(component::UTIL, util::PRE_AUTH) => "Util.preAuth",
|
||||
(component::UTIL, util::POST_AUTH) => "Util.postAuth",
|
||||
(component::UTIL, util::USER_SETTINGS_LOAD) => "Util.userSettingsLoad",
|
||||
(component::UTIL, util::USER_SETTINGS_SAVE) => "Util.userSettingsSave",
|
||||
(component::UTIL, util::USER_SETTINGS_LOAD_ALL) => "Util.userSettingsLoadAll",
|
||||
(component::UTIL, util::DELETE_USER_SETTINGS) => "Util.deleteUserSettings",
|
||||
(component::UTIL, util::FILTER_FOR_PROFANITY) => "Util.filterForProfanity",
|
||||
(component::UTIL, util::FETCH_QOS_CONFIG) => "Util.fetchQosConfig",
|
||||
(component::UTIL, util::SET_CLIENT_METRICS) => "Util.setClientMetrics",
|
||||
(component::UTIL, util::SET_CONNECTION_STATE) => "Util.setConnectionState",
|
||||
(component::UTIL, util::GET_PSS_CONFIG) => "Util.getPssConfig",
|
||||
(component::UTIL, util::GET_USER_OPTIONS) => "Util.getUserOptions",
|
||||
(component::UTIL, util::SET_USER_OPTIONS) => "Util.setUserOptions",
|
||||
(component::UTIL, util::SUSPEND_USER_PING) => "Util.suspendUserPing",
|
||||
|
||||
(component::AUTHENTICATION, authentication::LIST_USER_ENTITLEMENTS_2) => {
|
||||
"Authentication.listUserEntitlements2"
|
||||
}
|
||||
(component::AUTHENTICATION, authentication::GET_AUTH_TOKEN) => "Authentication.getAuthToken",
|
||||
(component::AUTHENTICATION, authentication::LOGIN) => "Authentication.login",
|
||||
(component::AUTHENTICATION, authentication::SILENT_LOGIN) => "Authentication.silentLogin",
|
||||
(component::AUTHENTICATION, authentication::LOGIN_PERSONA) => "Authentication.loginPersona",
|
||||
(component::AUTHENTICATION, authentication::LOGOUT) => "Authentication.logout",
|
||||
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Name of a component alone, for logging requests whose command is unrecognised.
|
||||
pub fn component_name(component: u16) -> Option<&'static str> {
|
||||
Some(match component {
|
||||
component::AUTHENTICATION => "Authentication",
|
||||
component::GAME_MANAGER => "GameManager",
|
||||
component::REDIRECTOR => "Redirector",
|
||||
component::STATS => "Stats",
|
||||
component::UTIL => "Util",
|
||||
component::MESSAGING => "Messaging",
|
||||
component::ASSOCIATION_LISTS => "AssociationLists",
|
||||
component::GAME_REPORTING => "GameReporting",
|
||||
component::USER_SESSIONS => "UserSessions",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Label for logs: the known name, else `component/command` in hex so it is greppable.
|
||||
pub fn label(component: u16, command: u16) -> String {
|
||||
if let Some(name) = command_name(component, command) {
|
||||
return name.to_string();
|
||||
}
|
||||
match component_name(component) {
|
||||
Some(c) => format!("{c}.0x{command:04X}"),
|
||||
None => format!("0x{component:04X}.0x{command:04X}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_the_session_gating_commands() {
|
||||
assert_eq!(label(component::UTIL, util::PRE_AUTH), "Util.preAuth");
|
||||
assert_eq!(label(component::UTIL, util::PING), "Util.ping");
|
||||
assert_eq!(
|
||||
label(component::REDIRECTOR, redirector::GET_SERVER_INSTANCE),
|
||||
"Redirector.getServerInstance"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown command inside a known component must still be greppable, and must not
|
||||
/// be silently dressed up as something recognised.
|
||||
#[test]
|
||||
fn unknown_command_keeps_hex_and_names_the_component() {
|
||||
assert_eq!(label(component::UTIL, 0xFF), "Util.0x00FF");
|
||||
assert_eq!(label(0xABCD, 0x12), "0xABCD.0x0012");
|
||||
assert!(command_name(component::UTIL, 0xFF).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
use blaze_proto::FramingVariant;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub redirector : RedirectorConfig,
|
||||
pub blaze : BlazeConfig,
|
||||
pub tls : TlsConfig,
|
||||
pub capture : CaptureConfig,
|
||||
}
|
||||
|
||||
@@ -23,14 +23,28 @@ pub struct BlazeConfig {
|
||||
pub advertise_host : String,
|
||||
/// Port we tell the client to connect to (in the redirect response).
|
||||
pub advertise_port : u16,
|
||||
/// Wire framing: `fire2` (default) or `raw`.
|
||||
///
|
||||
/// Config rather than a constant on purpose. Which variant a title uses is not
|
||||
/// knowable until real bytes arrive, and the FIFA 23 effort showed the cost of
|
||||
/// putting that decision behind a rebuild: every wrong guess is a full
|
||||
/// edit → build → deploy → relaunch cycle. Flipping to `raw` here lets one
|
||||
/// session answer the question.
|
||||
pub framing : Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TlsConfig {
|
||||
/// PEM certificate file (RSA-2048 recommended — DirtySDK rejects ECDSA).
|
||||
pub cert : String,
|
||||
/// PEM private key file.
|
||||
pub key : String,
|
||||
impl BlazeConfig {
|
||||
/// Parse the configured framing, falling back to Fire2 for anything unrecognised.
|
||||
///
|
||||
/// Unknown values fall back rather than erroring: an unparseable framing name should
|
||||
/// not stop the server starting and capturing, since the raw tee makes the bytes
|
||||
/// useful regardless of which variant is active.
|
||||
pub fn framing_variant(&self) -> FramingVariant {
|
||||
match self.framing.as_deref().map(str::trim) {
|
||||
Some("raw") | Some("Raw") | Some("RAW") => FramingVariant::Raw,
|
||||
_ => FramingVariant::Fire2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -39,6 +53,13 @@ pub struct CaptureConfig {
|
||||
pub dir : String,
|
||||
/// Also print decoded packet trees to stdout.
|
||||
pub pretty_print : bool,
|
||||
/// Mirror every raw byte to `<dir>/<session>.raw` before framing is attempted.
|
||||
/// Defaults to on — this is the safety net against zero-byte captures.
|
||||
pub raw_tee : Option<bool>,
|
||||
}
|
||||
|
||||
impl CaptureConfig {
|
||||
pub fn raw_tee_enabled(&self) -> bool { self.raw_tee.unwrap_or(true) }
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -49,3 +70,32 @@ impl Config {
|
||||
.map_err(|e| anyhow::anyhow!("config parse error in {path}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg(framing: Option<&str>) -> BlazeConfig {
|
||||
BlazeConfig {
|
||||
listen: "0.0.0.0:10041".into(),
|
||||
advertise_host: "127.0.0.1".into(),
|
||||
advertise_port: 10041,
|
||||
framing: framing.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framing_defaults_to_fire2_and_accepts_raw() {
|
||||
assert!(matches!(cfg(None).framing_variant(), FramingVariant::Fire2));
|
||||
assert!(matches!(cfg(Some("raw")).framing_variant(), FramingVariant::Raw));
|
||||
assert!(matches!(cfg(Some("RAW")).framing_variant(), FramingVariant::Raw));
|
||||
// Unrecognised values must not stop the server booting.
|
||||
assert!(matches!(cfg(Some("fire3")).framing_variant(), FramingVariant::Fire2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_tee_defaults_on() {
|
||||
let c = CaptureConfig { dir: "captures".into(), pretty_print: true, raw_tee: None };
|
||||
assert!(c.raw_tee_enabled(), "the zero-byte-capture safety net must be opt-out");
|
||||
}
|
||||
}
|
||||
|
||||
+143
-65
@@ -1,27 +1,37 @@
|
||||
//! blaze-server: Milestone 1 — capture stub.
|
||||
//! blaze-server: Milestone 1 — capture stub (FIFA 17 target).
|
||||
//!
|
||||
//! Starts two TLS listeners:
|
||||
//! • redirector — answers FIFA 23's "where is the Blaze server?" query
|
||||
//! Starts two SSLv3 listeners:
|
||||
//! • redirector — answers the client's "where is the Blaze server?" query
|
||||
//! • blaze — accepts the actual game session and logs every packet
|
||||
//!
|
||||
//! All component/command IDs are unknown at this stage. Every packet receives
|
||||
//! an empty response so the client keeps talking. The capture log reveals the
|
||||
//! IDs to implement next.
|
||||
//!
|
||||
//! ## Why SSLv3 and not rustls
|
||||
//!
|
||||
//! EA's DirtySDK speaks ProtoSSL — a homegrown SSLv3 implementation restricted to
|
||||
//! RC4-SHA / RC4-MD5. A modern TLS stack cannot negotiate with it at all: there is no
|
||||
//! shared protocol version, let alone a shared cipher. `blaze-ssl-async` implements
|
||||
//! exactly that dialect, and ships a certificate crafted to satisfy older ProtoSSL
|
||||
//! verification — which also removes the cert-generation step this server used to need.
|
||||
|
||||
mod capture;
|
||||
mod components;
|
||||
mod config;
|
||||
mod dispatch;
|
||||
mod tls;
|
||||
mod routes;
|
||||
mod tee;
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use blaze_proto::{FramingVariant, Packet, PacketCodec};
|
||||
use bytes::Bytes;
|
||||
use blaze_ssl_async::{BlazeListener, BlazeServerContext, BlazeStream};
|
||||
use capture::{CaptureWriter, Dir};
|
||||
use dispatch::Dispatcher;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
use tee::TeeStream;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio_util::codec::Framed;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
@@ -37,37 +47,51 @@ async fn main() -> anyhow::Result<()> {
|
||||
let cfg = config::Config::load(&cfg_path)?;
|
||||
info!(config = cfg_path, "loaded");
|
||||
|
||||
let tls_cfg = tls::load_tls_config(&cfg.tls.cert, &cfg.tls.key)?;
|
||||
let acceptor = TlsAcceptor::from(Arc::clone(&tls_cfg));
|
||||
// Built-in ProtoSSL-bypass certificate — no cert/key files to generate or configure.
|
||||
let ssl_ctx = Arc::new(BlazeServerContext::default());
|
||||
|
||||
let capture = Arc::new(CaptureWriter::open(&cfg.capture.dir, cfg.capture.pretty_print)?);
|
||||
let dispatcher = Arc::new(Dispatcher::new());
|
||||
let dispatcher = Arc::new(build_dispatcher());
|
||||
|
||||
let framing = cfg.blaze.framing_variant();
|
||||
let raw_dir = cfg.capture.raw_tee_enabled().then(|| PathBuf::from(&cfg.capture.dir));
|
||||
info!(?framing, raw_tee = raw_dir.is_some(), "capture settings");
|
||||
|
||||
let redir_addr: SocketAddr = cfg.redirector.listen.parse()?;
|
||||
let blaze_addr: SocketAddr = cfg.blaze.listen.parse()?;
|
||||
let advertise_host = Arc::new(cfg.blaze.advertise_host.clone());
|
||||
let advertise_port = cfg.blaze.advertise_port;
|
||||
|
||||
let redir_listener = TcpListener::bind(redir_addr).await?;
|
||||
let blaze_listener = TcpListener::bind(blaze_addr).await?;
|
||||
info!(%redir_addr, "redirector listening");
|
||||
info!(%blaze_addr, "blaze listening");
|
||||
let redir_listener = BlazeListener::bind(redir_addr, Arc::clone(&ssl_ctx)).await?;
|
||||
let blaze_listener = BlazeListener::bind(blaze_addr, Arc::clone(&ssl_ctx)).await?;
|
||||
info!(%redir_addr, "redirector listening (SSLv3)");
|
||||
info!(%blaze_addr, "blaze listening (SSLv3)");
|
||||
|
||||
// ── Redirector loop ───────────────────────────────────────────────────────
|
||||
let redir_acceptor = acceptor.clone();
|
||||
let redir_cap = Arc::clone(&capture);
|
||||
let adv_host = Arc::clone(&advertise_host);
|
||||
let redir_cap = Arc::clone(&capture);
|
||||
let adv_host = Arc::clone(&advertise_host);
|
||||
let redir_raw = raw_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match redir_listener.accept().await {
|
||||
Err(e) => { error!(error=%e, "redirector accept error"); }
|
||||
Ok((tcp, peer)) => {
|
||||
let acc = redir_acceptor.clone();
|
||||
Ok(accept) => {
|
||||
let cap = Arc::clone(&redir_cap);
|
||||
let host = Arc::clone(&adv_host);
|
||||
let raw = redir_raw.clone();
|
||||
// finish_accept completes the SSL handshake, so it must run in its own
|
||||
// task — awaiting it here would stall the accept loop for the duration
|
||||
// of every handshake.
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_redirector(tcp, peer, acc, cap, &host, advertise_port).await {
|
||||
warn!(%peer, error=%e, "redirector session error");
|
||||
match accept.finish_accept().await {
|
||||
Err(e) => warn!(error=%e, "redirector SSL handshake failed"),
|
||||
Ok((stream, peer)) => {
|
||||
if let Err(e) = handle_redirector(
|
||||
stream, peer, cap, &host, advertise_port, framing, raw,
|
||||
).await {
|
||||
warn!(%peer, error=%e, "redirector session error");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -79,13 +103,20 @@ async fn main() -> anyhow::Result<()> {
|
||||
loop {
|
||||
match blaze_listener.accept().await {
|
||||
Err(e) => { error!(error=%e, "blaze accept error"); }
|
||||
Ok((tcp, peer)) => {
|
||||
let acc = acceptor.clone();
|
||||
Ok(accept) => {
|
||||
let cap = Arc::clone(&capture);
|
||||
let disp = Arc::clone(&dispatcher);
|
||||
let raw = raw_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_blaze(tcp, peer, acc, cap, disp).await {
|
||||
warn!(%peer, error=%e, "blaze session error");
|
||||
match accept.finish_accept().await {
|
||||
Err(e) => warn!(error=%e, "blaze SSL handshake failed"),
|
||||
Ok((stream, peer)) => {
|
||||
if let Err(e) =
|
||||
handle_blaze(stream, peer, cap, disp, framing, raw).await
|
||||
{
|
||||
warn!(%peer, error=%e, "blaze session error");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -93,33 +124,95 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the dispatch table with the handlers we can answer today.
|
||||
///
|
||||
/// Only the session-gating Util commands are registered. Everything else falls through to
|
||||
/// the dispatcher's empty-response default, which keeps the client talking so the capture
|
||||
/// log keeps growing — the point of Milestone 1 is to *learn* the command set, not to
|
||||
/// pre-empt it with guesses.
|
||||
fn build_dispatcher() -> Dispatcher {
|
||||
use components::{component, util};
|
||||
|
||||
let mut d = Dispatcher::new();
|
||||
|
||||
// Ping carries the server clock. Sampled per request rather than at startup so a
|
||||
// long-running server doesn't hand the client an increasingly stale timestamp.
|
||||
d.register(component::UTIL, util::PING, |pkt: Packet| async move {
|
||||
let now = chrono::Utc::now().timestamp() as u32;
|
||||
Some(Packet { frame: pkt.frame.response(), body: routes::ping_body(now) })
|
||||
});
|
||||
|
||||
// preAuth gates authentication — the client will not proceed until it is answered.
|
||||
d.register(component::UTIL, util::PRE_AUTH, |pkt: Packet| async move {
|
||||
let body = routes::pre_auth_body(&routes::PreAuthConfig::default());
|
||||
Some(Packet { frame: pkt.frame.response(), body })
|
||||
});
|
||||
|
||||
d
|
||||
}
|
||||
|
||||
/// Wrap `stream` in the raw byte tee when capture is enabled.
|
||||
///
|
||||
/// Returns a boxed trait object so both branches have one type. The dynamic dispatch
|
||||
/// costs nothing meaningful against SSL and disk I/O, and it keeps the two session
|
||||
/// handlers from having to be generic over the tee being on or off.
|
||||
fn with_tee(
|
||||
stream: BlazeStream,
|
||||
peer: SocketAddr,
|
||||
label: &str,
|
||||
raw_dir: Option<PathBuf>,
|
||||
) -> Box<dyn TeeIo> {
|
||||
let Some(dir) = raw_dir else {
|
||||
return Box::new(stream);
|
||||
};
|
||||
// Port, not IP, distinguishes concurrent sessions — the client dials from loopback
|
||||
// for every connection, so the addr alone collides.
|
||||
let name = format!("{label}-{}.raw", peer.port());
|
||||
// Open the log first: on failure the stream is still ours, so the session survives.
|
||||
match tee::open_sink(dir.join(&name)) {
|
||||
Ok(sink) => {
|
||||
info!(%peer, raw = name, "raw tee active");
|
||||
Box::new(TeeStream::with_sink(stream, sink))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(%peer, error=%e, "raw tee unavailable, continuing without it");
|
||||
Box::new(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker trait so `with_tee` can return one boxed type for both branches.
|
||||
pub trait TeeIo: AsyncRead + AsyncWrite + Send + Unpin {}
|
||||
impl<T: AsyncRead + AsyncWrite + Send + Unpin> TeeIo for T {}
|
||||
|
||||
// ── Redirector handler ────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_redirector(
|
||||
tcp : TcpStream,
|
||||
peer : SocketAddr,
|
||||
acceptor: TlsAcceptor,
|
||||
capture : Arc<CaptureWriter>,
|
||||
host : &str,
|
||||
port : u16,
|
||||
stream : BlazeStream,
|
||||
peer : SocketAddr,
|
||||
capture: Arc<CaptureWriter>,
|
||||
host : &str,
|
||||
port : u16,
|
||||
framing: FramingVariant,
|
||||
raw_dir: Option<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
let tls = acceptor.accept(tcp).await?;
|
||||
let codec = PacketCodec::new(FramingVariant::Fire2);
|
||||
let mut io = Framed::new(tls, codec);
|
||||
let io = with_tee(stream, peer, "redirector", raw_dir);
|
||||
let codec = PacketCodec::new(framing);
|
||||
let mut io = Framed::new(io, codec);
|
||||
|
||||
let peer_str = peer.to_string();
|
||||
info!(%peer, "redirector session started");
|
||||
|
||||
// Read the redirect request (component/command unknown; we just echo it back).
|
||||
if let Some(Ok(req)) = io.next().await {
|
||||
capture.record(&req, &peer_str, Dir::In);
|
||||
info!(%peer, component=format!("0x{:04X}", req.frame.component),
|
||||
command=format!("0x{:04X}", req.frame.command), "redirector request");
|
||||
info!(%peer, cmd = components::label(req.frame.component, req.frame.command),
|
||||
"redirector request");
|
||||
|
||||
// Build the redirect response. The TDF body tells the client which
|
||||
// Blaze server to connect to. We don't know the exact tag layout yet —
|
||||
// capture will reveal it. For now we return our advertise address as a
|
||||
// simple string blob so the client gets something to parse.
|
||||
let body = build_redirector_body(host, port);
|
||||
// Tell the client where the Blaze server is. `secure = true` because our Blaze
|
||||
// listener is SSLv3 like the redirector — if the client dials plaintext and gets
|
||||
// a handshake, this is the flag to flip.
|
||||
let body = routes::redirector_instance_body(host, port, true);
|
||||
let resp = Packet { frame: req.frame.response(), body };
|
||||
capture.record(&resp, &peer_str, Dir::Out);
|
||||
io.send(resp).await?;
|
||||
@@ -127,34 +220,19 @@ async fn handle_redirector(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds a minimal TDF body for the redirector response.
|
||||
///
|
||||
/// The exact tag layout is unknown until capture. This is a stub so the
|
||||
/// listener at least sends something; the real layout will be determined from
|
||||
/// captures of the client's request packet.
|
||||
///
|
||||
/// BF3/ME3 redirector response uses tags like "ADDR" (string), "PORT" (u32).
|
||||
/// FIFA 23 may differ. TODO: replace once captures reveal the real tags.
|
||||
fn build_redirector_body(host: &str, port: u16) -> Bytes {
|
||||
use tdf::writer::TdfSerializer;
|
||||
let mut w: Vec<u8> = Vec::new();
|
||||
w.tag_str(b"ADDR", host);
|
||||
w.tag_u32(b"PORT", port as u32);
|
||||
Bytes::from(w)
|
||||
}
|
||||
|
||||
// ── Blaze session handler ─────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_blaze(
|
||||
tcp : TcpStream,
|
||||
peer : SocketAddr,
|
||||
acceptor : TlsAcceptor,
|
||||
capture : Arc<CaptureWriter>,
|
||||
stream : BlazeStream,
|
||||
peer : SocketAddr,
|
||||
capture : Arc<CaptureWriter>,
|
||||
dispatcher: Arc<Dispatcher>,
|
||||
framing : FramingVariant,
|
||||
raw_dir : Option<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
let tls = acceptor.accept(tcp).await?;
|
||||
let codec = PacketCodec::new(FramingVariant::Fire2);
|
||||
let mut io = Framed::new(tls, codec);
|
||||
let io = with_tee(stream, peer, "blaze", raw_dir);
|
||||
let codec = PacketCodec::new(framing);
|
||||
let mut io = Framed::new(io, codec);
|
||||
|
||||
let peer_str = peer.to_string();
|
||||
info!(%peer, "blaze session started");
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Response bodies for the handful of commands that gate a Blaze session.
|
||||
//!
|
||||
//! ## Status of everything in this file
|
||||
//!
|
||||
//! These layouts are **scaffolds derived from other titles**, not FIFA 17 captures. The
|
||||
//! tag names and structure come from the Blaze framework's shape as published by
|
||||
//! open-source emulators (chiefly PocketRelay for Mass Effect 3, MIT). Every value is a
|
||||
//! placeholder and every layout is `TODO/CONFIRM`.
|
||||
//!
|
||||
//! They exist so the first FIFA 17 session gets *structurally plausible* answers instead
|
||||
//! of empty bodies. An empty body is guaranteed wrong and tells you nothing; a plausible
|
||||
//! body either advances the client — which is a result — or gets rejected in a way whose
|
||||
//! error code is itself information. Either beats silence.
|
||||
//!
|
||||
//! When a capture disagrees with anything here, the capture wins.
|
||||
|
||||
use bytes::Bytes;
|
||||
use std::net::Ipv4Addr;
|
||||
use tdf::writer::TdfSerializer;
|
||||
|
||||
/// Blaze `NetworkAddress` union discriminants.
|
||||
///
|
||||
/// TODO/CONFIRM: the discriminant the redirector response uses. ME3 emulators send the
|
||||
/// pair form for server instances. If FIFA 17 rejects the redirect or dials a nonsense
|
||||
/// address, this constant is the first thing to change — the variants are listed so the
|
||||
/// alternatives are one edit away.
|
||||
// The unused variants are the point: they are the alternatives to try when the client
|
||||
// rejects the redirect, kept named so the fix is a one-word edit rather than research.
|
||||
#[allow(dead_code)]
|
||||
pub mod net_address {
|
||||
/// `VALU` group holding `HOST` / `IP` / `PORT`.
|
||||
pub const IP_PAIR: u8 = 0x0;
|
||||
pub const XBOX_CLIENT: u8 = 0x1;
|
||||
pub const XBOX_SERVER: u8 = 0x2;
|
||||
pub const IP_ADDRESS: u8 = 0x3;
|
||||
pub const HOSTNAME: u8 = 0x4;
|
||||
}
|
||||
|
||||
/// Body of a `Redirector.getServerInstance` response — "connect to this Blaze server".
|
||||
///
|
||||
/// Replaces the earlier `ADDR` string / `PORT` u32 stub, which was not a real Blaze
|
||||
/// layout at all: `ADDR` is a **tagged union**, not a string. A client parsing the old
|
||||
/// stub would have hit a type mismatch on the first tag.
|
||||
///
|
||||
/// Layout (TODO/CONFIRM against a FIFA 17 capture):
|
||||
///
|
||||
/// ```text
|
||||
/// ADDR union(IP_PAIR)
|
||||
/// VALU group
|
||||
/// HOST string hostname we want the client to dial
|
||||
/// IP u32 same address as a big-endian v4 integer, 0 if not an IP
|
||||
/// PORT u16
|
||||
/// SECU bool does the Blaze port expect SSL
|
||||
/// XDNS bool Xbox DNS indirection — false on PC
|
||||
/// ```
|
||||
pub fn redirector_instance_body(host: &str, port: u16, secure: bool) -> Bytes {
|
||||
let mut w: Vec<u8> = Vec::new();
|
||||
|
||||
w.tag_union_start(b"ADDR", net_address::IP_PAIR);
|
||||
w.group(b"VALU", |w| {
|
||||
w.tag_str(b"HOST", host);
|
||||
// A hostname has no integer form; send 0 and let HOST carry it. Clients that
|
||||
// prefer IP will fail over to HOST rather than dial 0.0.0.0.
|
||||
w.tag_u32(b"IP", ipv4_as_u32(host).unwrap_or(0));
|
||||
w.tag_u16(b"PORT", port);
|
||||
});
|
||||
w.tag_bool(b"SECU", secure);
|
||||
w.tag_bool(b"XDNS", false);
|
||||
|
||||
Bytes::from(w)
|
||||
}
|
||||
|
||||
/// Body of a `Util.ping` response.
|
||||
///
|
||||
/// Ping carries the server's clock. Clients use it for drift correction and, in some
|
||||
/// titles, treat a missing value as a dead session — so answering with an empty body is
|
||||
/// actively risky where answering with a timestamp is not.
|
||||
pub fn ping_body(server_time: u32) -> Bytes {
|
||||
let mut w: Vec<u8> = Vec::new();
|
||||
w.tag_u32(b"STIM", server_time);
|
||||
Bytes::from(w)
|
||||
}
|
||||
|
||||
/// Body of a `Util.preAuth` response.
|
||||
///
|
||||
/// preAuth is the gate: the client will not attempt authentication until it is answered,
|
||||
/// so this is the first response that actually has to be structurally right.
|
||||
///
|
||||
/// This is a **minimal** scaffold. Real preAuth responses from shipping titles are large,
|
||||
/// carrying telemetry endpoints, QoS probe lists and a client config map. Those are
|
||||
/// deliberately omitted rather than invented — every extra fabricated field is another
|
||||
/// chance to fail in a way that is hard to attribute. Start small, read the client's
|
||||
/// reaction, grow it.
|
||||
///
|
||||
/// TODO/CONFIRM every tag and value below against a FIFA 17 capture.
|
||||
pub fn pre_auth_body(config: &PreAuthConfig<'_>) -> Bytes {
|
||||
let mut w: Vec<u8> = Vec::new();
|
||||
|
||||
w.tag_zero(b"ANON");
|
||||
w.tag_str(b"ASRC", config.auth_source);
|
||||
// Empty component-id list: we advertise no optional components until we know which
|
||||
// ones FIFA 17 expects. An empty list is honest; a fabricated one invites the client
|
||||
// to call something unimplemented.
|
||||
w.tag_var_int_list_empty(b"CIDS");
|
||||
w.tag_str_empty(b"CNGN");
|
||||
w.tag_group_empty(b"CONF");
|
||||
w.tag_str(b"INST", config.instance_name);
|
||||
w.tag_bool(b"MINR", false);
|
||||
w.tag_str(b"NASP", config.namespace);
|
||||
w.tag_str_empty(b"PILD");
|
||||
w.tag_str(b"PLAT", config.platform);
|
||||
w.tag_str_empty(b"PTAG");
|
||||
w.tag_str(b"RSRC", config.auth_source);
|
||||
w.tag_str(b"SVER", config.blaze_version);
|
||||
|
||||
Bytes::from(w)
|
||||
}
|
||||
|
||||
/// Values the preAuth response advertises about this server and title.
|
||||
///
|
||||
/// Grouped into a struct so the FIFA 17 identity lives in one place: when a capture
|
||||
/// reveals the real instance name or namespace, there is exactly one thing to correct.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreAuthConfig<'a> {
|
||||
/// Authentication source / resource identifier.
|
||||
pub auth_source: &'a str,
|
||||
/// Blaze instance name for the title.
|
||||
pub instance_name: &'a str,
|
||||
/// Persona namespace.
|
||||
pub namespace: &'a str,
|
||||
/// Platform string.
|
||||
pub platform: &'a str,
|
||||
/// Blaze SDK version the server claims to speak.
|
||||
pub blaze_version: &'a str,
|
||||
}
|
||||
|
||||
impl Default for PreAuthConfig<'_> {
|
||||
/// FIFA 17 PC guesses. All TODO/CONFIRM — these are the exact strings a capture will
|
||||
/// overwrite first, and getting them wrong is expected at this stage.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auth_source: "fifa-2017-pc",
|
||||
instance_name: "fifa-2017-pc",
|
||||
namespace: "cem_ea_id",
|
||||
platform: "pc",
|
||||
blaze_version: "Blaze 3.15.08.0",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `host` as an IPv4 address and return it as a big-endian u32, or `None` if it is
|
||||
/// a hostname rather than a literal address.
|
||||
fn ipv4_as_u32(host: &str) -> Option<u32> {
|
||||
host.parse::<Ipv4Addr>().ok().map(u32::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tdf::{reader::TdfDeserializer, stringify::TdfStringifier};
|
||||
|
||||
/// Render a body back to text via the TDF parser.
|
||||
///
|
||||
/// Tags are NOT searchable as ASCII in the encoded bytes: TDF packs each tag
|
||||
/// character into 6 bits spread across 3 bytes, so `b"ADDR"` never appears literally.
|
||||
/// Anything asserting on tag names has to parse. Stringifying also proves the body is
|
||||
/// well-formed TDF, which a substring check never could — the parser returning false
|
||||
/// means we emitted something a client could not read.
|
||||
fn decode(body: &Bytes) -> String {
|
||||
let (text, ok) = TdfStringifier::<String>::new_string(TdfDeserializer::new(body));
|
||||
assert!(ok, "body must be well-formed TDF, got partial parse:\n{text}");
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_is_converted_and_hostnames_are_not() {
|
||||
assert_eq!(ipv4_as_u32("127.0.0.1"), Some(0x7F00_0001));
|
||||
assert_eq!(ipv4_as_u32("10.10.0.5"), Some(0x0A0A_0005));
|
||||
assert_eq!(ipv4_as_u32("gosredirector.ea.com"), None);
|
||||
}
|
||||
|
||||
/// The redirector body must be a union carrying a VALU group — not the old
|
||||
/// `ADDR`-as-string stub, which a client would have rejected on a type mismatch at
|
||||
/// the very first tag.
|
||||
#[test]
|
||||
fn redirector_body_carries_the_address_union_and_port() {
|
||||
let text = decode(&redirector_instance_body("127.0.0.1", 10041, false));
|
||||
for tag in ["ADDR", "VALU", "HOST", "PORT", "SECU"] {
|
||||
assert!(text.contains(tag), "missing tag {tag} in redirector body:\n{text}");
|
||||
}
|
||||
assert!(text.contains("10041"), "advertised port must appear:\n{text}");
|
||||
assert!(text.contains("127.0.0.1"), "advertised host must appear:\n{text}");
|
||||
}
|
||||
|
||||
/// A hostname target must still produce a well-formed body — IP falls back to 0 and
|
||||
/// HOST carries the address, rather than the build failing.
|
||||
#[test]
|
||||
fn redirector_body_handles_a_hostname_target() {
|
||||
let text = decode(&redirector_instance_body("blaze.local", 10041, true));
|
||||
assert!(text.contains("blaze.local"), "hostname must survive:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ping_body_carries_server_time() {
|
||||
let text = decode(&ping_body(1_700_000_000));
|
||||
assert!(text.contains("STIM"), "ping must carry STIM:\n{text}");
|
||||
assert!(text.contains("1700000000"), "ping must carry the time:\n{text}");
|
||||
}
|
||||
|
||||
/// preAuth is the gate command — an empty or malformed body here stalls every session.
|
||||
#[test]
|
||||
fn pre_auth_body_is_populated() {
|
||||
let text = decode(&pre_auth_body(&PreAuthConfig::default()));
|
||||
for tag in ["ASRC", "INST", "NASP", "PLAT", "SVER"] {
|
||||
assert!(text.contains(tag), "missing tag {tag} in preAuth body:\n{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Raw byte tee — the capture safety net.
|
||||
//!
|
||||
//! The FIFA 23 effort produced six capture files containing zero bytes. The cause was
|
||||
//! structural: captures were only written *after* a packet decoded successfully, so a
|
||||
//! wrong framing guess meant no evidence at all — the one thing needed to fix the guess.
|
||||
//!
|
||||
//! This wrapper removes that failure mode. It sits between the SSL stream and the codec
|
||||
//! and appends every byte in both directions to a `.raw` log, before any framing is
|
||||
//! attempted. If Fire2 is the wrong variant for this title, the decode still fails — but
|
||||
//! the bytes are on disk and the correct layout is readable from them.
|
||||
//!
|
||||
//! Writes are synchronous `std::fs` calls inside `poll_read`/`poll_write`. That is
|
||||
//! technically blocking in an async context; it is deliberate and fine here because this
|
||||
//! is a capture tool handling one client at low packet rates, and the alternative
|
||||
//! (buffering through a channel) risks losing the tail on a crash — which is exactly when
|
||||
//! the bytes matter most.
|
||||
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
io::Write,
|
||||
path::Path,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
|
||||
/// Wraps a stream, mirroring every byte read and written into a hex log.
|
||||
pub struct TeeStream<S> {
|
||||
inner: S,
|
||||
sink: File,
|
||||
}
|
||||
|
||||
/// Open (or create) the raw log at `path`, creating the parent directory if needed.
|
||||
///
|
||||
/// Deliberately separate from `TeeStream::with_sink` so the fallible part happens
|
||||
/// *before* the stream is moved. If opening the log fails, the caller still owns the
|
||||
/// stream and can carry on without a tee — losing the log must never cost the session,
|
||||
/// because the session is the experiment.
|
||||
pub fn open_sink(path: impl AsRef<Path>) -> std::io::Result<File> {
|
||||
let path = path.as_ref();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
OpenOptions::new().create(true).append(true).open(path)
|
||||
}
|
||||
|
||||
impl<S> TeeStream<S> {
|
||||
/// Wrap `inner`, mirroring bytes into an already-opened `sink`.
|
||||
pub fn with_sink(inner: S, sink: File) -> Self {
|
||||
Self { inner, sink }
|
||||
}
|
||||
|
||||
/// One line per chunk: `<dir> <byte-count> <hex>`. Line-oriented so the log stays
|
||||
/// greppable and `xxd`-free; direction is explicit because a Blaze session is a
|
||||
/// request/response interleave and order alone is ambiguous.
|
||||
fn log(&mut self, dir: &str, bytes: &[u8]) {
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(self.sink, "{dir} {} {}", bytes.len(), hex::encode(bytes));
|
||||
let _ = self.sink.flush();
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncRead for TeeStream<S> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
// Record how much was already in the buffer so only the newly-filled region
|
||||
// is teed — poll_read appends, it does not reset the cursor.
|
||||
let before = buf.filled().len();
|
||||
let poll = Pin::new(&mut self.inner).poll_read(cx, buf);
|
||||
if let Poll::Ready(Ok(())) = &poll {
|
||||
let new = buf.filled()[before..].to_vec();
|
||||
self.log("IN ", &new);
|
||||
}
|
||||
poll
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncWrite + Unpin> AsyncWrite for TeeStream<S> {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
let poll = Pin::new(&mut self.inner).poll_write(cx, buf);
|
||||
// Log only what was actually accepted; a short write must not be over-reported
|
||||
// or the log stops matching the wire.
|
||||
if let Poll::Ready(Ok(n)) = &poll {
|
||||
let written = buf[..*n].to_vec();
|
||||
self.log("OUT", &written);
|
||||
}
|
||||
poll
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// The whole point of this module: bytes reach disk even though no framing ever ran.
|
||||
#[tokio::test]
|
||||
async fn tees_reads_and_writes_to_disk() {
|
||||
let dir = std::env::temp_dir().join("fifa-blaze-tee-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("session.raw");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// A duplex pair stands in for the SSL stream; the far end feeds and drains it.
|
||||
let (near, mut far) = tokio::io::duplex(64);
|
||||
let mut tee = TeeStream::with_sink(near, open_sink(&path).unwrap());
|
||||
|
||||
far.write_all(&[0xDE, 0xAD]).await.unwrap();
|
||||
let mut buf = [0u8; 2];
|
||||
tee.read_exact(&mut buf).await.unwrap();
|
||||
tee.write_all(&[0xBE, 0xEF]).await.unwrap();
|
||||
far.read_exact(&mut [0u8; 2]).await.unwrap();
|
||||
|
||||
let log = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(log.contains("IN 2 dead"), "inbound bytes must be logged, got: {log}");
|
||||
assert!(log.contains("OUT 2 beef"), "outbound bytes must be logged, got: {log}");
|
||||
}
|
||||
|
||||
/// End-to-end proof of the transport swap: a real SSLv3 handshake completes against
|
||||
/// `blaze-ssl-async`'s built-in ProtoSSL-bypass certificate, and application bytes
|
||||
/// survive the round trip into the tee as plaintext.
|
||||
///
|
||||
/// This is the check that would have caught the FIFA 17 showstopper early — the old
|
||||
/// rustls listener could not have completed this handshake at all, because ProtoSSL
|
||||
/// offers only SSLv3 with RC4 and a modern TLS stack shares no version with it.
|
||||
/// It runs without FIFA, so the server can be validated before touching the game.
|
||||
#[tokio::test]
|
||||
async fn sslv3_round_trip_reaches_the_tee() {
|
||||
use blaze_ssl_async::{BlazeListener, BlazeServerContext, BlazeStream};
|
||||
use std::sync::Arc;
|
||||
|
||||
let dir = std::env::temp_dir().join("fifa-blaze-ssl-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("ssl.raw");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let ctx = Arc::new(BlazeServerContext::default());
|
||||
// Port 0 → the OS picks a free port, so the test can't collide with a real run.
|
||||
let listener = BlazeListener::bind(("127.0.0.1", 0), ctx).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let sink = open_sink(&path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _peer) = listener.accept().await.unwrap().finish_accept().await.unwrap();
|
||||
let mut tee = TeeStream::with_sink(stream, sink);
|
||||
let mut buf = [0u8; 4];
|
||||
tee.read_exact(&mut buf).await.unwrap();
|
||||
tee.write_all(b"pong").await.unwrap();
|
||||
tee.flush().await.unwrap();
|
||||
});
|
||||
|
||||
let mut client = BlazeStream::connect(addr).await.unwrap();
|
||||
client.write_all(b"ping").await.unwrap();
|
||||
client.flush().await.unwrap();
|
||||
let mut reply = [0u8; 4];
|
||||
client.read_exact(&mut reply).await.unwrap();
|
||||
assert_eq!(&reply, b"pong", "client must receive the server's reply over SSLv3");
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
let log = std::fs::read_to_string(&path).unwrap();
|
||||
let ping = hex::encode(b"ping");
|
||||
let pong = hex::encode(b"pong");
|
||||
assert!(log.contains(&ping), "decrypted inbound must reach the tee, got: {log}");
|
||||
assert!(log.contains(&pong), "outbound must reach the tee, got: {log}");
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
use std::{fs::File, io::BufReader, sync::Arc};
|
||||
use rustls::{ServerConfig, pki_types::{CertificateDer, PrivateKeyDer}};
|
||||
use rustls_pemfile::{certs, private_key};
|
||||
use anyhow::Context;
|
||||
|
||||
/// Load a TLS `ServerConfig` from PEM cert and key files.
|
||||
///
|
||||
/// Uses `ring` as the crypto backend (specified in Cargo.toml features).
|
||||
/// This needs to match what DirtySDK (FIFA's network library) accepts:
|
||||
/// • RSA-2048 certificate (ECDSA causes BAD_CERTIFICATE)
|
||||
/// • TLS 1.2+ (DirtySDK can negotiate TLS 1.3 with AES-256-GCM)
|
||||
pub fn load_tls_config(cert_path: &str, key_path: &str) -> anyhow::Result<Arc<ServerConfig>> {
|
||||
// Install the ring crypto provider once. Harmless if called multiple times.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let cert_file = File::open(cert_path)
|
||||
.with_context(|| format!("open cert: {cert_path}"))?;
|
||||
let key_file = File::open(key_path)
|
||||
.with_context(|| format!("open key: {key_path}"))?;
|
||||
|
||||
let certs: Vec<CertificateDer> = certs(&mut BufReader::new(cert_file))
|
||||
.collect::<Result<_, _>>()
|
||||
.with_context(|| "parse PEM certs")?;
|
||||
|
||||
let key: PrivateKeyDer = private_key(&mut BufReader::new(key_file))
|
||||
.with_context(|| "parse PEM key")?
|
||||
.ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
|
||||
|
||||
let config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)
|
||||
.with_context(|| "build ServerConfig")?;
|
||||
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
Reference in New Issue
Block a user