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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
/target/
|
||||||
|
/certs/
|
||||||
|
/config.toml
|
||||||
|
/captures/
|
||||||
Generated
+1100
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["crates/blaze-proto", "crates/server"]
|
||||||
|
resolver = "2"
|
||||||
@@ -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/<timestamp>.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/<timestamp>.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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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"] }
|
||||||
@@ -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<PartialFire2>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<Self::Item>, 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<Packet> 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u8> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<V: tdf::TdfSerialize>(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::<String>::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,
|
||||||
|
}
|
||||||
@@ -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"] }
|
||||||
@@ -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<Mutex<File>>,
|
||||||
|
pretty : bool,
|
||||||
|
counter : Arc<Mutex<u64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CaptureWriter {
|
||||||
|
pub fn open(dir: &str, pretty: bool) -> anyhow::Result<Self> {
|
||||||
|
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::<Vec<_>>().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::<String>());
|
||||||
|
println!("║ TDF: {}", rec.tdf);
|
||||||
|
}
|
||||||
|
println!("╚══════════════════════════════════════════════════");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Self> {
|
||||||
|
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}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> = Pin<Box<dyn Future<Output = T> + Send>>;
|
||||||
|
pub type Handler = Arc<dyn Fn(Packet) -> BoxFuture<Option<Packet>> + Send + Sync>;
|
||||||
|
|
||||||
|
pub struct Dispatcher {
|
||||||
|
table: HashMap<(u16, u16), Handler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatcher {
|
||||||
|
pub fn new() -> Self { Dispatcher { table: HashMap::new() } }
|
||||||
|
|
||||||
|
pub fn register<F, Fut>(&mut self, component: u16, command: u16, f: F)
|
||||||
|
where
|
||||||
|
F: Fn(Packet) -> Fut + Send + Sync + 'static,
|
||||||
|
Fut: Future<Output = Option<Packet>> + Send + 'static,
|
||||||
|
{
|
||||||
|
self.table.insert((component, command),
|
||||||
|
Arc::new(move |p| Box::pin(f(p))));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch(&self, pkt: Packet) -> Option<Packet> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CaptureWriter>,
|
||||||
|
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<u8> = Vec::new();
|
||||||
|
w.tag_str(b"ADDR", host);
|
||||||
|
w.tag_u32(b"PORT", port as u32);
|
||||||
|
Bytes::from(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Blaze session handler ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn handle_blaze(
|
||||||
|
tcp : TcpStream,
|
||||||
|
peer : SocketAddr,
|
||||||
|
acceptor : TlsAcceptor,
|
||||||
|
capture : Arc<CaptureWriter>,
|
||||||
|
dispatcher: Arc<Dispatcher>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
@@ -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<Arc<ServerConfig>> {
|
||||||
|
// Install the ring crypto provider once. Harmless if called multiple times.
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
|
||||||
|
let cert_file = File::open(cert_path)
|
||||||
|
.with_context(|| format!("open cert: {cert_path}"))?;
|
||||||
|
let key_file = File::open(key_path)
|
||||||
|
.with_context(|| format!("open key: {key_path}"))?;
|
||||||
|
|
||||||
|
let certs: Vec<CertificateDer> = certs(&mut BufReader::new(cert_file))
|
||||||
|
.collect::<Result<_, _>>()
|
||||||
|
.with_context(|| "parse PEM certs")?;
|
||||||
|
|
||||||
|
let key: PrivateKeyDer = private_key(&mut BufReader::new(key_file))
|
||||||
|
.with_context(|| "parse PEM key")?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
|
||||||
|
|
||||||
|
let config = ServerConfig::builder()
|
||||||
|
.with_no_client_auth()
|
||||||
|
.with_single_cert(certs, key)
|
||||||
|
.with_context(|| "build ServerConfig")?;
|
||||||
|
|
||||||
|
Ok(Arc::new(config))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user