Files
OpenFUT/openfut-host-config/src/lib.rs
T
funman300 89f77470f3 redirector: Rust host on vendored OpenSSL; shared typed config extracted
TLS DEPENDENCY, as directed: the openssl crate directly with the `vendored`
feature. NOT native-tls. native-tls abstracts over whatever the platform
provides; here the requirement is the opposite -- precise, evidenced behaviour
for one legacy client -- which needs explicit control of the cipher list,
protocol floor/ceiling and security level. Vendored so a distro libssl update
cannot silently change whether FIFA 17 can connect.

Scoped to this crate alone. Neither OpenFUT Core nor the generic protocol
crates gain an OpenSSL dependency.

CIPHERS driven by the captured retail ClientHello, not by generic legacy
assumptions. The six RSA+AES suites it offers are enabled; RC4 and MD5 are
deliberately NOT, even though the client offers them -- it already negotiates
AES256-GCM-SHA384, so resurrecting RC4 for completeness would weaken the
service for nothing. TLS 1.2 floor and ceiling, matching the observed client;
the floor is not dropped to 1.0 pre-emptively because "the oracle permits it"
is not "the client requires it".

SECURITY LEVEL IS NOT LOWERED. Tried the default policy first, as directed,
and OpenSSL 3.6.3 accepts static-RSA/AES without weakening. No SECLEVEL change
was needed and none is applied; it remains overridable per-listener with
evidence.

CERTIFICATE: the proven Python redirector's material is reused, so the TLS
implementation stays the only variable in an A/B. Verified RSA-2048, CN
winter15.gosredirector.ea.com, cert/key modulus match; the key stays
gitignored.

SHARED CONFIG. New openfut-host-config is now the only crate that reads the
environment, and both hosts resolve endpoints through it. Two hosts each
parsing OPENFUT_ADVERTISE would be exactly the "separate helpers constructing
endpoints from different sources of truth" the address audit forbids.

VERIFICATION BY REAL HANDSHAKE, not by enumeration. The crate exposes no
accessor for a context's configured suites at this version, which turned out
better: the host now rehearses the retail handshake at startup with a client
restricted to exactly FIFA's eight suites and REFUSES TO SERVE if it fails, so
a cipher/version misconfiguration surfaces at boot rather than as an
unexplained failure during a live gate.

Gates 1-5 pass: TLS config unit tests; a FIFA-suite-only client negotiates
TLSv1.2/AES256-GCM-SHA384; each enabled RSA+AES suite negotiable alone; an
RC4-only client is refused; an ECDHE-only client is refused (proving no modern
policy was silently inherited); a full HTTPS round-trip returns bytes
IDENTICAL to the Python oracle's recorded response.

Cargo.lock committed for reproducibility: openssl 0.10.81, openssl-sys 0.9.117,
openssl-src 300.6.1+3.6.3 (OpenSSL 3.6.3). Updating openssl-src is NOT a
routine bump -- it requires re-running the FIFA compatibility gates.

Gates 6-14 need the retail client and are next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:28:04 +00:00

167 lines
5.8 KiB
Rust

