Skip to main content

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