Skip to main content

majordomus_cli/mesh/
config.rs

1//! The mesh declaration: whether this repository's servers participate in discovery, and
2//! how. One typed object of the `.ai/` layer (kind `mesh`, schema `mesh/v1`), parsed
3//! here and nowhere else. No declaration, or `enabled: false`, means the mesh is off and
4//! not one socket opens — the repository's default posture ("nothing leaves the
5//! machine") holds until a person commits the object that says otherwise.
6//!
7//! ```
8//! use majordomus_cli::mesh::config::{MeshConfig, BroadcastMode};
9//!
10//! // The defaults are the safe ones: multicast prepared, broadcast off, nobody trusted.
11//! let minimal: MeshConfig = serde_json::from_value(serde_json::json!({
12//!     "schema": "mesh/v1", "kind": "mesh-declaration", "id": "docs", "enabled": false
13//! })).unwrap();
14//! assert!(!minimal.enabled);
15//! assert_eq!(minimal.broadcast.mode, BroadcastMode::Disabled);
16//! assert!(minimal.trust.allow.is_empty());
17//! ```
18
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21
22use crate::model::Object;
23
24use super::trust::TrustPolicy;
25use super::MeshError;
26
27/// The kind the declaration is discovered under.
28pub const KIND: &str = "mesh-declaration";
29
30/// The schema version this executable reads.
31pub const SCHEMA_VERSION: &str = "mesh/v1";
32
33/// The default multicast group: an address in the IPv4 organization-local scope
34/// (239.0.0.0/8, RFC 2365), chosen once here.
35pub const DEFAULT_GROUP: &str = "239.255.77.77";
36
37/// The default UDP port for multicast and broadcast datagrams.
38pub const DEFAULT_PORT: u16 = 7741;
39
40/// The default seconds between announcements.
41pub const DEFAULT_INTERVAL: u64 = 15;
42
43/// The whole declaration: the master switch and one section per mechanism. Unknown
44/// keys are refused, so a typo is an error and not a silently ignored wish.
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46#[serde(deny_unknown_fields)]
47pub struct MeshConfig {
48    /// The format version: `mesh/v1`.
49    pub schema: String,
50    /// The kind: `mesh`, the same word the discovery class carries.
51    pub kind: String,
52    /// The declaration's identity.
53    pub id: String,
54    /// The master switch. `false` starts nothing.
55    #[serde(default)]
56    pub enabled: bool,
57    /// UDP multicast, the preferred LAN mechanism.
58    #[serde(default)]
59    pub multicast: MulticastConfig,
60    /// UDP broadcast, the controlled fallback.
61    #[serde(default)]
62    pub broadcast: BroadcastConfig,
63    /// Rendezvous endpoints, for networks multicast cannot cross.
64    #[serde(default)]
65    pub rendezvous: RendezvousConfig,
66    /// Who to trust.
67    #[serde(default)]
68    pub trust: TrustConfig,
69    /// Cooperation: the authenticated links to trusted runtimes of the same repository,
70    /// and the journal they replicate (ADR 0067).
71    #[serde(default)]
72    pub cooperation: CooperationConfig,
73}
74
75/// Cooperation settings: whether this runtime links to the trusted runtimes of its
76/// repository, how often it proves it is alive, when a silent runtime counts as gone, and
77/// which endpoints to dial when discovery cannot find them.
78///
79/// Every field has a default that is safe on its own, so a declaration that says nothing
80/// about cooperation still behaves as intended: links are on, but a link needs a trusted
81/// peer of the same repository, and under the default trust policy there is none until a
82/// person lists a key.
83///
84/// ```
85/// use majordomus_cli::mesh::config::{CooperationConfig, DEFAULT_EXPIRY, DEFAULT_HEARTBEAT};
86///
87/// let default = CooperationConfig::default();
88/// assert!(default.enabled && default.seeds.is_empty() && default.repository.is_none());
89/// assert!(
90///     default.expiry_seconds >= 3 * default.heartbeat_seconds,
91///     "one lost round must never expire a runtime",
92/// );
93///
94/// // and what a declaration leaves out, it gets
95/// let stated: CooperationConfig =
96///     serde_json::from_value(serde_json::json!({ "seeds": ["http://10.0.0.2:8742"] })).unwrap();
97/// assert_eq!(stated.seeds, ["http://10.0.0.2:8742"]);
98/// assert_eq!(stated.heartbeat_seconds, DEFAULT_HEARTBEAT);
99/// assert_eq!(stated.expiry_seconds, DEFAULT_EXPIRY);
100/// ```
101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
102#[serde(deny_unknown_fields)]
103pub struct CooperationConfig {
104    /// Whether links open at all. On by default once the mesh is enabled: a link needs a
105    /// trusted peer of the same repository, so under the default `deny_unknown` policy
106    /// with an empty allowlist nothing links until a person lists a key.
107    #[serde(default = "yes")]
108    pub enabled: bool,
109    /// Seconds between heartbeats: one beat and one sync per link per interval.
110    #[serde(default = "default_heartbeat")]
111    pub heartbeat_seconds: u64,
112    /// Seconds of silence after which a runtime is expired: its sessions end and its
113    /// claims stop excluding. At least three heartbeats, so one lost round never expires
114    /// anyone.
115    #[serde(default = "default_expiry")]
116    pub expiry_seconds: u64,
117    /// Endpoints (`http://host:port`) to dial whether or not discovery heard them: a
118    /// static fleet, or a segment neither multicast nor a rendezvous reaches.
119    #[serde(default)]
120    pub seeds: Vec<String>,
121    /// The repository's mesh identity, declared rather than derived from its root commits:
122    /// what a shallow clone needs, and what separates two repositories sharing a history.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub repository: Option<String>,
125}
126
127impl Default for CooperationConfig {
128    fn default() -> Self {
129        CooperationConfig {
130            enabled: true,
131            heartbeat_seconds: DEFAULT_HEARTBEAT,
132            expiry_seconds: DEFAULT_EXPIRY,
133            seeds: Vec::new(),
134            repository: None,
135        }
136    }
137}
138
139/// The default seconds between cooperation heartbeats.
140pub const DEFAULT_HEARTBEAT: u64 = 5;
141
142/// The default seconds of silence before a runtime expires.
143pub const DEFAULT_EXPIRY: u64 = 30;
144
145fn default_heartbeat() -> u64 {
146    DEFAULT_HEARTBEAT
147}
148fn default_expiry() -> u64 {
149    DEFAULT_EXPIRY
150}
151
152/// Multicast settings: the group, the port, how far a datagram travels and how often
153/// this node announces.
154#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
155#[serde(deny_unknown_fields)]
156pub struct MulticastConfig {
157    /// Whether the multicast provider runs.
158    #[serde(default = "yes")]
159    pub enabled: bool,
160    /// The group, dotted IPv4 in 239.0.0.0/8 by convention.
161    #[serde(default = "default_group")]
162    pub group: String,
163    /// The UDP port.
164    #[serde(default = "default_port")]
165    pub port: u16,
166    /// The datagram TTL; 1 keeps announcements on the local segment.
167    #[serde(default = "one")]
168    pub ttl: u32,
169    /// Seconds between announcements (a jitter is added).
170    #[serde(default = "default_interval")]
171    pub interval_seconds: u64,
172}
173
174impl Default for MulticastConfig {
175    fn default() -> Self {
176        MulticastConfig {
177            enabled: true,
178            group: DEFAULT_GROUP.into(),
179            port: DEFAULT_PORT,
180            ttl: 1,
181            interval_seconds: DEFAULT_INTERVAL,
182        }
183    }
184}
185
186/// Broadcast mode: off, automatic (the limited broadcast address), or explicit networks.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188#[serde(rename_all = "snake_case")]
189pub enum BroadcastMode {
190    /// No broadcast datagrams.
191    Disabled,
192    /// Announce to 255.255.255.255.
193    Auto,
194    /// Announce to the listed directed broadcast addresses.
195    Explicit,
196}
197
198/// Broadcast settings: the fallback's mode, destinations and cadence; `disabled`
199/// unless a person declared otherwise.
200#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
201#[serde(deny_unknown_fields)]
202pub struct BroadcastConfig {
203    /// The mode; `disabled` unless somebody decided otherwise.
204    #[serde(default = "disabled")]
205    pub mode: BroadcastMode,
206    /// The UDP port (the same protocol, one port).
207    #[serde(default = "default_port")]
208    pub port: u16,
209    /// Directed broadcast addresses for `explicit` mode, `a.b.c.255` shaped.
210    #[serde(default)]
211    pub networks: Vec<String>,
212    /// Seconds between announcements.
213    #[serde(default = "default_interval")]
214    pub interval_seconds: u64,
215}
216
217impl Default for BroadcastConfig {
218    fn default() -> Self {
219        BroadcastConfig {
220            mode: BroadcastMode::Disabled,
221            port: DEFAULT_PORT,
222            networks: Vec::new(),
223            interval_seconds: DEFAULT_INTERVAL,
224        }
225    }
226}
227
228/// Rendezvous settings: other Majordomus servers to register with. Plain HTTP on a
229/// private network; there is no TLS in this executable, and the protocol assumes none —
230/// candidates verify end-to-end by signature, a poisoned rendezvous can withhold nodes
231/// but not invent them.
232#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
233#[serde(deny_unknown_fields)]
234pub struct RendezvousConfig {
235    /// Base URLs, `http://host:port`. Several endpoints are several independent
236    /// sources; one failing never stops another.
237    #[serde(default)]
238    pub endpoints: Vec<String>,
239    /// Seconds between registrations per endpoint (backoff stretches it on failure).
240    #[serde(default = "rendezvous_interval")]
241    pub interval_seconds: u64,
242}
243
244impl Default for RendezvousConfig {
245    fn default() -> Self {
246        RendezvousConfig {
247            endpoints: Vec::new(),
248            interval_seconds: rendezvous_interval(),
249        }
250    }
251}
252
253/// Trust settings: the policy, and the public keys trusted regardless of it.
254#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
255#[serde(deny_unknown_fields)]
256pub struct TrustConfig {
257    /// The policy; `deny_unknown` unless somebody decided otherwise.
258    #[serde(default)]
259    pub policy: TrustPolicy,
260    /// Ed25519 public keys (hex) trusted regardless of policy.
261    #[serde(default)]
262    pub allow: Vec<String>,
263}
264
265fn yes() -> bool {
266    true
267}
268fn one() -> u32 {
269    1
270}
271fn disabled() -> BroadcastMode {
272    BroadcastMode::Disabled
273}
274fn default_group() -> String {
275    DEFAULT_GROUP.into()
276}
277fn default_port() -> u16 {
278    DEFAULT_PORT
279}
280fn default_interval() -> u64 {
281    DEFAULT_INTERVAL
282}
283fn rendezvous_interval() -> u64 {
284    60
285}
286
287impl CooperationConfig {
288    /// Refuse the settings that cannot work: a zero heartbeat, an expiry under three
289    /// heartbeats (one lost round would expire a live runtime), a seed that is not
290    /// `http://host:port`, an empty or oversized declared repository.
291    ///
292    /// ```
293    /// use majordomus_cli::mesh::config::CooperationConfig;
294    /// assert!(CooperationConfig::default().validate().is_ok());
295    /// let flapping = CooperationConfig { heartbeat_seconds: 10, expiry_seconds: 15, ..Default::default() };
296    /// assert!(flapping.validate().is_err());
297    /// ```
298    pub fn validate(&self) -> Result<(), String> {
299        if self.heartbeat_seconds == 0 {
300            return Err("cooperation.heartbeat_seconds of 0 would be a busy loop".into());
301        }
302        if self.expiry_seconds < 3 * self.heartbeat_seconds {
303            return Err(format!(
304                "cooperation.expiry_seconds {} is under three heartbeats ({}): one lost round would expire a live runtime",
305                self.expiry_seconds,
306                3 * self.heartbeat_seconds
307            ));
308        }
309        for seed in &self.seeds {
310            let authority = seed.strip_prefix("http://").unwrap_or_default();
311            if authority.is_empty() || !authority.contains(':') || authority.contains('/') {
312                return Err(format!("cooperation seed '{seed}' is not http://host:port"));
313            }
314        }
315        if let Some(repository) = &self.repository {
316            if repository.trim().is_empty() || repository.len() > 128 {
317                return Err("cooperation.repository is empty or over 128 characters".into());
318            }
319        }
320        Ok(())
321    }
322}
323
324impl MeshConfig {
325    /// Parse one indexed object into a mesh declaration, or say why it is not one:
326    /// the wrong schema version, an unknown key, and a zero interval are each refused
327    /// with the object's URI in the reason.
328    pub fn parse(object: &Object) -> Result<MeshConfig, MeshError> {
329        Self::from_metadata(&object.metadata, &object.uri)
330    }
331
332    /// [`MeshConfig::parse`] for a declaration that is not an indexed object: the metadata
333    /// mapping as the YAML subset parsed it, and the name to quote in a refusal. The trunk's
334    /// copy of the declaration is read this way (see [`super::root`]).
335    ///
336    /// ```
337    /// use majordomus_cli::mesh::MeshConfig;
338    /// let ok = serde_json::json!({ "schema": "mesh/v1", "kind": "mesh-declaration", "id": "x" });
339    /// assert!(MeshConfig::from_metadata(&ok, "master:x.yaml").is_ok());
340    /// let wrong = serde_json::json!({ "schema": "mesh/v0", "kind": "mesh-declaration", "id": "x" });
341    /// assert!(MeshConfig::from_metadata(&wrong, "master:x.yaml").unwrap_err().to_string().contains("master:x.yaml"));
342    /// ```
343    pub fn from_metadata(metadata: &serde_json::Value, uri: &str) -> Result<MeshConfig, MeshError> {
344        let parsed: MeshConfig = serde_json::from_value(metadata.clone())
345            .map_err(|e| MeshError::Config(format!("{}: {e}", uri)))?;
346        if parsed.schema != SCHEMA_VERSION {
347            return Err(MeshError::Config(format!(
348                "{}: schema {} is not {SCHEMA_VERSION}",
349                uri, parsed.schema
350            )));
351        }
352        if parsed.kind != KIND {
353            return Err(MeshError::Config(format!(
354                "{}: kind {} is not {KIND}",
355                uri, parsed.kind
356            )));
357        }
358        if parsed.multicast.interval_seconds == 0
359            || parsed.broadcast.interval_seconds == 0
360            || parsed.rendezvous.interval_seconds == 0
361        {
362            return Err(MeshError::Config(format!(
363                "{}: an interval of 0 would be a busy loop",
364                uri
365            )));
366        }
367        parsed
368            .cooperation
369            .validate()
370            .map_err(|e| MeshError::Config(format!("{}: {e}", uri)))?;
371        Ok(parsed)
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn object(metadata: serde_json::Value) -> Object {
380        Object {
381            kind: KIND.into(),
382            identity: "majordomus".into(),
383            uri: "majordomus://mesh-declaration/majordomus".into(),
384            title: None,
385            description: None,
386            metadata,
387            body: String::new(),
388            content: String::new(),
389            media_type: "application/yaml",
390            provenance: crate::model::Provenance {
391                path: ".ai/repo/mesh/majordomus.yaml".into(),
392                directory: ".ai/repo/mesh".into(),
393                source_class: KIND.into(),
394                section: None,
395                bytes: 0,
396                member: None,
397            },
398        }
399    }
400
401    #[test]
402    fn a_declaration_of_another_kind_or_a_cooperation_it_cannot_keep_is_refused() {
403        let refusal = |metadata: serde_json::Value| {
404            MeshConfig::from_metadata(&metadata, "master:m.yaml")
405                .unwrap_err()
406                .to_string()
407        };
408        let kind = refusal(serde_json::json!({
409            "schema": "mesh/v1", "kind": "rule", "id": "x"
410        }));
411        assert!(kind.contains("master:m.yaml: kind rule is not"), "{kind}");
412        let cooperation = refusal(serde_json::json!({
413            "schema": "mesh/v1", "kind": "mesh-declaration", "id": "x",
414            "cooperation": { "heartbeat_seconds": 5, "expiry_seconds": 6 }
415        }));
416        assert!(
417            cooperation
418                .contains("master:m.yaml: cooperation.expiry_seconds 6 is under three heartbeats"),
419            "{cooperation}"
420        );
421    }
422
423    #[test]
424    fn a_minimal_declaration_parses_with_safe_defaults() {
425        let config = MeshConfig::parse(&object(serde_json::json!({
426            "schema": "mesh/v1", "kind": "mesh-declaration", "id": "majordomus", "enabled": true
427        })))
428        .unwrap();
429        assert!(config.enabled);
430        assert!(config.multicast.enabled);
431        assert_eq!(config.multicast.group, DEFAULT_GROUP);
432        assert_eq!(config.broadcast.mode, BroadcastMode::Disabled);
433        assert!(config.rendezvous.endpoints.is_empty());
434        assert_eq!(config.trust.policy, TrustPolicy::DenyUnknown);
435    }
436
437    #[test]
438    fn a_wrong_schema_and_an_unknown_key_are_refused() {
439        assert!(MeshConfig::parse(&object(serde_json::json!({
440            "schema": "mesh/v2", "kind": "mesh", "id": "x"
441        })))
442        .is_err());
443        assert!(MeshConfig::parse(&object(serde_json::json!({
444            "schema": "mesh/v1", "kind": "mesh-declaration", "id": "x", "surprise": 1
445        })))
446        .is_err());
447    }
448
449    #[test]
450    fn a_zero_interval_is_refused_as_a_busy_loop() {
451        assert!(MeshConfig::parse(&object(serde_json::json!({
452            "schema": "mesh/v1", "kind": "mesh-declaration", "id": "x",
453            "multicast": {"interval_seconds": 0}
454        })))
455        .is_err());
456    }
457}