gatewayd: config, gatewayd.toml into a checked Config
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
//! `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<String, SecretSpec>,
|
||||
pub allow: AllowConfig,
|
||||
#[serde(default, rename = "loop")]
|
||||
pub loop_: LoopConfig,
|
||||
#[serde(default)]
|
||||
pub paths: Paths,
|
||||
#[serde(default)]
|
||||
pub limits: Limits,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MattermostConfig {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub ca_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
#[serde(default)]
|
||||
pub file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub channels: Vec<String>,
|
||||
}
|
||||
|
||||
#[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: "<path>: <error or why>", 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<Config, toml::de::Error> {
|
||||
// `toml::from_str`.
|
||||
toml::from_str(text)
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Config, ConfigError> {
|
||||
// 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<String> {
|
||||
// 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
|
||||
));
|
||||
}
|
||||
}
|
||||
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<ServerUrl, String> {
|
||||
// `parse_url` of the url.
|
||||
parse_url(&self.mattermost.url)
|
||||
}
|
||||
|
||||
pub fn token_source(&self) -> Result<SecretSource, String> {
|
||||
// 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 <home>/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()
|
||||
}
|
||||
|
||||
pub fn state_path(&self) -> PathBuf {
|
||||
// <home>/gateway/state.json.
|
||||
self.paths.home.join("gateway/state.json")
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretSpec {
|
||||
/// Exactly one source, well formed.
|
||||
pub fn source(&self) -> Result<SecretSource, String> {
|
||||
// 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<ServerUrl, String> {
|
||||
// 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<u16> {
|
||||
if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let n = p.parse::<u32>().ok()?;
|
||||
if !(1..=65535).contains(&n) || n.to_string() != p {
|
||||
return None;
|
||||
}
|
||||
u16::try_from(n).ok()
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
//! `gatewayd`: the Mattermost channel. It carries the owner's messages to `loopd` as turns and posts
|
||||
//! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`.
|
||||
|
||||
pub mod config;
|
||||
|
||||
Reference in New Issue
Block a user