roster-host: transport host for the FUT roster update, lifecycle-matched
Second consumer of openfut-tls, and the reason it was extracted first.
This host contains no roster content and no cipher choice: the adapter
owns the 67 bytes and the observed TLS profile, openfut-tls owns the
acceptor, and this crate owns accept/read/drain/write/close.
Lifecycle was MEASURED, not inherited. The obvious mistake here would
have been copying the redirector's 300ms dwell because the other host has
one. A probe against the oracle says otherwise:
dwell after responding 0 ms (redirector: 300 ms)
request body drained POST answered only once it arrives
close clean FIN, never RST
keep-alive none one request per connection
The probe ran against a REPLICA of roster_server.py loaded from its own
source, not against :8081 -- http.server.HTTPServer is single-threaded
and FIFA was mid-session, so holding a connection open to measure the
close would have stalled the game's poll and could have surfaced as the
squad-update error. The replica was then confirmed byte-identical to the
live oracle under masking, the 1-byte delta being the container's Python
version in the Server header.
Differential against the live oracle, every field identical, with the
Server header compared UNMASKED:
GET 230B HEAD 163B POST 163B
drained=True reset=False answered_before_body=False
keepalive: second request accepted by the socket, never answered
Testing follows the redirector's hard-won rule: where a property is
visible both to the client and inside the host, it is asserted inside the
host via ConnOutcome. A client-side check cannot tell "drained" from "not
drained" -- it reads the buffered response either way -- and that exact
mistake let a mutation survive once already.
9 parity tests, 6 unit tests, 5/5 mutations killed, including "answer
before draining", "hold the connection open like the redirector" and
"inherit the redirector's 300ms default".
drain_body is duplicated from the redirector deliberately. Unifying it
means editing the redirector, and the roster A/B must change exactly one
thing. Extraction is scheduled for after the roster gate closes.
Not deployed and not switched: Python still serves :8081.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+9
@@ -3230,6 +3230,15 @@ dependencies = [
|
|||||||
"openssl",
|
"openssl",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openfut-roster-host"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"openfut-adapter-fifa17",
|
||||||
|
"openfut-host-config",
|
||||||
|
"openfut-tls",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openfut-tls"
|
name = "openfut-tls"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ members = [
|
|||||||
"openfut-host-config",
|
"openfut-host-config",
|
||||||
"openfut-tls",
|
"openfut-tls",
|
||||||
"openfut-redirector-host",
|
"openfut-redirector-host",
|
||||||
|
"openfut-roster-host",
|
||||||
"openfut-bridge",
|
"openfut-bridge",
|
||||||
"openfut-launcher",
|
"openfut-launcher",
|
||||||
"openfut-launcher/openfut-hook",
|
"openfut-launcher/openfut-hook",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "openfut-roster-host"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
description = "FIFA 17 FUT roster-update transport host"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" }
|
||||||
|
openfut-host-config = { path = "../openfut-host-config" }
|
||||||
|
# Same listener as the redirector, by construction rather than by convention.
|
||||||
|
# Two hosts configuring TLS separately is how the 2026-08-11 certificate
|
||||||
|
# mismatch happened, and roster is the service where that mismatch first
|
||||||
|
# became visible to the player.
|
||||||
|
openfut-tls = { path = "../openfut-tls" }
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//! Stamp the commit this binary was built from.
|
||||||
|
//!
|
||||||
|
//! Deliberately records ONLY the commit — no dirty-tree flag. Cargo will not
|
||||||
|
//! re-run a build script because another crate's source changed, so a
|
||||||
|
//! compiled-in "clean" claim can be stale and is therefore not a safeguard.
|
||||||
|
//! (Verified on the Blaze host: editing the adapter and rebuilding left its
|
||||||
|
//! flag reading clean.)
|
||||||
|
//!
|
||||||
|
//! The authoritative checks run at launch, in `scripts/verify-build-identity.sh`,
|
||||||
|
//! which compares this stamp against the checkout's real HEAD and inspects the
|
||||||
|
//! working tree as it is at that moment.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn git(args: &[&str]) -> Option<String> {
|
||||||
|
let out = Command::new("git").args(args).output().ok()?;
|
||||||
|
out.status
|
||||||
|
.success()
|
||||||
|
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let commit = git(&["rev-parse", "--short=7", "HEAD"]).unwrap_or_else(|| "unknown".into());
|
||||||
|
println!("cargo:rustc-env=OPENFUT_BUILD_COMMIT={commit}");
|
||||||
|
|
||||||
|
// Committing updates refs/heads/<branch>, not the HEAD file, so watching
|
||||||
|
// HEAD alone leaves the stamp a commit behind.
|
||||||
|
for p in ["../.git/HEAD", "../.git/index"] {
|
||||||
|
if std::path::Path::new(p).exists() {
|
||||||
|
println!("cargo:rerun-if-changed={p}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(rf) = git(&["symbolic-ref", "-q", "HEAD"]) {
|
||||||
|
let path = format!("../.git/{rf}");
|
||||||
|
if std::path::Path::new(&path).exists() {
|
||||||
|
println!("cargo:rerun-if-changed={path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! Roster host configuration.
|
||||||
|
//!
|
||||||
|
//! Endpoints resolve through `openfut-host-config`, the single environment
|
||||||
|
//! reader, so every host builds client-visible addresses the same way. Only
|
||||||
|
//! transport settings are parsed here, and none of them are client-visible.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use openfut_adapter_fifa17::roster;
|
||||||
|
use openfut_host_config::{self as hostcfg, ConfigError};
|
||||||
|
use openfut_tls::{ProtocolVersion, TlsConfig};
|
||||||
|
|
||||||
|
/// How long the oracle holds a roster connection open after responding.
|
||||||
|
///
|
||||||
|
/// **Measured, and deliberately different from the redirector's.** The Blaze
|
||||||
|
/// redirector sleeps 300ms before closing (`time.sleep(0.3)` in
|
||||||
|
/// `redir_handle`); `roster_server.py` has no such sleep, and a lifecycle probe
|
||||||
|
/// against the oracle records `close_ms=0.0` for GET, HEAD and POST alike.
|
||||||
|
///
|
||||||
|
/// Copying the redirector's 300ms because "the other host does it" would have
|
||||||
|
/// been the obvious mistake: FIFA polls this endpoint roughly every 7-15s, so a
|
||||||
|
/// needless dwell per poll is a behaviour change, not a safety margin.
|
||||||
|
pub const ORACLE_CLOSE_DWELL: Duration = Duration::from_millis(0);
|
||||||
|
|
||||||
|
/// Compose the shared listener from the FIFA 17 adapter's observed TLS profile.
|
||||||
|
///
|
||||||
|
/// Identical construction to the redirector host. That is the point: the client
|
||||||
|
/// caches the certificate presented by the first service it reaches, so every
|
||||||
|
/// FIFA-facing listener must be built the same way from the same inputs.
|
||||||
|
fn fifa17_tls(cert: impl Into<String>, key: impl Into<String>) -> Result<TlsConfig, ConfigError> {
|
||||||
|
use openfut_adapter_fifa17::tls as profile;
|
||||||
|
let ver = |s: &str| {
|
||||||
|
ProtocolVersion::parse(s).map_err(|e| ConfigError(format!("FIFA 17 TLS profile: {e}")))
|
||||||
|
};
|
||||||
|
Ok(TlsConfig::new(
|
||||||
|
cert,
|
||||||
|
key,
|
||||||
|
profile::CIPHER_LIST,
|
||||||
|
ver(profile::MIN_VERSION)?,
|
||||||
|
ver(profile::MAX_VERSION)?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RosterConfig {
|
||||||
|
/// BIND: where this listener binds. Never client-visible.
|
||||||
|
pub listen_addr: String,
|
||||||
|
/// Required, with no default, so this host can never collide with the
|
||||||
|
/// Python roster server it runs beside.
|
||||||
|
pub listen_port: u16,
|
||||||
|
pub tls: TlsConfig,
|
||||||
|
/// The `Server:` header to emit.
|
||||||
|
///
|
||||||
|
/// Environment-derived, not protocol-derived: it carries the *oracle's*
|
||||||
|
/// Python version, so it changes when the container's Python does. Default
|
||||||
|
/// is what the container was last observed to send; overridable so matching
|
||||||
|
/// a redeployed oracle is a configuration change, not a release.
|
||||||
|
pub server_header: String,
|
||||||
|
/// Dwell before closing an answered connection. Overridable ONLY so the
|
||||||
|
/// causal experiment can be run without rebuilding.
|
||||||
|
pub close_dwell: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RosterConfig {
|
||||||
|
pub fn from_env() -> Result<RosterConfig, ConfigError> {
|
||||||
|
let adapter = hostcfg::adapter_from_env()?;
|
||||||
|
|
||||||
|
let listen_port = hostcfg::required_port(
|
||||||
|
"OPENFUT_ROSTER_HOST_PORT",
|
||||||
|
"this host runs beside the working Python roster server and must \
|
||||||
|
not collide with it, so the port is explicit and has no default",
|
||||||
|
)?;
|
||||||
|
let listen_addr = hostcfg::optional("OPENFUT_ROSTER_HOST_BIND", &adapter.endpoints.bind);
|
||||||
|
|
||||||
|
let cert = hostcfg::required(
|
||||||
|
"OPENFUT_ROSTER_CERT",
|
||||||
|
"path to the RSA certificate; it MUST be the same certificate every \
|
||||||
|
other FIFA-facing service presents, or the client's cached copy \
|
||||||
|
will not match and the handshake fails with nothing logged",
|
||||||
|
)?;
|
||||||
|
let key = hostcfg::required("OPENFUT_ROSTER_KEY", "path to the matching private key")?;
|
||||||
|
|
||||||
|
let tls = fifa17_tls(cert, key)?;
|
||||||
|
|
||||||
|
let server_header =
|
||||||
|
hostcfg::optional("OPENFUT_ROSTER_SERVER_HEADER", roster::ORACLE_SERVER);
|
||||||
|
|
||||||
|
let close_dwell = match hostcfg::optional_opt("OPENFUT_ROSTER_CLOSE_DWELL_MS") {
|
||||||
|
None => ORACLE_CLOSE_DWELL,
|
||||||
|
// Invalid input is an error, never a silent fallback: a typo that
|
||||||
|
// quietly restored the default would make the causal experiment
|
||||||
|
// report the wrong answer.
|
||||||
|
Some(v) => Duration::from_millis(v.trim().parse().map_err(|_| {
|
||||||
|
ConfigError(format!(
|
||||||
|
"OPENFUT_ROSTER_CLOSE_DWELL_MS is not a number of milliseconds: {v:?}"
|
||||||
|
))
|
||||||
|
})?),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(RosterConfig {
|
||||||
|
listen_addr,
|
||||||
|
listen_port,
|
||||||
|
tls,
|
||||||
|
server_header,
|
||||||
|
close_dwell,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn listen_on(&self) -> String {
|
||||||
|
format!("{}:{}", self.listen_addr, self.listen_port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test fixture: an ephemeral port and a certificate pair supplied by the
|
||||||
|
/// caller, so tests never depend on a deployed path.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn for_test(cert: &str, key: &str) -> RosterConfig {
|
||||||
|
RosterConfig {
|
||||||
|
listen_addr: "127.0.0.1".into(),
|
||||||
|
listen_port: 0,
|
||||||
|
tls: fifa17_tls(cert, key).expect("the adapter's TLS profile must be valid"),
|
||||||
|
server_header: roster::ORACLE_SERVER.to_string(),
|
||||||
|
close_dwell: ORACLE_CLOSE_DWELL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
//! FUT roster-update transport host.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! FIFA 17 ──HTTPS GET /fifa17/fut/rosterupdate.xml──> <rosterupdate version="0"/>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Division of labour
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! openfut-tls how to build the TLS acceptor
|
||||||
|
//! adapter-fifa17::tls what FIFA 17's TLS was observed to be
|
||||||
|
//! adapter-fifa17::roster what bytes the answer is
|
||||||
|
//! this crate accept, read, drain, write, close
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This host contains no roster content and no cipher choice. It owns the
|
||||||
|
//! socket and the connection lifecycle, nothing else.
|
||||||
|
//!
|
||||||
|
//! # Lifecycle is part of the contract
|
||||||
|
//!
|
||||||
|
//! The redirector proved that byte-identical responses are not behavioural
|
||||||
|
//! parity: answering while the client is still sending leaves unread data in
|
||||||
|
//! the receive queue and Linux turns the close into an RST rather than a FIN.
|
||||||
|
//! So the oracle's lifecycle was *measured* rather than inferred, with a probe
|
||||||
|
//! run against a replica of `roster_server.py` (the live one is single-threaded
|
||||||
|
//! and was serving a live game at the time):
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! dwell after responding 0 ms (the redirector's is 300 ms -- NOT shared)
|
||||||
|
//! request body drained POST is answered only after the body arrives
|
||||||
|
//! close clean FIN, never RST
|
||||||
|
//! keep-alive none one request per connection
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Every one of those is reproduced here, and asserted by `tests/`.
|
||||||
|
//!
|
||||||
|
//! # Why the roster matters more than its 67 bytes suggest
|
||||||
|
//!
|
||||||
|
//! `checkFUTRostersFlow` downloads this before entering FUT; failure aborts
|
||||||
|
//! with *"An error occurred downloading the FUT Squad Update"*. Because it is a
|
||||||
|
//! separate TLS connection from the redirector, it is also where a certificate
|
||||||
|
//! mismatch anywhere in the stack first becomes visible to the player.
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use openfut_adapter_fifa17::roster::{self, Method};
|
||||||
|
// The acceptor comes from the shared crate, so this host never names OpenSSL.
|
||||||
|
use openfut_tls::SslAcceptor;
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub use config::RosterConfig;
|
||||||
|
|
||||||
|
/// Commit this binary was built from, stamped by `build.rs`.
|
||||||
|
pub const BUILD_COMMIT: &str = env!("OPENFUT_BUILD_COMMIT");
|
||||||
|
|
||||||
|
const MAX_HEAD: usize = 16 * 1024;
|
||||||
|
|
||||||
|
pub fn identity() -> String {
|
||||||
|
format!(
|
||||||
|
"openfut-roster-host v{} commit={} profile={} openssl={}",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
BUILD_COMMIT,
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
"debug"
|
||||||
|
} else {
|
||||||
|
"release"
|
||||||
|
},
|
||||||
|
openfut_tls::openssl_version(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Machine-readable `key=value`, matching the redirector's banner so the two
|
||||||
|
/// hosts can be compared at a glance.
|
||||||
|
///
|
||||||
|
/// `cert_sha256` is printed unprompted: serving a different certificate from
|
||||||
|
/// the rest of the stack raises no error at startup and breaks the client much
|
||||||
|
/// later with nothing logged anywhere.
|
||||||
|
pub fn banner(cfg: &RosterConfig) -> String {
|
||||||
|
format!(
|
||||||
|
"{} listen={} server_header={:?} close_dwell_ms={} cert_sha256={} ciphers={}",
|
||||||
|
identity(),
|
||||||
|
cfg.listen_on(),
|
||||||
|
cfg.server_header,
|
||||||
|
cfg.close_dwell.as_millis(),
|
||||||
|
openfut_tls::certificate_fingerprint(&cfg.tls.cert_path)
|
||||||
|
.unwrap_or_else(|e| format!("unreadable ({e})")),
|
||||||
|
cfg.tls.cipher_list,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happened when the request body was drained.
|
||||||
|
///
|
||||||
|
/// Duplicated from the redirector host on purpose, for now. Unifying the two
|
||||||
|
/// would mean editing the redirector, and the roster A/B must change exactly
|
||||||
|
/// one thing. This is scheduled for extraction once the roster gate closes.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BodyRead {
|
||||||
|
/// No `Content-Length` header — nothing to drain.
|
||||||
|
None,
|
||||||
|
Complete(usize),
|
||||||
|
Short {
|
||||||
|
got: usize,
|
||||||
|
want: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the host did on a connection, beyond the bytes it sent.
|
||||||
|
///
|
||||||
|
/// Recorded because the divergence that cost two gate attempts on the
|
||||||
|
/// redirector — a request body left unread — is invisible to any comparison of
|
||||||
|
/// the response.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ConnOutcome {
|
||||||
|
pub id: u64,
|
||||||
|
pub method: Option<Method>,
|
||||||
|
pub path: String,
|
||||||
|
pub request_bytes: usize,
|
||||||
|
pub body: BodyRead,
|
||||||
|
pub response_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Outcomes = Arc<std::sync::Mutex<Vec<ConnOutcome>>>;
|
||||||
|
|
||||||
|
/// Bounded: a host polled every few seconds must not accumulate forever.
|
||||||
|
const OUTCOME_HISTORY: usize = 64;
|
||||||
|
|
||||||
|
fn record(outcomes: &Outcomes, o: ConnOutcome) {
|
||||||
|
if let Ok(mut v) = outcomes.lock() {
|
||||||
|
if v.len() >= OUTCOME_HISTORY {
|
||||||
|
v.remove(0);
|
||||||
|
}
|
||||||
|
v.push(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Server {
|
||||||
|
pub local_addr: std::net::SocketAddr,
|
||||||
|
listener: TcpListener,
|
||||||
|
acceptor: Arc<SslAcceptor>,
|
||||||
|
cfg: Arc<RosterConfig>,
|
||||||
|
outcomes: Outcomes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
pub fn outcomes(&self) -> Outcomes {
|
||||||
|
self.outcomes.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(self) -> std::io::Result<()> {
|
||||||
|
let counter = AtomicU64::new(0);
|
||||||
|
for incoming in self.listener.incoming() {
|
||||||
|
let Ok(stream) = incoming else { continue };
|
||||||
|
let id = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
let (acceptor, cfg) = (self.acceptor.clone(), self.cfg.clone());
|
||||||
|
let outcomes = self.outcomes.clone();
|
||||||
|
std::thread::spawn(move || handle(stream, id, &acceptor, &cfg, &outcomes));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build TLS, rehearse the retail handshake, and bind.
|
||||||
|
///
|
||||||
|
/// The rehearsal runs before the listener accepts anything, so a TLS
|
||||||
|
/// misconfiguration is a startup failure rather than a client-visible one.
|
||||||
|
pub fn bind(cfg: RosterConfig) -> std::io::Result<Server> {
|
||||||
|
use openfut_adapter_fifa17::tls as profile;
|
||||||
|
|
||||||
|
let acceptor = openfut_tls::build_acceptor(&cfg.tls)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
|
||||||
|
|
||||||
|
let neg = openfut_tls::self_test(
|
||||||
|
&cfg.tls,
|
||||||
|
profile::OBSERVED_CLIENT_SUITES,
|
||||||
|
profile::CLIENT_SNI,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
format!("TLS self-test failed: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
log(&format!(
|
||||||
|
"SELF-TEST OK: a FIFA-like client negotiates {} / {}",
|
||||||
|
neg.version, neg.cipher
|
||||||
|
));
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(cfg.listen_on())?;
|
||||||
|
let local_addr = listener.local_addr()?;
|
||||||
|
Ok(Server {
|
||||||
|
local_addr,
|
||||||
|
listener,
|
||||||
|
acceptor: Arc::new(acceptor),
|
||||||
|
cfg: Arc::new(cfg),
|
||||||
|
outcomes: Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serve(cfg: RosterConfig) -> std::io::Result<()> {
|
||||||
|
let server = bind(cfg)?;
|
||||||
|
log(&banner(&server.cfg));
|
||||||
|
server.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(msg: &str) {
|
||||||
|
let t = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs_f64())
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!("[{t:.3}] {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(
|
||||||
|
stream: TcpStream,
|
||||||
|
id: u64,
|
||||||
|
acceptor: &SslAcceptor,
|
||||||
|
cfg: &RosterConfig,
|
||||||
|
outcomes: &Outcomes,
|
||||||
|
) {
|
||||||
|
let peer = stream
|
||||||
|
.peer_addr()
|
||||||
|
.map(|a| a.to_string())
|
||||||
|
.unwrap_or_else(|_| "?".into());
|
||||||
|
|
||||||
|
let mut tls = match acceptor.accept(stream) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("conn-{id:04} {peer} TLS HANDSHAKE FAILED: {e}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buf = Vec::with_capacity(1024);
|
||||||
|
if !read_head(&mut tls, &mut buf) {
|
||||||
|
log(&format!("conn-{id:04} {peer} no request read"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let head = String::from_utf8_lossy(&buf).to_string();
|
||||||
|
let line0 = head.lines().next().unwrap_or_default().to_string();
|
||||||
|
let method = Method::parse(&line0);
|
||||||
|
let path = line0.split_whitespace().nth(1).unwrap_or("").to_string();
|
||||||
|
|
||||||
|
// Drain BEFORE answering, exactly as the oracle does. `do_POST` reads
|
||||||
|
// Content-Length bytes first; a lifecycle probe confirms the oracle does
|
||||||
|
// not answer until the body arrives. Answering early would leave unread
|
||||||
|
// data in the receive queue and turn our close into an RST.
|
||||||
|
let body = drain_body(&mut tls, &mut buf);
|
||||||
|
|
||||||
|
let response = match method {
|
||||||
|
Some(m) => roster::roster_response(m, &cfg.server_header, &now_http_date()),
|
||||||
|
// The oracle only defines GET/HEAD/POST; anything else is answered by
|
||||||
|
// BaseHTTPRequestHandler's own 501, which this host does not claim to
|
||||||
|
// reproduce. Closing without a response is honest about that rather
|
||||||
|
// than inventing a reply the oracle never sends.
|
||||||
|
None => {
|
||||||
|
log(&format!(
|
||||||
|
"conn-{id:04} {peer} unsupported method in {line0:?}; closing without a response"
|
||||||
|
));
|
||||||
|
record(
|
||||||
|
outcomes,
|
||||||
|
ConnOutcome {
|
||||||
|
id,
|
||||||
|
method: None,
|
||||||
|
path,
|
||||||
|
request_bytes: buf.len(),
|
||||||
|
body,
|
||||||
|
response_bytes: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let _ = tls.shutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = tls.write_all(&response);
|
||||||
|
let _ = tls.flush();
|
||||||
|
|
||||||
|
log(&format!(
|
||||||
|
"conn-{id:04} {peer} {line0} req_bytes={} body={} -> {}B",
|
||||||
|
buf.len(),
|
||||||
|
describe(body),
|
||||||
|
response.len()
|
||||||
|
));
|
||||||
|
record(
|
||||||
|
outcomes,
|
||||||
|
ConnOutcome {
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
request_bytes: buf.len(),
|
||||||
|
body,
|
||||||
|
response_bytes: response.len(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zero by default: the oracle has no post-response sleep. Kept
|
||||||
|
// configurable so the causal experiment is a config change.
|
||||||
|
if !cfg.close_dwell.is_zero() {
|
||||||
|
std::thread::sleep(cfg.close_dwell);
|
||||||
|
}
|
||||||
|
// One request per connection: the response says `Connection: close` and the
|
||||||
|
// oracle closes. No keep-alive loop, measured and matched.
|
||||||
|
let _ = tls.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe(b: BodyRead) -> String {
|
||||||
|
match b {
|
||||||
|
BodyRead::None => "none".into(),
|
||||||
|
BodyRead::Complete(n) => format!("{n}B complete"),
|
||||||
|
BodyRead::Short { got, want } => format!("{got}/{want}B SHORT"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Date:` for right now, in the oracle's format.
|
||||||
|
fn now_http_date() -> String {
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
roster::http_date(secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read until the end of the request head, or give up.
|
||||||
|
fn read_head<S: Read>(stream: &mut S, buf: &mut Vec<u8>) -> bool {
|
||||||
|
let mut chunk = [0u8; 1024];
|
||||||
|
loop {
|
||||||
|
match stream.read(&mut chunk) {
|
||||||
|
Ok(0) | Err(_) => return !buf.is_empty(),
|
||||||
|
Ok(n) => {
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A client that never terminates its head must not be able to
|
||||||
|
// grow this buffer without bound.
|
||||||
|
if buf.len() > MAX_HEAD {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `Content-Length` and read the remainder of the body into `buf`.
|
||||||
|
///
|
||||||
|
/// Header lookup is case-insensitive, and a client that stops early ends the
|
||||||
|
/// read rather than hanging.
|
||||||
|
fn drain_body<S: Read>(stream: &mut S, buf: &mut Vec<u8>) -> BodyRead {
|
||||||
|
let Some(sep) = buf.windows(4).position(|w| w == b"\r\n\r\n") else {
|
||||||
|
return BodyRead::None;
|
||||||
|
};
|
||||||
|
let head = String::from_utf8_lossy(&buf[..sep]).to_string();
|
||||||
|
let Some(want) = head
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.to_ascii_lowercase().starts_with("content-length"))
|
||||||
|
.and_then(|l| l.split(':').nth(1))
|
||||||
|
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||||
|
else {
|
||||||
|
return BodyRead::None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut got = buf.len() - (sep + 4);
|
||||||
|
let mut chunk = [0u8; 4096];
|
||||||
|
while got < want {
|
||||||
|
match stream.read(&mut chunk) {
|
||||||
|
Ok(0) | Err(_) => return BodyRead::Short { got, want },
|
||||||
|
Ok(n) => {
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
got += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BodyRead::Complete(got)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_dwell_is_zero_unlike_the_redirectors() {
|
||||||
|
// The redirector sleeps 300ms; the roster oracle does not. Inheriting
|
||||||
|
// the wrong one would add a needless delay to a poll that runs every
|
||||||
|
// few seconds, and would be a behaviour change rather than caution.
|
||||||
|
assert_eq!(config::ORACLE_CLOSE_DWELL.as_millis(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_names_the_commit_and_linked_openssl() {
|
||||||
|
let i = identity();
|
||||||
|
assert!(i.contains("commit="), "{i}");
|
||||||
|
assert!(i.contains("openssl="), "{i}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_body_is_reported_short_when_the_client_stops_early() {
|
||||||
|
let mut buf = b"POST / HTTP/1.1\r\nContent-Length: 10\r\n\r\nabc".to_vec();
|
||||||
|
let mut rest: &[u8] = b"";
|
||||||
|
assert_eq!(
|
||||||
|
drain_body(&mut rest, &mut buf),
|
||||||
|
BodyRead::Short { got: 3, want: 10 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_body_split_across_reads_is_completed() {
|
||||||
|
let mut buf = b"POST / HTTP/1.1\r\nContent-Length: 8\r\n\r\nab".to_vec();
|
||||||
|
let mut rest: &[u8] = b"cdefgh";
|
||||||
|
assert_eq!(drain_body(&mut rest, &mut buf), BodyRead::Complete(8));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_length_is_matched_case_insensitively() {
|
||||||
|
let mut buf = b"POST / HTTP/1.1\r\ncOnTeNt-LeNgTh: 4\r\n\r\n".to_vec();
|
||||||
|
let mut rest: &[u8] = b"abcd";
|
||||||
|
assert_eq!(drain_body(&mut rest, &mut buf), BodyRead::Complete(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_content_length_means_nothing_to_drain() {
|
||||||
|
let mut buf = b"GET / HTTP/1.1\r\nHost: x\r\n\r\n".to_vec();
|
||||||
|
let mut rest: &[u8] = b"";
|
||||||
|
assert_eq!(drain_body(&mut rest, &mut buf), BodyRead::None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Entry point for the FUT roster-update host.
|
||||||
|
|
||||||
|
fn main() -> std::io::Result<()> {
|
||||||
|
// `--identity` so a lifecycle script can ask the binary which commit it was
|
||||||
|
// built from, rather than inferring it from a file's mtime. Checking the
|
||||||
|
// artifact on disk instead of the running process has already produced one
|
||||||
|
// confidently wrong refusal on this project.
|
||||||
|
if std::env::args().any(|a| a == "--identity") {
|
||||||
|
println!("{}", openfut_roster_host::identity());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let cfg = openfut_roster_host::RosterConfig::from_env()
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
|
||||||
|
openfut_roster_host::serve(cfg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
//! The roster host held against the Python oracle's measured behaviour.
|
||||||
|
//!
|
||||||
|
//! Response bytes are only half of it. The redirector cost two live gate
|
||||||
|
//! attempts to a divergence no response comparison could see — a request body
|
||||||
|
//! left unread, which turns the close into an RST — so the oracle's lifecycle
|
||||||
|
//! was measured with a probe and every property is asserted here:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! dwell after responding 0 ms
|
||||||
|
//! request body drained before the response is written
|
||||||
|
//! close immediately after, cleanly
|
||||||
|
//! keep-alive none: one request per connection
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Where a property is observable both from the client and from inside the
|
||||||
|
//! host, it is asserted **inside the host** via `ConnOutcome`. That distinction
|
||||||
|
//! is not pedantry: the first attempt to test body-draining asserted that the
|
||||||
|
//! client still received its response, and that test passed against a host that
|
||||||
|
//! did not drain at all — the client reads the buffered response and sees
|
||||||
|
//! `close_notify` before any reset.
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use openfut_adapter_fifa17::roster;
|
||||||
|
use openfut_roster_host::{bind, BodyRead, RosterConfig};
|
||||||
|
|
||||||
|
const PATH: &str = "/fifa17/fut/rosterupdate.xml";
|
||||||
|
const BODY: &[u8] = b"probe=1&pad=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||||
|
|
||||||
|
fn cert_pair() -> (String, String) {
|
||||||
|
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/tools");
|
||||||
|
(
|
||||||
|
format!("{base}/redir_cert.pem"),
|
||||||
|
format!("{base}/redir_key.pem"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Harness {
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
outcomes: openfut_roster_host::Outcomes,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start() -> Harness {
|
||||||
|
start_with(|_| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_with(tweak: impl FnOnce(&mut RosterConfig)) -> Harness {
|
||||||
|
let (c, k) = cert_pair();
|
||||||
|
let mut cfg = RosterConfig::for_test(&c, &k);
|
||||||
|
tweak(&mut cfg);
|
||||||
|
let server = bind(cfg).expect("bind");
|
||||||
|
let addr = server.local_addr;
|
||||||
|
let outcomes = server.outcomes();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = server.run();
|
||||||
|
});
|
||||||
|
Harness { addr, outcomes }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client that behaves like FIFA: legacy suites, no certificate checking.
|
||||||
|
fn connect(addr: std::net::SocketAddr) -> openfut_tls::SslStream<TcpStream> {
|
||||||
|
use openfut_tls::{SslConnector, SslMethod, SslVerifyMode};
|
||||||
|
let mut b = SslConnector::builder(SslMethod::tls()).expect("connector");
|
||||||
|
b.set_cipher_list(openfut_adapter_fifa17::tls::OBSERVED_CLIENT_SUITES)
|
||||||
|
.expect("ciphers");
|
||||||
|
b.set_verify(SslVerifyMode::NONE);
|
||||||
|
let sock = TcpStream::connect(addr).expect("connect");
|
||||||
|
sock.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||||
|
let ssl = b
|
||||||
|
.build()
|
||||||
|
.configure()
|
||||||
|
.and_then(|c| c.verify_hostname(false).into_ssl("roster-test"))
|
||||||
|
.expect("ssl");
|
||||||
|
let mut s = openfut_tls::SslStream::new(ssl, sock).expect("stream");
|
||||||
|
s.connect().expect("handshake");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(method: &str, body: Option<&[u8]>) -> Vec<u8> {
|
||||||
|
let mut r = format!("{method} {PATH} HTTP/1.1\r\nHost: roster-test\r\nAccept: */*\r\n");
|
||||||
|
if let Some(b) = body {
|
||||||
|
r.push_str(&format!("Content-Length: {}\r\n", b.len()));
|
||||||
|
}
|
||||||
|
r.push_str("\r\n");
|
||||||
|
let mut out = r.into_bytes();
|
||||||
|
if let Some(b) = body {
|
||||||
|
out.extend_from_slice(b);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_to_eof(s: &mut openfut_tls::SslStream<TcpStream>) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut chunk = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
match s.read(&mut chunk) {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => out.extend_from_slice(&chunk[..n]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exchange(addr: std::net::SocketAddr, method: &str, body: Option<&[u8]>) -> Vec<u8> {
|
||||||
|
let mut s = connect(addr);
|
||||||
|
s.write_all(&request(method, body)).expect("write");
|
||||||
|
s.flush().ok();
|
||||||
|
read_to_eof(&mut s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask(raw: &[u8]) -> String {
|
||||||
|
String::from_utf8_lossy(raw)
|
||||||
|
.split("\r\n")
|
||||||
|
.map(|l| {
|
||||||
|
if l.starts_with("Date: ") {
|
||||||
|
"Date: <MASKED>".to_string()
|
||||||
|
} else if l.starts_with("Server: ") {
|
||||||
|
"Server: <MASKED>".to_string()
|
||||||
|
} else {
|
||||||
|
l.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bytes the adapter says the oracle sends, masked the same way.
|
||||||
|
fn expected(method: roster::Method) -> String {
|
||||||
|
mask(&roster::roster_response(
|
||||||
|
method,
|
||||||
|
roster::ORACLE_SERVER,
|
||||||
|
"IRRELEVANT",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_returns_the_oracles_bytes() {
|
||||||
|
let h = start();
|
||||||
|
let got = exchange(h.addr, "GET", None);
|
||||||
|
assert_eq!(mask(&got), expected(roster::Method::Get));
|
||||||
|
assert!(String::from_utf8_lossy(&got).starts_with("HTTP/1.0 200 OK\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn head_and_post_are_headers_only_but_still_advertise_the_body() {
|
||||||
|
let h = start();
|
||||||
|
for (m, adapter_m) in [
|
||||||
|
("HEAD", roster::Method::Head),
|
||||||
|
("POST", roster::Method::Post),
|
||||||
|
] {
|
||||||
|
let body = if m == "POST" { Some(BODY) } else { None };
|
||||||
|
let got = exchange(h.addr, m, body);
|
||||||
|
assert_eq!(mask(&got), expected(adapter_m), "{m} differs");
|
||||||
|
let text = String::from_utf8_lossy(&got);
|
||||||
|
assert!(text.contains("Content-Length: 67"), "{m}: {text}");
|
||||||
|
assert!(text.ends_with("\r\n\r\n"), "{m} must send no body");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asserted on the HOST's record, not on what the client received.
|
||||||
|
///
|
||||||
|
/// A client-side assertion cannot distinguish "drained" from "not drained":
|
||||||
|
/// it reads the buffered response either way. This is the exact mistake that
|
||||||
|
/// let a mutation survive on the redirector.
|
||||||
|
#[test]
|
||||||
|
fn the_request_body_is_drained_before_the_response_is_written() {
|
||||||
|
let h = start();
|
||||||
|
let _ = exchange(h.addr, "POST", Some(BODY));
|
||||||
|
let rec = h.outcomes.lock().unwrap();
|
||||||
|
let last = rec.last().expect("a connection was recorded");
|
||||||
|
assert_eq!(
|
||||||
|
last.body,
|
||||||
|
BodyRead::Complete(BODY.len()),
|
||||||
|
"the whole body must be read before answering, or the close becomes an RST"
|
||||||
|
);
|
||||||
|
assert!(last.response_bytes > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The oracle answers a POST only after the body arrives. Sending the head,
|
||||||
|
/// pausing, then sending the body proves the host waits rather than replying
|
||||||
|
/// early and leaving the socket dirty.
|
||||||
|
#[test]
|
||||||
|
fn a_late_body_is_waited_for_rather_than_answered_early() {
|
||||||
|
let h = start();
|
||||||
|
let mut s = connect(h.addr);
|
||||||
|
let head = format!(
|
||||||
|
"POST {PATH} HTTP/1.1\r\nHost: roster-test\r\nContent-Length: {}\r\n\r\n",
|
||||||
|
BODY.len()
|
||||||
|
);
|
||||||
|
s.write_all(head.as_bytes()).unwrap();
|
||||||
|
s.flush().ok();
|
||||||
|
|
||||||
|
s.get_ref()
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(600)))
|
||||||
|
.unwrap();
|
||||||
|
let mut early = [0u8; 64];
|
||||||
|
let early_n = s.read(&mut early).unwrap_or(0);
|
||||||
|
assert_eq!(
|
||||||
|
early_n,
|
||||||
|
0,
|
||||||
|
"answered before the body arrived: {:?}",
|
||||||
|
String::from_utf8_lossy(&early[..early_n])
|
||||||
|
);
|
||||||
|
|
||||||
|
s.get_ref()
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
s.write_all(BODY).unwrap();
|
||||||
|
s.flush().ok();
|
||||||
|
let late = read_to_eof(&mut s);
|
||||||
|
assert!(!late.is_empty(), "no response after the body arrived");
|
||||||
|
assert_eq!(mask(&late), expected(roster::Method::Post));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One request per connection: the response says `Connection: close` and the
|
||||||
|
/// oracle closes. Measured, not assumed — a keep-alive loop would be a
|
||||||
|
/// behaviour change invisible in any single response.
|
||||||
|
#[test]
|
||||||
|
fn the_connection_closes_after_one_request() {
|
||||||
|
let h = start();
|
||||||
|
let mut s = connect(h.addr);
|
||||||
|
s.write_all(&request("GET", None)).unwrap();
|
||||||
|
s.flush().ok();
|
||||||
|
let first = read_to_eof(&mut s);
|
||||||
|
assert!(!first.is_empty());
|
||||||
|
// read_to_eof already ran to EOF; a second request must not be answered.
|
||||||
|
let _ = s.write_all(&request("GET", None));
|
||||||
|
let mut buf = [0u8; 32];
|
||||||
|
let n = s.read(&mut buf).unwrap_or(0);
|
||||||
|
assert_eq!(n, 0, "a second request was answered on the same connection");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The oracle has no post-response sleep — unlike the redirector's 300ms.
|
||||||
|
/// Inheriting the wrong dwell would add delay to a poll that runs every few
|
||||||
|
/// seconds.
|
||||||
|
#[test]
|
||||||
|
fn the_connection_is_not_held_open_after_responding() {
|
||||||
|
let h = start();
|
||||||
|
let mut s = connect(h.addr);
|
||||||
|
s.write_all(&request("GET", None)).unwrap();
|
||||||
|
s.flush().ok();
|
||||||
|
let start = Instant::now();
|
||||||
|
let got = read_to_eof(&mut s);
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
assert!(!got.is_empty());
|
||||||
|
assert!(
|
||||||
|
elapsed < Duration::from_millis(150),
|
||||||
|
"closed after {elapsed:?}; the roster oracle closes immediately"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dwell stays configurable so the causal experiment — set it and confirm
|
||||||
|
/// the delay appears — can run without a rebuild. Guards against the knob
|
||||||
|
/// being quietly ignored.
|
||||||
|
#[test]
|
||||||
|
fn the_dwell_is_overridable() {
|
||||||
|
let h = start_with(|c| c.close_dwell = Duration::from_millis(400));
|
||||||
|
let mut s = connect(h.addr);
|
||||||
|
s.write_all(&request("GET", None)).unwrap();
|
||||||
|
s.flush().ok();
|
||||||
|
let start = Instant::now();
|
||||||
|
let _ = read_to_eof(&mut s);
|
||||||
|
assert!(
|
||||||
|
start.elapsed() >= Duration::from_millis(350),
|
||||||
|
"dwell override had no effect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The oracle never routes on the path — it logs it and answers the same way.
|
||||||
|
/// A host that started 404ing unknown paths would be a behaviour change.
|
||||||
|
#[test]
|
||||||
|
fn any_path_gets_the_same_answer() {
|
||||||
|
let h = start();
|
||||||
|
let mut s = connect(h.addr);
|
||||||
|
s.write_all(b"GET /something/else HTTP/1.1\r\nHost: x\r\n\r\n")
|
||||||
|
.unwrap();
|
||||||
|
s.flush().ok();
|
||||||
|
let got = read_to_eof(&mut s);
|
||||||
|
assert_eq!(mask(&got), expected(roster::Method::Get));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Masking must not be able to hide a real difference.
|
||||||
|
#[test]
|
||||||
|
fn masking_does_not_hide_a_missing_header() {
|
||||||
|
let good = roster::roster_response(roster::Method::Get, roster::ORACLE_SERVER, "X");
|
||||||
|
let without: Vec<u8> = String::from_utf8_lossy(&good)
|
||||||
|
.split("\r\n")
|
||||||
|
.filter(|l| !l.starts_with("Date: "))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\r\n")
|
||||||
|
.into_bytes();
|
||||||
|
assert_ne!(mask(&good), mask(&without));
|
||||||
|
}
|
||||||
@@ -33,7 +33,15 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslOptions, SslVersion};
|
use openssl::ssl::{SslFiletype, SslOptions, SslVersion};
|
||||||
|
|
||||||
|
/// Re-exported so a transport host never has to name OpenSSL itself.
|
||||||
|
///
|
||||||
|
/// Hosts depend on this crate, not on `openssl`. That keeps the linked TLS
|
||||||
|
/// implementation a property of one manifest rather than of every host — the
|
||||||
|
/// version is gate evidence, and a host that could pull its own would be able
|
||||||
|
/// to drift from the rest of the stack silently.
|
||||||
|
pub use openssl::ssl::{SslAcceptor, SslConnector, SslMethod, SslStream, SslVerifyMode};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TlsError(pub String);
|
pub struct TlsError(pub String);
|
||||||
|
|||||||
Reference in New Issue
Block a user