//! Environment → typed configuration, shared by every OpenFUT service host.
//!
//! **This is the only crate in the migration that reads the environment.** The
//! adapter and protocol crates never do, and each service host resolves its
//! endpoints through here rather than parsing its own. That single construction
//! path is what the deployment-address audit established; two hosts each
//! parsing `OPENFUT_ADVERTISE` would be exactly the "separate helpers
//! constructing endpoints from different sources of truth" the audit forbids.
//!
//! Bind and advertise stay distinct throughout:
//!
//! * **BIND** — where a listener binds. Per-host, never client-visible.
//! * **ADVERTISE** — what the remote client is told to contact. Required, with
//! no default, because a backend that guesses its own reachable address
//! advertises a wrong one and fails far from the cause.
use std::env;
use std::fmt;
use openfut_adapter_fifa17::blaze::config as adapter_config;
use openfut_adapter_fifa17::blaze::{AdapterConfig, Endpoints, Identity};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigError(pub String);
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ConfigError {}
pub fn required(key: &str, why: &str) -> Result<String, ConfigError> {
match env::var(key) {
Ok(v) if !v.trim().is_empty() => Ok(v),
_ => Err(ConfigError(format!("{key} must be set — {why}"))),
}
}
pub fn optional(key: &str, default: &str) -> String {
optional_opt(key).unwrap_or_else(|| default.to_string())
}
pub fn optional_opt(key: &str) -> Option<String> {
env::var(key).ok().filter(|v| !v.trim().is_empty())
}
/// An optional numeric port. A present-but-invalid value is an ERROR, never a
/// silent fallback — a typo must not quietly leave the previous port in place.
pub fn optional_port(key: &str) -> Result<Option<u16>, ConfigError> {
match optional_opt(key) {
None => Ok(None),
Some(v) => v
.trim()
.parse()
.map(Some)
.map_err(|_| ConfigError(format!("{key} is not a valid port: {v:?}"))),
}
}
pub fn required_port(key: &str, why: &str) -> Result<u16, ConfigError> {
let raw = required(key, why)?;
raw.trim()
.parse()
.map_err(|_| ConfigError(format!("{key} is not a valid port: {raw:?}")))
}
/// Resolve the endpoints every client-visible URL is built from.
///
/// POW hosts DERIVE from the advertised address, matching the deployed Python
/// entrypoint (`POW_HOST="${POW_HOST:-$ADV:8094}"`). They must not fall back to
/// loopback independently: that would leave a remote deployment emitting
/// loopback POW URLs while every other URL was correct.
pub fn endpoints_from_env() -> Result<Endpoints, ConfigError> {
let advertise = required(
"OPENFUT_ADVERTISE",
"it is the address the game machine uses to reach this host; \
there is no loopback fallback in remote mode",
)?;
let mut endpoints = Endpoints::advertising(&advertise);
// The advertised-config bind, NOT any listener's bind. The adapter derives
// nucleusConnect from it, reproducing the oracle; see the vault's
// compatibility exceptions.
endpoints.bind = optional("OPENFUT_BIND", "127.0.0.1");
if let Some(v) = optional_opt("POW_CONTENT_HOST") {
endpoints.pow_content_host = v;
}
if let Some(v) = optional_opt("POW_HOST") {
endpoints.pow_host = v;
}
if let Some(p) = optional_port("OPENFUT_BLAZE_ADVERTISED_PORT")? {
endpoints.blaze_port = p;
}
if let Some(p) = optional_port("OPENFUT_UTAS_PORT")? {
endpoints.utas_port = p;
}
Ok(endpoints)
}
/// The full adapter configuration a host hands to the FIFA 17 adapter.
pub fn adapter_from_env() -> Result<AdapterConfig, ConfigError> {
Ok(AdapterConfig {
identity: Identity::default(),
endpoints: endpoints_from_env()?,
server_version: adapter_config::DEFAULT_SERVER_VERSION.into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Env is process-global, so this runs as one test rather than several that
/// would race each other.
#[test]
fn env_contract() {
let keys = [
"OPENFUT_ADVERTISE",
"OPENFUT_BIND",
"POW_CONTENT_HOST",
"POW_HOST",
"OPENFUT_BLAZE_ADVERTISED_PORT",
"OPENFUT_UTAS_PORT",
];
let saved: Vec<_> = keys.iter().map(|k| (*k, env::var(k).ok())).collect();
for k in keys {
env::remove_var(k);
}
// Missing advertise is refused, never defaulted.
let err = endpoints_from_env().unwrap_err().to_string();
assert!(err.contains("OPENFUT_ADVERTISE"), "{err}");
// POW derives from advertise; no independent loopback fallback.
env::set_var("OPENFUT_ADVERTISE", "198.51.100.7");
let e = endpoints_from_env().expect("resolves");
assert_eq!(e.pow_content_host, "198.51.100.7:8080");
assert_eq!(e.pow_host, "198.51.100.7:8094");
// Bind is independent of advertise.
env::set_var("OPENFUT_BIND", "0.0.0.0");
let e = endpoints_from_env().unwrap();
assert_eq!(e.bind, "0.0.0.0");
assert_eq!(e.advertise, "198.51.100.7");
// A bad port is an error, not a silent fallback.
env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "nope");
assert!(endpoints_from_env()
.unwrap_err()
.to_string()
.contains("not a valid port"));
env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "42999");
assert_eq!(endpoints_from_env().unwrap().blaze_port, 42999);
for (k, v) in saved {
match v {
Some(v) => env::set_var(k, v),
None => env::remove_var(k),
}
}
}
}