Files
boxmaker/crates/brokerd/src/config.rs
T
2026-09-28 22:28:41 -07:00

326 lines
11 KiB
Rust

//! `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<Runner>,
#[serde(default)]
pub gateway: Option<Gateway>,
#[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<Config, toml::de::Error> {
toml::from_str(text)
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
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: <name>@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<PathBuf> {
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")
}
}