wip: checkpoint Blaze server work for Windows migration

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