84e81f2037
The redirector was the only host that spoke TLS, so its TLS lived inside
it. The roster host needs the same listener, and that made the choice
explicit: share this code or copy it.
Copying it is what already went wrong. On 2026-08-11 the Rust redirector
served one certificate while the container served another. ProtoSSL
caches the server certificate per backend, so the redirector -- the first
TLS connection of a session -- decided what the client expected, and
every later service failed its handshake. Silently: Python's socketserver
swallows ssl.SSLError as OSError. Three gates went to it. One place to
configure TLS is the structural fix, so it exists before the second host
does rather than after.
Split along the line the architecture already draws:
openfut-tls how to build an acceptor. Game-independent.
Knows nothing about which suites any client
offers.
adapter-fifa17::tls what FIFA 17 was OBSERVED to offer: the six
enabled suites, the two refused, the TLS 1.2
window, the EA SNI. Plain strings, so the
adapter keeps its lean dependencies -- reading
a card table should not build OpenSSL.
redirector-host joins the two. Chooses no cipher of its own.
Behaviour is unchanged, and shown to be:
* tests/fifa17_tls_profile.rs carries over every case from the deleted
module -- FIFA's eight suites negotiate AES256-GCM-SHA384, each enabled
suite works alone, RC4-only is refused, ECDHE-only is refused. Deleting
a module must not quietly delete its evidence.
* one test pins the composed values literally against the host as it was
when gates 1-14 passed. A "pure refactor" that cannot fail is not a
claim, it is an assumption.
* the rebuilt binary self-tests to the same TLSv1.2 / AES256-GCM-SHA384
the retail client negotiated at 17:09 today.
Two improvements fall out of having one place to look:
* the startup banner now prints cert_sha256. The mismatch above raised no
error at startup and broke the client much later with nothing logged;
it is now the first line of the log.
* tls_min/tls_max print as TLSv1.2 rather than SslVersion(771). This line
is gate evidence and gets read by people.
Nothing deployed and nothing restarted: FIFA is mid-session on the
running redirector, which is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
5.5 KiB
Rust
135 lines
5.5 KiB
Rust
//! Redirector host configuration.
|
|
//!
|
|
//! Endpoints resolve through `openfut-host-config`, the single environment
|
|
//! reader, so this host and the Blaze sidecar build client-visible addresses by
|
|
//! the same construction path. Only the transport settings — listener, cert,
|
|
//! key, TLS knobs — are parsed here, and none of them are client-visible.
|
|
|
|
use std::time::Duration;
|
|
|
|
use openfut_adapter_fifa17::blaze::AdapterConfig;
|
|
use openfut_host_config::{self as hostcfg, ConfigError};
|
|
|
|
use openfut_tls::{ProtocolVersion, TlsConfig};
|
|
|
|
/// How long the oracle holds a redirector connection open after responding
|
|
/// (`time.sleep(0.3)` in `blaze_responder_v3b.redir_handle`).
|
|
///
|
|
/// Measured, not guessed: a differential lifecycle probe records the Python
|
|
/// redirector holding the socket ~300ms and the first version of this host
|
|
/// closing at 0ms. FIFA 17 was proven against the former, so it is the default
|
|
/// here. Not a tuning knob — changing it changes what this host is a
|
|
/// reimplementation OF.
|
|
pub const ORACLE_CLOSE_DWELL: Duration = Duration::from_millis(300);
|
|
|
|
/// Compose the shared TLS listener from the FIFA 17 adapter's observed profile.
|
|
///
|
|
/// The split is deliberate: `openfut-tls` knows how to build an acceptor,
|
|
/// `openfut-adapter-fifa17` knows what FIFA 17 offers, and this host knows only
|
|
/// how to join them. A second host serving the same client uses this same pair
|
|
/// rather than repeating either half — one configuration path is the structural
|
|
/// guard against the 2026-08-11 certificate mismatch, where two services
|
|
/// configured TLS separately and disagreed.
|
|
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 RedirectorConfig {
|
|
/// BIND: where this listener binds. Never client-visible.
|
|
pub listen_addr: String,
|
|
/// Required, with no default, so the host can never collide with the
|
|
/// Python redirector it runs beside.
|
|
pub listen_port: u16,
|
|
pub tls: TlsConfig,
|
|
/// ADVERTISE and everything derived from it.
|
|
pub adapter: AdapterConfig,
|
|
/// Dwell before closing an answered connection. Overridable ONLY so the
|
|
/// causal experiment — set it to 0 and confirm the failure returns — can be
|
|
/// run without rebuilding.
|
|
pub close_dwell: Duration,
|
|
}
|
|
|
|
impl RedirectorConfig {
|
|
pub fn from_env() -> Result<RedirectorConfig, ConfigError> {
|
|
let adapter = hostcfg::adapter_from_env()?;
|
|
|
|
let listen_port = hostcfg::required_port(
|
|
"OPENFUT_REDIRECTOR_HOST_PORT",
|
|
"this host runs beside the working Python redirector and must not \
|
|
collide with it, so the port is explicit and has no default",
|
|
)?;
|
|
let listen_addr =
|
|
hostcfg::optional("OPENFUT_REDIRECTOR_HOST_BIND", &adapter.endpoints.bind);
|
|
|
|
let cert = hostcfg::required(
|
|
"OPENFUT_REDIRECTOR_CERT",
|
|
"path to the RSA certificate; reuse the proven redirector's so TLS \
|
|
implementation stays the only variable in an A/B",
|
|
)?;
|
|
let key = hostcfg::required("OPENFUT_REDIRECTOR_KEY", "path to the matching private key")?;
|
|
|
|
// FIFA 17's observed profile, supplied by the adapter. The transport
|
|
// host chooses no cipher and no protocol window of its own: those are
|
|
// facts about the client, and belong with the game they describe.
|
|
let mut tls = fifa17_tls(cert, key)?;
|
|
if let Some(list) = hostcfg::optional_opt("OPENFUT_REDIRECTOR_CIPHERS") {
|
|
tls.cipher_list = list;
|
|
}
|
|
// Overridable so a failed retail handshake is a configuration change,
|
|
// not a code change — but never lowered pre-emptively.
|
|
if let Some(level) = hostcfg::optional_opt("OPENFUT_REDIRECTOR_SECURITY_LEVEL") {
|
|
tls.security_level = level.trim().parse().ok();
|
|
}
|
|
|
|
let close_dwell = match hostcfg::optional_opt("OPENFUT_REDIRECTOR_CLOSE_DWELL_MS") {
|
|
None => ORACLE_CLOSE_DWELL,
|
|
Some(v) => Duration::from_millis(v.trim().parse().map_err(|_| {
|
|
ConfigError(format!(
|
|
"OPENFUT_REDIRECTOR_CLOSE_DWELL_MS is not a number of milliseconds: {v:?}"
|
|
))
|
|
})?),
|
|
};
|
|
|
|
Ok(RedirectorConfig {
|
|
listen_addr,
|
|
listen_port,
|
|
tls,
|
|
adapter,
|
|
close_dwell,
|
|
})
|
|
}
|
|
|
|
pub fn listen_on(&self) -> String {
|
|
format!("{}:{}", self.listen_addr, self.listen_port)
|
|
}
|
|
|
|
/// Test fixture: the oracle's certificate, an ephemeral port, and an
|
|
/// explicit advertised address.
|
|
#[doc(hidden)]
|
|
pub fn for_test(advertise: &str) -> RedirectorConfig {
|
|
let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/tools");
|
|
RedirectorConfig {
|
|
listen_addr: "127.0.0.1".into(),
|
|
listen_port: 0,
|
|
tls: fifa17_tls(
|
|
format!("{base}/redir_cert.pem"),
|
|
format!("{base}/redir_key.pem"),
|
|
)
|
|
.expect("the adapter's TLS profile must be valid"),
|
|
adapter: AdapterConfig::advertising(advertise),
|
|
close_dwell: ORACLE_CLOSE_DWELL,
|
|
}
|
|
}
|
|
}
|