Files
OpenFUT/openfut-roster-host/src/config.rs
T
funman300 c9ae914910 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>
2026-08-11 17:41:11 +00:00

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,
}
}
}