diff --git a/Cargo.lock b/Cargo.lock index bb28c4b..3e4c3ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,17 +114,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - [[package]] name = "ahash" version = "0.8.12" @@ -457,6 +446,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.7.9" @@ -613,19 +625,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "blaze-ssl-async" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fec08f35919613bda0b3eb3bc772c2f793b3634133923b931874b18e1ac55de" -dependencies = [ - "bytes", - "num_enum", - "rsa", - "tokio", - "x509-cert", -] - [[package]] name = "block" version = "0.1.6" @@ -811,16 +810,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clipboard-win" version = "5.4.1" @@ -830,6 +819,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -1062,23 +1060,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", - "der_derive", - "flagset", "pem-rfc7468", "zeroize", ] -[[package]] -name = "der_derive" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1187,6 +1172,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecolor" version = "0.29.1" @@ -1457,12 +1448,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flagset" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" - [[package]] name = "flate2" version = "1.1.9" @@ -1547,6 +1532,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.33" @@ -2113,9 +2104,9 @@ dependencies = [ "futures-util", "http 0.2.12", "hyper 0.14.32", - "rustls", + "rustls 0.21.12", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", ] [[package]] @@ -2316,15 +2307,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "ipnet" version = "2.12.1" @@ -3126,11 +3108,28 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openfut-adapter-fifa17" +version = "0.1.0" +dependencies = [ + "openfut-protocol-blaze", + "serde_json", +] + +[[package]] +name = "openfut-blaze-host" +version = "0.1.0" +dependencies = [ + "openfut-adapter-fifa17", + "openfut-protocol-blaze", + "rand", + "serde_json", +] + [[package]] name = "openfut-bridge" version = "0.1.0" dependencies = [ - "aes", "anyhow", "axum", "bytes", @@ -3141,13 +3140,13 @@ dependencies = [ "hyper-util", "rcgen", "reqwest", - "rustls", - "rustls-pemfile", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", "serde", "serde_json", "thiserror 1.0.69", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", "tokio-stream", "tower 0.4.13", "tower-http", @@ -3156,6 +3155,10 @@ dependencies = [ "uuid", ] +[[package]] +name = "openfut-common" +version = "0.1.0" + [[package]] name = "openfut-core" version = "0.1.0" @@ -3184,9 +3187,17 @@ dependencies = [ name = "openfut-hook" version = "0.1.0" dependencies = [ + "openfut-common", "windows-sys 0.59.0", ] +[[package]] +name = "openfut-host-config" +version = "0.1.0" +dependencies = [ + "openfut-adapter-fifa17", +] + [[package]] name = "openfut-launcher" version = "0.1.0" @@ -3196,11 +3207,28 @@ dependencies = [ "dirs", "eframe", "egui", + "openfut-common", "serde", "serde_json", "tokio", ] +[[package]] +name = "openfut-protocol-blaze" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "openfut-redirector-host" +version = "0.1.0" +dependencies = [ + "openfut-adapter-fifa17", + "openfut-host-config", + "openssl", +] + [[package]] name = "openssl" version = "0.10.81" @@ -3232,6 +3260,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.117" @@ -3240,6 +3277,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -3680,8 +3718,8 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", - "rustls-pemfile", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", "serde", "serde_json", "serde_urlencoded", @@ -3689,7 +3727,7 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", @@ -3828,10 +3866,26 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", "ring 0.17.14", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring 0.17.14", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -3841,6 +3895,24 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -3851,6 +3923,18 @@ dependencies = [ "untrusted 0.9.0", ] +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring 0.17.14", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -4037,15 +4121,17 @@ version = "0.1.0" dependencies = [ "anyhow", "blaze-proto", - "blaze-ssl-async", "bytes", "chrono", "futures-util", "hex", + "rustls 0.23.43", + "rustls-pemfile 2.2.0", "serde", "serde_json", "tdf", "tokio", + "tokio-rustls 0.26.4", "tokio-util", "toml", "tracing", @@ -4329,8 +4415,8 @@ dependencies = [ "once_cell", "paste", "percent-encoding", - "rustls", - "rustls-pemfile", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", "serde", "serde_json", "sha2", @@ -4784,27 +4870,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tls_codec" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" -dependencies = [ - "tls_codec_derive", - "zeroize", -] - -[[package]] -name = "tls_codec_derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tokio" version = "1.53.1" @@ -4849,7 +4914,17 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls", + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.43", "tokio", ] @@ -6152,18 +6227,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "x509-cert" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" -dependencies = [ - "const-oid", - "der", - "spki", - "tls_codec", -] - [[package]] name = "xcursor" version = "0.3.11" @@ -6388,20 +6451,6 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index aa921df..4a7694c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ members = [ "openfut-protocol-blaze", "openfut-adapter-fifa17", "openfut-blaze-host", + "openfut-host-config", + "openfut-redirector-host", "openfut-bridge", "openfut-launcher", "openfut-launcher/openfut-hook", diff --git a/openfut-host-config/Cargo.toml b/openfut-host-config/Cargo.toml new file mode 100644 index 0000000..dca445b --- /dev/null +++ b/openfut-host-config/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "openfut-host-config" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Shared environment->typed configuration resolution for OpenFUT service hosts" +publish = false + +# The ONLY crate in the migration that reads the environment. Both service +# hosts resolve their endpoints through it, so there is a single construction +# path for every client-visible address — the property the deployment-address +# audit established and these hosts must not quietly reintroduce. +[dependencies] +openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" } diff --git a/openfut-host-config/src/lib.rs b/openfut-host-config/src/lib.rs new file mode 100644 index 0000000..1149f78 --- /dev/null +++ b/openfut-host-config/src/lib.rs @@ -0,0 +1,166 @@ +//! 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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), + } + } + } +} diff --git a/openfut-redirector-host/Cargo.toml b/openfut-redirector-host/Cargo.toml new file mode 100644 index 0000000..e28ac94 --- /dev/null +++ b/openfut-redirector-host/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "openfut-redirector-host" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "FIFA 17 Blaze redirector transport host (legacy TLS compatibility island)" +publish = false + +[dependencies] +openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" } +openfut-host-config = { path = "../openfut-host-config" } + +# Direct openssl, NOT native-tls. +# +# This is deliberately the opposite of what native-tls is for. native-tls +# abstracts over whatever the platform provides; here the requirement is +# precise, evidenced behaviour for one legacy client that offers exactly eight +# static-RSA suites. That needs explicit control of the cipher list, protocol +# floor and ceiling, and security level — knobs the openssl crate exposes and +# native-tls deliberately hides. +# +# VENDORED, because this project's reproducibility work would be undone by a +# distro libssl update silently changing whether FIFA 17 can connect. The exact +# linked version is printed at startup and recorded in gate evidence. +# +# Scoped to THIS crate only: neither OpenFUT Core nor the generic protocol +# crates gain an OpenSSL dependency. +openssl = { version = "0.10", features = ["vendored"] } diff --git a/openfut-redirector-host/src/config.rs b/openfut-redirector-host/src/config.rs new file mode 100644 index 0000000..b8403b7 --- /dev/null +++ b/openfut-redirector-host/src/config.rs @@ -0,0 +1,81 @@ +//! 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 openfut_adapter_fifa17::blaze::AdapterConfig; +use openfut_host_config::{self as hostcfg, ConfigError}; + +use crate::tls::TlsConfig; + +#[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, +} + +impl RedirectorConfig { + pub fn from_env() -> Result { + 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")?; + + let mut tls = TlsConfig::new(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(); + } + + Ok(RedirectorConfig { + listen_addr, + listen_port, + tls, + adapter, + }) + } + + 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: TlsConfig::new( + format!("{base}/redir_cert.pem"), + format!("{base}/redir_key.pem"), + ), + adapter: AdapterConfig::advertising(advertise), + } + } +} diff --git a/openfut-redirector-host/src/lib.rs b/openfut-redirector-host/src/lib.rs new file mode 100644 index 0000000..be33799 --- /dev/null +++ b/openfut-redirector-host/src/lib.rs @@ -0,0 +1,225 @@ +//! # openfut-redirector-host +//! +//! Transport host for the FIFA 17 Blaze redirector — the first hop. +//! +//! ```text +//! FIFA 17 ──TLS 1.2, static-RSA──> this host ──> +//! "connect to :" +//! ``` +//! +//! ## A deliberately small compatibility island +//! +//! This is the only OpenFUT crate that links OpenSSL, and it does so because a +//! retail FIFA 17 client offers exactly eight static-RSA suites and nothing +//! forward-secret. Neither OpenFUT Core nor the generic protocol crates gain +//! that dependency. See [`tls`] for what the observed ClientHello dictates. +//! +//! ## Division of responsibility +//! +//! The host owns the listener, TLS, HTTP framing, connection lifecycle and +//! diagnostics. `openfut-adapter-fifa17::redirector` owns the response and +//! nothing else — the same split as the Blaze sidecar, where the adapter +//! decides what to say and the host owns the socket. +//! +//! No FUT state lives here. +//! +//! ## Configuration +//! +//! Resolved through `openfut-host-config`, the single environment reader, so +//! the advertised address reaches this host by the same construction path as +//! the Blaze sidecar's. + +pub mod config; +pub mod tls; + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use openfut_adapter_fifa17::redirector; +use openssl::ssl::SslAcceptor; + +pub use config::RedirectorConfig; + +fn log(msg: &str) { + let ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + eprintln!("[{}.{:03}] {msg}", ms / 1000, ms % 1000); +} + +/// One line naming the binary, its linked TLS, and what it will advertise. +pub fn banner(cfg: &RedirectorConfig) -> String { + format!( + "openfut-redirector-host v{} openssl={} listen={} advertise={}:{} ciphers={}", + env!("CARGO_PKG_VERSION"), + tls::openssl_version(), + cfg.listen_on(), + cfg.adapter.endpoints.advertise, + cfg.adapter.endpoints.blaze_port, + cfg.tls.cipher_list, + ) +} + +/// A bound listener, so a caller can learn the real port before serving +/// (ephemeral ports in tests) and so the self-test runs before anything is +/// accepted. +pub struct Server { + pub local_addr: std::net::SocketAddr, + listener: TcpListener, + acceptor: Arc, + cfg: Arc, +} + +impl Server { + pub fn run(self) -> std::io::Result<()> { + let counter = AtomicU64::new(0); + for incoming in self.listener.incoming() { + let Ok(stream) = incoming else { continue }; + let id = counter.fetch_add(1, Ordering::Relaxed) + 1; + let (acceptor, cfg) = (self.acceptor.clone(), self.cfg.clone()); + std::thread::spawn(move || handle(stream, id, &acceptor, &cfg)); + } + Ok(()) + } +} + +/// Build TLS, rehearse the retail handshake, and bind. +pub fn bind(cfg: RedirectorConfig) -> std::io::Result { + let acceptor = tls::build_acceptor(&cfg.tls) + .map_err(|e| std::io::Error::other(format!("TLS setup failed: {e}")))?; + // Rehearse the retail handshake BEFORE accepting anything: a client + // restricted to exactly the suites FIFA offers must connect. Catching a + // cipher/version misconfiguration here means it never shows up as an + // unexplained failure during a live gate. + let neg = tls::self_test(&cfg.tls, tls::OBSERVED_CLIENT_SUITES).map_err(|e| { + std::io::Error::other(format!( + "self-test failed — a FIFA-like client cannot connect: {e}" + )) + })?; + log(&format!( + "SELF-TEST OK: a FIFA-like client negotiates {} / {}", + neg.version, neg.cipher + )); + + let listener = TcpListener::bind(cfg.listen_on())?; + let local_addr = listener.local_addr()?; + log(&banner(&cfg)); + log(&format!( + "TLS min={:?} max={:?} security_level={}", + cfg.tls.min_version, + cfg.tls.max_version, + cfg.tls + .security_level + .map(|l| l.to_string()) + .unwrap_or_else(|| "default (not lowered)".into()) + )); + Ok(Server { + local_addr, + listener, + acceptor: Arc::new(acceptor), + cfg: Arc::new(cfg), + }) +} + +/// Serve until the process is killed. +pub fn serve(cfg: RedirectorConfig) -> std::io::Result<()> { + bind(cfg)?.run() +} + +fn handle(stream: TcpStream, id: u64, acceptor: &SslAcceptor, cfg: &RedirectorConfig) { + let peer = stream + .peer_addr() + .map(|a| a.to_string()) + .unwrap_or_else(|_| "".into()); + let _ = stream.set_read_timeout(Some(Duration::from_secs(15))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(15))); + + let mut tls = match acceptor.accept(stream) { + Ok(s) => s, + Err(e) => { + // The most valuable diagnostic this host produces: a handshake + // failure names the client and the reason, so a cipher/version + // mismatch is obvious rather than looking like a network fault. + log(&format!("conn-{id:04} {peer} TLS HANDSHAKE FAILED: {e}")); + return; + } + }; + + // Exactly what the retail client negotiated. Recorded per connection + // because it is the evidence a gate is judged on. + { + let s = tls.ssl(); + log(&format!( + "conn-{id:04} {peer} TLS OK version={} cipher={} sni={}", + s.version_str(), + s.current_cipher().map(|c| c.name()).unwrap_or("?"), + s.servername(openssl::ssl::NameType::HOST_NAME) + .unwrap_or("") + )); + } + + // Read the request head. The oracle answers any request with the same body, + // so this is parsed for diagnostics, not for routing — a redirector that + // started 404ing unexpected paths would be a behaviour change, not a fix. + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + match tls.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + if buf.windows(4).any(|w| w == b"\r\n\r\n") || buf.len() > 65536 { + break; + } + } + Err(e) => { + log(&format!("conn-{id:04} {peer} read failed: {e}")); + return; + } + } + } + + let head = String::from_utf8_lossy(&buf); + let line0 = head.lines().next().unwrap_or("").to_string(); + log(&format!("conn-{id:04} {peer} REQ {line0}")); + if !line0.is_empty() && !redirector::is_get_server_instance(&line0) { + log(&format!( + "conn-{id:04} {peer} NOTE: unexpected request line; answering anyway (oracle behaviour)" + )); + } + + let response = redirector::redirect_response(&cfg.adapter); + if let Err(e) = tls.write_all(&response) { + log(&format!("conn-{id:04} {peer} write failed: {e}")); + return; + } + let _ = tls.flush(); + log(&format!( + "conn-{id:04} {peer} SENT {}B serverinstanceinfo -> {}:{}", + response.len(), + cfg.adapter.endpoints.advertise, + cfg.adapter.endpoints.blaze_port + )); + + // The oracle closes after responding; the redirector is a one-shot hop. + let _ = tls.shutdown(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn banner_names_the_linked_openssl_and_what_is_advertised() { + let cfg = RedirectorConfig::for_test("198.51.100.7"); + let b = banner(&cfg); + assert!(b.contains("openssl="), "{b}"); + assert!(b.contains("198.51.100.7"), "{b}"); + // The cipher list is part of the identity of a compatibility host. + assert!(b.contains("AES256-GCM-SHA384"), "{b}"); + } +} diff --git a/openfut-redirector-host/src/main.rs b/openfut-redirector-host/src/main.rs new file mode 100644 index 0000000..c6caeef --- /dev/null +++ b/openfut-redirector-host/src/main.rs @@ -0,0 +1,33 @@ +//! Entry point for the FIFA 17 redirector host. + +use openfut_redirector_host::{serve, RedirectorConfig}; + +fn main() { + let cfg = match RedirectorConfig::from_env() { + Ok(c) => c, + Err(e) => { + eprintln!("openfut-redirector-host: {e}\n"); + eprintln!("Required:"); + eprintln!( + " OPENFUT_ADVERTISE address the game machine reaches this host at" + ); + eprintln!( + " OPENFUT_REDIRECTOR_HOST_PORT listen port (no default: runs beside Python)" + ); + eprintln!( + " OPENFUT_REDIRECTOR_CERT RSA certificate (reuse the oracle's for A/B)" + ); + eprintln!(" OPENFUT_REDIRECTOR_KEY matching private key"); + eprintln!("Optional:"); + eprintln!(" OPENFUT_REDIRECTOR_HOST_BIND listener bind (defaults to OPENFUT_BIND)"); + eprintln!(" OPENFUT_BLAZE_ADVERTISED_PORT Blaze port put in the redirect"); + eprintln!(" OPENFUT_REDIRECTOR_CIPHERS override the cipher list"); + eprintln!(" OPENFUT_REDIRECTOR_SECURITY_LEVEL only with evidence it is needed"); + std::process::exit(2); + } + }; + if let Err(e) = serve(cfg) { + eprintln!("openfut-redirector-host: fatal: {e}"); + std::process::exit(1); + } +} diff --git a/openfut-redirector-host/src/tls.rs b/openfut-redirector-host/src/tls.rs new file mode 100644 index 0000000..79e9778 --- /dev/null +++ b/openfut-redirector-host/src/tls.rs @@ -0,0 +1,358 @@ +//! TLS for the FIFA 17 redirector — a deliberately narrow legacy compatibility +//! island. +//! +//! # Driven by an observed ClientHello, not by generic legacy assumptions +//! +//! A retail FIFA 17 client was captured through a passive proxy while reaching +//! the FUT hub. It offers **exactly eight suites, every one static-RSA**: +//! +//! ```text +//! 0x009D TLS_RSA_WITH_AES_256_GCM_SHA384 0x0035 TLS_RSA_WITH_AES_256_CBC_SHA +//! 0x009C TLS_RSA_WITH_AES_128_GCM_SHA256 0x002F TLS_RSA_WITH_AES_128_CBC_SHA +//! 0x003D TLS_RSA_WITH_AES_256_CBC_SHA256 0x0005 TLS_RSA_WITH_RC4_128_SHA +//! 0x003C TLS_RSA_WITH_AES_128_CBC_SHA256 0x0004 TLS_RSA_WITH_RC4_128_MD5 +//! +//! client_version TLS 1.2 extensions: server_name, signature_algorithms only +//! ``` +//! +//! Zero forward-secret suites, which is why `rustls` cannot serve this client +//! and why this crate uses `openssl` directly. +//! +//! # What is deliberately NOT enabled +//! +//! * **RC4 and MD5.** The client offers them; we do not need them. It already +//! negotiates `AES256-GCM-SHA384` against the Python oracle, so resurrecting +//! RC4 for historical completeness would weaken the service for nothing. +//! * **SSLv3.** Never. +//! * **A lowered security level.** Not applied pre-emptively. The default +//! policy is tried first; if the retail handshake fails because OpenSSL +//! rejects something actually required, the narrowest possible change is made +//! and documented — not a blanket `SECLEVEL=0`. +//! +//! # Scope +//! +//! Every setting here applies to THIS listener's `SslContext` only. Nothing +//! global is weakened, and no other OpenFUT component links OpenSSL. + +use std::fmt; +use std::path::Path; + +use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslOptions, SslVersion}; + +/// The suites we enable: the six RSA+AES options the client offers, strongest +/// first, in OpenSSL's pre-TLS-1.3 naming. +/// +/// RC4/MD5 are excluded on purpose (see module docs). Order expresses our +/// preference; the client's own order put AES-256-GCM first anyway. +pub const CIPHER_LIST: &str = + "AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA"; + +/// Suites the observed client offers that we deliberately refuse. +pub const REFUSED_SUITES: [&str; 2] = ["RC4-SHA", "RC4-MD5"]; + +#[derive(Debug)] +pub struct TlsError(pub String); + +impl fmt::Display for TlsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for TlsError {} + +/// TLS knobs, all overridable so a failed retail handshake is a configuration +/// change rather than a code change. +#[derive(Debug, Clone)] +pub struct TlsConfig { + pub cert_path: String, + pub key_path: String, + pub cipher_list: String, + pub min_version: SslVersion, + pub max_version: SslVersion, + /// `None` = leave OpenSSL's default policy alone. Only set this if a real + /// handshake proves it necessary, and record why. + pub security_level: Option, +} + +impl TlsConfig { + /// Defaults chosen from the observed client: it speaks TLS 1.2 and offers + /// no TLS 1.3, so both floor and ceiling are 1.2. + /// + /// The floor is NOT dropped to TLS 1.0 pre-emptively. The oracle allows it, + /// but no evidence shows this client needs it, and "the oracle permits it" + /// is not the same as "the client requires it". + pub fn new(cert_path: impl Into, key_path: impl Into) -> TlsConfig { + TlsConfig { + cert_path: cert_path.into(), + key_path: key_path.into(), + cipher_list: CIPHER_LIST.to_string(), + min_version: SslVersion::TLS1_2, + max_version: SslVersion::TLS1_2, + security_level: None, + } + } +} + +/// The OpenSSL version this binary is linked against. +/// +/// Vendored, so it is fixed at build time rather than inherited from the host. +/// Printed at startup and recorded in gate evidence: a TLS implementation +/// change is not a routine dependency bump, it invalidates the compatibility +/// testing. +pub fn openssl_version() -> String { + openssl::version::version().to_string() +} + +/// Build the acceptor for the redirector listener. +pub fn build_acceptor(cfg: &TlsConfig) -> Result { + // `mozilla_intermediate` would preload a modern, forward-secret-only + // policy — precisely wrong here. Start from the bare method and state every + // choice explicitly, so nothing is inherited silently. + let mut b = SslAcceptor::mozilla_intermediate(SslMethod::tls()) + .map_err(|e| TlsError(format!("acceptor: {e}")))?; + + b.set_min_proto_version(Some(cfg.min_version)) + .map_err(|e| TlsError(format!("min proto: {e}")))?; + b.set_max_proto_version(Some(cfg.max_version)) + .map_err(|e| TlsError(format!("max proto: {e}")))?; + + // Explicit list, replacing whatever policy the profile brought with it. + b.set_cipher_list(&cfg.cipher_list) + .map_err(|e| TlsError(format!("cipher list {:?}: {e}", cfg.cipher_list)))?; + + if let Some(level) = cfg.security_level { + b.set_security_level(level); + } + + // SSLv3 is never acceptable, whatever the version floor says. + b.set_options(SslOptions::NO_SSLV3); + + let cert = Path::new(&cfg.cert_path); + let key = Path::new(&cfg.key_path); + if !cert.exists() { + return Err(TlsError(format!( + "certificate not found: {}", + cfg.cert_path + ))); + } + if !key.exists() { + return Err(TlsError(format!("private key not found: {}", cfg.key_path))); + } + b.set_certificate_chain_file(cert) + .map_err(|e| TlsError(format!("certificate {}: {e}", cfg.cert_path)))?; + b.set_private_key_file(key, SslFiletype::PEM) + .map_err(|e| TlsError(format!("private key {}: {e}", cfg.key_path)))?; + // Catches a mismatched pair at startup rather than mid-handshake, where it + // would look like a client problem. + b.check_private_key() + .map_err(|e| TlsError(format!("certificate and key do not match: {e}")))?; + + Ok(b.build()) +} + +/// The eight suites the captured retail ClientHello offers, in OpenSSL naming. +/// +/// Used to build a client that behaves like FIFA 17 for the self-test below. +pub const OBSERVED_CLIENT_SUITES: &str = + "AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:\ + AES256-SHA:AES128-SHA:RC4-SHA:RC4-MD5"; + +/// What a handshake actually negotiated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Negotiated { + pub version: String, + pub cipher: String, +} + +/// Handshake against ourselves with a client restricted to `client_ciphers`. +/// +/// Enumerating the server's configured list would be the obvious check, but the +/// crate exposes no such accessor at this version — and a real handshake is +/// better evidence anyway: it proves MUTUAL support and reports what was +/// actually chosen, which is the thing a gate is judged on. Defaulting +/// `client_ciphers` to the observed FIFA suites makes this a direct rehearsal +/// of the retail handshake. +pub fn self_test(cfg: &TlsConfig, client_ciphers: &str) -> Result { + use openssl::ssl::{SslConnector, SslVerifyMode}; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + + let acceptor = build_acceptor(cfg)?; + let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| TlsError(format!("bind: {e}")))?; + let addr = listener + .local_addr() + .map_err(|e| TlsError(format!("addr: {e}")))?; + + let server = std::thread::spawn(move || -> Result { + let (sock, _) = listener.accept().map_err(|e| e.to_string())?; + let mut tls = acceptor.accept(sock).map_err(|e| e.to_string())?; + let neg = { + let s = tls.ssl(); + Negotiated { + version: s.version_str().to_string(), + cipher: s + .current_cipher() + .map(|c| c.name().to_string()) + .unwrap_or_default(), + } + }; + let mut buf = [0u8; 64]; + let _ = tls.read(&mut buf); + let _ = tls.write_all(b"ok"); + let _ = tls.flush(); + Ok(neg) + }); + + let mut cb = + SslConnector::builder(SslMethod::tls()).map_err(|e| TlsError(format!("connector: {e}")))?; + cb.set_cipher_list(client_ciphers) + .map_err(|e| TlsError(format!("client cipher list: {e}")))?; + // The point is the cipher/version negotiation, not PKI: the oracle's + // certificate is self-signed and the real client's checks are patched out. + cb.set_verify(SslVerifyMode::NONE); + let connector = cb.build(); + + let sock = TcpStream::connect(addr).map_err(|e| TlsError(format!("connect: {e}")))?; + let mut client = connector + .configure() + .and_then(|c| { + c.verify_hostname(false) + .into_ssl("winter15.gosredirector.ea.com") + }) + .and_then(|ssl| openssl::ssl::SslStream::new(ssl, sock)) + .map_err(|e| TlsError(format!("client setup: {e}")))?; + client + .connect() + .map_err(|e| TlsError(format!("client handshake failed: {e}")))?; + let _ = client.write_all(b"ping"); + let _ = client.flush(); + let mut buf = [0u8; 8]; + let _ = client.read(&mut buf); + + server + .join() + .map_err(|_| TlsError("server thread panicked".into()))? + .map_err(|e| TlsError(format!("server handshake failed: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Reuse the proven Python redirector's material, so TLS implementation is + /// the only variable in the A/B. + fn oracle_cert() -> (String, String) { + let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/tools"); + ( + format!("{base}/redir_cert.pem"), + format!("{base}/redir_key.pem"), + ) + } + + fn cfg() -> TlsConfig { + let (c, k) = oracle_cert(); + TlsConfig::new(c, k) + } + + #[test] + fn acceptor_builds_with_the_oracle_certificate() { + assert!(build_acceptor(&cfg()).is_ok(), "acceptor must build"); + } + + /// The decisive test: the configured list must actually contain a suite the + /// observed client offers, or the retail handshake cannot succeed. + /// The decisive test: a client restricted to exactly the suites the retail + /// ClientHello offered must complete a handshake, and must land on the + /// suite the real client already negotiated against Python. + #[test] + fn a_client_offering_only_fifas_suites_completes_a_handshake() { + let neg = self_test(&cfg(), OBSERVED_CLIENT_SUITES).expect("handshake"); + assert_eq!(neg.cipher, "AES256-GCM-SHA384", "negotiated {neg:?}"); + assert_eq!(neg.version, "TLSv1.2", "negotiated {neg:?}"); + } + + /// Each RSA+AES suite individually, so a client that offered only one of + /// them would still connect. + #[test] + fn every_enabled_rsa_aes_suite_can_be_negotiated_alone() { + for suite in [ + "AES256-GCM-SHA384", + "AES128-GCM-SHA256", + "AES256-SHA256", + "AES128-SHA256", + "AES256-SHA", + "AES128-SHA", + ] { + let neg = self_test(&cfg(), suite) + .unwrap_or_else(|e| panic!("{suite} could not be negotiated: {e}")); + assert_eq!(neg.cipher, suite); + } + } + + /// RC4/MD5 are offered by the client and deliberately refused by us, so a + /// client offering ONLY those must fail to connect. This is the test that + /// proves the refusal is real rather than aspirational. + #[test] + fn a_client_offering_only_rc4_is_refused() { + let err = self_test(&cfg(), "RC4-SHA:RC4-MD5"); + assert!(err.is_err(), "RC4-only client should not connect: {err:?}"); + } + + /// A forward-secret-only client must also fail — proving we did not + /// silently inherit a modern profile that would exclude FIFA. + #[test] + fn a_forward_secret_only_client_is_not_what_we_serve() { + let r = self_test(&cfg(), "ECDHE-RSA-AES256-GCM-SHA384"); + assert!(r.is_err(), "we should not be offering ECDHE; got {r:?}"); + } + + #[test] + fn security_level_is_not_lowered_by_default() { + assert_eq!( + cfg().security_level, + None, + "the default policy is tried first; lowering requires evidence" + ); + } + + #[test] + fn protocol_window_matches_the_observed_client() { + let c = cfg(); + assert_eq!(c.min_version, SslVersion::TLS1_2); + assert_eq!(c.max_version, SslVersion::TLS1_2); + } + + #[test] + fn a_missing_certificate_fails_clearly() { + let mut c = cfg(); + c.cert_path = "/nonexistent/cert.pem".into(); + let e = match build_acceptor(&c) { + Err(e) => e.to_string(), + Ok(_) => panic!("a missing certificate must not build"), + }; + assert!(e.contains("certificate not found"), "{e}"); + } + + #[test] + fn a_mismatched_key_is_caught_at_startup() { + let (cert, _) = oracle_cert(); + let mut c = cfg(); + // Point the key at the certificate: a valid PEM that is not the key. + c.key_path = cert; + assert!(build_acceptor(&c).is_err(), "mismatch must not build"); + } + + #[test] + fn an_unusable_cipher_list_is_an_error_not_a_silent_empty_set() { + let mut c = cfg(); + c.cipher_list = "THIS-IS-NOT-A-CIPHER".into(); + assert!(build_acceptor(&c).is_err()); + } + + #[test] + fn openssl_version_is_reportable() { + let v = openssl_version(); + assert!(v.contains("OpenSSL"), "{v}"); + } +} diff --git a/openfut-redirector-host/tests/oracle_parity.rs b/openfut-redirector-host/tests/oracle_parity.rs new file mode 100644 index 0000000..641e8f7 --- /dev/null +++ b/openfut-redirector-host/tests/oracle_parity.rs @@ -0,0 +1,163 @@ +//! End-to-end: a real TLS request against the real host, compared with the +//! Python oracle byte-for-byte. +//! +//! The adapter's own suite already proves the response bytes. This proves the +//! whole transport: OpenSSL handshake, HTTP framing, and the same bytes coming +//! back over the wire — the redirector equivalent of the Blaze sidecar's +//! live-transport suite. + +use std::io::{Read, Write}; +use std::net::TcpStream; + +use openfut_redirector_host::{bind, tls, RedirectorConfig}; +use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode}; + +/// Start the real host on an ephemeral port. +fn start(advertise: &str) -> String { + let mut cfg = RedirectorConfig::for_test(advertise); + cfg.listen_port = 0; + let server = bind(cfg).expect("host binds"); + let addr = server.local_addr.to_string(); + std::thread::spawn(move || { + let _ = server.run(); + }); + addr +} + +/// A client that behaves like the captured FIFA 17 ClientHello. +fn fifa_like_request(addr: &str) -> (String, Vec) { + let mut b = SslConnector::builder(SslMethod::tls()).expect("connector"); + b.set_cipher_list(tls::OBSERVED_CLIENT_SUITES) + .expect("client cipher list"); + // The retail client's cert checks are patched out and the oracle cert is + // self-signed; the variable under test is cipher/version negotiation. + b.set_verify(SslVerifyMode::NONE); + let connector = b.build(); + + let sock = TcpStream::connect(addr).expect("connect"); + let ssl = connector + .configure() + .expect("configure") + .verify_hostname(false) + .into_ssl("winter15.gosredirector.ea.com") + .expect("ssl"); + let mut stream = openssl::ssl::SslStream::new(ssl, sock).expect("stream"); + stream.connect().expect("TLS handshake"); + + let negotiated = format!( + "{} / {}", + stream.ssl().version_str(), + stream + .ssl() + .current_cipher() + .map(|c| c.name()) + .unwrap_or("?") + ); + + stream + .write_all( + b"POST /redirector/getServerInstance HTTP/1.1\r\n\ + Host: winter15.gosredirector.ea.com\r\n\ + Content-Type: application/xml\r\n\ + Content-Length: 0\r\n\r\n", + ) + .expect("write request"); + stream.flush().ok(); + + let mut out = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => out.extend_from_slice(&chunk[..n]), + } + } + (negotiated, out) +} + +/// Gate 4: a FIFA-like client completes TLS and gets an HTTP response. +#[test] +fn a_fifa_like_client_gets_a_response_over_tls() { + let addr = start("198.51.100.7"); + let (negotiated, body) = fifa_like_request(&addr); + + assert_eq!( + negotiated, "TLSv1.2 / AES256-GCM-SHA384", + "negotiated the wrong thing" + ); + let text = String::from_utf8_lossy(&body); + assert!(text.starts_with("HTTP/1.1 200 OK"), "{text}"); + assert!(text.contains(""), "{text}"); +} + +/// Gate 5: what comes back over TLS is byte-identical to the Python oracle's +/// recorded response for the same advertised address. +#[test] +fn the_response_over_tls_is_byte_identical_to_the_oracle() { + let path = format!( + "{}/../openfut-adapter-fifa17/fixtures/redirector.json", + env!("CARGO_MANIFEST_DIR") + ); + let text = std::fs::read_to_string(&path).expect("oracle fixtures"); + + // Minimal extraction: the fixture is a flat {address: hex} object written + // by our own generator, so a full JSON dependency would be overkill here. + let advertise = "198.51.100.7"; + let key = format!("\"{advertise}\""); + let start_idx = text.find(&key).expect("fixture for this address"); + let rest = &text[start_idx + key.len()..]; + let open = rest.find('"').expect("hex opens"); + let close = rest[open + 1..].find('"').expect("hex closes"); + let want_hex = &rest[open + 1..open + 1 + close]; + let want: Vec = (0..want_hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&want_hex[i..i + 2], 16).expect("hex")) + .collect(); + + let addr = start(advertise); + let (_, got) = fifa_like_request(&addr); + + assert_eq!( + String::from_utf8_lossy(&got), + String::from_utf8_lossy(&want), + "the response over TLS differs from the Python oracle" + ); +} + +/// The advertised address must come from configuration, over the wire, not +/// just in a unit test. +#[test] +fn the_advertised_address_reaches_the_wire() { + let addr = start("203.0.113.42"); + let (_, body) = fifa_like_request(&addr); + let text = String::from_utf8_lossy(&body); + assert!(text.contains("203.0.113.42"), "{text}"); + assert!(text.contains("3405803818"), "{text}"); + assert!(!text.contains("198.51.100.7")); +} + +/// A client that offers only forward-secret suites — i.e. anything modern — +/// must fail, confirming this listener really is the legacy island it claims +/// to be and has not silently acquired a modern policy. +#[test] +fn a_modern_client_cannot_connect_to_this_listener() { + let addr = start("198.51.100.7"); + let mut b = SslConnector::builder(SslMethod::tls()).expect("connector"); + b.set_cipher_list("ECDHE-RSA-AES256-GCM-SHA384") + .expect("list"); + b.set_verify(SslVerifyMode::NONE); + let connector = b.build(); + + let sock = TcpStream::connect(&addr).expect("connect"); + let ssl = connector + .configure() + .expect("configure") + .verify_hostname(false) + .into_ssl("winter15.gosredirector.ea.com") + .expect("ssl"); + let mut stream = openssl::ssl::SslStream::new(ssl, sock).expect("stream"); + assert!( + stream.connect().is_err(), + "an ECDHE-only client should not negotiate with this listener" + ); +}