Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
20 kB · 504 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505use 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<String>, /// 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<String>, /// HMAC key for the session cookie. pub session_secret: Vec<u8>, /// `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<String>, /// 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<String>, 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<MatchConfig>, /// 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<String>, /// The arena image tag the deployment launches matches from /// (mm<megamek>-sur<suramadu>-<branch>-<sha>). The task definition pins /// the image in infra, so infra sets this alongside it; display-only here. pub arena_version: Option<String>, /// 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<String>, /// 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<String>,}
#[derive(Debug, Clone)]pub struct MatchConfig { pub cluster: String, pub task_family: String, pub subnets: Vec<String>, 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<Config, String> { 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<String>) -> Result<Config, String> { // `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://<did>/app.userinput.space/<rkey>" )); } 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<Config, String> { let map: HashMap<String, String> = 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)); }}