1 Commits

Author SHA1 Message Date
funman300 d2a9a01ec9 wip: checkpoint Blaze server work for Windows migration 2026-08-07 12:03:21 -07:00
11 changed files with 1567 additions and 402 deletions
Generated
+380 -226
View File
@@ -33,26 +33,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "aws-lc-rs" name = "base64ct"
version = "1.17.0" version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
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",
]
[[package]] [[package]]
name = "blaze-proto" name = "blaze-proto"
@@ -66,6 +50,19 @@ dependencies = [
"tokio-util", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -85,8 +82,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
@@ -111,13 +106,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "cmake" name = "const-oid"
version = "0.1.58" version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
@@ -125,6 +117,16 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@@ -161,10 +163,38 @@ dependencies = [
] ]
[[package]] [[package]]
name = "dunce" name = "der"
version = "1.0.5" version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "equivalent" name = "equivalent"
@@ -178,18 +208,18 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.32"
@@ -233,6 +263,16 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -244,18 +284,6 @@ dependencies = [
"wasi", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@@ -314,16 +342,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 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]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.103" version = "0.3.103"
@@ -340,6 +358,9 @@ name = "lazy_static"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]] [[package]]
name = "libc" name = "libc"
@@ -347,6 +368,12 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.33" version = "0.4.33"
@@ -376,7 +403,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
"windows-sys 0.61.2", "windows-sys",
] ]
[[package]] [[package]]
@@ -385,7 +412,42 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ 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]] [[package]]
@@ -395,6 +457,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [ dependencies = [
"autocfg", "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]] [[package]]
@@ -403,12 +488,60 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 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]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -428,10 +561,33 @@ dependencies = [
] ]
[[package]] [[package]]
name = "r-efi" name = "rand"
version = "5.3.0" version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "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]] [[package]]
name = "regex-automata" name = "regex-automata"
@@ -451,65 +607,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]] [[package]]
name = "ring" name = "rsa"
version = "0.17.14" version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [ dependencies = [
"cc", "const-oid",
"cfg-if", "digest",
"getrandom 0.2.17", "num-bigint-dig",
"libc", "num-integer",
"untrusted", "num-traits",
"windows-sys 0.52.0", "pkcs1",
] "pkcs8",
"rand_core",
[[package]] "signature",
name = "rustls" "spki",
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",
"subtle", "subtle",
"zeroize", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -574,17 +690,15 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"blaze-proto", "blaze-proto",
"blaze-ssl-async",
"bytes", "bytes",
"chrono", "chrono",
"futures-util", "futures-util",
"hex", "hex",
"rustls",
"rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"tdf", "tdf",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"toml", "toml",
"tracing", "tracing",
@@ -606,6 +720,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" 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]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -625,7 +749,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [ dependencies = [
"libc", "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]] [[package]]
@@ -702,6 +842,27 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.3" version = "1.52.3"
@@ -714,7 +875,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys",
] ]
[[package]] [[package]]
@@ -728,16 +889,6 @@ dependencies = [
"syn", "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]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -759,8 +910,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [ dependencies = [
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime 0.6.11",
"toml_edit", "toml_edit 0.22.27",
] ]
[[package]] [[package]]
@@ -772,6 +923,15 @@ dependencies = [
"serde", "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]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.22.27" version = "0.22.27"
@@ -781,9 +941,30 @@ dependencies = [
"indexmap", "indexmap",
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime 0.6.11",
"toml_write", "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]] [[package]]
@@ -853,39 +1034,36 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 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]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.126" version = "0.2.126"
@@ -990,15 +1168,6 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -1008,70 +1177,6 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.15" version = "0.7.15"
@@ -1082,16 +1187,65 @@ dependencies = [
] ]
[[package]] [[package]]
name = "wit-bindgen" name = "winnow"
version = "0.57.1" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "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]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.9.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" 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]] [[package]]
name = "zmij" name = "zmij"
+146 -60
View File
@@ -1,112 +1,198 @@
# fifa-blaze # 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 ## Status
**Milestone 1 — capture stub.** **Milestone 1 — capture stub.** Two SSLv3 listeners start and log every byte. No FIFA 17
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. 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 ## 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 ▼ 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" │ replies: "connect to 127.0.0.1:10041"
blaze-server Blaze listener (TLS, Fire2 framing) blaze-server Blaze listener (SSLv3, Fire2 framing)
every packet → JSONL capture + pretty-print
captures/<timestamp>.jsonl 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 ## Quick start
### 1. Generate a self-signed TLS cert (RSA-2048)
DirtySDK (FIFA's network library) rejects ECDSA — RSA-2048 is required.
```bash ```bash
mkdir certs cp config.example.toml config.toml # edit if needed
openssl req -x509 -newkey rsa:2048 \ cargo run --release --bin blaze-server -- config.toml
-keyout certs/key.pem -out certs/cert.pem \
-days 3650 -nodes \
-subj "/CN=gosredirector.ea.com"
``` ```
### 2. Configure Then redirect the hostname:
```bash
cp config.example.toml config.toml
# edit if needed
```
### 3. /etc/hosts redirect
``` ```
127.0.0.1 gosredirector.ea.com 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 ```bash
cd fifa-blaze cargo test
cargo run --release --bin blaze-server -- config.toml
``` ```
### 5. Start FIFA 23 and go to FUT ## Reading captures
The capture log at `captures/<timestamp>.jsonl` will contain every packet.
Inspect with jq:
```bash ```bash
# Raw bytes (always present)
cat captures/blaze-*.raw
# Framed packets (only if the framing guess is right)
jq '.' captures/capture-*.jsonl | less jq '.' captures/capture-*.jsonl | less
# Find all unique component/command pairs:
jq -r '[.component, .command] | @tsv' captures/*.jsonl | sort -u 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 ```bash
{ blaze-replay captures/blaze-54321.raw # test fire2
"n": 1, blaze-replay captures/blaze-54321.raw --framing raw # dump unparsed, read the header by hand
"ts": "2026-06-26T12:00:00.000Z", blaze-replay captures/blaze-54321.raw --dir out # decode our replies instead
"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 ...}"
}
``` ```
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) ## Next steps (Milestone 2)
Once captures reveal component/command IDs: Once captures reveal component/command IDs:
1. Identify the redirector request/response tag layout
2. Identify Util `preAuth` / `postAuth` / `ping` commands 1. Confirm the framing variant from the raw bytes
3. Identify Authentication `login` command 2. Identify the redirector request/response tag layout
4. Implement handlers and test with FIFA 23 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 ## Development
```bash ```bash
# Build
cargo build cargo build
cargo test
# Run with verbose logging
RUST_LOG=debug cargo run --bin blaze-server -- config.toml RUST_LOG=debug cargo run --bin blaze-server -- config.toml
``` ```
+12 -6
View File
@@ -7,14 +7,20 @@ listen = "0.0.0.0:42127"
listen = "0.0.0.0:10041" listen = "0.0.0.0:10041"
advertise_host = "127.0.0.1" # IP/hostname we tell the client to connect to advertise_host = "127.0.0.1" # IP/hostname we tell the client to connect to
advertise_port = 10041 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] # No [tls] section: the server uses blaze-ssl-async's built-in certificate, which is
# Generate with: openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem # built to satisfy older EA ProtoSSL verification. Nothing to generate.
# -days 3650 -nodes -subj "/CN=gosredirector.ea.com"
# DirtySDK rejects ECDSA — RSA-2048 required.
cert = "certs/cert.pem"
key = "certs/key.pem"
[capture] [capture]
dir = "captures" dir = "captures"
pretty_print = true 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
+5 -3
View File
@@ -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 = { version = "1", features = ["rt-multi-thread", "net", "io-util", "macros", "sync", "time", "fs"] }
tokio-util = { version = "0.7", features = ["codec"] } 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"] } futures-util = { version = "0.3", features = ["sink"] }
bytes = "1" bytes = "1"
@@ -29,3 +26,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
anyhow = "1" anyhow = "1"
hex = "0.4" hex = "0.4"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
blaze-ssl-async = "0.4.0"
[[bin]]
name = "blaze-replay"
path = "src/bin/replay.rs"
+248
View File
@@ -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");
}
}
+173
View File
@@ -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());
}
}
+57 -7
View File
@@ -1,10 +1,10 @@
use blaze_proto::FramingVariant;
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Config { pub struct Config {
pub redirector : RedirectorConfig, pub redirector : RedirectorConfig,
pub blaze : BlazeConfig, pub blaze : BlazeConfig,
pub tls : TlsConfig,
pub capture : CaptureConfig, pub capture : CaptureConfig,
} }
@@ -23,14 +23,28 @@ pub struct BlazeConfig {
pub advertise_host : String, pub advertise_host : String,
/// Port we tell the client to connect to (in the redirect response). /// Port we tell the client to connect to (in the redirect response).
pub advertise_port : u16, 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)] impl BlazeConfig {
pub struct TlsConfig { /// Parse the configured framing, falling back to Fire2 for anything unrecognised.
/// PEM certificate file (RSA-2048 recommended — DirtySDK rejects ECDSA). ///
pub cert : String, /// Unknown values fall back rather than erroring: an unparseable framing name should
/// PEM private key file. /// not stop the server starting and capturing, since the raw tee makes the bytes
pub key : String, /// 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)] #[derive(Debug, Deserialize)]
@@ -39,6 +53,13 @@ pub struct CaptureConfig {
pub dir : String, pub dir : String,
/// Also print decoded packet trees to stdout. /// Also print decoded packet trees to stdout.
pub pretty_print : bool, 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 { impl Config {
@@ -49,3 +70,32 @@ impl Config {
.map_err(|e| anyhow::anyhow!("config parse error in {path}: {e}")) .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");
}
}
+133 -55
View File
@@ -1,27 +1,37 @@
//! blaze-server: Milestone 1 — capture stub. //! blaze-server: Milestone 1 — capture stub (FIFA 17 target).
//! //!
//! Starts two TLS listeners: //! Starts two SSLv3 listeners:
//! • redirector — answers FIFA 23's "where is the Blaze server?" query //! • redirector — answers the client's "where is the Blaze server?" query
//! • blaze — accepts the actual game session and logs every packet //! • blaze — accepts the actual game session and logs every packet
//! //!
//! All component/command IDs are unknown at this stage. Every packet receives //! 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 //! an empty response so the client keeps talking. The capture log reveals the
//! IDs to implement next. //! 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 capture;
mod components;
mod config; mod config;
mod dispatch; 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 blaze_proto::{FramingVariant, Packet, PacketCodec};
use bytes::Bytes; use blaze_ssl_async::{BlazeListener, BlazeServerContext, BlazeStream};
use capture::{CaptureWriter, Dir}; use capture::{CaptureWriter, Dir};
use dispatch::Dispatcher; use dispatch::Dispatcher;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use tokio::net::{TcpListener, TcpStream}; use tee::TeeStream;
use tokio_rustls::TlsAcceptor; use tokio::io::{AsyncRead, AsyncWrite};
use tokio_util::codec::Framed; use tokio_util::codec::Framed;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@@ -37,38 +47,52 @@ async fn main() -> anyhow::Result<()> {
let cfg = config::Config::load(&cfg_path)?; let cfg = config::Config::load(&cfg_path)?;
info!(config = cfg_path, "loaded"); info!(config = cfg_path, "loaded");
let tls_cfg = tls::load_tls_config(&cfg.tls.cert, &cfg.tls.key)?; // Built-in ProtoSSL-bypass certificate — no cert/key files to generate or configure.
let acceptor = TlsAcceptor::from(Arc::clone(&tls_cfg)); let ssl_ctx = Arc::new(BlazeServerContext::default());
let capture = Arc::new(CaptureWriter::open(&cfg.capture.dir, cfg.capture.pretty_print)?); 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 redir_addr: SocketAddr = cfg.redirector.listen.parse()?;
let blaze_addr: SocketAddr = cfg.blaze.listen.parse()?; let blaze_addr: SocketAddr = cfg.blaze.listen.parse()?;
let advertise_host = Arc::new(cfg.blaze.advertise_host.clone()); let advertise_host = Arc::new(cfg.blaze.advertise_host.clone());
let advertise_port = cfg.blaze.advertise_port; let advertise_port = cfg.blaze.advertise_port;
let redir_listener = TcpListener::bind(redir_addr).await?; let redir_listener = BlazeListener::bind(redir_addr, Arc::clone(&ssl_ctx)).await?;
let blaze_listener = TcpListener::bind(blaze_addr).await?; let blaze_listener = BlazeListener::bind(blaze_addr, Arc::clone(&ssl_ctx)).await?;
info!(%redir_addr, "redirector listening"); info!(%redir_addr, "redirector listening (SSLv3)");
info!(%blaze_addr, "blaze listening"); info!(%blaze_addr, "blaze listening (SSLv3)");
// ── Redirector loop ─────────────────────────────────────────────────────── // ── Redirector loop ───────────────────────────────────────────────────────
let redir_acceptor = acceptor.clone();
let redir_cap = Arc::clone(&capture); let redir_cap = Arc::clone(&capture);
let adv_host = Arc::clone(&advertise_host); let adv_host = Arc::clone(&advertise_host);
let redir_raw = raw_dir.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
match redir_listener.accept().await { match redir_listener.accept().await {
Err(e) => { error!(error=%e, "redirector accept error"); } Err(e) => { error!(error=%e, "redirector accept error"); }
Ok((tcp, peer)) => { Ok(accept) => {
let acc = redir_acceptor.clone();
let cap = Arc::clone(&redir_cap); let cap = Arc::clone(&redir_cap);
let host = Arc::clone(&adv_host); 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 { tokio::spawn(async move {
if let Err(e) = handle_redirector(tcp, peer, acc, cap, &host, advertise_port).await { 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"); warn!(%peer, error=%e, "redirector session error");
} }
}
}
}); });
} }
} }
@@ -79,47 +103,116 @@ async fn main() -> anyhow::Result<()> {
loop { loop {
match blaze_listener.accept().await { match blaze_listener.accept().await {
Err(e) => { error!(error=%e, "blaze accept error"); } Err(e) => { error!(error=%e, "blaze accept error"); }
Ok((tcp, peer)) => { Ok(accept) => {
let acc = acceptor.clone();
let cap = Arc::clone(&capture); let cap = Arc::clone(&capture);
let disp = Arc::clone(&dispatcher); let disp = Arc::clone(&dispatcher);
let raw = raw_dir.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_blaze(tcp, peer, acc, cap, disp).await { 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"); warn!(%peer, error=%e, "blaze session error");
} }
}
}
}); });
} }
} }
} }
} }
/// 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 ──────────────────────────────────────────────────────── // ── Redirector handler ────────────────────────────────────────────────────────
async fn handle_redirector( async fn handle_redirector(
tcp : TcpStream, stream : BlazeStream,
peer : SocketAddr, peer : SocketAddr,
acceptor: TlsAcceptor,
capture: Arc<CaptureWriter>, capture: Arc<CaptureWriter>,
host : &str, host : &str,
port : u16, port : u16,
framing: FramingVariant,
raw_dir: Option<PathBuf>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let tls = acceptor.accept(tcp).await?; let io = with_tee(stream, peer, "redirector", raw_dir);
let codec = PacketCodec::new(FramingVariant::Fire2); let codec = PacketCodec::new(framing);
let mut io = Framed::new(tls, codec); let mut io = Framed::new(io, codec);
let peer_str = peer.to_string(); let peer_str = peer.to_string();
info!(%peer, "redirector session started");
// Read the redirect request (component/command unknown; we just echo it back). // Read the redirect request (component/command unknown; we just echo it back).
if let Some(Ok(req)) = io.next().await { if let Some(Ok(req)) = io.next().await {
capture.record(&req, &peer_str, Dir::In); capture.record(&req, &peer_str, Dir::In);
info!(%peer, component=format!("0x{:04X}", req.frame.component), info!(%peer, cmd = components::label(req.frame.component, req.frame.command),
command=format!("0x{:04X}", req.frame.command), "redirector request"); "redirector request");
// Build the redirect response. The TDF body tells the client which // Tell the client where the Blaze server is. `secure = true` because our Blaze
// Blaze server to connect to. We don't know the exact tag layout yet — // listener is SSLv3 like the redirector — if the client dials plaintext and gets
// capture will reveal it. For now we return our advertise address as a // a handshake, this is the flag to flip.
// simple string blob so the client gets something to parse. let body = routes::redirector_instance_body(host, port, true);
let body = build_redirector_body(host, port);
let resp = Packet { frame: req.frame.response(), body }; let resp = Packet { frame: req.frame.response(), body };
capture.record(&resp, &peer_str, Dir::Out); capture.record(&resp, &peer_str, Dir::Out);
io.send(resp).await?; io.send(resp).await?;
@@ -127,34 +220,19 @@ async fn handle_redirector(
Ok(()) 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 ───────────────────────────────────────────────────── // ── Blaze session handler ─────────────────────────────────────────────────────
async fn handle_blaze( async fn handle_blaze(
tcp : TcpStream, stream : BlazeStream,
peer : SocketAddr, peer : SocketAddr,
acceptor : TlsAcceptor,
capture : Arc<CaptureWriter>, capture : Arc<CaptureWriter>,
dispatcher: Arc<Dispatcher>, dispatcher: Arc<Dispatcher>,
framing : FramingVariant,
raw_dir : Option<PathBuf>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let tls = acceptor.accept(tcp).await?; let io = with_tee(stream, peer, "blaze", raw_dir);
let codec = PacketCodec::new(FramingVariant::Fire2); let codec = PacketCodec::new(framing);
let mut io = Framed::new(tls, codec); let mut io = Framed::new(io, codec);
let peer_str = peer.to_string(); let peer_str = peer.to_string();
info!(%peer, "blaze session started"); info!(%peer, "blaze session started");
+218
View File
@@ -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}");
}
}
}
+185
View File
@@ -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}");
}
}
-35
View File
@@ -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))
}