Files
OpenFUT/openfut-blaze-host/src/lib.rs
T
funman300 23374312bc blaze-host: opt-in raw frame capture + auditable sanitizer
Evidence infrastructure, not protocol functionality. Built before gates 7-10
because those sessions cannot be reproduced -- a later run is a different
session, and the migration-validation runs happen once. Gates 5-6 already went
past without their bytes being recorded.

TWO LAYERS

  live FIFA traffic
    ├── raw capture      exact RX/TX bytes, mode 0600, gitignored
    └── blaze-sanitize   → repository-safe, replayable fixtures

CAPTURE. Off unless OPENFUT_BLAZE_CAPTURE names a file. Deterministic
big-endian container: 20-byte file header, then per-frame records carrying
connection id, a global monotonic sequence, timestamp, direction and the EXACT
frame bytes. RX is recorded as received; TX only AFTER a successful write, so a
record means the bytes were sent rather than intended.

Component/command/msgNum/msgType/payload length are deliberately NOT stored
beside the frame: they are already in its 16-byte header, and a redundant copy
can disagree with the bytes, leaving a reader unable to tell which is true.
Record::header() derives them, so every field the requirements name is
available without duplicating it.

SANITIZER. Redacts only the named tags in SENSITIVE_TAGS (KEY, AUTH, SESS,
MAIL, PML) and reports every substitution with path, kind and length.
Replacement is LENGTH-PRESERVING, so the TDF varint, payload length and Fire2
header are unchanged and the sanitized frame is exactly the size of the
captured one -- asserted per frame, failing rather than emitting a subtly
different conversation. Frames with nothing sensitive keep their exact wire
bytes. Payloads that will not decode are passed through and REPORTED, so a
reader knows they were never inspected rather than assuming they were checked.

TESTS. 39 in this crate. All nine required cases: capture disabled produces no
artefact; RX and TX captured exactly; ordering preserved; fragmented input
(one byte at a time) reconstructs the same frames as a single write; coalesced
input is captured as separate frames, not per-read; capture does not alter wire
output; sanitization removes a real session key from a real captured login;
malformed/truncated/wrong-version captures fail clearly; every listed sensitive
tag is provably reachable.

MUTATION TESTED. Dropping TX capture, truncating captured frames to their
header, and removing KEY from the sensitive list were each verified to turn the
suite red. One mutation was NOT caught: moving the TX capture above the write.
It is indistinguishable while writes succeed and only diverges when one fails.
That invariant is held by code placement and a comment saying so, not by a
test, and the code says as much rather than implying coverage it does not have.

Python oracle unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:14:25 +00:00

208 lines
7.0 KiB
Rust

