Skip to main content

didbot_config/
lib.rs

1//! The sectioned TOML file an operator edits, and its schema.
2//!
3//! `didbot-pds` grew twenty-one command-line flags before this crate existed,
4//! and every epic since has added another. A flag list that long stops being
5//! a bootstrap interface: there is no way to see the shape of a deployment
6//! at a glance, and a secret cannot travel on one at all: process arguments
7//! are world-readable in `/proc/<pid>/cmdline`. This is the file that
8//! replaces the list.
9//!
10//! # The tier this is
11//!
12//! [`docs/deployment.md`](../../../docs/deployment.md) names three
13//! configuration tiers by what they hold, not by what stores them: the
14//! operator's repository holds what an agent may do — scope ceilings, admitted
15//! apps, collection permissions — and is not reachable on the host at all.
16//! Process arguments and this file are the next tier down, reachable by
17//! anyone who holds the host, and hold **how the server runs**: bind
18//! address, zone, state directory, which DID's policy to read. Local state —
19//! caches, refusal records, the emergency stop — is the third.
20//!
21//! **Nothing in [`Config`] may widen what an agent may do.** A scope, a
22//! permission, or an allowlist of apps has no field here, on purpose, even
23//! though a config file is the single most tempting place to let one drift
24//! in: it is right there, it is already TOML, and it is where an operator is
25//! already looking. Those stay in the operator's own repository, per
26//! `plan/policy-store.md`, and are read at runtime rather than configured.
27//! [`sections::PolicySection`] is the one field this file may hold about
28//! policy at all: **which** DID's repository to read, never what that
29//! repository is allowed to grant.
30//!
31//! # Unknown keys refuse startup
32//!
33//! Every section carries `#[serde(deny_unknown_fields)]`. An ignored key is
34//! a setting the operator believes is in force and is not — the same
35//! fail-closed reasoning `plan/policy-store.md` applies to a policy version
36//! that will not validate. [`Config::load`] and [`Config::parse`] surface
37//! the offending key by name, because `toml`'s own deserialization error
38//! already names it.
39//!
40//! # Secrets are paths, never values
41//!
42//! A field that names a secret holds where the secret lives, not the secret
43//! itself. [`check_secret_permissions`] checks that file's own mode and
44//! reports — does not refuse — anything readable beyond its owner.
45//!
46//! No section here names a secret file today, so nothing calls it: the flags
47//! it was written for went away with the shared secret they carried.
48//! [`sections::TlsSection::cert_source`] is the first field that will need
49//! it, once a path to a fixed key pair is something a run reads rather than
50//! schema. It is kept because that is a question of when, not whether, and
51//! because it is the one place this project writes down what mode a secret
52//! file must have.
53//!
54//! It is not what keeps the secrets this project writes itself safe. Those
55//! are created `0600` under a `0700` directory by the crate that owns them,
56//! and tightened rather than trusted if they were already on disk — see
57//! `didbot_pds::wal`'s `create_dir` and `open_append`, and `didbot-tls`'s
58//! certificate store.
59//!
60//! # Precedence
61//!
62//! [`precedence()`] is the one place the file, command-line arguments and the
63//! environment are resolved against each other: arguments win, then the
64//! file, then the environment. See its own doc for why arguments are still
65//! highest despite being the leakiest tier.
66//!
67//! # What is wired today
68//!
69//! `didbot-pds` reads and applies `[server]`, `[zone]`, `[tls]`, `[blobs]`,
70//! `[capacity]`, `[names]`, `[park]`, `[tree]`, `[oidc]`, `[sessions]`,
71//! `[credential]`, `[intervals]`, `[disclosure]`, `[operator]`, `[oauth]`,
72//! `[relay]`, `[writes]` and `[proxy]`.
73//!
74//! `[dns]` and `[policy]` are schema: each names a setting whose reader
75//! lives in an epic of its own — `plan/dns-providers.md` for provider
76//! selection, `plan/policy-store.md` for which DID's records are read as
77//! policy — and `plan/config.md` tracks both. A file that sets one parses
78//! and validates like any other; an unknown key anywhere in it still
79//! refuses startup.
80
81#![forbid(unsafe_code)]
82
83mod precedence;
84mod secret;
85pub mod sections;
86
87pub use precedence::precedence;
88pub use secret::{check_secret_permissions, PermissionWarning};
89
90use std::path::{Path, PathBuf};
91
92use serde::{Deserialize, Serialize};
93
94use sections::{
95    BlobsSection, CapacitySection, CredentialSection, DisclosureSection, DnsSection,
96    IntervalsSection, NamesSection, OAuthSection, OidcSection, OperatorSection, ParkSection,
97    PolicySection, PoolSection, ProxySection, RelaySection, ServerSection, SessionsSection,
98    TlsSection, TreeSection, WritesSection, ZoneSection,
99};
100
101/// The whole file, one optional table per section.
102///
103/// A section an operator has not written is a section this binary decides
104/// on its own, the same as a flag nobody passed — so every field here is
105/// `Option`, and an empty file parses to a `Config` of every section absent
106/// rather than an error.
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct Config {
110    /// `[server]` — bind address, port.
111    #[serde(default)]
112    pub server: Option<ServerSection>,
113    /// `[zone]` — the zones accounts are minted under, and who answers for them.
114    #[serde(default)]
115    pub zone: Option<ZoneSection>,
116    /// `[dns]` — provider selection and its zone.
117    #[serde(default)]
118    pub dns: Option<DnsSection>,
119    /// `[tls]` — ACME environment, contact, certificate source.
120    #[serde(default)]
121    pub tls: Option<TlsSection>,
122    /// `[blobs]` — max size, the per-account and total quotas, retention
123    /// window.
124    #[serde(default)]
125    pub blobs: Option<BlobsSection>,
126    /// `[capacity]` — how many accounts this deployment may hold.
127    #[serde(default)]
128    pub capacity: Option<CapacitySection>,
129    /// `[[pool]]` — named sources of names: which zones and labels each
130    /// holds, and how it makes a name.
131    #[serde(default)]
132    pub pool: Option<Vec<PoolSection>>,
133    /// `[names]` — the naming spec, its fallback, the number encoding
134    /// and the hold period.
135    #[serde(default)]
136    pub names: Option<NamesSection>,
137    /// `[park]` — how `bot.did.parkKey` holds a key.
138    #[serde(default)]
139    pub park: Option<ParkSection>,
140    /// `[tree]` — the shape one creator may give the ownership tree.
141    #[serde(default)]
142    pub tree: Option<TreeSection>,
143    /// `[oidc]` — which issuers' tokens are accepted, and how fresh.
144    #[serde(default)]
145    pub oidc: Option<OidcSection>,
146    /// `[sessions]` — sessions minted from a proof.
147    #[serde(default)]
148    pub sessions: Option<SessionsSection>,
149    /// `[credential]` — how long the agent token provisioning mints lasts.
150    #[serde(default)]
151    pub credential: Option<CredentialSection>,
152    /// `[intervals]` — how often each background loop runs.
153    #[serde(default)]
154    pub intervals: Option<IntervalsSection>,
155    /// `[disclosure]` — which `bot.did.*` routes this run has closed.
156    #[serde(default)]
157    pub disclosure: Option<DisclosureSection>,
158    /// `[operator]` — the operator sign-in's session lifetime, and the
159    /// grace window an unreachable operator gets.
160    #[serde(default)]
161    pub operator: Option<OperatorSection>,
162    /// `[oauth]` — the scope ceiling, the pending-decision bounds, and the
163    /// resolver permission sets are found through.
164    #[serde(default)]
165    pub oauth: Option<OAuthSection>,
166    /// `[policy]` — which DID's records to read as policy.
167    #[serde(default)]
168    pub policy: Option<PolicySection>,
169    /// `[relay]` — which relays an operator's announce reaches.
170    #[serde(default)]
171    pub relay: Option<RelaySection>,
172    /// `[writes]` — how fast one account may write.
173    #[serde(default)]
174    pub writes: Option<WritesSection>,
175    /// `[proxy]` — what one account may spend of `atproto-proxy`.
176    #[serde(default)]
177    pub proxy: Option<ProxySection>,
178}
179
180/// Everything that can go wrong reading a configuration file, each naming
181/// the path it came from.
182#[derive(Debug, thiserror::Error)]
183pub enum ConfigError {
184    /// The file could not be read at all — missing, a permissions problem
185    /// of its own, or similar.
186    #[error("could not read {path}: {source}")]
187    Io {
188        /// The file that could not be read.
189        path: PathBuf,
190        /// What the filesystem said.
191        #[source]
192        source: std::io::Error,
193    },
194    /// The file was read but is not valid TOML for this schema: a syntax
195    /// error, a value of the wrong type, or an unknown key. `toml`'s own
196    /// error already names the offending key or line.
197    #[error("{path}: {source}")]
198    Parse {
199        /// The file that failed to parse.
200        path: PathBuf,
201        /// What `toml` said, including which key or line was the problem.
202        #[source]
203        source: Box<toml::de::Error>,
204    },
205}
206
207impl Config {
208    /// Reads and parses a configuration file, naming the path in any error.
209    pub fn load(path: &Path) -> Result<Config, ConfigError> {
210        let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
211            path: path.to_owned(),
212            source,
213        })?;
214        Config::parse(&text).map_err(|source| ConfigError::Parse {
215            path: path.to_owned(),
216            source: Box::new(source),
217        })
218    }
219
220    /// Parses configuration text directly, for a caller that already has it
221    /// (or a test that would rather not touch a filesystem).
222    pub fn parse(text: &str) -> Result<Config, toml::de::Error> {
223        toml::from_str(text)
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn an_empty_file_is_every_section_absent() {
233        let config = Config::parse("").unwrap();
234        assert_eq!(config, Config::default());
235    }
236
237    #[test]
238    fn a_known_section_and_field_parse() {
239        let config = Config::parse(
240            r#"
241            [blobs]
242            max_blob_bytes = 1048576
243            account_quota_bytes = 16777216
244            total_quota_bytes = 1073741824
245
246            [disclosure]
247            stats = false
248            "#,
249        )
250        .unwrap();
251        let blobs = config.blobs.unwrap();
252        assert_eq!(blobs.max_blob_bytes, Some(1048576));
253        assert_eq!(blobs.account_quota_bytes, Some(16777216));
254        assert_eq!(blobs.total_quota_bytes, Some(1073741824));
255        let disclosure = config.disclosure.unwrap();
256        assert_eq!(disclosure.stats, Some(false));
257        assert_eq!(disclosure.list_accounts, None);
258    }
259
260    #[test]
261    fn the_two_log_retention_keys_parse_under_capacity() {
262        let config = Config::parse(
263            r#"
264            [capacity]
265            evaluation_log_retention_days = 7
266            write_log_retention_days = 365
267            "#,
268        )
269        .unwrap();
270        let capacity = config.capacity.unwrap();
271        assert_eq!(capacity.evaluation_log_retention_days, Some(7));
272        assert_eq!(capacity.write_log_retention_days, Some(365));
273        assert_eq!(capacity.max_accounts, None);
274    }
275
276    #[test]
277    fn the_agent_token_ttl_key_parses_under_credential() {
278        let config = Config::parse("[credential]\nagent_token_ttl_days = 30\n").unwrap();
279        let credential = config.credential.unwrap();
280        assert_eq!(credential.agent_token_ttl_days, Some(30));
281    }
282
283    #[test]
284    fn the_eight_proxy_keys_parse_under_proxy() {
285        let config = Config::parse(
286            r#"
287            [proxy]
288            calls_per_minute = 120
289            burst_calls = 30
290            in_flight_per_account = 4
291            in_flight_per_tree = 32
292            in_flight_total = 64
293            target_cache_secs = 60
294            target_cache_entries = 256
295            app_view = "did:web:api.bsky.app#bsky_appview"
296            "#,
297        )
298        .unwrap();
299        let proxy = config.proxy.unwrap();
300        assert_eq!(proxy.calls_per_minute, Some(120));
301        assert_eq!(proxy.burst_calls, Some(30));
302        assert_eq!(proxy.in_flight_per_account, Some(4));
303        assert_eq!(proxy.in_flight_per_tree, Some(32));
304        assert_eq!(proxy.in_flight_total, Some(64));
305        assert_eq!(proxy.target_cache_secs, Some(60));
306        assert_eq!(proxy.target_cache_entries, Some(256));
307        assert_eq!(
308            proxy.app_view.as_deref(),
309            Some("did:web:api.bsky.app#bsky_appview")
310        );
311    }
312
313    #[test]
314    fn an_unknown_top_level_key_refuses() {
315        let err = Config::parse("nonsense = true").unwrap_err();
316        assert!(
317            err.to_string().contains("nonsense"),
318            "error should name the key: {err}"
319        );
320    }
321
322    #[test]
323    fn an_unknown_key_inside_a_known_section_refuses() {
324        let err = Config::parse("[blobs]\nnot_a_real_field = 1").unwrap_err();
325        assert!(
326            err.to_string().contains("not_a_real_field"),
327            "error should name the key: {err}"
328        );
329    }
330
331    #[test]
332    fn a_malformed_value_refuses_naming_the_problem() {
333        // `max_blob_bytes` is a u64; a string cannot fill it.
334        let err = Config::parse("[blobs]\nmax_blob_bytes = \"lots\"").unwrap_err();
335        let message = err.to_string();
336        assert!(
337            message.contains("max_blob_bytes") || message.contains("invalid type"),
338            "error should point at the bad field or its type: {message}"
339        );
340    }
341
342    #[test]
343    fn invalid_toml_syntax_refuses() {
344        assert!(Config::parse("this is not = = toml").is_err());
345    }
346
347    #[test]
348    fn load_names_the_path_on_a_missing_file() {
349        let path = Path::new("/nonexistent/path/that/should/not/exist/didbot.toml");
350        let err = Config::load(path).unwrap_err();
351        assert!(err.to_string().contains("nonexistent"));
352    }
353
354    #[test]
355    fn load_names_the_path_on_a_malformed_file() {
356        let dir = std::env::temp_dir().join(format!(
357            "didbot-config-lib-test-{}-{}",
358            std::process::id(),
359            std::time::SystemTime::now()
360                .duration_since(std::time::UNIX_EPOCH)
361                .unwrap()
362                .as_nanos()
363        ));
364        std::fs::create_dir_all(&dir).unwrap();
365        let path = dir.join("didbot.toml");
366        std::fs::write(&path, "nonsense = true").unwrap();
367        let err = Config::load(&path).unwrap_err();
368        let message = err.to_string();
369        assert!(message.contains("didbot.toml"));
370        assert!(message.contains("nonsense"));
371        let _ = std::fs::remove_dir_all(&dir);
372    }
373}