mcpmesh_node/config.rs
1//! The `config.toml` model. Every table and key here is real, implemented surface —
2//! docs/config.md is the operator-facing reference for all of it.
3use figment::{
4 Figment,
5 providers::{Format, Toml},
6};
7use serde::Deserialize;
8use std::path::PathBuf;
9
10#[derive(Debug, Default, Deserialize)]
11#[serde(default)]
12pub struct Config {
13 pub identity: IdentityCfg,
14 pub network: NetworkCfg,
15 pub limits: LimitsCfg,
16 /// Roster-mode `[roster]` tunables: the degraded-expiry grace window, the roster URL +
17 /// poll interval, and the freshness bound — one `RosterState` machine consumes them all.
18 pub roster: RosterCfg,
19 /// `[services.<name>]` registry — each entry is a served MCP server plus its allow
20 /// list. Peers do NOT live in config; they live in the daemon's state store, so
21 /// there is no `[peers]` table here.
22 pub services: std::collections::BTreeMap<String, ServiceCfg>,
23}
24
25/// A `[services.<name>]` entry: exactly one backend kind (`run` xor `socket`) plus the
26/// nicknames/groups admitted to it. The xor is validated at access time via
27/// [`ServiceCfg::backend_result`] rather than at parse time, so a malformed entry is a
28/// per-service error, not a whole-config load failure.
29#[derive(Debug, Default, Deserialize)]
30#[serde(default)]
31pub struct ServiceCfg {
32 /// `run`: spawn this command per session (a stdio MCP server).
33 pub run: Option<Vec<String>>,
34 /// `socket`: dial this local UDS (an already-running MCP server).
35 pub socket: Option<String>,
36 /// STABLE principals admitted to this service (b64u:/eid:/roster names, #38 — never display nicknames).
37 pub allow: Vec<String>,
38}
39
40/// The resolved backend kind of a [`ServiceCfg`], borrowing the config as slices (no
41/// clone). `&[String]`/`&str` rather than `&Vec`/`&String` — idiomatic and gives the
42/// daemon's backend builders the most flexible borrow.
43#[derive(Debug)]
44pub enum Backend<'a> {
45 Run(&'a [String]),
46 Socket(&'a str),
47}
48
49impl ServiceCfg {
50 /// Resolve the backend, enforcing exactly-one-of `run`/`socket`. Both or neither is an
51 /// error — surfaced to the operator, never a silent default.
52 #[allow(dead_code)] // consumed by the daemon service wiring
53 pub fn backend_result(&self) -> Result<Backend<'_>, String> {
54 match (&self.run, &self.socket) {
55 (Some(cmd), None) => Ok(Backend::Run(cmd.as_slice())),
56 (None, Some(p)) => Ok(Backend::Socket(p.as_str())),
57 (Some(_), Some(_)) => Err("service has both run and socket".into()),
58 (None, None) => Err("service has neither run nor socket".into()),
59 }
60 }
61}
62
63#[derive(Debug, Default, Deserialize)]
64#[serde(default)]
65pub struct IdentityCfg {
66 pub device_key: Option<PathBuf>, // None → paths::default_device_key_path()
67 /// This device's suggested name for itself, carried in a minted pairing invite.
68 /// `None` → the daemon defaults to a short fingerprint of the endpoint id.
69 /// Additive (`#[serde(default)]` at the struct level).
70 pub nickname: Option<String>,
71 /// Roster mode: the org id this node joined (pinned at install/join).
72 pub org_id: Option<String>,
73 /// Roster mode: the pinned org-root public key, `b64u:`. The single trust anchor
74 /// roster signatures verify against. Pinned on first roster install / `join`.
75 pub org_root_pk: Option<String>,
76 /// Roster mode: this node's stable user_id in the org. Pinned at `join` (proposed)
77 /// and reconciled to the roster's authoritative value once installed.
78 pub user_id: Option<String>,
79 /// Roster mode: path to this person's user key. Minted by `join`; binds this
80 /// person's devices. `None` → paths::default_user_key_path() when needed.
81 pub user_key: Option<PathBuf>,
82}
83
84/// `[network]`. The knobs are exactly what `daemon::net_plan` implements —
85/// no aspirational surface:
86/// - `relay_mode = "default" | "custom" | "disabled"`. `"custom"` requires `relay_urls`
87/// (self-hosted iroh relays); `"disabled"` is the HERMETIC mode — no relay AND no
88/// discovery (localhost/tests).
89/// - `discovery_mode = "default" | "custom"`. `"custom"` requires `discovery_urls` —
90/// self-hosted pkarr relay URLs (e.g. an iroh-dns-server), used for BOTH publishing and
91/// resolving peer addresses in place of n0's DNS/pkarr. Ignored (off) when
92/// `relay_mode = "disabled"`.
93///
94/// Unknown modes or a `custom` without URLs are startup ERRORS (`net_plan`), never a silent
95/// fallback — a metadata-privacy knob must not quietly revert to public infrastructure.
96#[derive(Debug, Clone, Deserialize)]
97#[serde(default)]
98pub struct NetworkCfg {
99 pub relay_mode: String,
100 /// Self-hosted relay URLs, required when `relay_mode = "custom"`.
101 pub relay_urls: Vec<String>,
102 pub discovery_mode: String,
103 /// Self-hosted pkarr relay URLs, required when `discovery_mode = "custom"`.
104 pub discovery_urls: Vec<String>,
105}
106impl Default for NetworkCfg {
107 fn default() -> Self {
108 Self {
109 relay_mode: "default".into(),
110 relay_urls: Vec::new(),
111 discovery_mode: "default".into(),
112 discovery_urls: Vec::new(),
113 }
114 }
115}
116
117/// `[limits]`. NOTE — the frame cap is deliberately NOT here: the 16 MiB `max_frame`
118/// default is a fixed CONSTANT at each wire (`mcpmesh_net::endpoint` for the mesh,
119/// `ipc::MAX_FRAME_BYTES` for the control socket, `backends::MAX_FRAME_BYTES` for local MCP
120/// servers), not a config tunable. A `max_frame` config field existed historically but was never
121/// threaded into any `FrameReader` (dead surface); threading it into the mesh path would widen
122/// `mcpmesh-net`'s public API for no demonstrated need, so the field was removed instead (serde
123/// ignores an unknown `max_frame` key in existing configs).
124#[derive(Debug, Deserialize)]
125#[serde(default)]
126pub struct LimitsCfg {
127 pub rate_limit_per_min: u32,
128 pub max_inflight: u32,
129 pub max_sessions: u32,
130}
131impl Default for LimitsCfg {
132 fn default() -> Self {
133 Self {
134 rate_limit_per_min: 120,
135 max_inflight: 16,
136 max_sessions: 4,
137 }
138 }
139}
140
141/// The default degraded-expiry grace window (`[roster].grace_period` default "72h").
142/// A stale roster keeps serving for this window past `expires_at` (with a warning) before it
143/// stops granting roster identity. Kept here so [`RosterCfg::default`] and the parse fallback
144/// share one source; the gate mirrors it as `roster::gate::DEFAULT_GRACE_SECS`.
145const DEFAULT_GRACE_SECS: i64 = 72 * 3600;
146
147/// The default freshness bound (`[roster].max_staleness`, default "24h" = 86400s). A roster
148/// this node has not re-confirmed current within this window degrades on the SAME `RosterState`
149/// machine as expiry (warnings within `grace`, then serving stops) — bounding adversarial staleness at
150/// `max_staleness + grace` independent of `expires_at`. Shared by [`RosterCfg::default`] + the parse
151/// fallback.
152const DEFAULT_MAX_STALENESS_SECS: i64 = 24 * 3600;
153
154/// The `[roster]` config table. `grace_period` is the degraded-expiry grace window — how
155/// long a roster past `expires_at` keeps serving (degraded, warning) before it stops. Additive
156/// (`#[serde(default)]`): a config with no `[roster]` table gets the 72h default.
157#[derive(Debug, Deserialize)]
158#[serde(default)]
159pub struct RosterCfg {
160 /// Degraded-expiry grace window: `"72h"` / `"24h"` / plain seconds (default "72h").
161 pub grace_period: String,
162 /// The pinned roster URL for the HTTPS poll. Operator-managed static hosting; also how a
163 /// joiner bootstraps its FIRST roster. `None` → no URL poll (manual installs only).
164 /// Additive (`#[serde(default)]`): a config with no `url` key gets `None`.
165 pub url: Option<String>,
166 /// How often to poll `url` (default "1h"). Total-parse like `grace_period` — an
167 /// unparseable value falls back to the hourly default rather than disabling the poll.
168 pub poll_interval: String,
169 /// The freshness bound (default "24h"): how long this node may go without re-confirming
170 /// the installed roster current (via a TLS URL poll ≥ installed, a gossip install, or a
171 /// manual install) before it degrades on the SAME `RosterState` machine as expiry. Total-parse
172 /// like `grace_period` (an unparseable value falls back to the 24h default — a typo never disables
173 /// the bound). Additive (`#[serde(default)]`): a config with no `max_staleness` key gets 24h.
174 pub max_staleness: String,
175}
176impl Default for RosterCfg {
177 fn default() -> Self {
178 Self {
179 grace_period: "72h".into(),
180 url: None,
181 poll_interval: "1h".into(),
182 max_staleness: "24h".into(),
183 }
184 }
185}
186
187impl RosterCfg {
188 /// The grace window in SECONDS. An absent or unparseable `grace_period` falls back to the 72h
189 /// default rather than erroring — an operator typo must never disable degraded serving, and a
190 /// grace window is advisory, not a security bound (revocation is enforced regardless of
191 /// degraded state).
192 ///
193 /// Two paths degrade on the ONE `RosterState` machine (`RosterView::state`, Approved →
194 /// DegradedGrace → DegradedStopped): expiry (`expires_at` + THIS grace window) and freshness
195 /// (`last_confirmed` + `max_staleness`). Once DegradedStopped, the gate stops granting roster
196 /// identity (fail-closed — revocation is still enforced); within grace, serving continues
197 /// with a warning (`daemon::warn_if_degraded_grace`).
198 pub fn grace_seconds(&self) -> i64 {
199 parse_duration(&self.grace_period).unwrap_or(DEFAULT_GRACE_SECS)
200 }
201
202 /// The URL poll interval in SECONDS (default 3600). Like [`grace_seconds`](Self::grace_seconds)
203 /// it is TOTAL — an absent/unparseable value falls back to the hourly default rather than
204 /// erroring, so an operator typo slows the poll to hourly instead of disabling freshness.
205 pub fn poll_interval_seconds(&self) -> i64 {
206 parse_duration(&self.poll_interval).unwrap_or(3600)
207 }
208
209 /// The freshness bound in SECONDS (default 86400 = 24h). Like [`grace_seconds`](Self::grace_seconds)
210 /// it is TOTAL — an absent/unparseable value falls back to the 24h default rather than erroring, so
211 /// an operator typo tightens/loosens to 24h instead of disabling the freshness bound.
212 pub fn max_staleness_seconds(&self) -> i64 {
213 parse_duration(&self.max_staleness).unwrap_or(DEFAULT_MAX_STALENESS_SECS)
214 }
215}
216
217/// Parse a duration string to SECONDS: a `d`/`h`/`m`/`s` suffix (days/hours/minutes/seconds) or a
218/// bare number (seconds). Trim + suffix-strip + checked multiply; rejects a
219/// negative/overflowing/garbage value as `Err` (the caller supplies the
220/// default). `u64` parse then a checked `i64` conversion: a negative grace is meaningless, so `-1`
221/// fails the `u64` parse and falls back to the default rather than becoming a negative window.
222// Reached only by the accessors above and the `org create --expires` porcelain
223// (`enrollcmd`, the operator-managed validity window — now across the crate seam, hence
224// `pub`; still `#[doc(hidden)]` at the module level). Pure parser — no state.
225pub fn parse_duration(s: &str) -> Result<i64, String> {
226 let s = s.trim();
227 let (num, mult) = if let Some(n) = s.strip_suffix('d') {
228 (n, 24 * 3600)
229 } else if let Some(n) = s.strip_suffix('h') {
230 (n, 3600)
231 } else if let Some(n) = s.strip_suffix('m') {
232 (n, 60)
233 } else if let Some(n) = s.strip_suffix('s') {
234 (n, 1)
235 } else {
236 (s, 1)
237 };
238 num.trim()
239 .parse::<u64>()
240 .ok()
241 .and_then(|v| v.checked_mul(mult))
242 .and_then(|v| i64::try_from(v).ok())
243 .ok_or_else(|| format!("unparseable duration: {s}"))
244}
245
246// figment::Error is ~208 bytes; boxing it would churn the API for a cold path.
247#[allow(clippy::result_large_err)]
248impl Config {
249 #[allow(dead_code)] // exercised by unit tests; config-string entry point for later tooling
250 pub fn from_toml_str(s: &str) -> Result<Self, figment::Error> {
251 Figment::new().merge(Toml::string(s)).extract()
252 }
253
254 /// Missing file → defaults (first run); malformed file → Err.
255 /// Callers must surface the Err — swallowing it silently reverts user choices.
256 pub fn load(path: &std::path::Path) -> Result<Self, figment::Error> {
257 Figment::new().merge(Toml::file(path)).extract()
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn empty_file_yields_spec_defaults() {
267 let c = Config::from_toml_str("").unwrap();
268 assert_eq!(c.network.relay_mode, "default");
269 assert_eq!(c.network.discovery_mode, "default");
270 assert_eq!(c.limits.rate_limit_per_min, 120);
271 assert_eq!(c.limits.max_inflight, 16);
272 assert_eq!(c.limits.max_sessions, 4);
273 }
274
275 #[test]
276 fn values_override_defaults() {
277 let c = Config::from_toml_str(
278 "[network]\nrelay_mode = \"disabled\"\n[limits]\nrate_limit_per_min = 60\n",
279 )
280 .unwrap();
281 assert_eq!(c.network.relay_mode, "disabled");
282 assert_eq!(c.limits.rate_limit_per_min, 60);
283 assert_eq!(c.limits.max_inflight, 16);
284 }
285
286 /// A legacy config carrying the removed `max_frame` key still loads (serde ignores unknown
287 /// fields) — the frame cap is a fixed constant now, not a tunable (see the `LimitsCfg` doc).
288 #[test]
289 fn legacy_max_frame_key_is_ignored_not_an_error() {
290 let c =
291 Config::from_toml_str("[limits]\nmax_frame = \"1MiB\"\nmax_sessions = 2\n").unwrap();
292 assert_eq!(c.limits.max_sessions, 2);
293 }
294
295 /// The self-hosting knobs parse: `custom` modes with their URL lists. (Validation —
296 /// custom-without-urls, unknown modes — lives in `daemon::net_plan`, tested there.)
297 #[test]
298 fn network_relay_and_discovery_urls_parse() {
299 let c = Config::from_toml_str(
300 "[network]\nrelay_mode = \"custom\"\nrelay_urls = [\"https://relay.acme.com\"]\n\
301 discovery_mode = \"custom\"\ndiscovery_urls = [\"https://dns.acme.com/pkarr\"]\n",
302 )
303 .unwrap();
304 assert_eq!(c.network.relay_mode, "custom");
305 assert_eq!(
306 c.network.relay_urls,
307 vec!["https://relay.acme.com".to_string()]
308 );
309 assert_eq!(c.network.discovery_mode, "custom");
310 assert_eq!(
311 c.network.discovery_urls,
312 vec!["https://dns.acme.com/pkarr".to_string()]
313 );
314 // Absent → empty lists (the defaults need no URLs).
315 let c = Config::from_toml_str("").unwrap();
316 assert!(c.network.relay_urls.is_empty() && c.network.discovery_urls.is_empty());
317 }
318
319 #[test]
320 fn missing_file_loads_defaults() {
321 let dir = tempfile::tempdir().unwrap();
322 let c = Config::load(&dir.path().join("nope.toml")).unwrap();
323 assert_eq!(c.network.relay_mode, "default");
324 }
325
326 #[test]
327 fn roster_url_and_poll_interval_parse_with_defaults() {
328 // No [roster] table → url None, poll 1h default.
329 let c = Config::from_toml_str("").unwrap();
330 assert!(c.roster.url.is_none());
331 assert_eq!(c.roster.poll_interval_seconds(), 3600);
332 // A configured url + poll interval.
333 let c = Config::from_toml_str(
334 "[roster]\nurl = \"https://intranet.acme.com/roster.json\"\npoll_interval = \"30m\"\n",
335 )
336 .unwrap();
337 assert_eq!(
338 c.roster.url.as_deref(),
339 Some("https://intranet.acme.com/roster.json")
340 );
341 assert_eq!(c.roster.poll_interval_seconds(), 30 * 60);
342 // An unparseable poll_interval falls back to the hourly default (never disables the poll).
343 let c = Config::from_toml_str("[roster]\npoll_interval = \"never\"\n").unwrap();
344 assert_eq!(c.roster.poll_interval_seconds(), 3600);
345 // The url is additive: setting only grace_period keeps url None + the default poll.
346 let c = Config::from_toml_str("[roster]\ngrace_period = \"24h\"\n").unwrap();
347 assert!(c.roster.url.is_none());
348 assert_eq!(c.roster.poll_interval_seconds(), 3600);
349 }
350
351 #[test]
352 fn roster_max_staleness_defaults_to_24h_and_parses() {
353 // No [roster] table → the 24h freshness bound (the default).
354 let c = Config::from_toml_str("").unwrap();
355 assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
356 // A configured value parses (units, like grace_period).
357 let c = Config::from_toml_str("[roster]\nmax_staleness = \"6h\"\n").unwrap();
358 assert_eq!(c.roster.max_staleness_seconds(), 6 * 3600);
359 // An unparseable value falls back to the 24h default (never disables the freshness bound).
360 let c = Config::from_toml_str("[roster]\nmax_staleness = \"forever\"\n").unwrap();
361 assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
362 // Additive: setting only grace_period keeps the 24h max_staleness default.
363 let c = Config::from_toml_str("[roster]\ngrace_period = \"48h\"\n").unwrap();
364 assert_eq!(c.roster.max_staleness_seconds(), 24 * 3600);
365 }
366
367 #[test]
368 fn roster_grace_defaults_to_72h_and_parses_units() {
369 // Absent `[roster]` → the 72h default.
370 let c = Config::from_toml_str("").unwrap();
371 assert_eq!(c.roster.grace_seconds(), 72 * 3600);
372 // Hours / days / minutes / seconds / bare-seconds all resolve to seconds.
373 for (body, want) in [
374 ("[roster]\ngrace_period = \"24h\"\n", 24 * 3600),
375 ("[roster]\ngrace_period = \"72h\"\n", 72 * 3600),
376 ("[roster]\ngrace_period = \"1d\"\n", 24 * 3600),
377 ("[roster]\ngrace_period = \"30m\"\n", 30 * 60),
378 ("[roster]\ngrace_period = \"90s\"\n", 90),
379 ("[roster]\ngrace_period = \"3600\"\n", 3600), // bare seconds
380 ] {
381 assert_eq!(
382 Config::from_toml_str(body).unwrap().roster.grace_seconds(),
383 want,
384 "{body}"
385 );
386 }
387 }
388
389 #[test]
390 fn roster_grace_unparseable_or_negative_falls_back_to_default() {
391 // A garbage / negative / overflowing grace never disables degraded serving — it defaults.
392 for body in [
393 "[roster]\ngrace_period = \"seventy-two hours\"\n",
394 "[roster]\ngrace_period = \"-5h\"\n",
395 "[roster]\ngrace_period = \"18446744073709551615d\"\n", // overflows the checked_mul
396 "[roster]\ngrace_period = \"\"\n",
397 ] {
398 assert_eq!(
399 Config::from_toml_str(body).unwrap().roster.grace_seconds(),
400 72 * 3600,
401 "{body}"
402 );
403 }
404 }
405
406 #[test]
407 fn services_parse_run_and_socket() {
408 let c = Config::from_toml_str(concat!(
409 "[services.notes]\nrun = [\"npx\", \"server\"]\nallow = [\"bob\"]\n",
410 "[services.kb]\nsocket = \"/run/kb.sock\"\nallow = [\"team-eng\"]\n",
411 ))
412 .unwrap();
413 let notes = c.services.get("notes").unwrap();
414 assert!(
415 matches!(notes.backend_result(), Ok(Backend::Run(cmd)) if cmd == &["npx".to_string(), "server".to_string()][..])
416 );
417 assert_eq!(notes.allow, vec!["bob".to_string()]);
418 assert!(
419 matches!(c.services.get("kb").unwrap().backend_result(), Ok(Backend::Socket(p)) if p == "/run/kb.sock")
420 );
421 }
422
423 #[test]
424 fn service_with_both_run_and_socket_is_an_error() {
425 let e = Config::from_toml_str("[services.x]\nrun=[\"a\"]\nsocket=\"/s\"\nallow=[]\n");
426 // exactly one backend kind is required — validate at access time.
427 assert!(
428 e.unwrap()
429 .services
430 .get("x")
431 .unwrap()
432 .backend_result()
433 .is_err()
434 );
435 }
436
437 #[test]
438 fn identity_reads_user_id_and_user_key() {
439 let toml = "[identity]\n\
440 org_id = \"acme\"\n\
441 org_root_pk = \"b64u:AAAA\"\n\
442 user_id = \"alice\"\n\
443 user_key = \"/home/alice/.config/mcpmesh/user.key\"\n";
444 let cfg: Config = toml::from_str(toml).unwrap();
445 assert_eq!(cfg.identity.user_id.as_deref(), Some("alice"));
446 assert_eq!(
447 cfg.identity.user_key.as_deref(),
448 Some(std::path::Path::new("/home/alice/.config/mcpmesh/user.key"))
449 );
450 // Absent → None (pure-pairing / operator-only node).
451 let bare: Config = toml::from_str("[identity]\n").unwrap();
452 assert!(bare.identity.user_id.is_none() && bare.identity.user_key.is_none());
453 }
454}