//! # openfut-blaze-host
//!
//! A deliberately thin TCP host for the FIFA 17 Blaze RPC surface.
//!
//! ```text
//! listener → Fire2 stream framing → per-connection Session
//! │
//! openfut-adapter-fifa17::dispatch
//! │
//! write returned frames, in order
//! ```
//!
//! ## What it owns
//!
//! A socket, a read buffer, one `Session` per connection, and diagnostics.
//! That is the complete list.
//!
//! ## What it must never acquire
//!
//! Coins, club state, packs, profiles, market state, UTAS logic. Those belong
//! to OpenFUT Core, reached later through the adapter. A transport host that
//! starts holding game state becomes a second backend, which is exactly the
//! architecture this migration exists to avoid.
//!
//! ## No TLS
//!
//! The Blaze main port is **plaintext**. Verified against the running Python
//! backend by sending a raw Fire2 `Util::ping` and receiving a plaintext
//! `PingResponse`; `blaze_responder_v3b.py::blaze_handle` uses the raw socket,
//! and only `redir_handle` wraps `ssl`. TLS belongs to the redirector phase.
//!
//! ## Running beside Python, never instead of it
//!
//! The listen port is required configuration with no default, so this cannot
//! silently collide with the working container. See the crate README for the
//! A/B procedure and the gate list.
pub mod capture;
pub mod config;
pub mod conn;
pub mod sanitize;
pub mod trace;
pub use config::HostConfig;
pub use conn::CloseReason;
use std::io;
use std::net::{SocketAddr, TcpListener};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use openfut_adapter_fifa17::blaze::Adapter;
/// The two values a live host reads from the outside world.
///
/// Injectable so a test can replay a recorded conversation byte-for-byte: the
/// session key and the server clock appear inside response bodies, so with the
/// real ones no live run could ever reproduce a fixture exactly. This is the
/// only test seam in the crate, and it exists because the alternative is to
/// verify transport *structurally only* and lose the byte-level guarantee.
pub struct Hooks {
pub session_key: Box<dyn Fn() -> String + Send + Sync>,
pub now: Box<dyn Fn() -> i64 + Send + Sync>,
}
impl Default for Hooks {
fn default() -> Hooks {
Hooks {
session_key: Box::new(conn::mint_session_key),
now: Box::new(conn::unix_now),
}
}
}
impl std::fmt::Debug for Hooks {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Hooks { .. }")
}
}
/// Build identity, stamped by `build.rs` and printed at startup.
///
/// `dirty` is the load-bearing field: a mutation-tested build is a dirty build,
/// and a live trace must never be attributable to one by accident.
pub struct BuildIdentity {
pub commit: &'static str,
pub dirty: &'static str,
pub profile: &'static str,
pub version: &'static str,
}
pub const BUILD: BuildIdentity = BuildIdentity {
commit: env!("OPENFUT_BUILD_COMMIT"),
dirty: env!("OPENFUT_BUILD_DIRTY"),
profile: if cfg!(debug_assertions) {
"debug"
} else {
"release"
},
version: env!("CARGO_PKG_VERSION"),
};
/// One line naming the binary and the generated data it carries.
pub fn build_banner() -> String {
format!(
"openfut-blaze-host v{} commit={} tree={} profile={} config-table={:016x}",
BUILD.version,
BUILD.commit,
BUILD.dirty,
BUILD.profile,
openfut_adapter_fifa17::blaze::client_config::table_fingerprint()
)
}
/// A bound listener plus everything a connection needs.
pub struct Server {
pub local_addr: SocketAddr,
listener: TcpListener,
cfg: Arc<HostConfig>,
adapter: Arc<Adapter>,
tracer: Arc<trace::Tracer>,
hooks: Arc<Hooks>,
capture: Arc<capture::Capture>,
}
/// Bind without accepting, so a caller can learn the real port first (useful
/// when binding port 0).
pub fn bind(cfg: HostConfig, hooks: Hooks) -> io::Result<Server> {
let listener = TcpListener::bind(cfg.listen_on())?;
let local_addr = listener.local_addr()?;
let tracer = Arc::new(trace::Tracer::new(cfg.trace_path.as_deref())?);
let capture = Arc::new(match cfg.capture_path.as_deref() {
Some(p) => capture::Capture::create(p)?,
None => capture::Capture::disabled(),
});
let adapter = Arc::new(Adapter::new(cfg.adapter.clone()));
Ok(Server {
local_addr,
listener,
cfg: Arc::new(cfg),
adapter,
tracer,
hooks: Arc::new(hooks),
capture,
})
}
impl Server {
/// Accept forever, one thread per connection.
///
/// A FIFA client opens a handful of connections, so a thread each mirrors
/// the Python oracle and keeps the host readable; a work-stealing runtime
/// would be weight without benefit.
pub fn run(self) -> io::Result<()> {
trace::log(&build_banner());
if BUILD.dirty == "DIRTY" {
trace::log(
"WARNING: built from a modified working tree — do NOT treat results from \
this binary as parity evidence",
);
}
trace::log(&format!(
"listening on {} (advertise={}, config-bind={})",
self.local_addr, self.cfg.adapter.endpoints.advertise, self.cfg.adapter.endpoints.bind
));
// The trace is the artefact a live FIFA session is judged from, so it
// must carry the same identity as the log.
self.tracer.write_line(&format!("# {}", build_banner()));
if self.tracer.enabled() {
trace::log(&format!(
"structural trace -> {}",
self.cfg.trace_path.as_deref().unwrap_or("")
));
}
if self.capture.enabled() {
trace::log(&format!(
"RAW FRAME CAPTURE -> {} (forensic evidence: never commit, \
sanitize with blaze-sanitize before sharing)",
self.cfg.capture_path.as_deref().unwrap_or("")
));
}
let counter = AtomicU64::new(0);
for incoming in self.listener.incoming() {
match incoming {
Ok(stream) => {
let id = counter.fetch_add(1, Ordering::Relaxed) + 1;
let cfg = self.cfg.clone();
let adapter = self.adapter.clone();
let tracer = self.tracer.clone();
let hooks = self.hooks.clone();
let capture = self.capture.clone();
std::thread::spawn(move || {
conn::handle(stream, id, &cfg, &adapter, &tracer, &hooks, &capture);
});
}
Err(e) => trace::log(&format!("accept failed: {e}")),
}
}
Ok(())
}
}
/// Bind and serve until the process is killed.
pub fn serve(cfg: HostConfig) -> io::Result<()> {
bind(cfg, Hooks::default())?.run()
}