//! 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, key: impl Into) -> Result { 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 { 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, } } }