//! `brokerd` configuration: read `brokerd.toml` into a typed `Config`. //! //! This is our own format, so unknown keys are errors in every table: a //! misspelt key that silently fell back to its default would be a setting the //! owner believes is set and is not. use serde::Deserialize; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct Paths { pub home: PathBuf, pub grants: PathBuf, } impl Default for Paths { fn default() -> Self { Self { home: std::env::var_os("BOXMAKER_HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker")), grants: PathBuf::from("/etc/boxmaker/grants"), } } } /// The sockets `brokerd` serves. An empty path means "the default under `home`". #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(deny_unknown_fields, default)] pub struct Sockets { pub broker: PathBuf, pub admin: PathBuf, /// `memoryd`'s session-state questions (M5c spec, section 7). pub state: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct Approvals { pub ttl_ms: u64, } impl Default for Approvals { fn default() -> Self { Self { ttl_ms: 900_000 } } } /// `[gateway]`: where `gatewayd` takes approvals to post in Mattermost. Without the section, /// Mattermost approvals are off and only `bxctl` answers. An empty path means the default. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(deny_unknown_fields, default)] pub struct Gateway { pub socket: PathBuf, } /// `[memory]`: where `memoryd` takes the decided `recall` and `memory` calls. The memory tools are /// always on, so the section is optional; an empty path means the default under `home`. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(deny_unknown_fields, default)] pub struct Memory { pub socket: PathBuf, } fn default_podman() -> PathBuf { PathBuf::from("podman") } fn default_egress_network() -> String { "pasta".to_string() } fn default_output_cap() -> u64 { 262_144 } fn default_memory() -> String { "512m".to_string() } fn default_pids() -> u32 { 128 } fn default_read_file_ms() -> u64 { 30_000 } fn default_write_file_ms() -> u64 { 30_000 } fn default_shell_ms() -> u64 { 100_000 } fn default_http_fetch_ms() -> u64 { 60_000 } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields)] pub struct Runner { #[serde(default = "default_podman")] pub podman: PathBuf, pub image: String, #[serde(default = "default_egress_network")] pub egress_network: String, #[serde(default = "default_output_cap")] pub output_cap: u64, #[serde(default = "default_memory")] pub memory: String, #[serde(default = "default_pids")] pub pids: u32, #[serde(default = "default_read_file_ms")] pub read_file_ms: u64, #[serde(default = "default_write_file_ms")] pub write_file_ms: u64, #[serde(default = "default_shell_ms")] pub shell_ms: u64, #[serde(default = "default_http_fetch_ms")] pub http_fetch_ms: u64, } impl Runner { /// The time limit for one call of `tool`: the matching `_ms` field. pub fn time_limit(&self, tool: crate::args::ToolName) -> std::time::Duration { let ms = match tool { crate::args::ToolName::ReadFile => self.read_file_ms, crate::args::ToolName::WriteFile => self.write_file_ms, crate::args::ToolName::Shell => self.shell_ms, crate::args::ToolName::HttpFetch => self.http_fetch_ms, // `memoryd` runs these; they never reach a container. crate::args::ToolName::Recall | crate::args::ToolName::Memory | crate::args::ToolName::AskMemory => self.read_file_ms, }; std::time::Duration::from_millis(ms) } } /// `memory` is well formed if it is one or more digits followed by `b`, `k`, `m` or `g`. fn valid_memory(memory: &str) -> bool { let bytes = memory.as_bytes(); bytes.len() >= 2 && matches!(bytes[bytes.len() - 1], b'b' | b'k' | b'm' | b'g') && bytes[..bytes.len() - 1].iter().all(|&c| c.is_ascii_digit()) } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct Config { #[serde(default)] pub paths: Paths, #[serde(default)] pub sockets: Sockets, #[serde(default)] pub approvals: Approvals, #[serde(default)] pub runner: Option, #[serde(default)] pub gateway: Option, #[serde(default)] pub memory: Memory, } /// The longest an approval may wait: a day, the longest `loopd` waits after a pending frame. pub const MAX_TTL_MS: u64 = 86_400_000; #[derive(Debug)] pub enum ConfigError { Read(PathBuf, std::io::Error), Parse(PathBuf, toml::de::Error), /// The file parses, but a value is outside what brokerd accepts. Invalid(PathBuf, String), } impl std::fmt::Display for ConfigError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { ConfigError::Read(path, err) => write!(f, "{}: {err}", path.display()), ConfigError::Parse(path, err) => write!(f, "{}: {err}", path.display()), ConfigError::Invalid(path, why) => write!(f, "{}: {why}", path.display()), } } } impl std::error::Error for ConfigError {} impl Config { pub fn parse(text: &str) -> Result { toml::from_str(text) } pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?; let config: Config = toml::from_str(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?; // `loopd` waits at most a day after a pending frame, so a longer approval would be given up // by the caller while `bxctl approvals` still listed it. if !(1..=MAX_TTL_MS).contains(&config.approvals.ttl_ms) { return Err(ConfigError::Invalid( path.to_path_buf(), format!( "[approvals] ttl_ms is {}; it must be from 1 to {MAX_TTL_MS} (a day)", config.approvals.ttl_ms ), )); } if let Some(runner) = &config.runner { let where_image = format!( "[runner] image is {:?}; it must be named by digest: @sha256:<64 hex digits>", runner.image ); let (name, hex) = match runner.image.rsplit_once("@sha256:") { Some(pair) => pair, None => { return Err(ConfigError::Invalid( path.to_path_buf(), where_image.clone(), )); } }; if name.is_empty() || hex.len() != 64 || !hex.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { return Err(ConfigError::Invalid(path.to_path_buf(), where_image)); } if !valid_memory(&runner.memory) { return Err(ConfigError::Invalid( path.to_path_buf(), format!( "[runner] memory is {:?}; it must be a number and one of b, k, m, g", runner.memory ), )); } if runner.egress_network.is_empty() || runner.podman.as_os_str().is_empty() { return Err(ConfigError::Invalid( path.to_path_buf(), "[runner] podman and egress_network must not be empty".to_string(), )); } if runner.output_cap == 0 { return Err(ConfigError::Invalid( path.to_path_buf(), "[runner] output_cap must be at least 1".to_string(), )); } if runner.pids == 0 { return Err(ConfigError::Invalid( path.to_path_buf(), "[runner] pids must be at least 1".to_string(), )); } if runner.read_file_ms == 0 { return Err(ConfigError::Invalid( path.to_path_buf(), "[runner] read_file_ms must be at least 1".to_string(), )); } if runner.write_file_ms == 0 { return Err(ConfigError::Invalid( path.to_path_buf(), "[runner] write_file_ms must be at least 1".to_string(), )); } if runner.shell_ms == 0 { return Err(ConfigError::Invalid( path.to_path_buf(), "[runner] shell_ms must be at least 1".to_string(), )); } if runner.http_fetch_ms == 0 { return Err(ConfigError::Invalid( path.to_path_buf(), "[runner] http_fetch_ms must be at least 1".to_string(), )); } } Ok(config) } pub fn broker_socket(&self) -> PathBuf { if self.sockets.broker.as_os_str().is_empty() { self.paths.home.join("run/loop-broker/broker.sock") } else { self.sockets.broker.clone() } } pub fn admin_socket(&self) -> PathBuf { if self.sockets.admin.as_os_str().is_empty() { self.paths.home.join("run/owner-broker/admin.sock") } else { self.sockets.admin.clone() } } /// `state.sock`, where `memoryd` asks for a session's state. pub fn state_socket(&self) -> PathBuf { if self.sockets.state.as_os_str().is_empty() { self.paths.home.join("run/memory-broker/state.sock") } else { self.sockets.state.clone() } } /// `gateway.sock`, when `[gateway]` is set. pub fn gateway_socket(&self) -> Option { let gateway = self.gateway.as_ref()?; if gateway.socket.as_os_str().is_empty() { Some(self.paths.home.join("run/gateway/gateway.sock")) } else { Some(gateway.socket.clone()) } } /// `memoryd`'s socket for `brokerd`. pub fn memory_socket(&self) -> PathBuf { if self.memory.socket.as_os_str().is_empty() { self.paths.home.join("run/broker-memory/memory.sock") } else { self.memory.socket.clone() } } pub fn audit_dir(&self) -> PathBuf { self.paths.home.join("audit") } pub fn state_dir(&self) -> PathBuf { self.paths.home.join("broker/sessions") } pub fn egress_dir(&self) -> PathBuf { self.paths.home.join("run/egress") } }