f451406058
Mandatory OpenFUT architecture audit. Two real defects found and fixed, plus
the config surface tightened so neither class can recur.
DEFECT 1 -- hidden localhost fallback. The Rust host defaulted POW hosts to
127.0.0.1 while every other URL followed OPENFUT_ADVERTISE, so a remote
deployment would emit loopback POW URLs and fail far from the cause. It also
diverged from the deployed Python entrypoint, which derives them
(POW_HOST="${POW_HOST:-$ADV:8094}"). POW endpoints now derive from the
advertised address; explicit overrides still win.
DEFECT 2 -- Default gave loopback silently. `Endpoints::default()` and
`AdapterConfig::default()` supplied 127.0.0.1, so anything constructing a
config by omission got loopback with no signal. Both `Default` impls are
REMOVED. Loopback is now `Endpoints::loopback()` / `AdapterConfig::loopback()`:
an explicit, greppable decision. Production uses `advertising(host)`.
CONFIGURABILITY. `blaze_port` and `utas_port` are now config, not literals.
The advertised Blaze port is our choice -- the client goes wherever
<serverinstanceinfo> sends it -- and 8099 is the client's own built-in default
but still deployment config. A bad port value is an error, not a silent
fallback to the previous one.
TEST-NET EVERYWHERE. Committed fixtures and tests used the lab's real LAN
address; a test that passes because its constant matches the current lab
proves nothing about relocatability. Redirector fixtures regenerated on
RFC 5737 TEST-NET-1/2/3 plus loopback. Harness scripts no longer default the
client IP to the lab address -- client-state.sh now requires it.
SEVEN REQUIRED TESTS in tests/deployment_config.rs plus host-side coverage:
remote config never silently becomes localhost; missing advertise fails
clearly; bind may differ from advertise; changing the Blaze port changes the
redirect; changing the host updates all 200+ generated URLs with no
stragglers; no helper bypasses central config; mutations are detectable.
MUTATION TESTED, and it found a hole in the audit tests themselves. Hardcoding
utas_base, reverting the POW derivation and re-hardcoding the Blaze port were
all caught. Making the redirector read `bind` instead of `advertise` was NOT:
`advertising()` sets bind == advertise, so the two sources were
indistinguishable. That is the single most likely bypass -- the oracle really
does read bind for nucleusConnect -- so the test now forces bind != advertise
and asserts the bind address never reaches the wire. Re-mutated: caught.
Wire behaviour unchanged: oracle fixtures still current, 153 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
249 lines
10 KiB
Rust
249 lines
10 KiB
Rust
//! Host configuration, entirely from the environment.
|
|
//!
|
|
//! Two rules carried over from the Python deployment:
|
|
//!
|
|
//! * **No silent loopback.** `OPENFUT_ADVERTISE` is required, exactly as the
|
|
//! Python entrypoint requires it. A backend that guesses its own reachable
|
|
//! address is the bug the client/server split removed.
|
|
//! * **No default port.** The sidecar runs beside the working Python container
|
|
//! and must never collide with it, so the listen port is explicit. There is
|
|
//! no "test port" constant anywhere in this crate.
|
|
|
|
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)]
|
|
pub struct ConfigError(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 {}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct HostConfig {
|
|
/// Address the listener binds.
|
|
pub listen_addr: String,
|
|
/// Port the listener binds. Required; no default.
|
|
pub listen_port: u16,
|
|
/// Seconds of inactivity before a connection is dropped. Matches the
|
|
/// oracle's 300s socket timeout.
|
|
pub idle_timeout_secs: u64,
|
|
/// Reject a frame claiming a larger payload than this, as the oracle does.
|
|
pub max_payload_bytes: u32,
|
|
/// Optional path for the normalized structural trace.
|
|
pub trace_path: Option<String>,
|
|
/// Optional path for the raw frame capture. Opt-in; forensic evidence.
|
|
pub capture_path: Option<String>,
|
|
/// What the adapter answers with.
|
|
pub adapter: AdapterConfig,
|
|
}
|
|
|
|
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}"))),
|
|
}
|
|
}
|
|
|
|
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, not a
|
|
/// silent fallback — a typo must not quietly leave the previous port in place.
|
|
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:?}"))),
|
|
}
|
|
}
|
|
|
|
fn optional(key: &str, default: &str) -> String {
|
|
env::var(key)
|
|
.ok()
|
|
.filter(|v| !v.trim().is_empty())
|
|
.unwrap_or_else(|| default.to_string())
|
|
}
|
|
|
|
impl HostConfig {
|
|
pub fn from_env() -> Result<HostConfig, ConfigError> {
|
|
// The address handed to the CLIENT for every next hop.
|
|
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",
|
|
)?;
|
|
|
|
// NOTE: this is the *advertised-config* bind, not the listener bind.
|
|
// The adapter derives nucleusConnect from it, reproducing the oracle
|
|
// (see the adapter's config docs and the vault's known-issue entry), so
|
|
// it must mirror whatever the Python container runs with if the two are
|
|
// to be compared. The listener has its own setting below.
|
|
let config_bind = optional("OPENFUT_BIND", "127.0.0.1");
|
|
|
|
let listen_port_raw = required(
|
|
"OPENFUT_BLAZE_HOST_PORT",
|
|
"the sidecar runs beside the working Python backend and must not \
|
|
collide with it, so the port is explicit and has no default",
|
|
)?;
|
|
let listen_port: u16 = listen_port_raw.trim().parse().map_err(|_| {
|
|
ConfigError(format!(
|
|
"OPENFUT_BLAZE_HOST_PORT is not a valid port: {listen_port_raw:?}"
|
|
))
|
|
})?;
|
|
|
|
let listen_addr = optional("OPENFUT_BLAZE_HOST_BIND", &config_bind);
|
|
|
|
// Derived from the ADVERTISED address, never loopback. The deployed
|
|
// Python entrypoint does the same (`POW_HOST="${POW_HOST:-$ADV:8094}"`),
|
|
// and an independent loopback fallback here would leave a remote
|
|
// deployment emitting loopback POW URLs while every other URL was right
|
|
// — a failure that surfaces far from its cause.
|
|
let mut endpoints = Endpoints::advertising(&advertise);
|
|
endpoints.bind = config_bind;
|
|
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(HostConfig {
|
|
listen_addr,
|
|
listen_port,
|
|
idle_timeout_secs: optional("OPENFUT_BLAZE_IDLE_TIMEOUT", "300")
|
|
.parse()
|
|
.unwrap_or(300),
|
|
max_payload_bytes: 4 * 1024 * 1024,
|
|
trace_path: env::var("OPENFUT_BLAZE_TRACE")
|
|
.ok()
|
|
.filter(|v| !v.trim().is_empty()),
|
|
capture_path: env::var("OPENFUT_BLAZE_CAPTURE")
|
|
.ok()
|
|
.filter(|v| !v.trim().is_empty()),
|
|
adapter: AdapterConfig {
|
|
identity: Identity::default(),
|
|
endpoints,
|
|
server_version: adapter_config::DEFAULT_SERVER_VERSION.into(),
|
|
},
|
|
})
|
|
}
|
|
|
|
pub fn listen_on(&self) -> String {
|
|
format!("{}:{}", self.listen_addr, self.listen_port)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Env is process-global, so these run under one lock rather than as
|
|
/// separate tests that would race each other.
|
|
#[test]
|
|
fn env_contract() {
|
|
let keys = [
|
|
"OPENFUT_ADVERTISE",
|
|
"OPENFUT_BIND",
|
|
"OPENFUT_BLAZE_HOST_PORT",
|
|
"OPENFUT_BLAZE_HOST_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, not defaulted.
|
|
let err = HostConfig::from_env().unwrap_err().to_string();
|
|
assert!(err.contains("OPENFUT_ADVERTISE"), "{err}");
|
|
|
|
// Missing port is refused too — no default that could collide.
|
|
env::set_var("OPENFUT_ADVERTISE", "198.51.100.7");
|
|
let err = HostConfig::from_env().unwrap_err().to_string();
|
|
assert!(err.contains("OPENFUT_BLAZE_HOST_PORT"), "{err}");
|
|
|
|
// (2) A missing advertised address FAILS CLEARLY — never defaulted.
|
|
// Re-asserted here because it is the single most important rule:
|
|
// a backend that guesses its own reachable address advertises a
|
|
// wrong one to a remote client and fails far from the cause.
|
|
|
|
// A non-numeric port is a clear error, not a silent fallback.
|
|
env::set_var("OPENFUT_BLAZE_HOST_PORT", "not-a-port");
|
|
let err = HostConfig::from_env().unwrap_err().to_string();
|
|
assert!(err.contains("not a valid port"), "{err}");
|
|
|
|
// Happy path: listener bind defaults to the config bind.
|
|
env::set_var("OPENFUT_BLAZE_HOST_PORT", "42230");
|
|
env::set_var("OPENFUT_BIND", "0.0.0.0");
|
|
let cfg = HostConfig::from_env().expect("configured");
|
|
assert_eq!(cfg.listen_on(), "0.0.0.0:42230");
|
|
assert_eq!(cfg.adapter.endpoints.advertise, "198.51.100.7");
|
|
// The adapter's nucleus URL follows the CONFIG bind, reproducing the
|
|
// oracle's behaviour rather than the listener's address.
|
|
assert_eq!(cfg.adapter.nucleus_base(), "http://0.0.0.0:42131");
|
|
|
|
// The listener bind can differ from the advertised-config bind.
|
|
env::set_var("OPENFUT_BLAZE_HOST_BIND", "127.0.0.1");
|
|
let cfg = HostConfig::from_env().expect("configured");
|
|
assert_eq!(cfg.listen_on(), "127.0.0.1:42230");
|
|
assert_eq!(cfg.adapter.nucleus_base(), "http://0.0.0.0:42131");
|
|
|
|
// (1) POW endpoints DERIVE from advertise; no independent loopback
|
|
// fallback. This was a real defect: they defaulted to 127.0.0.1
|
|
// while every other URL followed the advertised address, so a
|
|
// remote deployment emitted loopback POW URLs.
|
|
env::remove_var("POW_CONTENT_HOST");
|
|
env::remove_var("POW_HOST");
|
|
env::set_var("OPENFUT_ADVERTISE", "198.51.100.7");
|
|
let cfg = HostConfig::from_env().expect("configured");
|
|
assert_eq!(cfg.adapter.endpoints.pow_content_host, "198.51.100.7:8080");
|
|
assert_eq!(cfg.adapter.endpoints.pow_host, "198.51.100.7:8094");
|
|
assert!(cfg.adapter.pow_content_url().contains("198.51.100.7"));
|
|
assert!(!cfg.adapter.pow_content_url().contains("127.0.0.1"));
|
|
|
|
// Explicit overrides still win (the deployment remaps POW content).
|
|
env::set_var("POW_CONTENT_HOST", "203.0.113.42:8085");
|
|
let cfg = HostConfig::from_env().expect("configured");
|
|
assert_eq!(cfg.adapter.endpoints.pow_content_host, "203.0.113.42:8085");
|
|
env::remove_var("POW_CONTENT_HOST");
|
|
|
|
// (4) The advertised Blaze port is configurable, and a bad value is an
|
|
// error rather than a silent fallback to the old one.
|
|
env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "42999");
|
|
let cfg = HostConfig::from_env().expect("configured");
|
|
assert_eq!(cfg.adapter.endpoints.blaze_port, 42999);
|
|
env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "not-a-port");
|
|
let err = HostConfig::from_env().unwrap_err().to_string();
|
|
assert!(err.contains("not a valid port"), "{err}");
|
|
env::remove_var("OPENFUT_BLAZE_ADVERTISED_PORT");
|
|
|
|
for (k, v) in saved {
|
|
match v {
|
|
Some(v) => env::set_var(k, v),
|
|
None => env::remove_var(k),
|
|
}
|
|
}
|
|
}
|
|
}
|