//! `gatewayd.toml` into a typed `Config`. Our own format: unknown keys are errors in every table. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use serde::Deserialize; #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { pub mattermost: MattermostConfig, pub secrets: BTreeMap, pub allow: AllowConfig, #[serde(default, rename = "loop")] pub loop_: LoopConfig, #[serde(default)] pub paths: Paths, #[serde(default)] pub limits: Limits, #[serde(default)] pub approvals: ApprovalsConfig, } /// `[approvals]`: who may answer approvals, and where `brokerd` brings them. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(deny_unknown_fields, default)] pub struct ApprovalsConfig { /// Mattermost user ids; empty means everyone in `[allow] users`. pub users: Vec, /// Empty: `/run/gateway/gateway.sock`. pub socket: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields)] pub struct MattermostConfig { pub url: String, #[serde(default)] pub ca_file: Option, } /// Where one secret comes from: exactly one of the three is set (checked by `Config::load`). #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct SecretSpec { #[serde(default)] pub credential: Option, #[serde(default)] pub env: Option, #[serde(default)] pub file: Option, } /// A checked `SecretSpec`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SecretSource { Credential(String), Env(String), File(PathBuf), } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields)] pub struct AllowConfig { pub users: Vec, #[serde(default)] pub channels: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(deny_unknown_fields, default)] pub struct LoopConfig { pub socket: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct Paths { pub home: PathBuf, } impl Default for Paths { fn default() -> Self { Paths { home: std::env::var_os("BOXMAKER_HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker")), } } } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct Limits { pub queue: u32, pub typing_every_ms: u64, pub ping_every_ms: u64, pub dead_after_ms: u64, } impl Default for Limits { fn default() -> Self { Limits { queue: 20, typing_every_ms: 3_000, ping_every_ms: 30_000, dead_after_ms: 60_000, } } } /// `url` taken apart. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServerUrl { pub tls: bool, pub host: String, pub port: u16, } #[derive(Debug)] pub enum ConfigError { Read(PathBuf, std::io::Error), Parse(PathBuf, toml::de::Error), Invalid(PathBuf, String), } impl std::fmt::Display for ConfigError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Each variant: ": ", with `path.display()`. match self { ConfigError::Read(path, e) => write!(f, "{}: {}", path.display(), e), ConfigError::Parse(path, e) => write!(f, "{}: {}", path.display(), e), ConfigError::Invalid(path, why) => write!(f, "{}: {}", path.display(), why), } } } impl std::error::Error for ConfigError {} /// The one secret M4a needs. pub const MATTERMOST_TOKEN: &str = "mattermost_token"; impl Config { /// Parse without the checks `load` makes. pub fn parse(text: &str) -> Result { // `toml::from_str`. toml::from_str(text) } pub fn load(path: &Path) -> Result { // Read the file (else Read), parse (else Parse), then `problem()` (Some(why) is Invalid). let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?; let config = Config::parse(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?; match config.problem() { Some(why) => Err(ConfigError::Invalid(path.to_path_buf(), why)), None => Ok(config), } } /// The first thing wrong with the values, or `None`. pub fn problem(&self) -> Option { // The first of these, in this order, with the exact messages in the task: the url // (`parse_url`); ca_file not absolute; [secrets.mattermost_token] missing; each secret // whose `source()` fails; allow.users empty; any id in allow.users or allow.channels not // `valid_id`; any limit that is 0. if let Err(why) = parse_url(&self.mattermost.url) { return Some(why); } if let Some(ca_file) = &self.mattermost.ca_file && !ca_file.is_absolute() { return Some(format!( "[mattermost] ca_file {:?} must be an absolute path", ca_file.display() )); } if !self.secrets.contains_key(MATTERMOST_TOKEN) { return Some("[secrets.mattermost_token] is missing".to_string()); } for (name, spec) in &self.secrets { if let Err(why) = spec.source() { return Some(format!("[secrets.{}] {}", name, why)); } } if self.allow.users.is_empty() { return Some( "[allow] users is empty: a gateway that answers nobody is a mistake".to_string(), ); } for id in self.allow.users.iter().chain(self.allow.channels.iter()) { if !valid_id(id) { return Some(format!( "[allow] {:?} is not a Mattermost id (26 characters of a-z and 0-9)", id )); } } for id in &self.approvals.users { if !self.allow.users.contains(id) { return Some(format!( "[approvals] {id:?} is not in [allow] users: an approver who is not heard could never answer" )); } } if self.limits.queue == 0 { return Some("[limits] queue must be at least 1".to_string()); } for (name, value) in [ ("typing_every_ms", self.limits.typing_every_ms), ("ping_every_ms", self.limits.ping_every_ms), ("dead_after_ms", self.limits.dead_after_ms), ] { if value == 0 { return Some(format!("[limits] {} must be at least 1", name)); } } None } /// The server's address, from `url`. Call only on a checked `Config`. pub fn server(&self) -> Result { // `parse_url` of the url. parse_url(&self.mattermost.url) } pub fn token_source(&self) -> Result { // The `source()` of [secrets.mattermost_token], or an error if it is missing. match self.secrets.get(MATTERMOST_TOKEN) { Some(spec) => spec.source(), None => Err("[secrets.mattermost_token] is missing".to_string()), } } pub fn loop_socket(&self) -> PathBuf { // [loop] socket, or /run/loop/loop.sock when it is empty. if self.loop_.socket.as_os_str().is_empty() { return self.paths.home.join("run/loop/loop.sock"); } self.loop_.socket.clone() } /// Who may answer approvals: `[approvals] users`, or `[allow] users` when that is empty. pub fn approvers(&self) -> &[String] { if self.approvals.users.is_empty() { return &self.allow.users; } &self.approvals.users } /// The first approver, or `None` when there are none. pub fn first_approver(&self) -> Option<&str> { self.approvers().first().map(String::as_str) } /// `gateway.sock`: `[approvals] socket`, or `/run/gateway/gateway.sock` when empty. pub fn gateway_socket(&self) -> PathBuf { if self.approvals.socket.as_os_str().is_empty() { return self.paths.home.join("run/gateway/gateway.sock"); } self.approvals.socket.clone() } pub fn state_path(&self) -> PathBuf { // /gateway/state.json. self.paths.home.join("gateway/state.json") } } impl SecretSpec { /// Exactly one source, well formed. pub fn source(&self) -> Result { // Exactly one of the three set, else "needs exactly one of credential, env and file". // credential: not empty, only ASCII letters, digits, _ . -. env: not empty, only A-Z, 0-9, // _. file: an absolute path. The messages are in the task. let mut count = 0; if self.credential.is_some() { count += 1; } if self.env.is_some() { count += 1; } if self.file.is_some() { count += 1; } if count != 1 { return Err("needs exactly one of credential, env and file".to_string()); } if let Some(credential) = &self.credential { if credential.is_empty() || !credential.bytes().all(|b| { b.is_ascii_alphabetic() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-') }) { return Err(format!( "credential {:?} is not a credential name (letters, digits, _ . -)", credential )); } return Ok(SecretSource::Credential(credential.clone())); } if let Some(env) = &self.env { if env.is_empty() || !env .bytes() .all(|b| matches!(b, b'A'..=b'Z' | b'0'..=b'9' | b'_')) { return Err(format!( "env {:?} is not a variable name (A-Z, 0-9, _)", env )); } return Ok(SecretSource::Env(env.clone())); } // `count == 1` and the two above are `None`, so this is `Some`. let file = match &self.file { Some(file) => file, None => return Err("needs exactly one of credential, env and file".to_string()), }; if !file.is_absolute() { return Err(format!( "file {:?} must be an absolute path", file.display() )); } Ok(SecretSource::File(file.clone())) } } /// A Mattermost id: 26 characters of `a-z0-9`. pub fn valid_id(id: &str) -> bool { // 26 bytes, each a-z or 0-9. id.len() == 26 && id .bytes() .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase()) } /// `http://host[:port]` or `https://host[:port]`, nothing else. pub fn parse_url(url: &str) -> Result { // http:// or https://, then a host, then optionally ":" and a port. The port: 1..=65535 written // exactly as `port.to_string()` (so no "+1", no "080"). Default 443 for https, 80 for http. The // host: 1..=253 bytes of a-z, 0-9, "." and "-", not starting or ending with "." or "-". // Anything else, including a path, a user or an upper-case letter, is the one error message in // the task. let (tls, rest) = match url.strip_prefix("https://") { Some(rest) => (true, rest), None => match url.strip_prefix("http://") { Some(rest) => (false, rest), None => return Err(url_error(url)), }, }; let (host, port) = match rest.rsplit_once(':') { Some((h, p)) => { if !valid_host(h) { return Err(url_error(url)); } let port = match parse_port(p) { Some(port) => port, None => return Err(url_error(url)), }; (h.to_string(), port) } None => { if !valid_host(rest) { return Err(url_error(url)); } (rest.to_string(), if tls { 443 } else { 80 }) } }; Ok(ServerUrl { tls, host, port }) } fn url_error(url: &str) -> String { format!( "[mattermost] url {:?} must be http:// or https://, a host, an optional port, and nothing else", url ) } fn valid_host(host: &str) -> bool { let bytes = host.as_bytes(); let len = bytes.len(); if len == 0 || len > 253 { return false; } if bytes[0] == b'.' || bytes[0] == b'-' || bytes[len - 1] == b'.' || bytes[len - 1] == b'-' { return false; } bytes .iter() .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-') } fn parse_port(p: &str) -> Option { if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) { return None; } let n = p.parse::().ok()?; if !(1..=65535).contains(&n) || n.to_string() != p { return None; } u16::try_from(n).ok() }