use std::net::SocketAddr; use std::path::PathBuf; pub struct Config { pub bind_addr: SocketAddr, /// The API's own public origin, e.g. `https://api.lance.blue`. Set, it /// selects the confidential OAuth client and `Secure` cookies; unset, /// development runs the loopback client over plain HTTP. pub public_url: Option, /// The site's origin — the one origin CORS admits and where the OAuth /// callback sends the browser back to. pub web_origin: String, /// Where the public match pages are published. `None` means they are /// served from `public_url`, which is the case with nothing in front. pub share_origin: Option, /// HMAC key for the session cookie. pub session_secret: Vec, /// `Domain` attribute for the session cookie, e.g. `lance.blue`. Set, the /// browser sends the cookie to every subdomain, so sibling services that /// hold the session secret can verify players without this API being up. /// Unset, the cookie is host-only. pub cookie_domain: Option, /// ES256 private JWK (object or array), kept opaque here and parsed by /// the atproto module. Required in public mode, rejected outside it. pub private_key_jwk: Option, pub db_path: PathBuf, /// Present iff MATCH_CLUSTER is set; absent means this instance cannot /// launch matches and /api/matches says so. pub matches: Option, /// The branch and commit this build came from — the BUILD_REF push.sh /// bakes into the image. Absent in development, where the code is a /// bind mount. pub build_ref: Option, /// The arena image tag the deployment launches matches from /// (mm-sur--). The task definition pins /// the image in infra, so infra sets this alongside it; display-only here. pub arena_version: Option, /// The `at://` URI of the userinput.app space flares are filed to. /// Absent, /api/flare answers that flares are not enabled. pub flare_space: Option, /// helm's published unit index for the release matches run, e.g. /// `https://lance.blue/assets/helm/0.51.0/units.json`. Absent, a match /// report draws each machine as its weight class rather than as itself. pub unit_index_url: Option, } #[derive(Debug, Clone)] pub struct MatchConfig { pub cluster: String, pub task_family: String, pub subnets: Vec, pub task_security_group: String, pub artifacts_bucket: String, /// The scenario the bot match runs, named relative to the arena image's /// scenario library. pub scenario_name: String, /// Slot names from the scenario's Factions= line. pub human_slot: String, pub bot_slot: String, } // The default Debug would print the session secret and the private key. impl std::fmt::Debug for Config { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Config") .field("bind_addr", &self.bind_addr) .field("public_url", &self.public_url) .field("web_origin", &self.web_origin) .field("share_origin", &self.share_origin) .field("cookie_domain", &self.cookie_domain) .field("db_path", &self.db_path) .finish_non_exhaustive() } } impl Config { /// Reads configuration from the environment. pub fn from_env() -> Result { Self::from_lookup(|key| std::env::var(key).ok()) } /// The environment is process-global, so tests use this with a map. pub fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result { // `BIND_ADDR` defaults to loopback: on a laptop nothing else should // be able to reach a development instance (docs/local-dev.md). Inside // a container it must be `0.0.0.0:3000` — there, compose's port // publish is what keeps it on loopback from the outside. let bind_addr = match lookup("BIND_ADDR") { Some(s) => s .parse() .map_err(|e| format!("BIND_ADDR {s:?} is not a socket address: {e}"))?, None => SocketAddr::from(([127, 0, 0, 1], 3000)), }; let public_url = match lookup("PUBLIC_URL") { Some(s) => { let s = s.trim_end_matches('/').to_owned(); if !s.starts_with("https://") || s.len() <= "https://".len() { return Err(format!("PUBLIC_URL {s:?} must be an https:// URL")); } Some(s) } None => None, }; let web_origin = match lookup("WEB_ORIGIN") { Some(s) => { let valid = (s.starts_with("https://") || s.starts_with("http://")) && !s.ends_with('/') && !s .splitn(3, '/') .nth(2) .is_some_and(|rest| rest.contains('/')); if !valid { return Err(format!( "WEB_ORIGIN {s:?} must be a bare origin - scheme://host[:port], no path or trailing slash" )); } s } None => "http://127.0.0.1:5173".to_owned(), }; // Where a finished match is published, which is not necessarily where // this service answers. The pages live under /reports/, and a CDN in // front of the site can route that prefix here - so what a card, an // og:url and a copied link have to say is the site's address, not the // API's. Defaults to PUBLIC_URL, which is where they are served from // when nothing is in front. let share_origin = match lookup("SHARE_ORIGIN") { Some(s) => { let valid = (s.starts_with("https://") || s.starts_with("http://")) && !s.ends_with('/') && !s .splitn(3, '/') .nth(2) .is_some_and(|rest| rest.contains('/')); if !valid { return Err(format!( "SHARE_ORIGIN {s:?} must be a bare origin - scheme://host[:port], no path or trailing slash" )); } Some(s) } None => None, }; let session_secret = match lookup("SESSION_SECRET") { Some(s) => { if s.len() < 32 { return Err("SESSION_SECRET must be at least 32 bytes".to_owned()); } s.into_bytes() } None => { if public_url.is_some() { return Err("SESSION_SECRET is required when PUBLIC_URL is set".to_owned()); } // Development convenience only: sessions die with the process. use rand::RngCore; let mut secret = vec![0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut secret); tracing::warn!("SESSION_SECRET not set; sessions will not survive a restart"); secret } }; // A Domain the API's own host is not under would make the browser // discard the Set-Cookie silently, which presents as login working // and every later request being signed out. Refused here instead. let cookie_domain = match lookup("COOKIE_DOMAIN") { None => None, Some(domain) => { let domain = domain.trim_start_matches('.').to_owned(); if domain.is_empty() { return Err("COOKIE_DOMAIN is set but empty".to_owned()); } match &public_url { None => { return Err( "COOKIE_DOMAIN is set but PUBLIC_URL is not; a loopback cookie cannot carry a domain" .to_owned(), ); } Some(url) => { let host = url .trim_start_matches("https://") .split(['/', ':']) .next() .unwrap_or(""); if host != domain && !host.ends_with(&format!(".{domain}")) { return Err(format!( "COOKIE_DOMAIN {domain:?} does not cover PUBLIC_URL's host {host:?}; the browser would reject the cookie" )); } } } Some(domain) } }; let private_key_jwk = lookup("PRIVATE_KEY_JWK"); match (&public_url, &private_key_jwk) { (Some(_), None) => { return Err("PRIVATE_KEY_JWK is required when PUBLIC_URL is set".to_owned()); } (None, Some(_)) => { return Err( "PRIVATE_KEY_JWK is set but PUBLIC_URL is not; refusing a half-configured confidential client" .to_owned(), ); } _ => {} } let db_path = lookup("DB_PATH") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("./data/headquarters.sqlite")); let matches = match lookup("MATCH_CLUSTER") { None => None, Some(cluster) => { let require = |key: &str| { lookup(key) .ok_or_else(|| format!("{key} is required when MATCH_CLUSTER is set")) }; let scenario_name = lookup("MATCH_SCENARIO") .unwrap_or_else(|| "TrainingScenarios/1-FirstRun.mms".to_owned()); Some(MatchConfig { cluster, task_family: require("MATCH_TASK_FAMILY")?, subnets: require("MATCH_SUBNETS")? .split(',') .map(str::to_owned) .collect(), task_security_group: require("MATCH_TASK_SG")?, artifacts_bucket: require("ARTIFACTS_BUCKET")?, scenario_name, human_slot: lookup("MATCH_HUMAN_SLOT").unwrap_or_else(|| "TraineeA".to_owned()), bot_slot: lookup("MATCH_BOT_SLOT").unwrap_or_else(|| "TraineeB".to_owned()), }) } }; // Refused at startup rather than on the first flare: a typo here // would otherwise sit quiet until a player hit send. let flare_space = match lookup("FLARE_SPACE") { None => None, Some(uri) => { if crate::atproto::split_space_uri(&uri).is_none() { return Err(format!( "FLARE_SPACE {uri:?} is not at:///app.userinput.space/" )); } Some(uri) } }; // Which index, and nothing more: the release it describes and where // that release's art is published both come out of the file, so this // is the only place a version is named and it is named once. // Empty is unset, not a bad value: infra passes every entry of one // env map, so "no index configured" arrives as `UNIT_INDEX_URL=""` // rather than as an absent variable, and refusing to start over it // would take the whole API down for a report detail. let unit_index_url = match lookup("UNIT_INDEX_URL").filter(|url| !url.is_empty()) { None => None, Some(url) => { if !url.starts_with("https://") && !url.starts_with("http://") { return Err(format!("UNIT_INDEX_URL {url:?} must be an http(s) URL")); } Some(url) } }; Ok(Config { bind_addr, public_url, web_origin, share_origin, session_secret, cookie_domain, private_key_jwk, db_path, matches, // "unknown" is the Dockerfile's default for a bare `docker // build`; like empty, it means unset here. build_ref: lookup("BUILD_REF").filter(|s| !s.is_empty() && s != "unknown"), arena_version: lookup("ARENA_VERSION").filter(|s| !s.is_empty()), flare_space, unit_index_url, }) } /// The OAuth redirect URI. Loopback development derives it from the bind /// port: the container binds 0.0.0.0:3000 but publishes on /// 127.0.0.1:3000, so the port carries over. pub fn redirect_uri(&self) -> String { match &self.public_url { Some(url) => format!("{url}/oauth/callback"), None => format!("http://127.0.0.1:{}/oauth/callback", self.bind_addr.port()), } } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; fn config(vars: &[(&str, &str)]) -> Result { let map: HashMap = vars .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); Config::from_lookup(|key| map.get(key).cloned()) } const SECRET: &str = "0123456789abcdef0123456789abcdef"; /// The one env map infra fills passes every key, so a deployment with no /// index set sends an empty string. That has to mean "no index" and not /// "refuse to start" - it is a detail of one page, and taking the API /// down for it would be far worse than a silhouette. #[test] fn an_empty_unit_index_is_no_index() { assert_eq!( config(&[("UNIT_INDEX_URL", "")]).unwrap().unit_index_url, None ); assert_eq!(config(&[]).unwrap().unit_index_url, None); assert_eq!( config(&[( "UNIT_INDEX_URL", "https://lance.blue/assets/helm/0.51.0/units.json" )]) .unwrap() .unit_index_url .as_deref(), Some("https://lance.blue/assets/helm/0.51.0/units.json") ); assert!(config(&[("UNIT_INDEX_URL", "lance.blue/units.json")]).is_err()); } #[test] fn defaults_are_loopback_dev() { let c = config(&[]).unwrap(); assert_eq!(c.bind_addr, SocketAddr::from(([127, 0, 0, 1], 3000))); assert_eq!(c.public_url, None); assert_eq!(c.web_origin, "http://127.0.0.1:5173"); assert_eq!(c.session_secret.len(), 32); assert_eq!(c.redirect_uri(), "http://127.0.0.1:3000/oauth/callback"); } #[test] fn public_mode_requires_secret_and_key() { let e = config(&[("PUBLIC_URL", "https://api.lance.blue")]).unwrap_err(); assert!(e.contains("SESSION_SECRET")); let e = config(&[ ("PUBLIC_URL", "https://api.lance.blue"), ("SESSION_SECRET", SECRET), ]) .unwrap_err(); assert!(e.contains("PRIVATE_KEY_JWK")); } #[test] fn public_mode_config_resolves() { let c = config(&[ ("PUBLIC_URL", "https://api.lance.blue/"), ("SESSION_SECRET", SECRET), ("PRIVATE_KEY_JWK", "{}"), ("WEB_ORIGIN", "https://lance.blue"), ]) .unwrap(); assert_eq!(c.public_url.as_deref(), Some("https://api.lance.blue")); assert_eq!(c.redirect_uri(), "https://api.lance.blue/oauth/callback"); assert_eq!(c.web_origin, "https://lance.blue"); } #[test] fn public_url_must_be_https() { assert!(config(&[("PUBLIC_URL", "http://api.lance.blue")]).is_err()); assert!(config(&[("PUBLIC_URL", "https://")]).is_err()); } #[test] fn key_without_public_url_is_refused() { assert!(config(&[("PRIVATE_KEY_JWK", "{}")]).is_err()); } #[test] fn cookie_domain_must_cover_the_api_host() { let public = |domain: &str| { config(&[ ("PUBLIC_URL", "https://api.lance.blue"), ("SESSION_SECRET", SECRET), ("PRIVATE_KEY_JWK", "{}"), ("COOKIE_DOMAIN", domain), ]) }; assert_eq!( public("lance.blue").unwrap().cookie_domain.as_deref(), Some("lance.blue") ); // A leading dot is the pre-RFC-6265 spelling; browsers ignore it. assert_eq!( public(".lance.blue").unwrap().cookie_domain.as_deref(), Some("lance.blue") ); assert!(public("example.com").is_err()); // Suffix match is on labels, not characters. assert!(public("ance.blue").is_err()); assert!(public("").is_err()); } #[test] fn cookie_domain_without_public_url_is_refused() { assert!(config(&[("COOKIE_DOMAIN", "lance.blue")]).is_err()); } #[test] fn cookie_domain_defaults_off() { assert_eq!(config(&[]).unwrap().cookie_domain, None); } #[test] fn flare_space_must_be_a_space_uri() { assert_eq!(config(&[]).unwrap().flare_space, None); assert_eq!( config(&[("FLARE_SPACE", "at://did:plc:abc/app.userinput.space/3kxyz")]) .unwrap() .flare_space .as_deref(), Some("at://did:plc:abc/app.userinput.space/3kxyz") ); assert!(config(&[("FLARE_SPACE", "https://userinput.app/s/x/y")]).is_err()); assert!(config(&[("FLARE_SPACE", "at://did:plc:abc/other.thing/3k")]).is_err()); } #[test] fn short_secret_is_refused() { assert!(config(&[("SESSION_SECRET", "short")]).is_err()); } #[test] fn web_origin_must_be_bare() { assert!(config(&[("WEB_ORIGIN", "https://lance.blue/app")]).is_err()); assert!(config(&[("WEB_ORIGIN", "https://lance.blue/")]).is_err()); assert!(config(&[("WEB_ORIGIN", "lance.blue")]).is_err()); assert!(config(&[("WEB_ORIGIN", "http://127.0.0.1:5173")]).is_ok()); } #[test] fn match_config_is_all_or_nothing() { assert!(config(&[]).unwrap().matches.is_none()); let e = config(&[("MATCH_CLUSTER", "c")]).unwrap_err(); assert!(e.contains("MATCH_TASK_FAMILY")); let c = config(&[ ("MATCH_CLUSTER", "c"), ("MATCH_TASK_FAMILY", "f"), ("MATCH_SUBNETS", "subnet-1,subnet-2"), ("MATCH_TASK_SG", "sg-1"), ("ARTIFACTS_BUCKET", "b"), ]) .unwrap(); let m = c.matches.unwrap(); assert_eq!(m.subnets, vec!["subnet-1", "subnet-2"]); assert_eq!(m.scenario_name, "TrainingScenarios/1-FirstRun.mms"); assert_eq!(m.human_slot, "TraineeA"); assert_eq!(m.bot_slot, "TraineeB"); } #[test] fn version_vars_treat_empty_and_unknown_as_unset() { let c = config(&[("BUILD_REF", ""), ("ARENA_VERSION", "")]).unwrap(); assert_eq!(c.build_ref, None); assert_eq!(c.arena_version, None); let c = config(&[("BUILD_REF", "unknown")]).unwrap(); assert_eq!(c.build_ref, None); let c = config(&[ ("BUILD_REF", "abc123def456"), ("ARENA_VERSION", "mm0.51.0-ws26.4.5-abc123def456"), ]) .unwrap(); assert_eq!(c.build_ref.as_deref(), Some("abc123def456")); assert_eq!( c.arena_version.as_deref(), Some("mm0.51.0-ws26.4.5-abc123def456") ); } #[test] fn debug_hides_secrets() { let c = config(&[("SESSION_SECRET", SECRET)]).unwrap(); let s = format!("{c:?}"); assert!(!s.contains(SECRET)); } }