wip: checkpoint Blaze server work for Windows migration
This commit is contained in:
@@ -13,9 +13,6 @@ tdf = { version = "0.1", features = ["serde"] }
|
||||
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "macros", "sync", "time", "fs"] }
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
tokio-rustls = "0.26"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
rustls-pemfile = "2"
|
||||
futures-util = { version = "0.3", features = ["sink"] }
|
||||
|
||||
bytes = "1"
|
||||
@@ -29,3 +26,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
anyhow = "1"
|
||||
hex = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
blaze-ssl-async = "0.4.0"
|
||||
|
||||
[[bin]]
|
||||
name = "blaze-replay"
|
||||
path = "src/bin/replay.rs"
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! blaze-replay — decode a recorded `.raw` byte log offline.
|
||||
//!
|
||||
//! The raw tee writes every byte of a session before framing is attempted, precisely so a
|
||||
//! wrong framing guess is recoverable. This tool is the other half of that bargain: it
|
||||
//! feeds a recorded log back through the codec so a framing hypothesis can be tested in a
|
||||
//! second, against real bytes, without the game.
|
||||
//!
|
||||
//! That inverts the FIFA 23 iteration loop. There, testing a guess meant
|
||||
//! edit → build → deploy → relaunch FIFA → hope. Here it means rerunning a binary against
|
||||
//! a file, so wrong guesses cost nothing and can be tried exhaustively.
|
||||
//!
|
||||
//! ```bash
|
||||
//! blaze-replay captures/blaze-54321.raw # default: try fire2
|
||||
//! blaze-replay captures/blaze-54321.raw --framing raw
|
||||
//! blaze-replay captures/blaze-54321.raw --dir out # decode outbound instead
|
||||
//! ```
|
||||
//!
|
||||
//! Input format, one record per line, as written by `tee.rs`:
|
||||
//!
|
||||
//! ```text
|
||||
//! IN 12 0000000900000001...
|
||||
//! OUT 30 0000001e00090001...
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use blaze_proto::{FramingVariant, PacketCodec};
|
||||
use bytes::BytesMut;
|
||||
use tokio_util::codec::Decoder;
|
||||
|
||||
/// One parsed line of a `.raw` log.
|
||||
struct Record {
|
||||
inbound: bool,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let mut path: Option<PathBuf> = None;
|
||||
let mut framing = FramingVariant::Fire2;
|
||||
let mut want_inbound = true;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--framing" => {
|
||||
let v = args.next().unwrap_or_default();
|
||||
framing = match v.trim() {
|
||||
"raw" | "Raw" | "RAW" => FramingVariant::Raw,
|
||||
"fire2" | "Fire2" | "FIRE2" => FramingVariant::Fire2,
|
||||
other => anyhow::bail!("unknown framing {other:?} (expected fire2 or raw)"),
|
||||
};
|
||||
}
|
||||
"--dir" => {
|
||||
let v = args.next().unwrap_or_default();
|
||||
want_inbound = match v.trim() {
|
||||
"in" | "IN" => true,
|
||||
"out" | "OUT" => false,
|
||||
other => anyhow::bail!("unknown direction {other:?} (expected in or out)"),
|
||||
};
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
eprintln!("usage: blaze-replay <file.raw> [--framing fire2|raw] [--dir in|out]");
|
||||
return Ok(());
|
||||
}
|
||||
other => path = Some(PathBuf::from(other)),
|
||||
}
|
||||
}
|
||||
|
||||
let path = path.ok_or_else(|| {
|
||||
anyhow::anyhow!("usage: blaze-replay <file.raw> [--framing fire2|raw] [--dir in|out]")
|
||||
})?;
|
||||
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?;
|
||||
|
||||
let records = parse_log(&text);
|
||||
let (kept, skipped): (Vec<_>, Vec<_>) = records.iter().partition(|r| r.inbound == want_inbound);
|
||||
|
||||
println!("file : {}", path.display());
|
||||
println!("framing : {framing:?}");
|
||||
println!("direction : {}", if want_inbound { "IN" } else { "OUT" });
|
||||
println!(
|
||||
"records : {} matching, {} in the other direction",
|
||||
kept.len(),
|
||||
skipped.len()
|
||||
);
|
||||
|
||||
if kept.is_empty() {
|
||||
println!("\nNothing to decode. If the file is non-empty, try --dir out.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Concatenate into one stream: TCP does not preserve message boundaries, so a single
|
||||
// Blaze packet may span several reads and one read may hold several packets. Decoding
|
||||
// each record in isolation would misparse exactly the cases that matter.
|
||||
let mut buf = BytesMut::new();
|
||||
for r in &kept {
|
||||
buf.extend_from_slice(&r.bytes);
|
||||
}
|
||||
let total = buf.len();
|
||||
|
||||
let mut codec = PacketCodec::new(framing);
|
||||
let mut count = 0usize;
|
||||
|
||||
println!("\n{total} bytes\n");
|
||||
|
||||
loop {
|
||||
match codec.decode(&mut buf) {
|
||||
Ok(Some(pkt)) => {
|
||||
count += 1;
|
||||
println!(
|
||||
"#{count} component=0x{:04X} command=0x{:04X} type={} seq={} error={} body={}B",
|
||||
pkt.frame.component,
|
||||
pkt.frame.command,
|
||||
pkt.frame.ty,
|
||||
pkt.frame.seq,
|
||||
pkt.frame.error,
|
||||
pkt.body.len()
|
||||
);
|
||||
if !pkt.body.is_empty() {
|
||||
println!(" {}", hex::encode(&pkt.body));
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
println!("decode error after {count} packet(s): {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let leftover = buf.len();
|
||||
println!("\n{count} packet(s) decoded, {leftover} byte(s) undecoded");
|
||||
|
||||
// The verdict line. A framing guess that leaves a large tail undecoded is wrong, and
|
||||
// saying so plainly is more useful than a pile of packets that happen to parse.
|
||||
match framing {
|
||||
// Raw is a passthrough: it swallows the whole buffer unconditionally, so it can
|
||||
// never fail and a "clean fit" here would mean nothing. Say so, rather than
|
||||
// reporting a success that isn't evidence of anything.
|
||||
FramingVariant::Raw => {
|
||||
println!("VERDICT: raw passthrough — no framing was tested.");
|
||||
println!(" The hex above is the unparsed stream; read the header by hand,");
|
||||
println!(" then rerun with --framing fire2 to test the hypothesis.");
|
||||
}
|
||||
FramingVariant::Fire2 if count == 0 => {
|
||||
println!("VERDICT: fire2 does not fit — nothing decoded. Inspect with --framing raw.");
|
||||
}
|
||||
FramingVariant::Fire2 if leftover > 0 => {
|
||||
println!("VERDICT: partial fit — {count} decoded but {leftover} bytes left over.");
|
||||
println!(" Either the stream is truncated, or the framing is wrong.");
|
||||
}
|
||||
FramingVariant::Fire2 => {
|
||||
println!("VERDICT: fire2 is a clean fit — every byte accounted for.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse `.raw` log lines into records, ignoring anything malformed.
|
||||
///
|
||||
/// Malformed lines are skipped rather than fatal: a log truncated mid-write by a crashing
|
||||
/// game is a normal and valuable artifact, and refusing to read the good 99% of it because
|
||||
/// the last line is half-written would defeat the purpose.
|
||||
fn parse_log(text: &str) -> Vec<Record> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split_whitespace();
|
||||
let dir = parts.next()?;
|
||||
let inbound = match dir {
|
||||
"IN" => true,
|
||||
"OUT" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let _len = parts.next()?;
|
||||
let hex_str = parts.next()?;
|
||||
let bytes = hex::decode(hex_str).ok()?;
|
||||
Some(Record { inbound, bytes })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_directions_and_hex() {
|
||||
let log = "IN 2 dead\nOUT 2 beef\n";
|
||||
let recs = parse_log(log);
|
||||
assert_eq!(recs.len(), 2);
|
||||
assert!(recs[0].inbound);
|
||||
assert_eq!(recs[0].bytes, vec![0xDE, 0xAD]);
|
||||
assert!(!recs[1].inbound);
|
||||
assert_eq!(recs[1].bytes, vec![0xBE, 0xEF]);
|
||||
}
|
||||
|
||||
/// A log truncated by a crashing game must still yield its complete records.
|
||||
#[test]
|
||||
fn skips_malformed_lines_without_losing_good_ones() {
|
||||
let log = "IN 2 dead\ngarbage line\nIN 1 zz\nIN 2 beef\nIN 4\n";
|
||||
let recs = parse_log(log);
|
||||
assert_eq!(recs.len(), 2, "both well-formed records must survive");
|
||||
assert_eq!(recs[1].bytes, vec![0xBE, 0xEF]);
|
||||
}
|
||||
|
||||
/// A Fire2 frame written by the codec must survive a round trip through the log
|
||||
/// format — this is what makes a replayed capture trustworthy.
|
||||
#[test]
|
||||
fn fire2_frame_round_trips_through_the_log_format() {
|
||||
use blaze_proto::{FireFrame, FrameType, Packet, PacketOptions};
|
||||
use tokio_util::codec::Encoder;
|
||||
|
||||
let pkt = Packet {
|
||||
frame: FireFrame {
|
||||
component: 0x0009,
|
||||
command: 0x0007,
|
||||
error: 0,
|
||||
ty: FrameType::Request,
|
||||
options: PacketOptions::NONE,
|
||||
seq: 42,
|
||||
},
|
||||
body: bytes::Bytes::from_static(&[1, 2, 3, 4]),
|
||||
};
|
||||
|
||||
let mut encoded = BytesMut::new();
|
||||
PacketCodec::new(FramingVariant::Fire2)
|
||||
.encode(pkt, &mut encoded)
|
||||
.unwrap();
|
||||
|
||||
// Through the log representation and back.
|
||||
let line = format!("IN {} {}\n", encoded.len(), hex::encode(&encoded));
|
||||
let recs = parse_log(&line);
|
||||
let mut buf = BytesMut::from(&recs[0].bytes[..]);
|
||||
|
||||
let decoded = PacketCodec::new(FramingVariant::Fire2)
|
||||
.decode(&mut buf)
|
||||
.unwrap()
|
||||
.expect("frame must decode");
|
||||
|
||||
assert_eq!(decoded.frame.component, 0x0009);
|
||||
assert_eq!(decoded.frame.command, 0x0007);
|
||||
assert_eq!(decoded.frame.seq, 42);
|
||||
assert_eq!(&decoded.body[..], &[1, 2, 3, 4]);
|
||||
assert!(buf.is_empty(), "a clean fit must consume every byte");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Blaze component and command identifiers.
|
||||
//!
|
||||
//! ## Provenance and confidence
|
||||
//!
|
||||
//! These are **not FIFA 17 captures.** They are the Blaze framework's component numbering
|
||||
//! as published by open-source emulators for other EA titles — chiefly
|
||||
//! [`PocketRelay/Server`](https://github.com/PocketRelay/Server) (Mass Effect 3, MIT) with
|
||||
//! corroboration from the BF3 emulators. No EA source is involved; those projects derived
|
||||
//! the numbering by observing their own clients, the same clean-room basis this project
|
||||
//! uses.
|
||||
//!
|
||||
//! Confidence splits cleanly, and the split matters:
|
||||
//!
|
||||
//! * **Component IDs are framework-level** and have been stable across EA titles for a
|
||||
//! decade — Util is 0x9 in every title anyone has published numbers for. Treat these as
|
||||
//! likely-correct starting points.
|
||||
//! * **Command IDs are per-title.** Blaze lets each game define its own command set inside
|
||||
//! a component, so FIFA 17's may diverge from ME3's. Treat every command constant here as
|
||||
//! **TODO/CONFIRM** until a capture agrees with it.
|
||||
//!
|
||||
//! The purpose of this table is not to be right. It is to turn the capture log from a wall
|
||||
//! of hex into named requests, so an unknown ID stands out as unknown instead of hiding
|
||||
//! among the ones we could already have identified.
|
||||
|
||||
/// Blaze component identifiers.
|
||||
///
|
||||
/// Framework-level and stable across titles — the higher-confidence half of this module.
|
||||
pub mod component {
|
||||
pub const AUTHENTICATION: u16 = 0x1;
|
||||
pub const GAME_MANAGER: u16 = 0x4;
|
||||
pub const REDIRECTOR: u16 = 0x5;
|
||||
pub const STATS: u16 = 0x7;
|
||||
pub const UTIL: u16 = 0x9;
|
||||
pub const MESSAGING: u16 = 0xF;
|
||||
pub const ASSOCIATION_LISTS: u16 = 0x19;
|
||||
pub const GAME_REPORTING: u16 = 0x1C;
|
||||
pub const USER_SESSIONS: u16 = 0x7802;
|
||||
}
|
||||
|
||||
/// Redirector commands — the first exchange a Blaze client makes.
|
||||
pub mod redirector {
|
||||
/// "Where is the Blaze server?" The only command the redirector listener should see.
|
||||
pub const GET_SERVER_INSTANCE: u16 = 0x1;
|
||||
}
|
||||
|
||||
/// Util commands. `PRE_AUTH`, `PING` and `POST_AUTH` are the three that gate a session:
|
||||
/// the client will not proceed to authentication until preAuth is answered.
|
||||
pub mod util {
|
||||
pub const FETCH_CLIENT_CONFIG: u16 = 0x1;
|
||||
pub const PING: u16 = 0x2;
|
||||
pub const SET_CLIENT_DATA: u16 = 0x3;
|
||||
pub const LOCALIZE_STRINGS: u16 = 0x4;
|
||||
pub const GET_TELEMETRY_SERVER: u16 = 0x5;
|
||||
pub const GET_TICKER_SERVER: u16 = 0x6;
|
||||
pub const PRE_AUTH: u16 = 0x7;
|
||||
pub const POST_AUTH: u16 = 0x8;
|
||||
pub const USER_SETTINGS_LOAD: u16 = 0xA;
|
||||
pub const USER_SETTINGS_SAVE: u16 = 0xB;
|
||||
pub const USER_SETTINGS_LOAD_ALL: u16 = 0xC;
|
||||
pub const DELETE_USER_SETTINGS: u16 = 0xE;
|
||||
pub const FILTER_FOR_PROFANITY: u16 = 0x14;
|
||||
pub const FETCH_QOS_CONFIG: u16 = 0x15;
|
||||
pub const SET_CLIENT_METRICS: u16 = 0x16;
|
||||
pub const SET_CONNECTION_STATE: u16 = 0x17;
|
||||
pub const GET_PSS_CONFIG: u16 = 0x18;
|
||||
pub const GET_USER_OPTIONS: u16 = 0x19;
|
||||
pub const SET_USER_OPTIONS: u16 = 0x1A;
|
||||
pub const SUSPEND_USER_PING: u16 = 0x1B;
|
||||
}
|
||||
|
||||
/// Authentication commands.
|
||||
pub mod authentication {
|
||||
pub const LIST_USER_ENTITLEMENTS_2: u16 = 0x1D;
|
||||
pub const GET_AUTH_TOKEN: u16 = 0x24;
|
||||
pub const LOGIN: u16 = 0x28;
|
||||
pub const SILENT_LOGIN: u16 = 0x32;
|
||||
pub const LOGIN_PERSONA: u16 = 0x6E;
|
||||
pub const LOGOUT: u16 = 0x46;
|
||||
}
|
||||
|
||||
/// Human-readable name for a `(component, command)` pair, or `None` if unrecognised.
|
||||
///
|
||||
/// Returning `None` rather than a generic placeholder is deliberate: the capture log should
|
||||
/// make an unknown pair conspicuous, because unknown pairs are the entire point of
|
||||
/// Milestone 1.
|
||||
pub fn command_name(component: u16, command: u16) -> Option<&'static str> {
|
||||
Some(match (component, command) {
|
||||
(component::REDIRECTOR, redirector::GET_SERVER_INSTANCE) => "Redirector.getServerInstance",
|
||||
|
||||
(component::UTIL, util::FETCH_CLIENT_CONFIG) => "Util.fetchClientConfig",
|
||||
(component::UTIL, util::PING) => "Util.ping",
|
||||
(component::UTIL, util::SET_CLIENT_DATA) => "Util.setClientData",
|
||||
(component::UTIL, util::LOCALIZE_STRINGS) => "Util.localizeStrings",
|
||||
(component::UTIL, util::GET_TELEMETRY_SERVER) => "Util.getTelemetryServer",
|
||||
(component::UTIL, util::GET_TICKER_SERVER) => "Util.getTickerServer",
|
||||
(component::UTIL, util::PRE_AUTH) => "Util.preAuth",
|
||||
(component::UTIL, util::POST_AUTH) => "Util.postAuth",
|
||||
(component::UTIL, util::USER_SETTINGS_LOAD) => "Util.userSettingsLoad",
|
||||
(component::UTIL, util::USER_SETTINGS_SAVE) => "Util.userSettingsSave",
|
||||
(component::UTIL, util::USER_SETTINGS_LOAD_ALL) => "Util.userSettingsLoadAll",
|
||||
(component::UTIL, util::DELETE_USER_SETTINGS) => "Util.deleteUserSettings",
|
||||
(component::UTIL, util::FILTER_FOR_PROFANITY) => "Util.filterForProfanity",
|
||||
(component::UTIL, util::FETCH_QOS_CONFIG) => "Util.fetchQosConfig",
|
||||
(component::UTIL, util::SET_CLIENT_METRICS) => "Util.setClientMetrics",
|
||||
(component::UTIL, util::SET_CONNECTION_STATE) => "Util.setConnectionState",
|
||||
(component::UTIL, util::GET_PSS_CONFIG) => "Util.getPssConfig",
|
||||
(component::UTIL, util::GET_USER_OPTIONS) => "Util.getUserOptions",
|
||||
(component::UTIL, util::SET_USER_OPTIONS) => "Util.setUserOptions",
|
||||
(component::UTIL, util::SUSPEND_USER_PING) => "Util.suspendUserPing",
|
||||
|
||||
(component::AUTHENTICATION, authentication::LIST_USER_ENTITLEMENTS_2) => {
|
||||
"Authentication.listUserEntitlements2"
|
||||
}
|
||||
(component::AUTHENTICATION, authentication::GET_AUTH_TOKEN) => "Authentication.getAuthToken",
|
||||
(component::AUTHENTICATION, authentication::LOGIN) => "Authentication.login",
|
||||
(component::AUTHENTICATION, authentication::SILENT_LOGIN) => "Authentication.silentLogin",
|
||||
(component::AUTHENTICATION, authentication::LOGIN_PERSONA) => "Authentication.loginPersona",
|
||||
(component::AUTHENTICATION, authentication::LOGOUT) => "Authentication.logout",
|
||||
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Name of a component alone, for logging requests whose command is unrecognised.
|
||||
pub fn component_name(component: u16) -> Option<&'static str> {
|
||||
Some(match component {
|
||||
component::AUTHENTICATION => "Authentication",
|
||||
component::GAME_MANAGER => "GameManager",
|
||||
component::REDIRECTOR => "Redirector",
|
||||
component::STATS => "Stats",
|
||||
component::UTIL => "Util",
|
||||
component::MESSAGING => "Messaging",
|
||||
component::ASSOCIATION_LISTS => "AssociationLists",
|
||||
component::GAME_REPORTING => "GameReporting",
|
||||
component::USER_SESSIONS => "UserSessions",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Label for logs: the known name, else `component/command` in hex so it is greppable.
|
||||
pub fn label(component: u16, command: u16) -> String {
|
||||
if let Some(name) = command_name(component, command) {
|
||||
return name.to_string();
|
||||
}
|
||||
match component_name(component) {
|
||||
Some(c) => format!("{c}.0x{command:04X}"),
|
||||
None => format!("0x{component:04X}.0x{command:04X}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_the_session_gating_commands() {
|
||||
assert_eq!(label(component::UTIL, util::PRE_AUTH), "Util.preAuth");
|
||||
assert_eq!(label(component::UTIL, util::PING), "Util.ping");
|
||||
assert_eq!(
|
||||
label(component::REDIRECTOR, redirector::GET_SERVER_INSTANCE),
|
||||
"Redirector.getServerInstance"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown command inside a known component must still be greppable, and must not
|
||||
/// be silently dressed up as something recognised.
|
||||
#[test]
|
||||
fn unknown_command_keeps_hex_and_names_the_component() {
|
||||
assert_eq!(label(component::UTIL, 0xFF), "Util.0x00FF");
|
||||
assert_eq!(label(0xABCD, 0x12), "0xABCD.0x0012");
|
||||
assert!(command_name(component::UTIL, 0xFF).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
use blaze_proto::FramingVariant;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub redirector : RedirectorConfig,
|
||||
pub blaze : BlazeConfig,
|
||||
pub tls : TlsConfig,
|
||||
pub capture : CaptureConfig,
|
||||
}
|
||||
|
||||
@@ -23,14 +23,28 @@ pub struct BlazeConfig {
|
||||
pub advertise_host : String,
|
||||
/// Port we tell the client to connect to (in the redirect response).
|
||||
pub advertise_port : u16,
|
||||
/// Wire framing: `fire2` (default) or `raw`.
|
||||
///
|
||||
/// Config rather than a constant on purpose. Which variant a title uses is not
|
||||
/// knowable until real bytes arrive, and the FIFA 23 effort showed the cost of
|
||||
/// putting that decision behind a rebuild: every wrong guess is a full
|
||||
/// edit → build → deploy → relaunch cycle. Flipping to `raw` here lets one
|
||||
/// session answer the question.
|
||||
pub framing : Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TlsConfig {
|
||||
/// PEM certificate file (RSA-2048 recommended — DirtySDK rejects ECDSA).
|
||||
pub cert : String,
|
||||
/// PEM private key file.
|
||||
pub key : String,
|
||||
impl BlazeConfig {
|
||||
/// Parse the configured framing, falling back to Fire2 for anything unrecognised.
|
||||
///
|
||||
/// Unknown values fall back rather than erroring: an unparseable framing name should
|
||||
/// not stop the server starting and capturing, since the raw tee makes the bytes
|
||||
/// useful regardless of which variant is active.
|
||||
pub fn framing_variant(&self) -> FramingVariant {
|
||||
match self.framing.as_deref().map(str::trim) {
|
||||
Some("raw") | Some("Raw") | Some("RAW") => FramingVariant::Raw,
|
||||
_ => FramingVariant::Fire2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -39,6 +53,13 @@ pub struct CaptureConfig {
|
||||
pub dir : String,
|
||||
/// Also print decoded packet trees to stdout.
|
||||
pub pretty_print : bool,
|
||||
/// Mirror every raw byte to `<dir>/<session>.raw` before framing is attempted.
|
||||
/// Defaults to on — this is the safety net against zero-byte captures.
|
||||
pub raw_tee : Option<bool>,
|
||||
}
|
||||
|
||||
impl CaptureConfig {
|
||||
pub fn raw_tee_enabled(&self) -> bool { self.raw_tee.unwrap_or(true) }
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -49,3 +70,32 @@ impl Config {
|
||||
.map_err(|e| anyhow::anyhow!("config parse error in {path}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg(framing: Option<&str>) -> BlazeConfig {
|
||||
BlazeConfig {
|
||||
listen: "0.0.0.0:10041".into(),
|
||||
advertise_host: "127.0.0.1".into(),
|
||||
advertise_port: 10041,
|
||||
framing: framing.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framing_defaults_to_fire2_and_accepts_raw() {
|
||||
assert!(matches!(cfg(None).framing_variant(), FramingVariant::Fire2));
|
||||
assert!(matches!(cfg(Some("raw")).framing_variant(), FramingVariant::Raw));
|
||||
assert!(matches!(cfg(Some("RAW")).framing_variant(), FramingVariant::Raw));
|
||||
// Unrecognised values must not stop the server booting.
|
||||
assert!(matches!(cfg(Some("fire3")).framing_variant(), FramingVariant::Fire2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_tee_defaults_on() {
|
||||
let c = CaptureConfig { dir: "captures".into(), pretty_print: true, raw_tee: None };
|
||||
assert!(c.raw_tee_enabled(), "the zero-byte-capture safety net must be opt-out");
|
||||
}
|
||||
}
|
||||
|
||||
+143
-65
@@ -1,27 +1,37 @@
|
||||
//! blaze-server: Milestone 1 — capture stub.
|
||||
//! blaze-server: Milestone 1 — capture stub (FIFA 17 target).
|
||||
//!
|
||||
//! Starts two TLS listeners:
|
||||
//! • redirector — answers FIFA 23's "where is the Blaze server?" query
|
||||
//! Starts two SSLv3 listeners:
|
||||
//! • redirector — answers the client's "where is the Blaze server?" query
|
||||
//! • blaze — accepts the actual game session and logs every packet
|
||||
//!
|
||||
//! All component/command IDs are unknown at this stage. Every packet receives
|
||||
//! an empty response so the client keeps talking. The capture log reveals the
|
||||
//! IDs to implement next.
|
||||
//!
|
||||
//! ## Why SSLv3 and not rustls
|
||||
//!
|
||||
//! EA's DirtySDK speaks ProtoSSL — a homegrown SSLv3 implementation restricted to
|
||||
//! RC4-SHA / RC4-MD5. A modern TLS stack cannot negotiate with it at all: there is no
|
||||
//! shared protocol version, let alone a shared cipher. `blaze-ssl-async` implements
|
||||
//! exactly that dialect, and ships a certificate crafted to satisfy older ProtoSSL
|
||||
//! verification — which also removes the cert-generation step this server used to need.
|
||||
|
||||
mod capture;
|
||||
mod components;
|
||||
mod config;
|
||||
mod dispatch;
|
||||
mod tls;
|
||||
mod routes;
|
||||
mod tee;
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use blaze_proto::{FramingVariant, Packet, PacketCodec};
|
||||
use bytes::Bytes;
|
||||
use blaze_ssl_async::{BlazeListener, BlazeServerContext, BlazeStream};
|
||||
use capture::{CaptureWriter, Dir};
|
||||
use dispatch::Dispatcher;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
use tee::TeeStream;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio_util::codec::Framed;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
@@ -37,37 +47,51 @@ async fn main() -> anyhow::Result<()> {
|
||||
let cfg = config::Config::load(&cfg_path)?;
|
||||
info!(config = cfg_path, "loaded");
|
||||
|
||||
let tls_cfg = tls::load_tls_config(&cfg.tls.cert, &cfg.tls.key)?;
|
||||
let acceptor = TlsAcceptor::from(Arc::clone(&tls_cfg));
|
||||
// Built-in ProtoSSL-bypass certificate — no cert/key files to generate or configure.
|
||||
let ssl_ctx = Arc::new(BlazeServerContext::default());
|
||||
|
||||
let capture = Arc::new(CaptureWriter::open(&cfg.capture.dir, cfg.capture.pretty_print)?);
|
||||
let dispatcher = Arc::new(Dispatcher::new());
|
||||
let dispatcher = Arc::new(build_dispatcher());
|
||||
|
||||
let framing = cfg.blaze.framing_variant();
|
||||
let raw_dir = cfg.capture.raw_tee_enabled().then(|| PathBuf::from(&cfg.capture.dir));
|
||||
info!(?framing, raw_tee = raw_dir.is_some(), "capture settings");
|
||||
|
||||
let redir_addr: SocketAddr = cfg.redirector.listen.parse()?;
|
||||
let blaze_addr: SocketAddr = cfg.blaze.listen.parse()?;
|
||||
let advertise_host = Arc::new(cfg.blaze.advertise_host.clone());
|
||||
let advertise_port = cfg.blaze.advertise_port;
|
||||
|
||||
let redir_listener = TcpListener::bind(redir_addr).await?;
|
||||
let blaze_listener = TcpListener::bind(blaze_addr).await?;
|
||||
info!(%redir_addr, "redirector listening");
|
||||
info!(%blaze_addr, "blaze listening");
|
||||
let redir_listener = BlazeListener::bind(redir_addr, Arc::clone(&ssl_ctx)).await?;
|
||||
let blaze_listener = BlazeListener::bind(blaze_addr, Arc::clone(&ssl_ctx)).await?;
|
||||
info!(%redir_addr, "redirector listening (SSLv3)");
|
||||
info!(%blaze_addr, "blaze listening (SSLv3)");
|
||||
|
||||
// ── Redirector loop ───────────────────────────────────────────────────────
|
||||
let redir_acceptor = acceptor.clone();
|
||||
let redir_cap = Arc::clone(&capture);
|
||||
let adv_host = Arc::clone(&advertise_host);
|
||||
let redir_cap = Arc::clone(&capture);
|
||||
let adv_host = Arc::clone(&advertise_host);
|
||||
let redir_raw = raw_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match redir_listener.accept().await {
|
||||
Err(e) => { error!(error=%e, "redirector accept error"); }
|
||||
Ok((tcp, peer)) => {
|
||||
let acc = redir_acceptor.clone();
|
||||
Ok(accept) => {
|
||||
let cap = Arc::clone(&redir_cap);
|
||||
let host = Arc::clone(&adv_host);
|
||||
let raw = redir_raw.clone();
|
||||
// finish_accept completes the SSL handshake, so it must run in its own
|
||||
// task — awaiting it here would stall the accept loop for the duration
|
||||
// of every handshake.
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_redirector(tcp, peer, acc, cap, &host, advertise_port).await {
|
||||
warn!(%peer, error=%e, "redirector session error");
|
||||
match accept.finish_accept().await {
|
||||
Err(e) => warn!(error=%e, "redirector SSL handshake failed"),
|
||||
Ok((stream, peer)) => {
|
||||
if let Err(e) = handle_redirector(
|
||||
stream, peer, cap, &host, advertise_port, framing, raw,
|
||||
).await {
|
||||
warn!(%peer, error=%e, "redirector session error");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -79,13 +103,20 @@ async fn main() -> anyhow::Result<()> {
|
||||
loop {
|
||||
match blaze_listener.accept().await {
|
||||
Err(e) => { error!(error=%e, "blaze accept error"); }
|
||||
Ok((tcp, peer)) => {
|
||||
let acc = acceptor.clone();
|
||||
Ok(accept) => {
|
||||
let cap = Arc::clone(&capture);
|
||||
let disp = Arc::clone(&dispatcher);
|
||||
let raw = raw_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_blaze(tcp, peer, acc, cap, disp).await {
|
||||
warn!(%peer, error=%e, "blaze session error");
|
||||
match accept.finish_accept().await {
|
||||
Err(e) => warn!(error=%e, "blaze SSL handshake failed"),
|
||||
Ok((stream, peer)) => {
|
||||
if let Err(e) =
|
||||
handle_blaze(stream, peer, cap, disp, framing, raw).await
|
||||
{
|
||||
warn!(%peer, error=%e, "blaze session error");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -93,33 +124,95 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the dispatch table with the handlers we can answer today.
|
||||
///
|
||||
/// Only the session-gating Util commands are registered. Everything else falls through to
|
||||
/// the dispatcher's empty-response default, which keeps the client talking so the capture
|
||||
/// log keeps growing — the point of Milestone 1 is to *learn* the command set, not to
|
||||
/// pre-empt it with guesses.
|
||||
fn build_dispatcher() -> Dispatcher {
|
||||
use components::{component, util};
|
||||
|
||||
let mut d = Dispatcher::new();
|
||||
|
||||
// Ping carries the server clock. Sampled per request rather than at startup so a
|
||||
// long-running server doesn't hand the client an increasingly stale timestamp.
|
||||
d.register(component::UTIL, util::PING, |pkt: Packet| async move {
|
||||
let now = chrono::Utc::now().timestamp() as u32;
|
||||
Some(Packet { frame: pkt.frame.response(), body: routes::ping_body(now) })
|
||||
});
|
||||
|
||||
// preAuth gates authentication — the client will not proceed until it is answered.
|
||||
d.register(component::UTIL, util::PRE_AUTH, |pkt: Packet| async move {
|
||||
let body = routes::pre_auth_body(&routes::PreAuthConfig::default());
|
||||
Some(Packet { frame: pkt.frame.response(), body })
|
||||
});
|
||||
|
||||
d
|
||||
}
|
||||
|
||||
/// Wrap `stream` in the raw byte tee when capture is enabled.
|
||||
///
|
||||
/// Returns a boxed trait object so both branches have one type. The dynamic dispatch
|
||||
/// costs nothing meaningful against SSL and disk I/O, and it keeps the two session
|
||||
/// handlers from having to be generic over the tee being on or off.
|
||||
fn with_tee(
|
||||
stream: BlazeStream,
|
||||
peer: SocketAddr,
|
||||
label: &str,
|
||||
raw_dir: Option<PathBuf>,
|
||||
) -> Box<dyn TeeIo> {
|
||||
let Some(dir) = raw_dir else {
|
||||
return Box::new(stream);
|
||||
};
|
||||
// Port, not IP, distinguishes concurrent sessions — the client dials from loopback
|
||||
// for every connection, so the addr alone collides.
|
||||
let name = format!("{label}-{}.raw", peer.port());
|
||||
// Open the log first: on failure the stream is still ours, so the session survives.
|
||||
match tee::open_sink(dir.join(&name)) {
|
||||
Ok(sink) => {
|
||||
info!(%peer, raw = name, "raw tee active");
|
||||
Box::new(TeeStream::with_sink(stream, sink))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(%peer, error=%e, "raw tee unavailable, continuing without it");
|
||||
Box::new(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker trait so `with_tee` can return one boxed type for both branches.
|
||||
pub trait TeeIo: AsyncRead + AsyncWrite + Send + Unpin {}
|
||||
impl<T: AsyncRead + AsyncWrite + Send + Unpin> TeeIo for T {}
|
||||
|
||||
// ── Redirector handler ────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_redirector(
|
||||
tcp : TcpStream,
|
||||
peer : SocketAddr,
|
||||
acceptor: TlsAcceptor,
|
||||
capture : Arc<CaptureWriter>,
|
||||
host : &str,
|
||||
port : u16,
|
||||
stream : BlazeStream,
|
||||
peer : SocketAddr,
|
||||
capture: Arc<CaptureWriter>,
|
||||
host : &str,
|
||||
port : u16,
|
||||
framing: FramingVariant,
|
||||
raw_dir: Option<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
let tls = acceptor.accept(tcp).await?;
|
||||
let codec = PacketCodec::new(FramingVariant::Fire2);
|
||||
let mut io = Framed::new(tls, codec);
|
||||
let io = with_tee(stream, peer, "redirector", raw_dir);
|
||||
let codec = PacketCodec::new(framing);
|
||||
let mut io = Framed::new(io, codec);
|
||||
|
||||
let peer_str = peer.to_string();
|
||||
info!(%peer, "redirector session started");
|
||||
|
||||
// Read the redirect request (component/command unknown; we just echo it back).
|
||||
if let Some(Ok(req)) = io.next().await {
|
||||
capture.record(&req, &peer_str, Dir::In);
|
||||
info!(%peer, component=format!("0x{:04X}", req.frame.component),
|
||||
command=format!("0x{:04X}", req.frame.command), "redirector request");
|
||||
info!(%peer, cmd = components::label(req.frame.component, req.frame.command),
|
||||
"redirector request");
|
||||
|
||||
// Build the redirect response. The TDF body tells the client which
|
||||
// Blaze server to connect to. We don't know the exact tag layout yet —
|
||||
// capture will reveal it. For now we return our advertise address as a
|
||||
// simple string blob so the client gets something to parse.
|
||||
let body = build_redirector_body(host, port);
|
||||
// Tell the client where the Blaze server is. `secure = true` because our Blaze
|
||||
// listener is SSLv3 like the redirector — if the client dials plaintext and gets
|
||||
// a handshake, this is the flag to flip.
|
||||
let body = routes::redirector_instance_body(host, port, true);
|
||||
let resp = Packet { frame: req.frame.response(), body };
|
||||
capture.record(&resp, &peer_str, Dir::Out);
|
||||
io.send(resp).await?;
|
||||
@@ -127,34 +220,19 @@ async fn handle_redirector(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds a minimal TDF body for the redirector response.
|
||||
///
|
||||
/// The exact tag layout is unknown until capture. This is a stub so the
|
||||
/// listener at least sends something; the real layout will be determined from
|
||||
/// captures of the client's request packet.
|
||||
///
|
||||
/// BF3/ME3 redirector response uses tags like "ADDR" (string), "PORT" (u32).
|
||||
/// FIFA 23 may differ. TODO: replace once captures reveal the real tags.
|
||||
fn build_redirector_body(host: &str, port: u16) -> Bytes {
|
||||
use tdf::writer::TdfSerializer;
|
||||
let mut w: Vec<u8> = Vec::new();
|
||||
w.tag_str(b"ADDR", host);
|
||||
w.tag_u32(b"PORT", port as u32);
|
||||
Bytes::from(w)
|
||||
}
|
||||
|
||||
// ── Blaze session handler ─────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_blaze(
|
||||
tcp : TcpStream,
|
||||
peer : SocketAddr,
|
||||
acceptor : TlsAcceptor,
|
||||
capture : Arc<CaptureWriter>,
|
||||
stream : BlazeStream,
|
||||
peer : SocketAddr,
|
||||
capture : Arc<CaptureWriter>,
|
||||
dispatcher: Arc<Dispatcher>,
|
||||
framing : FramingVariant,
|
||||
raw_dir : Option<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
let tls = acceptor.accept(tcp).await?;
|
||||
let codec = PacketCodec::new(FramingVariant::Fire2);
|
||||
let mut io = Framed::new(tls, codec);
|
||||
let io = with_tee(stream, peer, "blaze", raw_dir);
|
||||
let codec = PacketCodec::new(framing);
|
||||
let mut io = Framed::new(io, codec);
|
||||
|
||||
let peer_str = peer.to_string();
|
||||
info!(%peer, "blaze session started");
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Response bodies for the handful of commands that gate a Blaze session.
|
||||
//!
|
||||
//! ## Status of everything in this file
|
||||
//!
|
||||
//! These layouts are **scaffolds derived from other titles**, not FIFA 17 captures. The
|
||||
//! tag names and structure come from the Blaze framework's shape as published by
|
||||
//! open-source emulators (chiefly PocketRelay for Mass Effect 3, MIT). Every value is a
|
||||
//! placeholder and every layout is `TODO/CONFIRM`.
|
||||
//!
|
||||
//! They exist so the first FIFA 17 session gets *structurally plausible* answers instead
|
||||
//! of empty bodies. An empty body is guaranteed wrong and tells you nothing; a plausible
|
||||
//! body either advances the client — which is a result — or gets rejected in a way whose
|
||||
//! error code is itself information. Either beats silence.
|
||||
//!
|
||||
//! When a capture disagrees with anything here, the capture wins.
|
||||
|
||||
use bytes::Bytes;
|
||||
use std::net::Ipv4Addr;
|
||||
use tdf::writer::TdfSerializer;
|
||||
|
||||
/// Blaze `NetworkAddress` union discriminants.
|
||||
///
|
||||
/// TODO/CONFIRM: the discriminant the redirector response uses. ME3 emulators send the
|
||||
/// pair form for server instances. If FIFA 17 rejects the redirect or dials a nonsense
|
||||
/// address, this constant is the first thing to change — the variants are listed so the
|
||||
/// alternatives are one edit away.
|
||||
// The unused variants are the point: they are the alternatives to try when the client
|
||||
// rejects the redirect, kept named so the fix is a one-word edit rather than research.
|
||||
#[allow(dead_code)]
|
||||
pub mod net_address {
|
||||
/// `VALU` group holding `HOST` / `IP` / `PORT`.
|
||||
pub const IP_PAIR: u8 = 0x0;
|
||||
pub const XBOX_CLIENT: u8 = 0x1;
|
||||
pub const XBOX_SERVER: u8 = 0x2;
|
||||
pub const IP_ADDRESS: u8 = 0x3;
|
||||
pub const HOSTNAME: u8 = 0x4;
|
||||
}
|
||||
|
||||
/// Body of a `Redirector.getServerInstance` response — "connect to this Blaze server".
|
||||
///
|
||||
/// Replaces the earlier `ADDR` string / `PORT` u32 stub, which was not a real Blaze
|
||||
/// layout at all: `ADDR` is a **tagged union**, not a string. A client parsing the old
|
||||
/// stub would have hit a type mismatch on the first tag.
|
||||
///
|
||||
/// Layout (TODO/CONFIRM against a FIFA 17 capture):
|
||||
///
|
||||
/// ```text
|
||||
/// ADDR union(IP_PAIR)
|
||||
/// VALU group
|
||||
/// HOST string hostname we want the client to dial
|
||||
/// IP u32 same address as a big-endian v4 integer, 0 if not an IP
|
||||
/// PORT u16
|
||||
/// SECU bool does the Blaze port expect SSL
|
||||
/// XDNS bool Xbox DNS indirection — false on PC
|
||||
/// ```
|
||||
pub fn redirector_instance_body(host: &str, port: u16, secure: bool) -> Bytes {
|
||||
let mut w: Vec<u8> = Vec::new();
|
||||
|
||||
w.tag_union_start(b"ADDR", net_address::IP_PAIR);
|
||||
w.group(b"VALU", |w| {
|
||||
w.tag_str(b"HOST", host);
|
||||
// A hostname has no integer form; send 0 and let HOST carry it. Clients that
|
||||
// prefer IP will fail over to HOST rather than dial 0.0.0.0.
|
||||
w.tag_u32(b"IP", ipv4_as_u32(host).unwrap_or(0));
|
||||
w.tag_u16(b"PORT", port);
|
||||
});
|
||||
w.tag_bool(b"SECU", secure);
|
||||
w.tag_bool(b"XDNS", false);
|
||||
|
||||
Bytes::from(w)
|
||||
}
|
||||
|
||||
/// Body of a `Util.ping` response.
|
||||
///
|
||||
/// Ping carries the server's clock. Clients use it for drift correction and, in some
|
||||
/// titles, treat a missing value as a dead session — so answering with an empty body is
|
||||
/// actively risky where answering with a timestamp is not.
|
||||
pub fn ping_body(server_time: u32) -> Bytes {
|
||||
let mut w: Vec<u8> = Vec::new();
|
||||
w.tag_u32(b"STIM", server_time);
|
||||
Bytes::from(w)
|
||||
}
|
||||
|
||||
/// Body of a `Util.preAuth` response.
|
||||
///
|
||||
/// preAuth is the gate: the client will not attempt authentication until it is answered,
|
||||
/// so this is the first response that actually has to be structurally right.
|
||||
///
|
||||
/// This is a **minimal** scaffold. Real preAuth responses from shipping titles are large,
|
||||
/// carrying telemetry endpoints, QoS probe lists and a client config map. Those are
|
||||
/// deliberately omitted rather than invented — every extra fabricated field is another
|
||||
/// chance to fail in a way that is hard to attribute. Start small, read the client's
|
||||
/// reaction, grow it.
|
||||
///
|
||||
/// TODO/CONFIRM every tag and value below against a FIFA 17 capture.
|
||||
pub fn pre_auth_body(config: &PreAuthConfig<'_>) -> Bytes {
|
||||
let mut w: Vec<u8> = Vec::new();
|
||||
|
||||
w.tag_zero(b"ANON");
|
||||
w.tag_str(b"ASRC", config.auth_source);
|
||||
// Empty component-id list: we advertise no optional components until we know which
|
||||
// ones FIFA 17 expects. An empty list is honest; a fabricated one invites the client
|
||||
// to call something unimplemented.
|
||||
w.tag_var_int_list_empty(b"CIDS");
|
||||
w.tag_str_empty(b"CNGN");
|
||||
w.tag_group_empty(b"CONF");
|
||||
w.tag_str(b"INST", config.instance_name);
|
||||
w.tag_bool(b"MINR", false);
|
||||
w.tag_str(b"NASP", config.namespace);
|
||||
w.tag_str_empty(b"PILD");
|
||||
w.tag_str(b"PLAT", config.platform);
|
||||
w.tag_str_empty(b"PTAG");
|
||||
w.tag_str(b"RSRC", config.auth_source);
|
||||
w.tag_str(b"SVER", config.blaze_version);
|
||||
|
||||
Bytes::from(w)
|
||||
}
|
||||
|
||||
/// Values the preAuth response advertises about this server and title.
|
||||
///
|
||||
/// Grouped into a struct so the FIFA 17 identity lives in one place: when a capture
|
||||
/// reveals the real instance name or namespace, there is exactly one thing to correct.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreAuthConfig<'a> {
|
||||
/// Authentication source / resource identifier.
|
||||
pub auth_source: &'a str,
|
||||
/// Blaze instance name for the title.
|
||||
pub instance_name: &'a str,
|
||||
/// Persona namespace.
|
||||
pub namespace: &'a str,
|
||||
/// Platform string.
|
||||
pub platform: &'a str,
|
||||
/// Blaze SDK version the server claims to speak.
|
||||
pub blaze_version: &'a str,
|
||||
}
|
||||
|
||||
impl Default for PreAuthConfig<'_> {
|
||||
/// FIFA 17 PC guesses. All TODO/CONFIRM — these are the exact strings a capture will
|
||||
/// overwrite first, and getting them wrong is expected at this stage.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auth_source: "fifa-2017-pc",
|
||||
instance_name: "fifa-2017-pc",
|
||||
namespace: "cem_ea_id",
|
||||
platform: "pc",
|
||||
blaze_version: "Blaze 3.15.08.0",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `host` as an IPv4 address and return it as a big-endian u32, or `None` if it is
|
||||
/// a hostname rather than a literal address.
|
||||
fn ipv4_as_u32(host: &str) -> Option<u32> {
|
||||
host.parse::<Ipv4Addr>().ok().map(u32::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tdf::{reader::TdfDeserializer, stringify::TdfStringifier};
|
||||
|
||||
/// Render a body back to text via the TDF parser.
|
||||
///
|
||||
/// Tags are NOT searchable as ASCII in the encoded bytes: TDF packs each tag
|
||||
/// character into 6 bits spread across 3 bytes, so `b"ADDR"` never appears literally.
|
||||
/// Anything asserting on tag names has to parse. Stringifying also proves the body is
|
||||
/// well-formed TDF, which a substring check never could — the parser returning false
|
||||
/// means we emitted something a client could not read.
|
||||
fn decode(body: &Bytes) -> String {
|
||||
let (text, ok) = TdfStringifier::<String>::new_string(TdfDeserializer::new(body));
|
||||
assert!(ok, "body must be well-formed TDF, got partial parse:\n{text}");
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_is_converted_and_hostnames_are_not() {
|
||||
assert_eq!(ipv4_as_u32("127.0.0.1"), Some(0x7F00_0001));
|
||||
assert_eq!(ipv4_as_u32("10.10.0.5"), Some(0x0A0A_0005));
|
||||
assert_eq!(ipv4_as_u32("gosredirector.ea.com"), None);
|
||||
}
|
||||
|
||||
/// The redirector body must be a union carrying a VALU group — not the old
|
||||
/// `ADDR`-as-string stub, which a client would have rejected on a type mismatch at
|
||||
/// the very first tag.
|
||||
#[test]
|
||||
fn redirector_body_carries_the_address_union_and_port() {
|
||||
let text = decode(&redirector_instance_body("127.0.0.1", 10041, false));
|
||||
for tag in ["ADDR", "VALU", "HOST", "PORT", "SECU"] {
|
||||
assert!(text.contains(tag), "missing tag {tag} in redirector body:\n{text}");
|
||||
}
|
||||
assert!(text.contains("10041"), "advertised port must appear:\n{text}");
|
||||
assert!(text.contains("127.0.0.1"), "advertised host must appear:\n{text}");
|
||||
}
|
||||
|
||||
/// A hostname target must still produce a well-formed body — IP falls back to 0 and
|
||||
/// HOST carries the address, rather than the build failing.
|
||||
#[test]
|
||||
fn redirector_body_handles_a_hostname_target() {
|
||||
let text = decode(&redirector_instance_body("blaze.local", 10041, true));
|
||||
assert!(text.contains("blaze.local"), "hostname must survive:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ping_body_carries_server_time() {
|
||||
let text = decode(&ping_body(1_700_000_000));
|
||||
assert!(text.contains("STIM"), "ping must carry STIM:\n{text}");
|
||||
assert!(text.contains("1700000000"), "ping must carry the time:\n{text}");
|
||||
}
|
||||
|
||||
/// preAuth is the gate command — an empty or malformed body here stalls every session.
|
||||
#[test]
|
||||
fn pre_auth_body_is_populated() {
|
||||
let text = decode(&pre_auth_body(&PreAuthConfig::default()));
|
||||
for tag in ["ASRC", "INST", "NASP", "PLAT", "SVER"] {
|
||||
assert!(text.contains(tag), "missing tag {tag} in preAuth body:\n{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Raw byte tee — the capture safety net.
|
||||
//!
|
||||
//! The FIFA 23 effort produced six capture files containing zero bytes. The cause was
|
||||
//! structural: captures were only written *after* a packet decoded successfully, so a
|
||||
//! wrong framing guess meant no evidence at all — the one thing needed to fix the guess.
|
||||
//!
|
||||
//! This wrapper removes that failure mode. It sits between the SSL stream and the codec
|
||||
//! and appends every byte in both directions to a `.raw` log, before any framing is
|
||||
//! attempted. If Fire2 is the wrong variant for this title, the decode still fails — but
|
||||
//! the bytes are on disk and the correct layout is readable from them.
|
||||
//!
|
||||
//! Writes are synchronous `std::fs` calls inside `poll_read`/`poll_write`. That is
|
||||
//! technically blocking in an async context; it is deliberate and fine here because this
|
||||
//! is a capture tool handling one client at low packet rates, and the alternative
|
||||
//! (buffering through a channel) risks losing the tail on a crash — which is exactly when
|
||||
//! the bytes matter most.
|
||||
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
io::Write,
|
||||
path::Path,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
|
||||
/// Wraps a stream, mirroring every byte read and written into a hex log.
|
||||
pub struct TeeStream<S> {
|
||||
inner: S,
|
||||
sink: File,
|
||||
}
|
||||
|
||||
/// Open (or create) the raw log at `path`, creating the parent directory if needed.
|
||||
///
|
||||
/// Deliberately separate from `TeeStream::with_sink` so the fallible part happens
|
||||
/// *before* the stream is moved. If opening the log fails, the caller still owns the
|
||||
/// stream and can carry on without a tee — losing the log must never cost the session,
|
||||
/// because the session is the experiment.
|
||||
pub fn open_sink(path: impl AsRef<Path>) -> std::io::Result<File> {
|
||||
let path = path.as_ref();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
OpenOptions::new().create(true).append(true).open(path)
|
||||
}
|
||||
|
||||
impl<S> TeeStream<S> {
|
||||
/// Wrap `inner`, mirroring bytes into an already-opened `sink`.
|
||||
pub fn with_sink(inner: S, sink: File) -> Self {
|
||||
Self { inner, sink }
|
||||
}
|
||||
|
||||
/// One line per chunk: `<dir> <byte-count> <hex>`. Line-oriented so the log stays
|
||||
/// greppable and `xxd`-free; direction is explicit because a Blaze session is a
|
||||
/// request/response interleave and order alone is ambiguous.
|
||||
fn log(&mut self, dir: &str, bytes: &[u8]) {
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(self.sink, "{dir} {} {}", bytes.len(), hex::encode(bytes));
|
||||
let _ = self.sink.flush();
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncRead for TeeStream<S> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
// Record how much was already in the buffer so only the newly-filled region
|
||||
// is teed — poll_read appends, it does not reset the cursor.
|
||||
let before = buf.filled().len();
|
||||
let poll = Pin::new(&mut self.inner).poll_read(cx, buf);
|
||||
if let Poll::Ready(Ok(())) = &poll {
|
||||
let new = buf.filled()[before..].to_vec();
|
||||
self.log("IN ", &new);
|
||||
}
|
||||
poll
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncWrite + Unpin> AsyncWrite for TeeStream<S> {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
let poll = Pin::new(&mut self.inner).poll_write(cx, buf);
|
||||
// Log only what was actually accepted; a short write must not be over-reported
|
||||
// or the log stops matching the wire.
|
||||
if let Poll::Ready(Ok(n)) = &poll {
|
||||
let written = buf[..*n].to_vec();
|
||||
self.log("OUT", &written);
|
||||
}
|
||||
poll
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// The whole point of this module: bytes reach disk even though no framing ever ran.
|
||||
#[tokio::test]
|
||||
async fn tees_reads_and_writes_to_disk() {
|
||||
let dir = std::env::temp_dir().join("fifa-blaze-tee-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("session.raw");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// A duplex pair stands in for the SSL stream; the far end feeds and drains it.
|
||||
let (near, mut far) = tokio::io::duplex(64);
|
||||
let mut tee = TeeStream::with_sink(near, open_sink(&path).unwrap());
|
||||
|
||||
far.write_all(&[0xDE, 0xAD]).await.unwrap();
|
||||
let mut buf = [0u8; 2];
|
||||
tee.read_exact(&mut buf).await.unwrap();
|
||||
tee.write_all(&[0xBE, 0xEF]).await.unwrap();
|
||||
far.read_exact(&mut [0u8; 2]).await.unwrap();
|
||||
|
||||
let log = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(log.contains("IN 2 dead"), "inbound bytes must be logged, got: {log}");
|
||||
assert!(log.contains("OUT 2 beef"), "outbound bytes must be logged, got: {log}");
|
||||
}
|
||||
|
||||
/// End-to-end proof of the transport swap: a real SSLv3 handshake completes against
|
||||
/// `blaze-ssl-async`'s built-in ProtoSSL-bypass certificate, and application bytes
|
||||
/// survive the round trip into the tee as plaintext.
|
||||
///
|
||||
/// This is the check that would have caught the FIFA 17 showstopper early — the old
|
||||
/// rustls listener could not have completed this handshake at all, because ProtoSSL
|
||||
/// offers only SSLv3 with RC4 and a modern TLS stack shares no version with it.
|
||||
/// It runs without FIFA, so the server can be validated before touching the game.
|
||||
#[tokio::test]
|
||||
async fn sslv3_round_trip_reaches_the_tee() {
|
||||
use blaze_ssl_async::{BlazeListener, BlazeServerContext, BlazeStream};
|
||||
use std::sync::Arc;
|
||||
|
||||
let dir = std::env::temp_dir().join("fifa-blaze-ssl-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("ssl.raw");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let ctx = Arc::new(BlazeServerContext::default());
|
||||
// Port 0 → the OS picks a free port, so the test can't collide with a real run.
|
||||
let listener = BlazeListener::bind(("127.0.0.1", 0), ctx).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let sink = open_sink(&path).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _peer) = listener.accept().await.unwrap().finish_accept().await.unwrap();
|
||||
let mut tee = TeeStream::with_sink(stream, sink);
|
||||
let mut buf = [0u8; 4];
|
||||
tee.read_exact(&mut buf).await.unwrap();
|
||||
tee.write_all(b"pong").await.unwrap();
|
||||
tee.flush().await.unwrap();
|
||||
});
|
||||
|
||||
let mut client = BlazeStream::connect(addr).await.unwrap();
|
||||
client.write_all(b"ping").await.unwrap();
|
||||
client.flush().await.unwrap();
|
||||
let mut reply = [0u8; 4];
|
||||
client.read_exact(&mut reply).await.unwrap();
|
||||
assert_eq!(&reply, b"pong", "client must receive the server's reply over SSLv3");
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
let log = std::fs::read_to_string(&path).unwrap();
|
||||
let ping = hex::encode(b"ping");
|
||||
let pong = hex::encode(b"pong");
|
||||
assert!(log.contains(&ping), "decrypted inbound must reach the tee, got: {log}");
|
||||
assert!(log.contains(&pong), "outbound must reach the tee, got: {log}");
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
use std::{fs::File, io::BufReader, sync::Arc};
|
||||
use rustls::{ServerConfig, pki_types::{CertificateDer, PrivateKeyDer}};
|
||||
use rustls_pemfile::{certs, private_key};
|
||||
use anyhow::Context;
|
||||
|
||||
/// Load a TLS `ServerConfig` from PEM cert and key files.
|
||||
///
|
||||
/// Uses `ring` as the crypto backend (specified in Cargo.toml features).
|
||||
/// This needs to match what DirtySDK (FIFA's network library) accepts:
|
||||
/// • RSA-2048 certificate (ECDSA causes BAD_CERTIFICATE)
|
||||
/// • TLS 1.2+ (DirtySDK can negotiate TLS 1.3 with AES-256-GCM)
|
||||
pub fn load_tls_config(cert_path: &str, key_path: &str) -> anyhow::Result<Arc<ServerConfig>> {
|
||||
// Install the ring crypto provider once. Harmless if called multiple times.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let cert_file = File::open(cert_path)
|
||||
.with_context(|| format!("open cert: {cert_path}"))?;
|
||||
let key_file = File::open(key_path)
|
||||
.with_context(|| format!("open key: {key_path}"))?;
|
||||
|
||||
let certs: Vec<CertificateDer> = certs(&mut BufReader::new(cert_file))
|
||||
.collect::<Result<_, _>>()
|
||||
.with_context(|| "parse PEM certs")?;
|
||||
|
||||
let key: PrivateKeyDer = private_key(&mut BufReader::new(key_file))
|
||||
.with_context(|| "parse PEM key")?
|
||||
.ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
|
||||
|
||||
let config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)
|
||||
.with_context(|| "build ServerConfig")?;
|
||||
|
||||
Ok(Arc::new(config))
|
||||
}
|
||||
Reference in New Issue
Block a user