commit eccd46f52b7958e77e5f08b0d4618b9e6d7eeb2f Author: funman300 Date: Fri Jun 26 15:24:50 2026 -0700 feat: scaffold fifa-blaze Blaze protocol emulator (Milestone 1) Two-crate workspace: blaze-proto (Fire2 framing via tokio_util codec, tdf=0.1 for TDF decode/stringify) and server (TLS listeners for redirector + Blaze, JSONL capture with TdfStringifier, pluggable FramingVariant enum). Both listeners bind on startup and write a capture JSONL for every packet. Component/command IDs are unknown — captures reveal them. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ac06b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +/certs/ +/config.toml +/captures/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c98dc1e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1100 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +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", +] + +[[package]] +name = "blaze-proto" +version = "0.1.0" +dependencies = [ + "bytes", + "hex", + "serde", + "tdf", + "thiserror", + "tokio-util", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "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", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "server" +version = "0.1.0" +dependencies = [ + "anyhow", + "blaze-proto", + "bytes", + "chrono", + "futures-util", + "hex", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "tdf", + "tokio", + "tokio-rustls", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tdf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140a16f6272972ab2718a1152f5844faa7ab23c31771e6bfe8271b1e59dc7884" +dependencies = [ + "serde", + "tdf-derive", +] + +[[package]] +name = "tdf-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd55cd8859658cb77bea257d1964502f12068ba6f80c88eee00acb64122bab73" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9f68cef --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["crates/blaze-proto", "crates/server"] +resolver = "2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7291d9 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# fifa-blaze + +EA Blaze protocol server emulator for FIFA 23 offline FUT. + +## 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. + +## Architecture + +``` +FIFA 23 (via openfut-hook DLL / /etc/hosts) + │ + ▼ gosredirector.ea.com → 127.0.0.1:42127 +blaze-server redirector listener (TLS, Fire2 framing) + │ replies: "connect to 127.0.0.1:10041" + ▼ +blaze-server Blaze listener (TLS, Fire2 framing) + │ every packet → JSONL capture + pretty-print + └─ captures/.jsonl +``` + +## Framing note + +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. + +## 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" +``` + +### 2. Configure + +```bash +cp config.example.toml config.toml +# edit if needed +``` + +### 3. /etc/hosts redirect + +``` +127.0.0.1 gosredirector.ea.com +``` + +Or deploy `openfut-hook` DLL which redirects at the DNS level inside Proton. + +### 4. Run + +```bash +cd fifa-blaze +cargo run --release --bin blaze-server -- config.toml +``` + +### 5. Start FIFA 23 and go to FUT + +The capture log at `captures/.jsonl` will contain every packet. +Inspect with jq: + +```bash +jq '.' captures/capture-*.jsonl | less +# Find all unique component/command pairs: +jq -r '[.component, .command] | @tsv' captures/*.jsonl | sort -u +``` + +## Capture log format + +Each line is a JSON object: + +```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 ...}" +} +``` + +## 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 + +## Development + +```bash +# Build +cargo build + +# Run with verbose logging +RUST_LOG=debug cargo run --bin blaze-server -- config.toml +``` diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..f54fb7f --- /dev/null +++ b/config.example.toml @@ -0,0 +1,20 @@ +[redirector] +# EA clients connect to gosredirector.ea.com — point that hostname to 127.0.0.1 +# in /etc/hosts (or via the openfut-hook DLL), and the redirector listens here. +listen = "0.0.0.0:42127" + +[blaze] +listen = "0.0.0.0:10041" +advertise_host = "127.0.0.1" # IP/hostname we tell the client to connect to +advertise_port = 10041 + +[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" + +[capture] +dir = "captures" +pretty_print = true diff --git a/crates/blaze-proto/Cargo.toml b/crates/blaze-proto/Cargo.toml new file mode 100644 index 0000000..ed98623 --- /dev/null +++ b/crates/blaze-proto/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "blaze-proto" +version = "0.1.0" +edition = "2021" + +[dependencies] +tdf = { version = "0.1", features = ["serde"] } +bytes = "1" +tokio-util = { version = "0.7", features = ["codec"] } +thiserror = "1" +hex = "0.4" +serde = { version = "1", features = ["derive"] } diff --git a/crates/blaze-proto/src/codec.rs b/crates/blaze-proto/src/codec.rs new file mode 100644 index 0000000..ba2c302 --- /dev/null +++ b/crates/blaze-proto/src/codec.rs @@ -0,0 +1,113 @@ +//! `tokio_util` codec for Blaze packets. +//! +//! The `FramingVariant` enum makes the framing pluggable. FIFA 23's variant +//! is unknown — capture determines it. Start with `FramingVariant::Fire2`. +//! +//! If Fire2 decode consistently fails (every packet's body length looks wrong, +//! or the frame type nibble is never 0-3), switch to `FramingVariant::Raw` +//! to bypass framing entirely and capture byte streams for manual analysis. + +use bytes::{Buf, BufMut, BytesMut}; +use std::io; +use tokio_util::codec::{Decoder, Encoder}; + +use super::{ + frame::{FireFrame, FIRE2_MIN_HEADER, FIRE2_JUMBO_EXT, PacketOptions}, + packet::Packet, +}; + +/// Which wire framing to use. +#[derive(Debug, Clone, Copy, Default)] +pub enum FramingVariant { + /// Modern EA framing used by ME3, BF3, and most post-2012 titles. + /// Most likely candidate for FIFA 23. + #[default] + Fire2, + /// Bypass framing: each `poll_next` yields whatever bytes are available + /// as a single packet with no header parsing. Useful for raw hex capture + /// when the correct framing is unknown. + Raw, +} + +/// Tracks partial-decode state between `decode()` calls. +struct PartialFire2 { + frame : FireFrame, + body_len : usize, +} + +/// Tokio codec for encoding and decoding Blaze packets. +#[derive(Default)] +pub struct PacketCodec { + pub variant: FramingVariant, + partial: Option, +} + +impl PacketCodec { + pub fn new(variant: FramingVariant) -> Self { + PacketCodec { variant, partial: None } + } +} + +impl Decoder for PacketCodec { + type Item = Packet; + type Error = io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + match self.variant { + FramingVariant::Raw => { + if src.is_empty() { return Ok(None); } + let body = src.copy_to_bytes(src.len()); + Ok(Some(Packet::raw(body))) + } + + FramingVariant::Fire2 => { + // ── Resume waiting for the body ─────────────────────────── + if let Some(ref p) = self.partial { + if src.len() < p.body_len { return Ok(None); } + let PartialFire2 { frame, body_len, .. } = self.partial.take().unwrap(); + let body = src.copy_to_bytes(body_len); + return Ok(Some(Packet { frame, body })); + } + + // ── Need at least the minimum header ───────────────────── + if src.len() < FIRE2_MIN_HEADER { return Ok(None); } + + // Peek at options to see if we need a jumbo ext too. + let opt_nibble = (src[9] >> 4) & 0xF; + let has_jumbo = (opt_nibble & PacketOptions::JUMBO_FRAME.0) != 0; + let header_len = FIRE2_MIN_HEADER + if has_jumbo { FIRE2_JUMBO_EXT } else { 0 }; + + if src.len() < header_len { return Ok(None); } + + let (frame, body_len) = FireFrame::read(src); + + // ── Now wait for the body ───────────────────────────────── + if src.len() < body_len { + self.partial = Some(PartialFire2 { frame, body_len }); + return Ok(None); + } + + let body = src.copy_to_bytes(body_len); + Ok(Some(Packet { frame, body })) + } + } + } +} + +impl Encoder for PacketCodec { + type Error = io::Error; + + fn encode(&mut self, item: Packet, dst: &mut BytesMut) -> Result<(), Self::Error> { + match self.variant { + FramingVariant::Raw => { + dst.put(item.body); + } + FramingVariant::Fire2 => { + let body_len = item.body.len(); + item.frame.write(dst, body_len); + dst.put(item.body); + } + } + Ok(()) + } +} diff --git a/crates/blaze-proto/src/frame.rs b/crates/blaze-proto/src/frame.rs new file mode 100644 index 0000000..4a2cccc --- /dev/null +++ b/crates/blaze-proto/src/frame.rs @@ -0,0 +1,143 @@ +//! Blaze packet frame header. +//! +//! Two framing variants exist ("Fire" and "Fire2"). FIFA 23's variant is UNKNOWN — +//! the capture tool will determine which it uses. This module implements Fire2 +//! (the modern EA format, used by ME3, BF3, and most post-2012 titles) because +//! it is the most likely candidate. +//! +//! Fire2 header layout (12 bytes, little-endian **big**-endian per wire): +//! ```text +//! [0..2] u16 body length (low 16 bits; or full length if < 65536) +//! [2..4] u16 component id +//! [4..6] u16 command id +//! [6..8] u16 error code +//! [8] u8 frame type (top nibble) | padding (low nibble) +//! [9] u8 options (top nibble) | padding (low nibble) +//! [10..12] u16 sequence number +//! +[12..14] u16 high 16 bits of length — only when JUMBO_FRAME option set +//! ``` + +use bytes::{Buf, BufMut, BytesMut}; +use serde::{Deserialize, Serialize}; + +/// Minimum header size for a Fire2 frame (without the optional jumbo extension). +pub const FIRE2_MIN_HEADER: usize = 12; + +/// Extra bytes used when JUMBO_FRAME is set (extends length to 32 bits). +pub const FIRE2_JUMBO_EXT: usize = 2; + +/// The type of a Blaze frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum FrameType { + Request = 0x0, + Response = 0x1, + Notify = 0x2, + Error = 0x3, +} + +impl From for FrameType { + fn from(v: u8) -> Self { + match v { + 0x1 => FrameType::Response, + 0x2 => FrameType::Notify, + 0x3 => FrameType::Error, + _ => FrameType::Request, + } + } +} + +impl std::fmt::Display for FrameType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameType::Request => write!(f, "REQUEST"), + FrameType::Response => write!(f, "RESPONSE"), + FrameType::Notify => write!(f, "NOTIFY"), + FrameType::Error => write!(f, "ERROR"), + } + } +} + +/// Option flags for a Fire2 frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PacketOptions(pub u8); + +impl PacketOptions { + pub const NONE : Self = Self(0x0); + /// Body length exceeds 16 bits; an extra u16 follows the header. + pub const JUMBO_FRAME: Self = Self(0x1); + + pub fn contains(self, flag: Self) -> bool { (self.0 & flag.0) != 0 } +} + +/// Fire2 packet header. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FireFrame { + pub component : u16, + pub command : u16, + pub error : u16, + pub ty : FrameType, + pub options : PacketOptions, + pub seq : u16, +} + +impl FireFrame { + /// Build a response frame that mirrors this frame's routing fields. + pub fn response(&self) -> Self { + Self { + component: self.component, + command: self.command, + error: 0, + ty: FrameType::Response, + options: PacketOptions::NONE, + seq: self.seq, + } + } + + /// Build a notify frame. + pub fn notify(component: u16, command: u16) -> Self { + Self { component, command, error: 0, ty: FrameType::Notify, options: PacketOptions::NONE, seq: 0 } + } + + /// Encode this header into `dst`, prepending `body_len` at the front. + pub fn write(&self, dst: &mut BytesMut, body_len: usize) { + let mut options = self.options; + if body_len > u16::MAX as usize { options = PacketOptions(options.0 | PacketOptions::JUMBO_FRAME.0); } + + dst.put_u16(body_len as u16); + dst.put_u16(self.component); + dst.put_u16(self.command); + dst.put_u16(self.error); + dst.put_u8((self.ty as u8) << 4); + dst.put_u8(options.0 << 4); + dst.put_u16(self.seq); + + if options.contains(PacketOptions::JUMBO_FRAME) { + dst.put_u16((body_len >> 16) as u16); + } + } + + /// Parse a Fire2 header from `src`, which must already have `>= FIRE2_MIN_HEADER` bytes. + /// Returns `(frame, body_length)`. Advances `src` past the header (including jumbo ext). + pub fn read(src: &mut BytesMut) -> (FireFrame, usize) { + let len_lo = src.get_u16() as usize; + let component = src.get_u16(); + let command = src.get_u16(); + let error = src.get_u16(); + let ty_byte = src.get_u8(); + let opt_byte = src.get_u8(); + let seq = src.get_u16(); + + let ty = FrameType::from(ty_byte >> 4); + let options = PacketOptions(opt_byte >> 4); + + let body_len = if options.contains(PacketOptions::JUMBO_FRAME) { + let len_hi = src.get_u16() as usize; + (len_hi << 16) | len_lo + } else { + len_lo + }; + + (FireFrame { component, command, error, ty, options, seq }, body_len) + } +} diff --git a/crates/blaze-proto/src/lib.rs b/crates/blaze-proto/src/lib.rs new file mode 100644 index 0000000..cd4bb8a --- /dev/null +++ b/crates/blaze-proto/src/lib.rs @@ -0,0 +1,7 @@ +pub mod frame; +pub mod codec; +pub mod packet; + +pub use frame::{FireFrame, FrameType, PacketOptions}; +pub use codec::{PacketCodec, FramingVariant}; +pub use packet::Packet; diff --git a/crates/blaze-proto/src/packet.rs b/crates/blaze-proto/src/packet.rs new file mode 100644 index 0000000..5aa596d --- /dev/null +++ b/crates/blaze-proto/src/packet.rs @@ -0,0 +1,86 @@ +use bytes::Bytes; +use serde::Serialize; + +use super::frame::{FireFrame, FrameType, PacketOptions}; +use tdf::stringify::TdfStringifier; +use tdf::reader::TdfDeserializer; + +/// A fully framed Blaze packet. +#[derive(Debug, Clone)] +pub struct Packet { + /// Decoded Fire2 header. + pub frame: FireFrame, + /// Raw body bytes (unparsed TDF). + pub body: Bytes, +} + +impl Packet { + /// Create a raw-mode packet (no frame header — body only). + pub fn raw(body: Bytes) -> Self { + Packet { + frame: FireFrame { + component: 0, + command: 0, + error: 0, + ty: FrameType::Request, + options: PacketOptions::NONE, + seq: 0, + }, + body, + } + } + + /// Build an empty response that mirrors `req`'s routing fields. + pub fn response_empty(req: &Packet) -> Self { + Packet { frame: req.frame.response(), body: Bytes::new() } + } + + /// Build a response with the given TDF body. + pub fn response(req: &Packet, value: &V) -> Self { + Packet { + frame: req.frame.response(), + body: Bytes::from(tdf::serialize_vec(value)), + } + } + + /// Try to decode the body as TDF and return a human-readable string. + /// On any decode error, return the raw hex. + pub fn tdf_string(&self) -> String { + if self.body.is_empty() { + return String::from("(empty)"); + } + let r = TdfDeserializer::new(&self.body); + let (s, ok) = TdfStringifier::::new_string(r); + if ok { + s + } else { + format!("(decode error) raw={}", hex::encode(&self.body)) + } + } + + /// Serialize the packet into a JSON-friendly struct for capture logs. + pub fn to_capture(&self) -> CaptureRecord { + CaptureRecord { + component: self.frame.component, + command: self.frame.command, + error: self.frame.error, + ty: self.frame.ty.to_string(), + seq: self.frame.seq, + body_len: self.body.len(), + raw_hex: hex::encode(&self.body), + tdf: self.tdf_string(), + } + } +} + +#[derive(Debug, Serialize)] +pub struct CaptureRecord { + pub component: u16, + pub command: u16, + pub error: u16, + pub ty: String, + pub seq: u16, + pub body_len: usize, + pub raw_hex: String, + pub tdf: String, +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..630fd3f --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "server" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "blaze-server" +path = "src/main.rs" + +[dependencies] +blaze-proto = { path = "../blaze-proto" } +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" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +anyhow = "1" +hex = "0.4" +chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/server/src/capture.rs b/crates/server/src/capture.rs new file mode 100644 index 0000000..0b101fc --- /dev/null +++ b/crates/server/src/capture.rs @@ -0,0 +1,90 @@ +//! JSONL + pretty-print capture writer. +//! +//! Every packet (in and out) is appended to a JSONL file so the session can +//! be replayed with `jq` and the component/command IDs can be catalogued. + +use std::{ + fs::{File, OpenOptions}, + io::Write, + sync::{Arc, Mutex}, +}; +use blaze_proto::Packet; +use chrono::Utc; +use serde_json::json; +use tracing::{info, warn}; + +#[derive(Clone, Copy)] +pub enum Dir { In, Out } + +impl Dir { + fn as_str(self) -> &'static str { match self { Dir::In => "IN", Dir::Out => "OUT" } } +} + +pub struct CaptureWriter { + file : Arc>, + pretty : bool, + counter : Arc>, +} + +impl CaptureWriter { + pub fn open(dir: &str, pretty: bool) -> anyhow::Result { + std::fs::create_dir_all(dir)?; + let ts = Utc::now().format("%Y%m%d-%H%M%S"); + let path = format!("{dir}/capture-{ts}.jsonl"); + let file = OpenOptions::new().create(true).append(true).open(&path)?; + info!(path, "capture file opened"); + Ok(CaptureWriter { + file: Arc::new(Mutex::new(file)), + pretty, + counter: Arc::new(Mutex::new(0)), + }) + } + + pub fn record(&self, pkt: &Packet, peer: &str, dir: Dir) { + let n = { let mut c = self.counter.lock().unwrap(); *c += 1; *c }; + let ts = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + + let rec = pkt.to_capture(); + let entry = json!({ + "n": n, + "ts": ts, + "peer": peer, + "dir": dir.as_str(), + "component": format!("0x{:04X}", rec.component), + "command": format!("0x{:04X}", rec.command), + "type": rec.ty, + "seq": rec.seq, + "error": rec.error, + "body_len": rec.body_len, + "raw_hex": rec.raw_hex, + "tdf": rec.tdf, + }); + + if let Ok(mut f) = self.file.lock() { + if let Err(e) = writeln!(f, "{}", entry) { + warn!(error=%e, "capture write failed"); + } + } + + if self.pretty { + println!("\n╔══ #{n:04} {ts} {peer} {d} ══", + d = dir.as_str()); + println!("║ component=0x{:04X} command=0x{:04X} {ty} seq={seq}", + rec.component, rec.command, ty = rec.ty, seq = rec.seq); + if rec.body_len > 0 { + println!("║ body ({} bytes):\n║ {}", + rec.body_len, + pkt.body.chunks(16) + .enumerate() + .map(|(i, chunk)| { + let h: String = chunk.iter().map(|b| format!("{b:02X}")).collect::>().join(" "); + let a: String = chunk.iter().map(|&b| if b.is_ascii_graphic() { b as char } else { '.' }).collect(); + format!("\n║ {:04X}: {h:<47} {a}", i * 16) + }) + .collect::()); + println!("║ TDF: {}", rec.tdf); + } + println!("╚══════════════════════════════════════════════════"); + } + } +} diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs new file mode 100644 index 0000000..2b67712 --- /dev/null +++ b/crates/server/src/config.rs @@ -0,0 +1,51 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub redirector : RedirectorConfig, + pub blaze : BlazeConfig, + pub tls : TlsConfig, + pub capture : CaptureConfig, +} + +#[derive(Debug, Deserialize)] +pub struct RedirectorConfig { + /// Address to listen on. EA clients connect to gosredirector.ea.com which + /// you redirect to 127.0.0.1 via /etc/hosts. + pub listen : String, +} + +#[derive(Debug, Deserialize)] +pub struct BlazeConfig { + /// Address the Blaze server listens on. + pub listen : String, + /// Address / hostname we tell the client to connect to (in the redirect response). + pub advertise_host : String, + /// Port we tell the client to connect to (in the redirect response). + pub advertise_port : u16, +} + +#[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, +} + +#[derive(Debug, Deserialize)] +pub struct CaptureConfig { + /// Directory where JSONL capture files are written. + pub dir : String, + /// Also print decoded packet trees to stdout. + pub pretty_print : bool, +} + +impl Config { + pub fn load(path: &str) -> anyhow::Result { + let text = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("cannot read {path}: {e}"))?; + toml::from_str(&text) + .map_err(|e| anyhow::anyhow!("config parse error in {path}: {e}")) + } +} diff --git a/crates/server/src/dispatch.rs b/crates/server/src/dispatch.rs new file mode 100644 index 0000000..2de03ec --- /dev/null +++ b/crates/server/src/dispatch.rs @@ -0,0 +1,40 @@ +//! Dispatch table: maps (component, command) → async handler. +//! +//! All FIFA 23 component/command IDs are UNKNOWN until capture reveals them. +//! The default handler returns an empty response — never panics, never hangs. + +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; +use blaze_proto::Packet; +use tracing::warn; + +pub type BoxFuture = Pin + Send>>; +pub type Handler = Arc BoxFuture> + Send + Sync>; + +pub struct Dispatcher { + table: HashMap<(u16, u16), Handler>, +} + +impl Dispatcher { + pub fn new() -> Self { Dispatcher { table: HashMap::new() } } + + pub fn register(&mut self, component: u16, command: u16, f: F) + where + F: Fn(Packet) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.table.insert((component, command), + Arc::new(move |p| Box::pin(f(p)))); + } + + pub async fn dispatch(&self, pkt: Packet) -> Option { + let key = (pkt.frame.component, pkt.frame.command); + if let Some(h) = self.table.get(&key) { + h(pkt).await + } else { + warn!(component = format!("0x{:04X}", key.0), + command = format!("0x{:04X}", key.1), + "unhandled — returning empty response"); + Some(Packet::response_empty(&pkt)) + } + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 0000000..b25487c --- /dev/null +++ b/crates/server/src/main.rs @@ -0,0 +1,183 @@ +//! blaze-server: Milestone 1 — capture stub. +//! +//! Starts two TLS listeners: +//! • redirector — answers FIFA 23'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. + +mod capture; +mod config; +mod dispatch; +mod tls; + +use std::{net::SocketAddr, sync::Arc}; + +use blaze_proto::{FramingVariant, Packet, PacketCodec}; +use bytes::Bytes; +use capture::{CaptureWriter, Dir}; +use dispatch::Dispatcher; +use futures_util::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_rustls::TlsAcceptor; +use tokio_util::codec::Framed; +use tracing::{error, info, warn}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + std::env::var("RUST_LOG").unwrap_or_else(|_| "blaze_server=debug,warn".into()), + ) + .init(); + + let cfg_path = std::env::args().nth(1).unwrap_or_else(|| "config.toml".into()); + 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)); + + let capture = Arc::new(CaptureWriter::open(&cfg.capture.dir, cfg.capture.pretty_print)?); + let dispatcher = Arc::new(Dispatcher::new()); + + 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"); + + // ── Redirector loop ─────────────────────────────────────────────────────── + let redir_acceptor = acceptor.clone(); + let redir_cap = Arc::clone(&capture); + let adv_host = Arc::clone(&advertise_host); + 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(); + let cap = Arc::clone(&redir_cap); + let host = Arc::clone(&adv_host); + 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"); + } + }); + } + } + } + }); + + // ── Blaze loop ──────────────────────────────────────────────────────────── + loop { + match blaze_listener.accept().await { + Err(e) => { error!(error=%e, "blaze accept error"); } + Ok((tcp, peer)) => { + let acc = acceptor.clone(); + let cap = Arc::clone(&capture); + let disp = Arc::clone(&dispatcher); + tokio::spawn(async move { + if let Err(e) = handle_blaze(tcp, peer, acc, cap, disp).await { + warn!(%peer, error=%e, "blaze session error"); + } + }); + } + } + } +} + +// ── Redirector handler ──────────────────────────────────────────────────────── + +async fn handle_redirector( + tcp : TcpStream, + peer : SocketAddr, + acceptor: TlsAcceptor, + capture : Arc, + host : &str, + port : u16, +) -> anyhow::Result<()> { + let tls = acceptor.accept(tcp).await?; + let codec = PacketCodec::new(FramingVariant::Fire2); + let mut io = Framed::new(tls, codec); + + let peer_str = peer.to_string(); + + // 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"); + + // 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); + let resp = Packet { frame: req.frame.response(), body }; + capture.record(&resp, &peer_str, Dir::Out); + io.send(resp).await?; + } + 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 = 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, + dispatcher: Arc, +) -> anyhow::Result<()> { + let tls = acceptor.accept(tcp).await?; + let codec = PacketCodec::new(FramingVariant::Fire2); + let mut io = Framed::new(tls, codec); + + let peer_str = peer.to_string(); + info!(%peer, "blaze session started"); + + while let Some(result) = io.next().await { + match result { + Err(e) => { + warn!(%peer, error=%e, "read error"); + break; + } + Ok(pkt) => { + capture.record(&pkt, &peer_str, Dir::In); + if let Some(resp) = dispatcher.dispatch(pkt).await { + capture.record(&resp, &peer_str, Dir::Out); + if let Err(e) = io.send(resp).await { + warn!(%peer, error=%e, "write error"); + break; + } + } + } + } + } + + info!(%peer, "blaze session ended"); + Ok(()) +} diff --git a/crates/server/src/tls.rs b/crates/server/src/tls.rs new file mode 100644 index 0000000..a04aa6c --- /dev/null +++ b/crates/server/src/tls.rs @@ -0,0 +1,35 @@ +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> { + // 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 = certs(&mut BufReader::new(cert_file)) + .collect::>() + .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)) +}