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,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