Skip to main content

majordomus_cli/mesh/
manager.rs

1//! The discovery manager: provider lifecycle, the one verification path, and the one
2//! registry. Providers hand raw bytes here; this thread parses, checks bounds and
3//! staleness, verifies the signature, evaluates trust, and only then touches the
4//! registry — in that order, for every transport alike. A provider cannot skip a step
5//! because no provider ever parses.
6//!
7//! The runtime lives on the capability [`Context`](crate::capability::Context) from
8//! process start, inactive, and a shared server activates it when the repository's mesh
9//! declaration says so. Activation is idempotent, bounded, and never blocks startup:
10//! sockets open on their own threads, and a provider that cannot start is a `Failed`
11//! status, not a failed server.
12
13use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
14use std::sync::{Arc, Mutex};
15use std::time::Duration;
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use super::broadcast::BroadcastProvider;
21use super::config::{BroadcastMode, MeshConfig, TrustConfig};
22use super::cooperation::{Cooperation, CooperationStatus};
23use super::identity::{NodeIdentity, PublicIdentity};
24use super::multicast::MulticastProvider;
25use super::protocol::{self, Refusal};
26use super::provider::{Beacon, MeshProvider, Observation, ProviderContext, ProviderStatus};
27use super::registry::{MeshRegistry, MeshSource, NodeRecord, Tallies};
28use super::rendezvous::{RegisterAnswer, RendezvousProvider};
29use super::trust;
30use super::MeshError;
31
32/// How often the manager thread checks the stop flag while nothing arrives.
33const RECV_TIMEOUT: Duration = Duration::from_millis(500);
34
35/// The most candidates one register answer carries.
36const MAX_CANDIDATES: usize = 32;
37
38/// Refused datagrams, by reason. The diagnosis of a hostile or misconfigured network:
39/// a mesh that hears garbage says so, with numbers, instead of being quietly empty.
40#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
41pub struct Refusals {
42    /// Longer than the datagram bound.
43    pub oversized: u64,
44    /// Not an envelope.
45    pub malformed: u64,
46    /// A protocol version this executable does not read.
47    pub version: u64,
48    /// Out-of-bounds fields.
49    pub bounds: u64,
50    /// Outside the clock-skew window.
51    pub stale: u64,
52    /// A signature that does not verify.
53    pub signature: u64,
54    /// This node's own datagrams, heard back and skipped.
55    pub self_heard: u64,
56    /// Observations dropped because the queue to the registry was full: a flood, counted
57    /// rather than held (I2133).
58    #[serde(default)]
59    pub overflow: u64,
60}
61
62#[derive(Default)]
63struct RefusalCounters {
64    oversized: AtomicU64,
65    malformed: AtomicU64,
66    version: AtomicU64,
67    bounds: AtomicU64,
68    stale: AtomicU64,
69    signature: AtomicU64,
70    self_heard: AtomicU64,
71    /// Shared with the providers' queue, which counts its drops here.
72    overflow: Arc<AtomicU64>,
73}
74
75impl RefusalCounters {
76    fn count(&self, refusal: &Refusal) {
77        let counter = match refusal {
78            Refusal::Oversized => &self.oversized,
79            Refusal::Malformed => &self.malformed,
80            Refusal::Version => &self.version,
81            Refusal::Bounds => &self.bounds,
82            Refusal::Stale => &self.stale,
83            Refusal::Signature => &self.signature,
84        };
85        counter.fetch_add(1, Ordering::Relaxed);
86    }
87
88    fn snapshot(&self) -> Refusals {
89        Refusals {
90            oversized: self.oversized.load(Ordering::Relaxed),
91            malformed: self.malformed.load(Ordering::Relaxed),
92            version: self.version.load(Ordering::Relaxed),
93            bounds: self.bounds.load(Ordering::Relaxed),
94            stale: self.stale.load(Ordering::Relaxed),
95            signature: self.signature.load(Ordering::Relaxed),
96            self_heard: self.self_heard.load(Ordering::Relaxed),
97            overflow: self.overflow.load(Ordering::Relaxed),
98        }
99    }
100}
101
102/// The whole mesh, as every surface reports it.
103#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
104pub struct MeshStatus {
105    /// Whether the mesh is running in this process.
106    pub active: bool,
107    /// Why not, when it is not: no declaration, disabled, no identity, stopped.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub reason: Option<String>,
110    /// This node, when the mesh has an identity.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub identity: Option<PublicIdentity>,
113    /// The trust policy in force.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub trust_policy: Option<String>,
116    /// Every provider, canonical order (by id).
117    pub providers: Vec<ProviderStatus>,
118    /// The registry's tallies.
119    pub tallies: Tallies,
120    /// Refused datagrams, by reason.
121    pub refusals: Refusals,
122    /// When the mesh started, RFC 3339.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub started_at: Option<String>,
125}
126
127struct Active {
128    beacon: Arc<Beacon>,
129    tx: super::provider::ObservationSender,
130    stop: Arc<AtomicBool>,
131    providers: Vec<Box<dyn MeshProvider>>,
132    trust: TrustConfig,
133    started_at: String,
134}
135
136/// The runtime: the registry (always there, so a surface can answer before activation)
137/// and the active machinery once a declaration switched it on.
138pub struct MeshRuntime {
139    registry: Arc<MeshRegistry>,
140    refusals: Arc<RefusalCounters>,
141    state: Mutex<Option<Active>>,
142    /// Why the mesh is not active, when it is not.
143    reason: Mutex<String>,
144    /// The cooperation runtime, once a server attached one.
145    cooperation: Mutex<Option<Arc<Cooperation>>>,
146    /// Why cooperation is not running, when the mesh is but cooperation is not.
147    cooperation_reason: Mutex<Option<String>>,
148    /// Whether a shared server decided anything about this runtime: activated it, or
149    /// declined with a reason. A runtime nobody decided on is the command line's, and its
150    /// inactivity is an absence the doctor may not report as a verdict (ADR 0059).
151    decided: AtomicBool,
152}
153
154impl Default for MeshRuntime {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160impl MeshRuntime {
161    /// An inactive runtime: an empty registry and the reason "nothing activated it".
162    pub fn new() -> Self {
163        MeshRuntime {
164            registry: Arc::new(MeshRegistry::new()),
165            refusals: Arc::new(RefusalCounters::default()),
166            state: Mutex::new(None),
167            reason: Mutex::new(
168                "not active: no shared server activated the mesh in this process".into(),
169            ),
170            cooperation: Mutex::new(None),
171            cooperation_reason: Mutex::new(None),
172            decided: AtomicBool::new(false),
173        }
174    }
175
176    /// Whether a shared server activated or declined this runtime. `false` in every process
177    /// that is not a server — the command line, a test — where nothing activates the mesh
178    /// and [`MeshRuntime::status`] reports an absence, not a decision.
179    ///
180    /// The distinction is what lets `mesh doctor` judge the server: an enabled declaration
181    /// and an inactive mesh is a failure when a server decided it and nothing at all when
182    /// nobody did.
183    ///
184    /// ```
185    /// use majordomus_cli::mesh::manager::MeshRuntime;
186    ///
187    /// let mesh = MeshRuntime::new();
188    /// assert!(!mesh.decided(), "nothing has decided the command line's runtime");
189    /// assert!(!mesh.status().active);
190    ///
191    /// // a server that could not activate the mesh says why, and that is a decision
192    /// mesh.decline("the node identity did not load");
193    /// assert!(mesh.decided());
194    /// assert!(mesh.status().reason.unwrap().contains("did not load"));
195    /// ```
196    pub fn decided(&self) -> bool {
197        self.decided.load(Ordering::SeqCst)
198    }
199
200    /// Attach the cooperation runtime a server built for this mesh. The runtime is
201    /// already started; a second attachment replaces nothing.
202    ///
203    /// Discovery and cooperation are built separately — one finds runtimes, the other
204    /// links to them — and this is where the surfaces find both behind one handle. The
205    /// first attachment wins, so a second server in one process cannot quietly take over
206    /// the journal the first one is already writing to.
207    ///
208    /// ```
209    /// # use std::sync::Arc;
210    /// # use majordomus_cli::mesh::config::{CooperationConfig, TrustConfig};
211    /// # use majordomus_cli::mesh::cooperation::{CheckoutFacts, Cooperation, CooperationSetup};
212    /// # use majordomus_cli::mesh::identity::NodeIdentity;
213    /// # use majordomus_cli::mesh::link::HttpTransport;
214    /// # use majordomus_cli::mesh::registry::MeshRegistry;
215    /// # use majordomus_cli::mesh::repository::of_root_commits;
216    /// # let built = |slot: &str| Cooperation::new(CooperationSetup {
217    /// #     identity: Arc::new(NodeIdentity::ephemeral().unwrap()), runtime: slot.into(),
218    /// #     repository: of_root_commits(&["root".into()]), endpoints: vec!["127.0.0.1:9".into()],
219    /// #     version: "doc".into(), config: CooperationConfig::default(), trust: TrustConfig::default(),
220    /// #     journal_path: None, registry: Arc::new(MeshRegistry::new()),
221    /// #     transport: Arc::new(HttpTransport), board: None, checkout: CheckoutFacts::default(),
222    /// # }).unwrap();
223    /// use majordomus_cli::mesh::manager::MeshRuntime;
224    ///
225    /// let mesh = MeshRuntime::new();
226    /// let first = built("0000000000000001");
227    /// mesh.attach_cooperation(Arc::clone(&first));
228    /// mesh.attach_cooperation(built("0000000000000002"));
229    ///
230    /// let attached = mesh.cooperation().expect("a cooperation runtime");
231    /// assert_eq!(attached.runtime_key(), first.runtime_key(), "the first one keeps the slot");
232    /// ```
233    pub fn attach_cooperation(&self, cooperation: Arc<Cooperation>) {
234        let mut slot = self.cooperation.lock().expect("mesh cooperation");
235        if slot.is_none() {
236            *slot = Some(cooperation);
237        }
238    }
239
240    /// Record why cooperation is not running while the mesh is.
241    ///
242    /// A mesh that discovers runtimes and links to none is the confusing case: everything
243    /// looks switched on and nothing is shared. The server that decided not to start
244    /// cooperation says so here, so the answer reaches whoever asks rather than living in
245    /// the log of a process they cannot see.
246    ///
247    /// ```
248    /// use majordomus_cli::mesh::manager::MeshRuntime;
249    ///
250    /// let mesh = MeshRuntime::new();
251    /// mesh.decline_cooperation("no endpoint to be dialed at");
252    ///
253    /// let status = mesh.cooperation_status();
254    /// assert!(!status.active);
255    /// assert_eq!(status.reason.as_deref(), Some("no endpoint to be dialed at"));
256    /// ```
257    pub fn decline_cooperation(&self, reason: &str) {
258        *self
259            .cooperation_reason
260            .lock()
261            .expect("mesh cooperation reason") = Some(reason.into());
262    }
263
264    /// The cooperation runtime, while one is still running. A stopped runtime is not
265    /// handed out: a caller that got it would write to a journal nobody is replicating and
266    /// believe it had told the mesh something. `None` here means the same to every caller
267    /// — do this locally, or say it cannot be done — however cooperation came to be absent.
268    ///
269    /// ```
270    /// # use std::sync::Arc;
271    /// # use majordomus_cli::mesh::config::{CooperationConfig, TrustConfig};
272    /// # use majordomus_cli::mesh::cooperation::{CheckoutFacts, Cooperation, CooperationSetup};
273    /// # use majordomus_cli::mesh::identity::NodeIdentity;
274    /// # use majordomus_cli::mesh::link::HttpTransport;
275    /// # use majordomus_cli::mesh::registry::MeshRegistry;
276    /// # use majordomus_cli::mesh::repository::of_root_commits;
277    /// # let cooperation = Cooperation::new(CooperationSetup {
278    /// #     identity: Arc::new(NodeIdentity::ephemeral().unwrap()), runtime: "0000000000000001".into(),
279    /// #     repository: of_root_commits(&["root".into()]), endpoints: vec!["127.0.0.1:9".into()],
280    /// #     version: "doc".into(), config: CooperationConfig::default(), trust: TrustConfig::default(),
281    /// #     journal_path: None, registry: Arc::new(MeshRegistry::new()),
282    /// #     transport: Arc::new(HttpTransport), board: None, checkout: CheckoutFacts::default(),
283    /// # }).unwrap();
284    /// use majordomus_cli::mesh::manager::MeshRuntime;
285    ///
286    /// let mesh = MeshRuntime::new();
287    /// assert!(mesh.cooperation().is_none(), "nothing has attached one");
288    ///
289    /// mesh.attach_cooperation(Arc::clone(&cooperation));
290    /// assert!(mesh.cooperation().is_some());
291    ///
292    /// cooperation.stop();
293    /// assert!(mesh.cooperation().is_none(), "a stopped runtime is not handed out");
294    /// ```
295    pub fn cooperation(&self) -> Option<Arc<Cooperation>> {
296        self.cooperation
297            .lock()
298            .expect("mesh cooperation")
299            .as_ref()
300            .filter(|c| !c.stopped())
301            .cloned()
302    }
303
304    /// Cooperation's status, or why there is none: the mesh's own reason when the mesh is
305    /// off, cooperation's reason when only cooperation is.
306    ///
307    /// The two reasons are kept apart because they call for different acts. A mesh that was
308    /// never switched on is a declaration to write; cooperation declined under a running
309    /// mesh is something about this server — no endpoint, no trusted key — and the more
310    /// specific reason is the one worth telling a person, so it wins when both exist.
311    ///
312    /// ```
313    /// use majordomus_cli::mesh::manager::MeshRuntime;
314    ///
315    /// let mesh = MeshRuntime::new();
316    /// let untouched = mesh.cooperation_status();
317    /// assert!(untouched.reason.unwrap().contains("no shared server"), "the mesh's reason");
318    ///
319    /// mesh.decline_cooperation("the declaration enables the mesh but not cooperation");
320    /// let declined = mesh.cooperation_status();
321    /// assert!(declined.reason.unwrap().contains("not cooperation"), "the nearer reason wins");
322    /// assert!(declined.peers.is_empty(), "and the shape is the one a live runtime answers");
323    /// ```
324    pub fn cooperation_status(&self) -> CooperationStatus {
325        if let Some(c) = self.cooperation() {
326            return c.status();
327        }
328        let reason = self
329            .cooperation_reason
330            .lock()
331            .expect("mesh cooperation reason")
332            .clone()
333            .unwrap_or_else(|| self.reason.lock().expect("mesh reason").clone());
334        CooperationStatus::inactive(&reason)
335    }
336
337    /// The registry behind this runtime, for surfaces that project it directly.
338    pub fn registry(&self) -> &Arc<MeshRegistry> {
339        &self.registry
340    }
341
342    /// Record why the mesh is not running, without activating anything: a disabled or
343    /// malformed declaration is an answer `mesh.status` must be able to give.
344    pub fn decline(&self, reason: &str) {
345        let state = self.state.lock().expect("mesh state");
346        if state.is_none() {
347            *self.reason.lock().expect("mesh reason") = format!("not active: {reason}");
348        }
349        self.decided.store(true, Ordering::SeqCst);
350    }
351
352    /// Activate the mesh from a declaration. Idempotent: a second activation of an
353    /// active runtime is a no-op. A disabled declaration records the reason and starts
354    /// nothing. Never blocks: every socket lives on its own thread.
355    pub fn activate(
356        &self,
357        config: &MeshConfig,
358        identity: NodeIdentity,
359        endpoints: Vec<String>,
360        repos: Vec<String>,
361        version: &str,
362    ) -> Result<(), MeshError> {
363        self.activate_as(config, Arc::new(identity), "", endpoints, repos, version)
364    }
365
366    /// [`MeshRuntime::activate`] for one runtime slot of the node: what a shared server
367    /// does, naming its checkout's runtime so that two servers of one machine announce,
368    /// hear and link to each other as the two runtimes they are.
369    ///
370    /// Without the slot, two worktrees of one repository on one machine would announce
371    /// under one identity and each would take the other's announcements for its own echo.
372    /// The slot is what makes them two peers that cooperate, which is the ordinary case on
373    /// a developer's machine and not an exotic one.
374    ///
375    /// ```
376    /// use std::sync::Arc;
377    ///
378    /// use majordomus_cli::mesh::config::MeshConfig;
379    /// use majordomus_cli::mesh::identity::NodeIdentity;
380    /// use majordomus_cli::mesh::manager::MeshRuntime;
381    ///
382    /// // a declaration with every discovery mechanism off: nothing opens a socket
383    /// let config: MeshConfig = serde_json::from_value(serde_json::json!({
384    ///     "schema": "mesh/v1", "kind": "mesh", "id": "majordomus", "enabled": true,
385    ///     "multicast": { "enabled": false },
386    /// })).unwrap();
387    ///
388    /// let mesh = MeshRuntime::new();
389    /// let identity = Arc::new(NodeIdentity::ephemeral().unwrap());
390    /// // a machine whose key the declaration does not list stays closed (see `closed_to`)
391    /// let mut config = config;
392    /// config.trust.allow = vec![identity.public.public_key.clone()];
393    /// mesh.activate_as(&config, Arc::clone(&identity), "0000000000000001",
394    ///     vec!["127.0.0.1:8742".into()], vec!["root".into()], "doc").unwrap();
395    ///
396    /// let status = mesh.status();
397    /// assert!(status.active);
398    /// assert_eq!(status.identity.unwrap().node_id, identity.public.node_id);
399    ///
400    /// // idempotent: the second server of this process does not restart the first's mesh
401    /// mesh.activate_as(&config, Arc::clone(&identity), "0000000000000002",
402    ///     vec!["127.0.0.1:8743".into()], vec!["root".into()], "doc").unwrap();
403    /// assert_eq!(mesh.status().started_at, status.started_at);
404    /// ```
405    pub fn activate_as(
406        &self,
407        config: &MeshConfig,
408        identity: Arc<NodeIdentity>,
409        runtime: &str,
410        endpoints: Vec<String>,
411        repos: Vec<String>,
412        version: &str,
413    ) -> Result<(), MeshError> {
414        self.decided.store(true, Ordering::SeqCst);
415        let mut state = self.state.lock().expect("mesh state");
416        if state.is_some() {
417            return Ok(());
418        }
419        if !config.enabled {
420            *self.reason.lock().expect("mesh reason") =
421                "not active: the mesh declaration is disabled".into();
422            return Ok(());
423        }
424        if let Some(why) = closed_to(config, &identity.public.public_key) {
425            *self.reason.lock().expect("mesh reason") = why;
426            return Ok(());
427        }
428        let beacon = Arc::new(
429            Beacon::new(
430                identity,
431                endpoints,
432                vec!["http".into(), "mcp".into(), "ws".into()],
433                repos,
434                version,
435            )
436            .with_runtime(runtime),
437        );
438        let (tx, rx) = super::provider::ObservationSender::counting(
439            super::provider::OBSERVATION_QUEUE,
440            Arc::clone(&self.refusals.overflow),
441        );
442        let stop = Arc::new(AtomicBool::new(false));
443        let ctx = ProviderContext {
444            tx: tx.clone(),
445            stop: Arc::clone(&stop),
446            beacon: Arc::clone(&beacon),
447        };
448
449        // The providers the declaration asks for. Each failure is that provider's
450        // status; the loop never stops on one.
451        let mut providers: Vec<Box<dyn MeshProvider>> = Vec::new();
452        if config.multicast.enabled {
453            providers.push(Box::new(MulticastProvider::new(config.multicast.clone())));
454        }
455        if config.broadcast.mode != BroadcastMode::Disabled {
456            providers.push(Box::new(BroadcastProvider::new(
457                config.broadcast.clone(),
458                !config.multicast.enabled,
459            )));
460        }
461        if !config.rendezvous.endpoints.is_empty() {
462            providers.push(Box::new(RendezvousProvider::new(
463                config.rendezvous.endpoints.clone(),
464                config.rendezvous.interval_seconds,
465            )));
466        }
467        for provider in providers.iter_mut() {
468            if let Err(e) = provider.start(&ctx) {
469                tracing::warn!(provider = provider.id(), error = %e, "mesh provider did not start; the others run on");
470            }
471        }
472
473        // The manager thread: the one verification path.
474        {
475            let registry = Arc::clone(&self.registry);
476            let refusals = Arc::clone(&self.refusals);
477            let stop = Arc::clone(&stop);
478            let own = (
479                beacon.identity().public.node_id.clone(),
480                beacon.runtime().to_string(),
481            );
482            let trust = config.trust.clone();
483            let _ = std::thread::Builder::new()
484                .name("majordomus-mesh".into())
485                .spawn(move || {
486                    while !stop.load(Ordering::SeqCst) {
487                        let observation = match rx.recv_timeout(RECV_TIMEOUT) {
488                            Ok(o) => o,
489                            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
490                            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
491                        };
492                        ingest(&registry, &refusals, &trust, &own, &observation);
493                    }
494                });
495        }
496
497        *state = Some(Active {
498            beacon,
499            tx,
500            stop,
501            providers,
502            trust: config.trust.clone(),
503            started_at: crate::peers::rfc3339(std::time::SystemTime::now()),
504        });
505        Ok(())
506    }
507
508    /// Attach one more provider to an active mesh. This is the entire registration a
509    /// new discovery mechanism needs: no surface, consumer or registry changes with it.
510    pub fn attach(&self, mut provider: Box<dyn MeshProvider>) -> Result<(), MeshError> {
511        let mut state = self.state.lock().expect("mesh state");
512        let Some(active) = state.as_mut() else {
513            return Err(MeshError::Provider("the mesh is not active".into()));
514        };
515        let ctx = ProviderContext {
516            tx: active.tx.clone(),
517            stop: Arc::clone(&active.stop),
518            beacon: Arc::clone(&active.beacon),
519        };
520        let outcome = provider.start(&ctx);
521        active.providers.push(provider);
522        outcome
523    }
524
525    /// Tell the mesh to stop, without waiting for anything: every loop and handler reads the
526    /// flag at once, and the draining that needs locks is left to [`stop`](Self::stop). A
527    /// process that is shutting down calls this first, does the work that must not be
528    /// delayed — closing its listeners, releasing its lease — and only then drains.
529    ///
530    /// ```
531    /// use majordomus_cli::mesh::manager::MeshRuntime;
532    ///
533    /// // a mesh that never activated has nothing to tell, and says so rather than failing:
534    /// // a shutdown path must not care whether the thing it is stopping ever started
535    /// let mesh = MeshRuntime::new();
536    /// mesh.begin_stop();
537    /// assert!(!mesh.status().active);
538    /// ```
539    pub fn begin_stop(&self) {
540        if let Some(cooperation) = self.cooperation.lock().expect("mesh cooperation").as_ref() {
541            cooperation.begin_stop();
542        }
543        if let Some(active) = self.state.lock().expect("mesh state").as_ref() {
544            active.stop.store(true, Ordering::SeqCst);
545        }
546    }
547
548    /// Stop the mesh. Every provider thread and the manager end at their next bounded
549    /// wait; the registry keeps what it saw for whoever still asks.
550    pub fn stop(&self) {
551        if let Some(cooperation) = self.cooperation.lock().expect("mesh cooperation").as_ref() {
552            cooperation.stop();
553        }
554        let mut state = self.state.lock().expect("mesh state");
555        if let Some(active) = state.take() {
556            active.stop.store(true, Ordering::SeqCst);
557            *self.reason.lock().expect("mesh reason") = "not active: stopped".into();
558        }
559    }
560
561    /// The status, as every surface answers it.
562    pub fn status(&self) -> MeshStatus {
563        let state = self.state.lock().expect("mesh state");
564        let mut status = match state.as_ref() {
565            Some(active) => {
566                let mut providers: Vec<ProviderStatus> =
567                    active.providers.iter().map(|p| p.status()).collect();
568                crate::order::canonical(&mut providers);
569                MeshStatus {
570                    active: true,
571                    reason: None,
572                    identity: Some(active.beacon.identity().public.clone()),
573                    trust_policy: Some(active.trust.policy.as_str().into()),
574                    providers,
575                    tallies: Tallies::default(),
576                    refusals: Refusals::default(),
577                    started_at: Some(active.started_at.clone()),
578                }
579            }
580            None => MeshStatus {
581                active: false,
582                reason: Some(self.reason.lock().expect("mesh reason").clone()),
583                identity: None,
584                trust_policy: None,
585                providers: Vec::new(),
586                tallies: Tallies::default(),
587                refusals: Refusals::default(),
588                started_at: None,
589            },
590        };
591        status.tallies = self.registry.tallies();
592        status.refusals = self.refusals.snapshot();
593        status
594    }
595
596    /// Every observed node, in the registry's canonical node-id order.
597    pub fn nodes(&self) -> Vec<NodeRecord> {
598        self.registry.list()
599    }
600
601    /// Handle one rendezvous registration: verify the presented envelope exactly as a
602    /// datagram, record it, and answer with this node's envelope and the candidates the
603    /// registry holds. Registration grants nothing — the caller becomes a record, not a
604    /// peer with rights.
605    pub fn register(&self, envelope: &serde_json::Value, path: &str) -> RegisterAnswer {
606        let state = self.state.lock().expect("mesh state");
607        let Some(active) = state.as_ref() else {
608            return RegisterAnswer {
609                accepted: false,
610                refusal: Some("the mesh is not active on this node".into()),
611                candidates: Vec::new(),
612            };
613        };
614        let own = (
615            active.beacon.identity().public.node_id.clone(),
616            active.beacon.runtime().to_string(),
617        );
618        let trust = active.trust.clone();
619        let own_envelope = active.beacon.next_envelope();
620        drop(state);
621
622        let bytes = serde_json::to_vec(envelope).unwrap_or_default();
623        let refusal = match ingest(
624            &self.registry,
625            &self.refusals,
626            &trust,
627            &own,
628            &Observation {
629                source: MeshSource::Rendezvous,
630                path: path.into(),
631                bytes,
632            },
633        ) {
634            Ingest::Accepted => None,
635            Ingest::Own => Some("that is this node's own envelope".into()),
636            Ingest::Replay => Some("replayed".into()),
637            Ingest::Refused(r) => Some(r.to_string()),
638        };
639        // A refused caller is answered with the refusal alone: it learns nothing of this
640        // node's peers. An admitted one learns this node and the trusted nodes of its own
641        // repositories (I2133).
642        let mut candidates = Vec::with_capacity(MAX_CANDIDATES + 1);
643        if refusal.is_none() {
644            candidates.extend(serde_json::to_value(&own_envelope).ok());
645            let repos: Vec<String> = envelope
646                .get("repos")
647                .and_then(|r| r.as_array())
648                .map(|r| {
649                    r.iter()
650                        .filter_map(|v| v.as_str().map(str::to_string))
651                        .collect()
652                })
653                .unwrap_or_default();
654            candidates.extend(self.registry.candidates_for(MAX_CANDIDATES, &repos));
655        }
656        let candidates = candidates
657            .into_iter()
658            .filter_map(|v| serde_json::from_value(v).ok())
659            .collect();
660        RegisterAnswer {
661            accepted: refusal.is_none(),
662            refusal,
663            candidates,
664        }
665    }
666}
667
668enum Ingest {
669    Accepted,
670    Own,
671    Replay,
672    Refused(Refusal),
673}
674
675/// The one verification path: bounds, shape, version, staleness, signature, identity,
676/// trust, registry — in that order, for every provider alike.
677fn ingest(
678    registry: &MeshRegistry,
679    refusals: &RefusalCounters,
680    trust: &TrustConfig,
681    own: &(super::identity::NodeId, String),
682    observation: &Observation,
683) -> Ingest {
684    let envelope = match protocol::parse(&observation.bytes) {
685        Ok(e) => e,
686        Err(refusal) => {
687            refusals.count(&refusal);
688            return Ingest::Refused(refusal);
689        }
690    };
691    let Some(node_id) = envelope.adv.node_id() else {
692        // Unreachable after a verified parse, but hostile input earns no unwrap.
693        refusals.count(&Refusal::Signature);
694        return Ingest::Refused(Refusal::Signature);
695    };
696    // Only this very runtime is "own": another server of the same machine carries the
697    // same key under another runtime slot, and is a runtime to hear, not an echo.
698    if node_id == own.0 && envelope.adv.rt == own.1 {
699        refusals.self_heard.fetch_add(1, Ordering::Relaxed);
700        return Ingest::Own;
701    }
702    let known_key = registry.known_key(&node_id);
703    let previous = registry.trust_of(&node_id);
704    let verdict = trust::evaluate(
705        &trust.policy,
706        &trust.allow,
707        &envelope.adv.pk,
708        known_key.as_deref(),
709        previous.as_ref(),
710    );
711    let raw = serde_json::to_value(&envelope).unwrap_or(serde_json::Value::Null);
712    if registry.observe(
713        &envelope.adv,
714        node_id,
715        verdict,
716        observation.source,
717        &observation.path,
718        raw,
719    ) {
720        Ingest::Accepted
721    } else {
722        Ingest::Replay
723    }
724}
725
726/// Why the mesh stays closed on a machine the declaration does not name, or `None` when
727/// it may open. Under `deny_unknown` and `allowlist` a machine joins only when its own
728/// public key is in `trust.allow`: an unlisted machine — a contributor's clone of a public
729/// repository, a new machine nobody enrolled — would otherwise announce itself on its
730/// network and dial whatever endpoints the repository commits, trusted by nobody. It
731/// opens no socket instead, and says which key to list. `tofu` trusts whoever answers
732/// first, so it never closes on this ground.
733///
734/// ```
735/// use majordomus_cli::mesh::config::MeshConfig;
736/// use majordomus_cli::mesh::manager::closed_to;
737///
738/// let declared = |allow: &[&str], policy: &str| -> MeshConfig {
739///     serde_json::from_value(serde_json::json!({
740///         "schema": "mesh/v1", "kind": "mesh", "id": "m", "enabled": true,
741///         "trust": { "policy": policy, "allow": allow },
742///     })).unwrap()
743/// };
744/// let key = "ab".repeat(32);
745/// assert!(closed_to(&declared(&[], "deny_unknown"), &key).unwrap().starts_with("closed: "));
746/// assert!(closed_to(&declared(&["cd"], "allowlist"), &key).is_some());
747/// assert_eq!(closed_to(&declared(&[&key], "deny_unknown"), &key), None, "listed: it opens");
748/// assert_eq!(closed_to(&declared(&[], "tofu"), &key), None);
749/// ```
750pub fn closed_to(config: &MeshConfig, public_key: &str) -> Option<String> {
751    if config.trust.policy == trust::TrustPolicy::Tofu
752        || config.trust.allow.iter().any(|k| k == public_key)
753    {
754        return None;
755    }
756    Some(format!(
757        "closed: this machine's key {public_key} is not in trust.allow of the {} declaration, so it neither announces nor dials; to join, list the key there (`majordomus mesh identity` prints it) and commit",
758        config.id
759    ))
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use crate::mesh::config::{MeshConfig, MulticastConfig};
766    use crate::mesh::identity::NodeIdentity;
767    use crate::mesh::protocol::advertise;
768
769    fn identity_in(dir: &tempfile::TempDir, name: &str) -> NodeIdentity {
770        NodeIdentity::load_or_create(&dir.path().join(name)).unwrap()
771    }
772
773    /// [`quiet_config`] with `identity`'s key listed: an unlisted machine stays closed.
774    fn listed(identity: &NodeIdentity) -> MeshConfig {
775        let mut config = quiet_config();
776        config.trust.allow = vec![identity.public.public_key.clone()];
777        config
778    }
779
780    fn quiet_config() -> MeshConfig {
781        // Enabled, but with every transport off: what unit tests want.
782        MeshConfig {
783            schema: "mesh/v1".into(),
784            kind: "mesh-declaration".into(),
785            id: "test".into(),
786            enabled: true,
787            multicast: MulticastConfig {
788                enabled: false,
789                ..MulticastConfig::default()
790            },
791            broadcast: Default::default(),
792            rendezvous: Default::default(),
793            trust: Default::default(),
794            cooperation: Default::default(),
795        }
796    }
797
798    /// The synthetic provider of the zero-registration test: implemented here, in a
799    /// test, through the public contract alone. It proves a new mechanism needs no
800    /// change to the registry, the manager's verification, or any surface.
801    struct SyntheticProvider {
802        datagrams: Vec<Vec<u8>>,
803    }
804
805    impl MeshProvider for SyntheticProvider {
806        fn id(&self) -> &'static str {
807            "synthetic"
808        }
809        fn start(&mut self, ctx: &ProviderContext) -> Result<(), MeshError> {
810            for bytes in self.datagrams.drain(..) {
811                let _ = ctx.tx.send(Observation {
812                    source: MeshSource::Synthetic,
813                    path: "test".into(),
814                    bytes,
815                });
816            }
817            Ok(())
818        }
819        fn status(&self) -> ProviderStatus {
820            ProviderStatus {
821                id: "synthetic".into(),
822                state: crate::mesh::provider::MeshProviderState::Running,
823                detail: None,
824                sent: 0,
825                received: 0,
826            }
827        }
828    }
829
830    fn wait_for<F: Fn() -> bool>(what: &str, check: F) {
831        for _ in 0..100 {
832            if check() {
833                return;
834            }
835            std::thread::sleep(Duration::from_millis(20));
836        }
837        panic!("timed out waiting for {what}");
838    }
839
840    #[test]
841    fn a_synthetic_provider_reaches_the_registry_with_zero_registration() {
842        let dir = tempfile::tempdir().unwrap();
843        let runtime = MeshRuntime::new();
844        runtime
845            .activate(
846                &listed(&identity_in(&dir, "self.json")),
847                identity_in(&dir, "self.json"),
848                vec![],
849                vec![],
850                "0.5.0",
851            )
852            .unwrap();
853        let other = identity_in(&dir, "other.json");
854        let envelope = advertise(
855            &other,
856            1,
857            &["127.0.0.1:1".into()],
858            &["http".into()],
859            &[],
860            "0.5.0",
861        );
862        let bytes = serde_json::to_vec(&envelope).unwrap();
863        runtime
864            .attach(Box::new(SyntheticProvider {
865                datagrams: vec![bytes],
866            }))
867            .unwrap();
868        wait_for("the observation to land", || runtime.nodes().len() == 1);
869        let nodes = runtime.nodes();
870        assert_eq!(nodes[0].node_id, other.public.node_id);
871        assert!(
872            !nodes[0].trust.is_trusted(),
873            "deny_unknown observes, never trusts"
874        );
875        runtime.stop();
876    }
877
878    #[test]
879    fn own_datagrams_are_skipped_not_recorded() {
880        let dir = tempfile::tempdir().unwrap();
881        let runtime = MeshRuntime::new();
882        let own = identity_in(&dir, "self.json");
883        let own_envelope = advertise(&own, 1, &[], &[], &[], "0.5.0");
884        runtime
885            .activate(&listed(&own), own, vec![], vec![], "0.5.0")
886            .unwrap();
887        let bytes = serde_json::to_vec(&own_envelope).unwrap();
888        runtime
889            .attach(Box::new(SyntheticProvider {
890                datagrams: vec![bytes],
891            }))
892            .unwrap();
893        wait_for("the self-heard counter", || {
894            runtime.status().refusals.self_heard == 1
895        });
896        assert!(runtime.nodes().is_empty());
897        runtime.stop();
898    }
899
900    #[test]
901    fn garbage_from_a_provider_is_counted_and_dropped() {
902        let dir = tempfile::tempdir().unwrap();
903        let runtime = MeshRuntime::new();
904        runtime
905            .activate(
906                &listed(&identity_in(&dir, "self.json")),
907                identity_in(&dir, "self.json"),
908                vec![],
909                vec![],
910                "0.5.0",
911            )
912            .unwrap();
913        runtime
914            .attach(Box::new(SyntheticProvider {
915                datagrams: vec![b"garbage".to_vec(), vec![0xff; 2000]],
916            }))
917            .unwrap();
918        wait_for("both refusals", || {
919            let r = runtime.status().refusals;
920            r.malformed == 1 && r.oversized == 1
921        });
922        assert!(runtime.nodes().is_empty());
923        runtime.stop();
924    }
925
926    #[test]
927    fn registration_records_and_answers_with_own_envelope() {
928        let dir = tempfile::tempdir().unwrap();
929        let runtime = MeshRuntime::new();
930        let own = identity_in(&dir, "self.json");
931        let own_node = own.public.node_id.clone();
932        runtime
933            .activate(&listed(&own), own, vec![], vec![], "0.5.0")
934            .unwrap();
935        let caller = identity_in(&dir, "caller.json");
936        let envelope = advertise(&caller, 1, &["127.0.0.1:2".into()], &[], &[], "0.5.0");
937        let answer = runtime.register(&serde_json::to_value(&envelope).unwrap(), "test");
938        assert!(answer.accepted, "{:?}", answer.refusal);
939        assert_eq!(
940            answer.candidates[0].adv.node_id(),
941            Some(own_node),
942            "the answering node's own envelope leads the candidates"
943        );
944        assert_eq!(runtime.nodes().len(), 1);
945        runtime.stop();
946    }
947
948    #[test]
949    fn registration_refuses_garbage_and_an_inactive_mesh_says_so() {
950        let inactive = MeshRuntime::new();
951        let answer = inactive.register(&serde_json::json!({"v": 1}), "test");
952        assert!(!answer.accepted);
953        let dir = tempfile::tempdir().unwrap();
954        let runtime = MeshRuntime::new();
955        runtime
956            .activate(
957                &listed(&identity_in(&dir, "self.json")),
958                identity_in(&dir, "self.json"),
959                vec![],
960                vec![],
961                "0.5.0",
962            )
963            .unwrap();
964        let answer = runtime.register(&serde_json::json!({"v": 1}), "test");
965        assert!(!answer.accepted);
966        assert_eq!(answer.refusal.as_deref(), Some("malformed"));
967        runtime.stop();
968    }
969
970    #[test]
971    fn an_unlisted_machine_keeps_the_mesh_closed_and_starts_no_provider() {
972        let runtime = MeshRuntime::new();
973        let dir = tempfile::tempdir().unwrap();
974        let mut config = quiet_config();
975        config.multicast.enabled = true;
976        config.rendezvous.endpoints = vec!["http://127.0.0.1:9".into()];
977        config.trust.allow = vec!["cd".repeat(32)];
978        let own = identity_in(&dir, "self.json");
979        let key = own.public.public_key.clone();
980        runtime
981            .activate(&config, own, vec![], vec![], "0.5.0")
982            .unwrap();
983        let status = runtime.status();
984        assert!(
985            !status.active,
986            "an unlisted machine announces and dials nothing"
987        );
988        assert!(
989            status.providers.is_empty(),
990            "no provider started: {:?}",
991            status.providers
992        );
993        let reason = status.reason.unwrap();
994        assert!(
995            reason.starts_with("closed: ") && reason.contains(&key),
996            "{reason}"
997        );
998        assert!(runtime.decided(), "closed is a decision, not an absence");
999        // listed, the same declaration opens
1000        let open = MeshRuntime::new();
1001        config.multicast.enabled = false;
1002        config.rendezvous.endpoints.clear();
1003        config.trust.allow.push(key);
1004        open.activate(
1005            &config,
1006            identity_in(&dir, "self.json"),
1007            vec![],
1008            vec![],
1009            "0.5.0",
1010        )
1011        .unwrap();
1012        assert!(open.status().active);
1013        open.stop();
1014    }
1015
1016    #[test]
1017    fn a_disabled_declaration_activates_nothing_and_says_why() {
1018        let runtime = MeshRuntime::new();
1019        let dir = tempfile::tempdir().unwrap();
1020        let mut config = quiet_config();
1021        config.enabled = false;
1022        runtime
1023            .activate(
1024                &config,
1025                identity_in(&dir, "self.json"),
1026                vec![],
1027                vec![],
1028                "0.5.0",
1029            )
1030            .unwrap();
1031        let status = runtime.status();
1032        assert!(!status.active);
1033        assert!(status.reason.unwrap().contains("disabled"));
1034        assert!(
1035            runtime.decided(),
1036            "a server that read a disabled declaration decided: off"
1037        );
1038    }
1039
1040    #[test]
1041    fn a_runtime_is_decided_by_an_activation_and_by_nothing_else_that_reads_it() {
1042        let dir = tempfile::tempdir().unwrap();
1043        let runtime = MeshRuntime::new();
1044        let _ = runtime.status();
1045        let _ = runtime.nodes();
1046        let _ = runtime.cooperation_status();
1047        assert!(!runtime.decided(), "reading a runtime decides nothing");
1048        runtime
1049            .activate(
1050                &listed(&identity_in(&dir, "a.json")),
1051                identity_in(&dir, "a.json"),
1052                vec![],
1053                vec![],
1054                "0.5.0",
1055            )
1056            .unwrap();
1057        assert!(runtime.decided());
1058        runtime.stop();
1059        assert!(runtime.decided(), "a stopped mesh was still decided on");
1060    }
1061
1062    #[test]
1063    fn activation_is_idempotent() {
1064        let dir = tempfile::tempdir().unwrap();
1065        let runtime = MeshRuntime::new();
1066        let config = listed(&identity_in(&dir, "a.json"));
1067        runtime
1068            .activate(
1069                &config,
1070                identity_in(&dir, "a.json"),
1071                vec![],
1072                vec![],
1073                "0.5.0",
1074            )
1075            .unwrap();
1076        runtime
1077            .activate(
1078                &config,
1079                identity_in(&dir, "b.json"),
1080                vec![],
1081                vec![],
1082                "0.5.0",
1083            )
1084            .unwrap();
1085        let status = runtime.status();
1086        assert!(status.active);
1087        runtime.stop();
1088    }
1089}