//! 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, /// Optional path for the raw frame capture. Opt-in; forensic evidence. pub capture_path: Option, /// What the adapter answers with. pub adapter: AdapterConfig, } 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}"))), } } 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, not a /// silent fallback — a typo must not quietly leave the previous port in place. 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:?}"))), } } 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 { // 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), } } } }