//! Configuration (hub spec §17.1): `hub.toml` in the data directory, every //! key overridable by `OXYHUB_
_`. use serde::Deserialize; use std::net::SocketAddr; use std::path::{Path, PathBuf}; #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Config { /// Set from the command line, never from the file. #[serde(skip)] pub data: PathBuf, pub site: Site, pub server: Server, pub tls: Tls, pub auth: Auth, pub registration: Registration, pub email: Email, pub limits: Limits, pub retention: Retention, pub flow: Flow, pub apt: Apt, pub diffusion: Diffusion, pub backups: Backups, pub outbound: Outbound, pub database: Database, pub tlog: Tlog, pub push: Push, } /// Where a hub built with the `postgres` feature keeps its tables (#211); a /// SQLite build keeps them in `/hub.db` and ignores this. #[derive(Clone, Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Database { /// `postgres://user@host/db`; the password from the URL or `PGPASSWORD`. pub url: String, /// The schema to use, made if missing; empty for `public`, `*` for one /// per data directory (named after it), as the tests use. pub schema: String, } /// Outbound requests to addresses people give (oxygenhub#219): private /// and local addresses are refused unless listed here, as `scheme://host` /// (any port) or `scheme://host:port`, e.g. a local Ollama: /// `allow = ["http://127.0.0.1:11434"]`. #[derive(Clone, Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Outbound { pub allow: Vec, } /// Phone alerts (oxygenhub-android#16, oxygenhub-gcp decision 0008): a /// notification is also sent, sealed to each phone's key, through Firebase /// Cloud Messaging. Off while `fcm_project` is empty. The hub sends as the /// service account its VM runs as, whose token the metadata server gives: /// no key. #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Push { /// The Firebase (GCP) project, `OXYHUB_PUSH_FCM_PROJECT`. pub fcm_project: String, /// Cloud Messaging and the token source; set only to point them /// elsewhere (tests). pub fcm_url: String, pub token_url: String, } impl Default for Push { fn default() -> Self { Push { fcm_project: String::new(), fcm_url: "https://fcm.googleapis.com".into(), token_url: "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token".into(), } } } /// The transparency log's witnesses (docs/transparency.md, #238): who /// cosigns this hub's checkpoints, and whose logs this hub cosigns. #[derive(Clone, Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Tlog { pub witnesses: Vec, pub witness_for: Vec, } /// A witness this hub's log asks: its name in cosignatures, its /// add-checkpoint address (`https://HUB/log/witness/add-checkpoint` for /// another hub), and its identity object in base64 (another hub's /// `/.well-known/oxyhub-key`). #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct TlogWitness { pub name: String, pub url: String, pub key: String, } /// A log this hub witnesses: its origin (`HOST/log`) and its key, an /// identity object in base64. #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct TlogWitnessed { pub origin: String, pub key: String, } /// Backups (docs/backups.md, oxygenhub#169): the directories on this host /// an owner may back up to. Empty, only GCS destinations can be chosen. #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Backups { pub directories: Vec, /// Google's token exchange and Cloud Storage, for GCS destinations; /// set only to point them elsewhere (tests, a private endpoint). pub sts_url: String, pub storage_url: String, /// AWS STS and S3, for S3 destinations; `{bucket}` and `{region}` in /// `s3_url` are filled in. Set only to point them elsewhere. pub aws_sts_url: String, pub s3_url: String, } impl Default for Backups { fn default() -> Self { Backups { directories: Vec::new(), sts_url: "https://sts.googleapis.com".into(), storage_url: "https://storage.googleapis.com".into(), aws_sts_url: "https://sts.amazonaws.com".into(), s3_url: "https://{bucket}.s3.{region}.amazonaws.com".into(), } } } /// Diffusion (#148, docs/diffusion.md §4.2): the site's default for /// repositories that say nothing. #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Diffusion { /// `off`, `green` or `any`. pub follow: String, } impl Default for Diffusion { fn default() -> Self { Diffusion { follow: "off".into() } } } /// The apt repository at `/apt` (oxygenhub#85): the `.deb` assets of these /// public repositories' releases, signed with the key in `gnupg_home`. #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Apt { /// `owner/name` of each repository whose release `.deb`s are served; /// none turns `/apt` off. pub repos: Vec, /// The suite apt's `sources` names. pub suite: String, pub component: String, /// GnuPG home holding the signing key (relative to the data directory). pub gnupg_home: String, /// Architectures the index lists, each with its Packages file. pub architectures: Vec, } impl Default for Apt { fn default() -> Self { Apt { repos: vec![], suite: "stable".into(), component: "main".into(), gnupg_home: "apt-gnupg".into(), architectures: vec!["amd64".into(), "arm64".into(), "riscv64".into()], } } } /// OxygenFlow runs (hub spec §13.2). #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Flow { /// Bearer tokens runners present to claim jobs. pub runner_tokens: Vec, /// `owner/*` or `owner/name`: repositories whose pushes start runs. pub repos: Vec, pub job_timeout_minutes: u64, /// A runner whose last this many jobs all ended in an infrastructure /// error (status `error`, not `failed`) is drained and the /// administrators told (oxygenflow#30); 0 never drains one. pub drain_after_errors: u32, } impl Default for Flow { fn default() -> Self { Flow { runner_tokens: vec![], repos: vec![], job_timeout_minutes: 60, drain_after_errors: 5 } } } impl Flow { /// Whether pushes to `owner/name` start runs. pub fn allows(&self, owner: &str, name: &str) -> bool { self.repos.iter().any(|p| match p.split_once('/') { Some((o, "*")) => o == owner, Some((o, n)) => o == owner && n == name, None => false, }) } } #[derive(Clone, Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Email { /// `smtp://user:pass@host:port` or `smtps://…`; empty disables email. pub smtp: String, /// `OxygenHub `. pub from: String, } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Registration { /// `open`, `invite` or `closed`; the admin page can override it. pub mode: String, /// The setup code the first account must bring (oxygenhub#245), for /// automation; empty, the hub makes one and keeps it in /// `/setup-code`. pub setup_code: String, } impl Default for Registration { fn default() -> Self { Registration { mode: "invite".into(), setup_code: String::new() } } } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Site { pub name: String, pub description: String, } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Server { /// Plain HTTP; empty disables it. pub bind: String, /// HTTPS; empty disables it. Needs `tls`. pub tls_bind: String, /// What links and clone URLs start with. pub base_url: String, /// Trust `X-Forwarded-*` from a terminating proxy. pub behind_proxy: bool, /// Strict-Transport-Security's max-age in seconds, sent when /// `base_url` is https; 0 sends none (oxygenhub#231). Raise it in /// stages: browsers keep it, so it is hard to take back. pub hsts_max_age: u64, /// The bearer token `/metrics` asks for (oxygenhub#212); empty, the /// metrics are off. pub metrics_token: String, /// A Grafana for this hub's metrics, linked from Insights (oxygenhub#257) /// for charts of one's own; empty, no link. pub grafana_url: String, /// What this process does (docs/roles.md, #272): `web` (pages, the /// API, the wire protocol, the runner API) and `worker` (the background /// tasks). Empty, both. `serve --role` overrides it. pub roles: Vec, } /// One part of the hub's work a process can take (docs/roles.md, #272). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Role { /// Pages, the JSON API, the wire protocol, live pages and the runner /// API, and the transparency log they append to. Web, /// The background tasks: webhooks, Flow's scheduler, reaper and /// placement pass, nightly upkeep, backups and Diffusion's index. Worker, } impl Role { pub const ALL: [Role; 2] = [Role::Web, Role::Worker]; pub fn name(self) -> &'static str { match self { Role::Web => "web", Role::Worker => "worker", } } } impl std::str::FromStr for Role { type Err = String; fn from_str(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { "web" => Ok(Role::Web), "worker" => Ok(Role::Worker), _ => Err(format!("unknown role {:?}: the roles are web and worker", s.trim())), } } } /// The roles one process takes: every role unless some are named. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Roles { pub web: bool, pub worker: bool, } impl Default for Roles { fn default() -> Self { Roles { web: true, worker: true } } } impl Roles { /// The roles named, each `web` or `worker`; none named is every role. pub fn from_names>(names: &[S]) -> Result { let names: Vec<&str> = names.iter().map(|s| s.as_ref().trim()).filter(|s| !s.is_empty()).collect(); if names.is_empty() { return Ok(Roles::default()); } let mut roles = Roles { web: false, worker: false }; for n in names { match n.parse::()? { Role::Web => roles.web = true, Role::Worker => roles.worker = true, } } Ok(roles) } pub fn has(self, role: Role) -> bool { match role { Role::Web => self.web, Role::Worker => self.worker, } } /// The roles' names, in a fixed order: what `/health` reports. pub fn names(self) -> Vec<&'static str> { Role::ALL.into_iter().filter(|r| self.has(*r)).map(Role::name).collect() } } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Tls { /// Generate a certificate at `init` (developer edition). pub self_signed: bool, /// PEM files; relative paths are under the data directory. pub cert: String, pub key: String, } #[derive(Clone, Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Auth { /// Bearer tokens allowed to push until accounts exist (hub 0.2). Empty /// means every push is accepted, as `oxy serve` does. pub push_tokens: Vec, /// For tests only: a base URL that stands in for every sign-in /// provider (`{base}/{provider}/authorize|token|userinfo`). pub signin_mock: Option, } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Limits { /// Largest file rendered in the browser, e.g. `2MiB`. pub render_limit: ByteSize, pub asset_limit: ByteSize, pub push_body_limit: ByteSize, /// Push protection's budget (oxygenhub#268): what one push may have /// scanned, the largest file or inserted text scanned, and how long /// the scan may take. Past them the rest goes unscanned. pub scan_limit: ByteSize, pub scan_file_limit: ByteSize, pub scan_time_ms: u64, /// The history scan's budget (oxygenhub#277): what one pass over one /// repository may scan, and for how long, before it moves on to the /// next and resumes there later. A limit of 0 turns the scan off. pub history_scan_limit: ByteSize, pub history_scan_time_ms: u64, } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Retention { pub trash_days: u32, pub webhook_log_days: u32, /// The UTC hour at which every repository is garbage collected /// (spec §17.2); 24 or more turns the nightly gc off. pub gc_hour: u32, } /// A byte count written as `512`, `2MiB`, `4GiB`, `100kB`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ByteSize(pub u64); impl ByteSize { pub fn parse(s: &str) -> Option { let s = s.trim(); let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect(); let unit = s[digits.len()..].trim(); let n: u64 = digits.parse().ok()?; let mult = match unit { "" | "B" => 1, "k" | "kB" | "KB" => 1000, "KiB" | "K" => 1 << 10, "M" | "MB" => 1_000_000, "MiB" => 1 << 20, "G" | "GB" => 1_000_000_000, "GiB" => 1 << 30, _ => return None, }; Some(ByteSize(n.checked_mul(mult)?)) } } impl<'de> Deserialize<'de> for ByteSize { fn deserialize>(d: D) -> Result { #[derive(Deserialize)] #[serde(untagged)] enum Raw { Number(u64), Text(String), } match Raw::deserialize(d)? { Raw::Number(n) => Ok(ByteSize(n)), Raw::Text(t) => ByteSize::parse(&t).ok_or_else(|| serde::de::Error::custom(format!("not a byte size: {t:?}"))), } } } impl Default for Site { fn default() -> Self { Site { name: "OxygenHub".into(), description: "Repositories, proposals and issues for Oxygen".into() } } } impl Default for Server { fn default() -> Self { Server { bind: "127.0.0.1:7300".into(), tls_bind: String::new(), base_url: "http://127.0.0.1:7300".into(), behind_proxy: false, hsts_max_age: 0, metrics_token: String::new(), grafana_url: String::new(), roles: Vec::new(), } } } impl Default for Tls { fn default() -> Self { Tls { self_signed: false, cert: "keys/tls.crt".into(), key: "keys/tls.key".into() } } } impl Default for Limits { fn default() -> Self { let scan = crate::push_protection::Budget::default(); let history = crate::history_scan::Budget::default(); Limits { render_limit: ByteSize(2 << 20), asset_limit: ByteSize(512 << 20), push_body_limit: ByteSize(4 << 30), scan_limit: ByteSize(scan.bytes), scan_file_limit: ByteSize(scan.file), scan_time_ms: scan.time.as_millis() as u64, history_scan_limit: ByteSize(history.bytes), history_scan_time_ms: history.time.as_millis() as u64, } } } impl Default for Retention { fn default() -> Self { Retention { trash_days: 30, webhook_log_days: 30, gc_hour: 3 } } } impl Default for Config { fn default() -> Self { Config { data: PathBuf::from("data"), site: Site::default(), server: Server::default(), tls: Tls::default(), auth: Auth::default(), registration: Registration::default(), email: Email::default(), limits: Limits::default(), retention: Retention::default(), flow: Flow::default(), apt: Apt::default(), diffusion: Diffusion::default(), backups: Backups::default(), outbound: Outbound::default(), database: Database::default(), tlog: Tlog::default(), push: Push::default(), } } } impl Config { /// Read `/hub.toml` if present, then apply environment overrides. pub fn load(data: &Path) -> anyhow::Result { let path = data.join("hub.toml"); let mut cfg = match std::fs::read_to_string(&path) { Ok(text) => Config::parse(&text)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(), Err(e) => return Err(anyhow::anyhow!("reading {}: {e}", path.display())), }; cfg.data = data.to_path_buf(); cfg.apply_env(|k| std::env::var(k).ok()); cfg.roles()?; Ok(cfg) } pub fn parse(text: &str) -> anyhow::Result { Ok(toml::from_str(text)?) } /// `OXYHUB_SERVER_BIND`, `OXYHUB_SITE_NAME`, … override the file. pub fn apply_env(&mut self, get: impl Fn(&str) -> Option) { if let Some(v) = get("OXYHUB_DATABASE_URL") { self.database.url = v; } if let Some(v) = get("OXYHUB_DATABASE_SCHEMA") { self.database.schema = v; } if let Some(v) = get("OXYHUB_PUSH_FCM_PROJECT") { self.push.fcm_project = v; } if let Some(v) = get("OXYHUB_SITE_NAME") { self.site.name = v; } if let Some(v) = get("OXYHUB_SITE_DESCRIPTION") { self.site.description = v; } if let Some(v) = get("OXYHUB_SERVER_BIND") { self.server.bind = v; } if let Some(v) = get("OXYHUB_SERVER_TLS_BIND") { self.server.tls_bind = v; } if let Some(v) = get("OXYHUB_SERVER_BASE_URL") { self.server.base_url = v; } if let Some(v) = get("OXYHUB_SERVER_BEHIND_PROXY") { self.server.behind_proxy = matches!(v.as_str(), "1" | "true" | "yes"); } if let Some(v) = get("OXYHUB_SERVER_HSTS_MAX_AGE") { self.server.hsts_max_age = v.parse().unwrap_or(0); } // The VM's setup gives these (oxygenhub-gcp#12): the token its // collector scrapes /metrics with, and its Grafana for Insights' link. if let Some(v) = get("OXYHUB_SERVER_METRICS_TOKEN") { self.server.metrics_token = v; } if let Some(v) = get("OXYHUB_SERVER_GRAFANA_URL") { self.server.grafana_url = v; } if let Some(v) = get("OXYHUB_SERVER_ROLES") { self.server.roles = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect(); } if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") { self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes"); } if let Some(v) = get("OXYHUB_REGISTRATION_MODE") { self.registration.mode = v; } if let Some(v) = get("OXYHUB_REGISTRATION_SETUP_CODE") { self.registration.setup_code = v; } if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") { self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect(); } if let Some(v) = get("OXYHUB_LIMITS_RENDER_LIMIT").and_then(|v| ByteSize::parse(&v)) { self.limits.render_limit = v; } } /// This process's roles (`[server] roles`); an unknown name is an error. pub fn roles(&self) -> anyhow::Result { Roles::from_names(&self.server.roles).map_err(|e| anyhow::anyhow!("[server] roles: {e}")) } pub fn bind_addr(&self) -> anyhow::Result> { parse_addr(&self.server.bind) } pub fn tls_bind_addr(&self) -> anyhow::Result> { parse_addr(&self.server.tls_bind) } pub fn tls_cert(&self) -> PathBuf { self.data.join(&self.tls.cert) } pub fn tls_key(&self) -> PathBuf { self.data.join(&self.tls.key) } pub fn repos_dir(&self) -> PathBuf { self.data.join("repos") } pub fn keys_dir(&self) -> PathBuf { self.data.join("keys") } pub fn db_path(&self) -> PathBuf { self.data.join("hub.db") } /// The base URL without a trailing slash. pub fn base_url(&self) -> &str { self.server.base_url.trim_end_matches('/') } } fn parse_addr(s: &str) -> anyhow::Result> { if s.trim().is_empty() { return Ok(None); } Ok(Some(s.parse().map_err(|e| anyhow::anyhow!("bind address {s:?}: {e}"))?)) } #[cfg(test)] mod tests { use super::*; #[test] fn byte_sizes() { assert_eq!(ByteSize::parse("2MiB"), Some(ByteSize(2 << 20))); assert_eq!(ByteSize::parse("512"), Some(ByteSize(512))); assert_eq!(ByteSize::parse("4 GiB"), Some(ByteSize(4 << 30))); assert_eq!(ByteSize::parse("100kB"), Some(ByteSize(100_000))); assert_eq!(ByteSize::parse("lots"), None); } #[test] fn file_then_environment() { let mut cfg = Config::parse("[server]\nbind = \"0.0.0.0:7300\"\n[limits]\nrender_limit = \"1MiB\"\n").unwrap(); assert_eq!(cfg.server.bind, "0.0.0.0:7300"); assert_eq!(cfg.limits.render_limit, ByteSize(1 << 20)); assert_eq!(cfg.site.name, "OxygenHub"); cfg.apply_env(|k| match k { "OXYHUB_SITE_NAME" => Some("Earth".into()), "OXYHUB_AUTH_PUSH_TOKENS" => Some("a, b".into()), "OXYHUB_SERVER_METRICS_TOKEN" => Some("m".into()), "OXYHUB_SERVER_GRAFANA_URL" => Some("https://grafana.example".into()), _ => None, }); assert_eq!(cfg.site.name, "Earth"); assert_eq!(cfg.auth.push_tokens, ["a", "b"]); assert_eq!((cfg.server.metrics_token.as_str(), cfg.server.grafana_url.as_str()), ("m", "https://grafana.example")); assert!(Config::parse("[nope]\nx = 1\n").is_err(), "unknown sections are errors, not silence"); } #[test] fn roles_default_to_all_and_unknown_names_are_errors() { let cfg = Config::default(); assert_eq!(cfg.roles().unwrap(), Roles { web: true, worker: true }); assert_eq!(cfg.roles().unwrap().names(), ["web", "worker"]); assert_eq!(Roles::from_names(&["worker"]).unwrap(), Roles { web: false, worker: true }); assert_eq!(Roles::from_names(&[" Web ", "web"]).unwrap().names(), ["web"]); assert_eq!(Roles::from_names(&["worker", "web"]).unwrap().names(), ["web", "worker"]); assert_eq!(Roles::from_names::<&str>(&[]).unwrap(), Roles::default()); let e = Roles::from_names(&["web", "edge"]).unwrap_err(); assert_eq!(e, "unknown role \"edge\": the roles are web and worker"); let mut cfg = Config::parse("[server]\nroles = [\"worker\"]\n").unwrap(); assert_eq!(cfg.roles().unwrap().names(), ["worker"]); cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "web, worker".into())); assert_eq!(cfg.roles().unwrap(), Roles::default()); cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "store".into())); assert!(cfg.roles().unwrap_err().to_string().contains("unknown role \"store\"")); } #[test] fn an_unknown_role_in_hub_toml_stops_loading() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("hub.toml"), "[server]\nroles = [\"wrker\"]\n").unwrap(); let e = Config::load(dir.path()).unwrap_err().to_string(); assert!(e.contains("unknown role \"wrker\""), "{e}"); } }