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}