c9ae914910
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>
126 lines
5.1 KiB
Rust
126 lines
5.1 KiB
Rust
//! 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,
|
|
}
|
|
}
|
|
}
|