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}