//! # 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 String + Send + Sync>, pub now: Box 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, adapter: Arc, tracer: Arc, hooks: Arc, capture: Arc, } /// 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 { 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() }