Skip to main content

majordomus_cli/mesh/
doctor.rs

1//! The mesh self-check: every prerequisite proved on this machine alone, no second node
2//! required. Deterministic checks in a fixed order, each with its own verdict, so "why
3//! is the mesh not working" has an answer that names the broken link instead of a
4//! shrug — and, when a check fails or limits cooperation, what that means and what to do.
5//! Read-only toward the repository; the sockets it probes are ephemeral and closed before
6//! it answers.
7//!
8//! One check judges a decision rather than the machine. `runtime` reads what the mesh
9//! runtime of the process it runs in decided: in a shared server, whether an enabled
10//! declaration actually activated, and why not when it did not — the failure a declaration
11//! that says on and a server that is off would otherwise be, silently (ADR 0059). In any
12//! other process nothing activates the mesh, and the check reports that absence instead of
13//! judging it; `majordomus mesh doctor` therefore asks the running server for the report
14//! when one serves the checkout, so that the verdict is the server's.
15//!
16//! What this cannot see is whether the server's cooperation thread beats and whether its
17//! peers answer. That is `mesh verify`, which asks the server for a live round.
18//!
19//! ```
20//! use majordomus_cli::mesh::doctor::doctor;
21//!
22//! // No declaration is the default posture, and the self-check says so and runs on.
23//! let report = doctor(None);
24//! let declaration = report.checks.iter().find(|c| c.check == "declaration").unwrap();
25//! assert!(declaration.ok);
26//! assert!(declaration.detail.contains("default posture"));
27//! ```
28
29use std::net::{Ipv4Addr, UdpSocket};
30use std::path::Path;
31
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34
35use super::config::MeshConfig;
36use super::firewall;
37use super::identity::{default_identity_path, NodeIdentity};
38use super::manager::MeshStatus;
39use super::protocol;
40use super::provider::MeshProviderState;
41use super::MeshError;
42
43/// One check's verdict.
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
45pub struct DoctorCheck {
46    /// What was checked.
47    pub check: String,
48    /// Whether it holds.
49    pub ok: bool,
50    /// The evidence: a path, an address, an error.
51    pub detail: String,
52    /// What a failure or a limitation means for the mesh.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub impact: Option<String>,
55    /// What to do about it.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub remediation: Option<String>,
58}
59
60impl DoctorCheck {
61    fn pass(check: &str, detail: impl Into<String>) -> Self {
62        DoctorCheck {
63            check: check.into(),
64            ok: true,
65            detail: detail.into(),
66            impact: None,
67            remediation: None,
68        }
69    }
70
71    fn fail(check: &str, detail: impl Into<String>, impact: &str, remediation: &str) -> Self {
72        DoctorCheck {
73            check: check.into(),
74            ok: false,
75            detail: detail.into(),
76            impact: Some(impact.into()),
77            remediation: Some(remediation.into()),
78        }
79    }
80}
81
82/// The `trust-root` check: which copy of the declaration is obeyed, and every line of the
83/// working tree's copy this runtime ignores because the trunk's copy does not say it
84/// ([`super::root`]). `None` when there is no declaration to judge.
85///
86/// ```
87/// use majordomus_cli::mesh::doctor::declaration_source;
88/// use majordomus_cli::mesh::root::Resolved;
89/// use majordomus_cli::mesh::MeshConfig;
90/// let config = MeshConfig::from_metadata(
91///     &serde_json::json!({"schema": "mesh/v1", "kind": "mesh-declaration", "id": "x"}), "x").unwrap();
92/// let ignored = Resolved {
93///     declaration: Some(Ok(config)),
94///     trunk: Some("master".into()),
95///     committed: true,
96///     narrowed: vec!["trust.allow: bb is not on the trunk".into()],
97/// };
98/// let check = declaration_source(&ignored).unwrap();
99/// assert!(!check.ok && check.detail.contains("bb is not on the trunk"));
100/// let nothing = Resolved { declaration: None, trunk: None, committed: false, narrowed: vec![] };
101/// assert!(declaration_source(&nothing).is_none());
102/// ```
103pub fn declaration_source(resolved: &super::root::Resolved) -> Option<DoctorCheck> {
104    resolved.declaration.as_ref()?;
105    let trunk = resolved.trunk.as_deref().unwrap_or("the trunk");
106    if resolved.narrowed.is_empty() {
107        let detail = if resolved.committed {
108            format!("obeyed as {trunk} holds it; the working tree widens nothing")
109        } else {
110            format!("not committed on {trunk}; obeyed as written, since it widens nothing a committed copy would have to")
111        };
112        return Some(DoctorCheck::pass("trust-root", detail));
113    }
114    Some(DoctorCheck::fail(
115        "trust-root",
116        format!(
117            "this checkout's runtime ignores {} line(s) of the working tree's declaration: {}",
118            resolved.narrowed.len(),
119            resolved.narrowed.join("; ")
120        ),
121        "a working tree may narrow trust and never widen it: a key, an address or a looser policy takes effect only once the trunk's copy says it",
122        &format!("commit the change on {trunk} (merge it), or revert it here"),
123    ))
124}
125
126/// The `journal` check: whether the cooperation journal's file holds what the journal
127/// holds. A failed append keeps the event in memory and in replication, loses it from this
128/// machine's disk and is not acknowledged as durable; an unreadable line is one the last
129/// reload could not use; unread bytes lie beyond the reload bound.
130///
131/// ```
132/// use majordomus_cli::mesh::doctor::journal_check;
133/// use majordomus_cli::mesh::journal::JournalTallies;
134/// assert!(journal_check(&JournalTallies::default()).ok);
135/// let lost = JournalTallies { write_failures: 2, ..Default::default() };
136/// let check = journal_check(&lost);
137/// assert!(!check.ok && check.detail.contains("2 event(s)"));
138/// let beyond = JournalTallies { unread_bytes: 4096, ..Default::default() };
139/// let check = journal_check(&beyond);
140/// assert!(!check.ok && check.detail.contains("4096 byte(s)"));
141/// ```
142pub fn journal_check(tallies: &super::journal::JournalTallies) -> DoctorCheck {
143    if tallies.write_failures == 0 && tallies.unreadable == 0 && tallies.unread_bytes == 0 {
144        return DoctorCheck::pass(
145            "journal",
146            format!(
147                "{} event(s) held, every append synced to disk, the file reloaded whole with no unreadable line",
148                tallies.events
149            ),
150        );
151    }
152    DoctorCheck::fail(
153        "journal",
154        format!(
155            "{} event(s) did not reach the journal file and were not acknowledged as durable; {} line(s) of it did not read on reload; {} byte(s) of it lie beyond the {}-byte reload bound and were left on disk unread",
156            tallies.write_failures,
157            tallies.unreadable,
158            tallies.unread_bytes,
159            super::journal::MAX_RELOAD_BYTES
160        ),
161        "an event that is not on disk is lost to this machine when the runtime restarts, unless a peer still holds it; the events beyond the reload bound are not held, so this runtime neither folds nor relays them",
162        "free disk space or fix the permissions of the mesh state directory; a torn line is left as one unreadable line, and the events after it read; a journal beyond the bound was not written by this executable's compaction — move it aside and let the peers refill it",
163    )
164}
165
166/// The whole self-check.
167#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
168pub struct MeshDoctorReport {
169    /// Whether every check holds.
170    pub ok: bool,
171    /// The checks, in the order they ran.
172    pub checks: Vec<DoctorCheck>,
173}
174
175/// Run the self-check against the declaration as parsed (or its absence, or its error),
176/// in a process no shared server runs in. Deterministic order, no second node required, no
177/// repository writes.
178pub fn doctor(declaration: Option<Result<MeshConfig, MeshError>>) -> MeshDoctorReport {
179    doctor_at(declaration, None, None)
180}
181
182/// [`doctor`] for the repository at `root`, in the process whose mesh runtime decided
183/// `runtime`: adds whether the repository has a mesh identity cooperation can match on, and
184/// judges what the runtime decided about the declaration.
185///
186/// Discovery works without a repository identity — two nodes can hear each other while
187/// belonging to different repositories — but a link does not, so a report that omits this
188/// check can say every prerequisite holds while cooperation is unreachable. `root` is
189/// optional because the caller may have no repository at hand, and then the check is not
190/// run rather than failed.
191///
192/// `runtime` is the status of a runtime a shared server decided on — activated, or declined
193/// with a reason — and `None` in any process no server runs in
194/// ([`MeshRuntime::decided`](super::manager::MeshRuntime::decided)). The `runtime` check
195/// fails exactly when the declaration is enabled and a server's mesh is not active.
196///
197/// ```
198/// use majordomus_cli::mesh::doctor::doctor_at;
199/// use majordomus_cli::mesh::manager::MeshRuntime;
200/// use majordomus_cli::mesh::MeshConfig;
201///
202/// // a directory holding no history has no identity to match on, and the check says so
203/// let nowhere = tempfile::tempdir().unwrap();
204/// let report = doctor_at(None, Some(nowhere.path()), None);
205/// let repository = report.checks.iter().find(|c| c.check == "repository").unwrap();
206/// assert!(!repository.ok);
207/// assert!(repository.remediation.as_deref().unwrap().contains("cooperation.repository"));
208///
209/// // with no root to ask about, the question is not asked at all
210/// assert!(doctor_at(None, None, None).checks.iter().all(|c| c.check != "repository"));
211///
212/// // an enabled declaration a server declined is the runtime's failure, with the reason
213/// let enabled: MeshConfig = serde_json::from_value(serde_json::json!({
214///     "schema": "mesh/v1", "kind": "mesh-declaration", "id": "doc", "enabled": true,
215/// })).unwrap();
216/// let server = MeshRuntime::new();
217/// server.decline("the node identity did not load");
218/// let report = doctor_at(Some(Ok(enabled)), None, Some(&server.status()));
219/// let runtime = report.checks.iter().find(|c| c.check == "runtime").unwrap();
220/// assert!(!runtime.ok && !report.ok);
221/// assert!(runtime.detail.ends_with("the node identity did not load"));
222/// ```
223pub fn doctor_at(
224    declaration: Option<Result<MeshConfig, MeshError>>,
225    root: Option<&Path>,
226    runtime: Option<&MeshStatus>,
227) -> MeshDoctorReport {
228    let mut checks = Vec::new();
229
230    // The declaration: present, parseable, and what it says.
231    match &declaration {
232        None => checks.push(DoctorCheck::pass(
233            "declaration",
234            "absent: the mesh is off, which is the default posture",
235        )),
236        Some(Err(e)) => checks.push(DoctorCheck::fail(
237            "declaration",
238            e.to_string(),
239            "the mesh stays off: no discovery socket opens and no link is made",
240            "fix the declaration under .ai/repo/mesh/ (majordomus check names the schema violation), then restart the server",
241        )),
242        Some(Ok(config)) => checks.push(DoctorCheck::pass(
243            "declaration",
244            format!(
245                "{}: enabled={}, multicast={}, broadcast={}, rendezvous endpoints={}, trust={} ({} allowed key(s)), cooperation={} (heartbeat {}s, expiry {}s, {} seed(s))",
246                config.id,
247                config.enabled,
248                config.multicast.enabled,
249                config.broadcast.mode != super::config::BroadcastMode::Disabled,
250                config.rendezvous.endpoints.len(),
251                config.trust.policy.as_str(),
252                config.trust.allow.len(),
253                config.cooperation.enabled,
254                config.cooperation.heartbeat_seconds,
255                config.cooperation.expiry_seconds,
256                config.cooperation.seeds.len()
257            ),
258        )),
259    }
260
261    // The identity: where it lives, and whether what is there loads.
262    let identity = match default_identity_path() {
263        None => {
264            checks.push(DoctorCheck::fail(
265                "identity",
266                "no HOME and no XDG_STATE_HOME: this process has nowhere to keep a node identity",
267                "the mesh cannot activate: a node without a key cannot be told apart from an impostor",
268                "run the server with HOME or XDG_STATE_HOME set",
269            ));
270            None
271        }
272        Some(path) if path.is_file() => match NodeIdentity::load_or_create(&path) {
273            Ok(identity) => {
274                checks.push(DoctorCheck::pass(
275                    "identity",
276                    format!("{} at {}", identity.public.node_id, path.display()),
277                ));
278                Some(identity)
279            }
280            Err(e) => {
281                checks.push(DoctorCheck::fail(
282                    "identity",
283                    e.to_string(),
284                    "the mesh cannot activate with a broken identity, and replacing it silently would make this machine a new node every peer distrusts",
285                    "restore the file from a backup, or remove it deliberately — a new key is a new node, and every allowlist naming the old key must be updated",
286                ));
287                None
288            }
289        },
290        Some(path) => {
291            checks.push(DoctorCheck::pass(
292                "identity",
293                format!("absent; created at first activation at {}", path.display()),
294            ));
295            None
296        }
297    };
298
299    // Trust: under an allowlist-shaped declaration, is this machine's own key listed? An
300    // unlisted machine keeps the mesh closed (`manager::closed_to`), so this is the check
301    // that names why and what to do.
302    if let (Some(Ok(config)), Some(identity)) = (&declaration, &identity) {
303        if config.enabled && config.trust.policy != super::trust::TrustPolicy::Tofu {
304            let listed = config.trust.allow.contains(&identity.public.public_key);
305            checks.push(if listed {
306                DoctorCheck::pass("trust", "this machine's key is on the allowlist")
307            } else {
308                DoctorCheck::fail(
309                    "trust",
310                    format!(
311                        "this machine's key {} is not among the {} allowed",
312                        identity.public.public_key,
313                        config.trust.allow.len()
314                    ),
315                    "the mesh stays closed on this machine: it neither announces nor dials, and no other machine links to it",
316                    "add the key above to trust.allow in the mesh declaration and commit it",
317                )
318            });
319        }
320    }
321
322    // The repository's mesh identity: what links are matched on.
323    if let Some(root) = root {
324        let declared = declaration
325            .as_ref()
326            .and_then(|d| d.as_ref().ok())
327            .and_then(|c| c.cooperation.repository.clone());
328        checks.push(match super::repository::resolve(root, declared.as_deref()) {
329            Ok(identity) => DoctorCheck::pass(
330                "repository",
331                format!(
332                    "{} from {:?}: {}",
333                    identity.id, identity.basis, identity.detail
334                ),
335            ),
336            Err(e) => DoctorCheck::fail(
337                "repository",
338                e.to_string(),
339                "cooperation stays off: without a repository identity no peer can be matched to this repository",
340                "declare cooperation.repository in the mesh declaration, or fetch the full history (git fetch --unshallow)",
341            ),
342        });
343    }
344
345    // A UDP socket at all.
346    checks.push(match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) {
347        Ok(socket) => DoctorCheck::pass(
348            "udp",
349            format!(
350                "ephemeral bind ok ({})",
351                socket
352                    .local_addr()
353                    .map(|a| a.to_string())
354                    .unwrap_or_else(|_| "?".into())
355            ),
356        ),
357        Err(e) => DoctorCheck::fail(
358            "udp",
359            format!("cannot bind an ephemeral UDP socket: {e}"),
360            "multicast and broadcast discovery cannot run; seeds and rendezvous still can",
361            "allow UDP for this process (a sandbox or a firewall is refusing it), or declare cooperation.seeds",
362        ),
363    });
364
365    // Multicast capability, against the declared (or default) group.
366    let group_text = declaration
367        .as_ref()
368        .and_then(|d| d.as_ref().ok())
369        .map(|c| c.multicast.group.clone())
370        .unwrap_or_else(|| super::config::DEFAULT_GROUP.into());
371    checks.push(match multicast_probe(&group_text) {
372        Ok(()) => DoctorCheck::pass(
373            "multicast",
374            format!("joined and left {group_text} on an ephemeral socket"),
375        ),
376        Err(e) => DoctorCheck::fail(
377            "multicast",
378            e.to_string(),
379            "runtimes on this segment will not hear each other by multicast",
380            "check the interface's multicast route, or declare cooperation.seeds or rendezvous endpoints instead",
381        ),
382    });
383
384    // Broadcast capability.
385    checks.push(
386        match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).and_then(|s| s.set_broadcast(true)) {
387            Ok(()) => DoctorCheck::pass("broadcast", "the broadcast flag sets"),
388            Err(e) => DoctorCheck::fail(
389                "broadcast",
390                format!("cannot enable broadcast: {e}"),
391                "the broadcast fallback cannot run",
392                "leave broadcast disabled and rely on multicast, seeds or rendezvous",
393            ),
394        },
395    );
396
397    // The discovery protocol, end to end in memory: sign, encode, parse, verify.
398    checks.push(match protocol_probe() {
399        Ok(detail) => DoctorCheck::pass("protocol", detail),
400        Err(e) => DoctorCheck::fail(
401            "protocol",
402            e,
403            "no advertisement this executable writes would verify anywhere",
404            "this is a defect of the executable: reinstall it and report the detail",
405        ),
406    });
407
408    // The link protocol, end to end in memory: a hello signed, verified, refused when
409    // tampered — the handshake's own cryptography, with no peer.
410    checks.push(match link_probe() {
411        Ok(detail) => DoctorCheck::pass("link", detail),
412        Err(e) => DoctorCheck::fail(
413            "link",
414            e,
415            "no link this executable opens would be admitted anywhere",
416            "this is a defect of the executable: reinstall it and report the detail",
417        ),
418    });
419
420    // The host firewall: what the declaration implies this machine must admit, and whether
421    // the host does. Every check above is a fact of the process; this one is a fact of the
422    // host, and the one that was missing when a declared hub dropped every registration
423    // at the kernel while the doctor said every check held.
424    checks.push(firewall_check(&declaration));
425
426    // The runtime: what the process's mesh runtime decided about the declaration, when
427    // anything decided. Every check above says the machine could run a mesh; this one says
428    // whether the server did.
429    checks.push(runtime_check(declared(&declaration), runtime));
430
431    MeshDoctorReport {
432        ok: checks.iter().all(|c| c.ok),
433        checks,
434    }
435}
436
437/// The `firewall` verdict: the admissions the declaration implies on this host, derived
438/// by [`firewall::plan`] from the declaration and this machine's addresses, against what
439/// the host's firewall says and what the kernel logged it dropping. It fails when a rule
440/// is observed missing or when the kernel logged drops toward the mesh's ports in the
441/// last five minutes; a firewall that cannot be asked without root, with nothing logged, holds —
442/// an absence, not a verdict — and says how to ask as root.
443fn firewall_check(declaration: &Option<Result<MeshConfig, MeshError>>) -> DoctorCheck {
444    let config = declaration.as_ref().and_then(|d| d.as_ref().ok());
445    let local: Vec<std::net::Ipv4Addr> = super::address::interfaces()
446        .into_iter()
447        .map(|(_, ip)| ip)
448        .collect();
449    firewall_verdict(&firewall::report(config, &local, None))
450}
451
452/// The `firewall` check of a report already built. Apart from [`firewall_check`] so that
453/// the verdict is a function of the report alone: which firewall a machine runs, and
454/// whether it admits the mesh, is a fact of that machine, and the failing verdict has to
455/// be held on every machine, not only on one whose firewall happens to drop the mesh.
456fn firewall_verdict(report: &firewall::FirewallReport) -> DoctorCheck {
457    let detail = if report.plan.rules.is_empty() {
458        report.verdict.clone()
459    } else {
460        format!(
461            "{} ({}): needs {}; {}",
462            report.backend.as_str(),
463            report.platform,
464            report
465                .plan
466                .rules
467                .iter()
468                .map(firewall::FirewallRule::label)
469                .collect::<Vec<_>>()
470                .join(", "),
471            report.verdict
472        )
473    };
474    if report.ok {
475        DoctorCheck::pass("firewall", detail)
476    } else {
477        DoctorCheck::fail(
478            "firewall",
479            detail,
480            "what the firewall drops never reaches the socket: a declared hub that drops registrations is a hub nobody can register with, and a machine that drops the multicast group hears no peer on its segment, while every other check holds",
481            "run `majordomus mesh firewall` for the rules and the commands that admit them, and `sudo majordomus mesh firewall apply` to admit them",
482        )
483    }
484}
485
486/// What the declaration says about the mesh, as far as the `runtime` verdict needs it.
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488enum Declared {
489    /// No mesh-declaration object in the layer.
490    Absent,
491    /// An object that does not parse.
492    Broken,
493    /// `enabled: false`.
494    Disabled,
495    /// `enabled: true`.
496    Enabled,
497}
498
499fn declared(declaration: &Option<Result<MeshConfig, MeshError>>) -> Declared {
500    match declaration {
501        None => Declared::Absent,
502        Some(Err(_)) => Declared::Broken,
503        Some(Ok(config)) if config.enabled => Declared::Enabled,
504        Some(Ok(_)) => Declared::Disabled,
505    }
506}
507
508/// The `runtime` verdict. `None` is a runtime nobody decided on — the command line's, a
509/// test's — whose inactivity is an absence and never a failure. A decided runtime is
510/// active (a degraded one names its failed providers, and fails only when no provider
511/// runs at all), off as declared, off because there is nothing to activate, or — the one
512/// failure — off under an enabled declaration, carrying the server's reason.
513/// The `runtime` verdict alone: what a server decided about the declaration, judged as
514/// `mesh doctor` judges it. Health reads this one check rather than running the doctor's
515/// probes, which open sockets.
516///
517/// ```
518/// use majordomus_cli::mesh::doctor::runtime_verdict;
519/// use majordomus_cli::mesh::manager::MeshRuntime;
520/// use majordomus_cli::mesh::MeshConfig;
521/// let enabled: MeshConfig = serde_json::from_value(serde_json::json!({
522///     "schema": "mesh/v1", "kind": "mesh-declaration", "id": "x", "enabled": true })).unwrap();
523/// let server = MeshRuntime::new();
524/// server.decline("the node identity did not load");
525/// assert!(!runtime_verdict(Some(Ok(enabled)), Some(&server.status())).ok);
526/// assert!(runtime_verdict(None, Some(&server.status())).ok, "no declaration, nothing owed");
527/// ```
528pub fn runtime_verdict(
529    declaration: Option<Result<MeshConfig, MeshError>>,
530    runtime: Option<&MeshStatus>,
531) -> DoctorCheck {
532    runtime_check(declared(&declaration), runtime)
533}
534
535fn runtime_check(declared: Declared, runtime: Option<&MeshStatus>) -> DoctorCheck {
536    let Some(status) = runtime else {
537        return DoctorCheck::pass(
538            "runtime",
539            match declared {
540                Declared::Enabled => {
541                    "not decided in this process: the mesh lives in the shared server, and `majordomus mesh doctor` asks the server when one serves this checkout"
542                }
543                Declared::Disabled => {
544                    "not decided in this process, and nothing to decide: the declaration is disabled"
545                }
546                Declared::Broken => {
547                    "not decided in this process, and nothing to decide: the declaration does not parse"
548                }
549                Declared::Absent => {
550                    "not decided in this process, and nothing to decide: the repository declares no mesh"
551                }
552            },
553        );
554    };
555    let reason = status.reason.as_deref().unwrap_or("no reason recorded");
556    let why = reason.strip_prefix("not active: ").unwrap_or(reason);
557    if !status.active {
558        return match declared {
559            // closed by the trust list: the trust check carries the failure and the remedy
560            Declared::Enabled if why.starts_with("closed: ") => {
561                DoctorCheck::pass("runtime", why.to_string())
562            }
563            Declared::Enabled => DoctorCheck::fail(
564                "runtime",
565                format!("the declaration is enabled and this server's mesh is not active — {why}"),
566                "this checkout's server announces nothing, hears nobody and links to no peer, while the committed declaration says the fleet sees it",
567                "fix what the reason names, then restart the server (`majordomus serve stop && majordomus serve ensure`)",
568            ),
569            Declared::Disabled => DoctorCheck::pass("runtime", format!("off, as declared — {why}")),
570            Declared::Broken => DoctorCheck::pass(
571                "runtime",
572                format!("off: the declaration does not parse, so nothing was activated — {why}"),
573            ),
574            Declared::Absent => DoctorCheck::pass(
575                "runtime",
576                format!("off: the repository declares no mesh — {why}"),
577            ),
578        };
579    }
580    let running = status
581        .providers
582        .iter()
583        .filter(|p| matches!(p.state, MeshProviderState::Running))
584        .count();
585    let failed: Vec<String> = status
586        .providers
587        .iter()
588        .filter(|p| matches!(p.state, MeshProviderState::Failed))
589        .map(|p| format!("{} ({})", p.id, p.detail.as_deref().unwrap_or("no detail")))
590        .collect();
591    let node = status
592        .identity
593        .as_ref()
594        .map(|i| i.node_id.to_string())
595        .unwrap_or_else(|| "?".into());
596    let mut detail = format!(
597        "active as {node} since {}: {running} of {} provider(s) running; {} node(s) known, {} trusted, {} present",
598        status.started_at.as_deref().unwrap_or("?"),
599        status.providers.len(),
600        status.tallies.nodes,
601        status.tallies.trusted,
602        status.tallies.present
603    );
604    if !failed.is_empty() {
605        detail.push_str(&format!("; failed: {}", failed.join(", ")));
606    }
607    // A provider that failed beside one that runs is a degraded mesh, named and holding;
608    // every declared transport failed is a mesh that hears nothing and is heard by nobody.
609    if failed.is_empty() || running > 0 {
610        DoctorCheck::pass("runtime", detail)
611    } else {
612        DoctorCheck::fail(
613            "runtime",
614            detail,
615            "the mesh is active and every discovery transport it declares has failed: it hears nothing and is heard by nobody",
616            "read each failed provider's detail (`majordomus mesh status`), fix what it names, then restart the server",
617        )
618    }
619}
620
621fn multicast_probe(group_text: &str) -> Result<(), MeshError> {
622    let group: Ipv4Addr = group_text
623        .parse()
624        .map_err(|_| MeshError::Provider(format!("'{group_text}' is not an IPv4 group")))?;
625    if !group.is_multicast() {
626        return Err(MeshError::Provider(format!(
627            "{group} is not a multicast address"
628        )));
629    }
630    let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))
631        .map_err(|e| MeshError::Provider(format!("cannot bind: {e}")))?;
632    socket
633        .join_multicast_v4(&group, &Ipv4Addr::UNSPECIFIED)
634        .map_err(|e| MeshError::Provider(format!("cannot join {group}: {e}")))?;
635    let _ = socket.leave_multicast_v4(&group, &Ipv4Addr::UNSPECIFIED);
636    Ok(())
637}
638
639fn protocol_probe() -> Result<String, String> {
640    let identity = NodeIdentity::ephemeral().map_err(|e| e.to_string())?;
641    let envelope = protocol::advertise_as(
642        &identity,
643        "0000000000000001",
644        1,
645        &["127.0.0.1:1".into()],
646        &["http".into()],
647        &[],
648        "self-check",
649    );
650    let bytes = protocol::encode(&envelope).map_err(|e| e.to_string())?;
651    let parsed = protocol::parse(&bytes).map_err(|r| format!("a fresh envelope refused: {r}"))?;
652    if parsed.adv.node_id() != Some(identity.public.node_id.clone()) {
653        return Err("the parsed envelope proves a different node".into());
654    }
655    Ok(format!(
656        "sign → encode ({} bytes) → parse → verify, protocol {}",
657        bytes.len(),
658        protocol::PROTOCOL_VERSION
659    ))
660}
661
662fn link_probe() -> Result<String, String> {
663    use super::link::{sign, verify_signed, Domain, LINK_PROTOCOL_MAX, LINK_PROTOCOL_MIN};
664    let identity = NodeIdentity::ephemeral().map_err(|e| e.to_string())?;
665    let mut signed = sign(
666        &identity,
667        Domain::Hello,
668        serde_json::json!({"probe": super::link::fresh_token()}),
669    );
670    if !verify_signed(&identity.public.public_key, Domain::Hello, &signed) {
671        return Err("a fresh hello does not verify".into());
672    }
673    signed.body = serde_json::json!({"probe": "tampered"});
674    if verify_signed(&identity.public.public_key, Domain::Hello, &signed) {
675        return Err("a tampered hello verifies".into());
676    }
677    Ok(format!(
678        "hello signed → verified → tampered copy refused, link protocol {LINK_PROTOCOL_MIN}..{LINK_PROTOCOL_MAX}"
679    ))
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    #[test]
687    fn the_runtime_verdict_judges_what_a_server_decided_about_the_declaration() {
688        let enabled: MeshConfig = serde_json::from_value(serde_json::json!({
689            "schema": "mesh/v1", "kind": "mesh-declaration", "id": "x", "enabled": true,
690        }))
691        .unwrap();
692        let server = super::super::manager::MeshRuntime::new();
693        server.decline("the node identity did not load");
694        let declined = runtime_verdict(Some(Ok(enabled)), Some(&server.status()));
695        assert_eq!(declined.check, "runtime");
696        assert!(
697            !declined.ok,
698            "a declared mesh the server declined is owed: {declined:?}"
699        );
700        let undeclared = runtime_verdict(None, Some(&server.status()));
701        assert!(
702            undeclared.ok,
703            "no declaration, nothing owed: {undeclared:?}"
704        );
705    }
706
707    #[test]
708    fn the_self_check_runs_without_a_declaration_and_without_a_network() {
709        let report = doctor(None);
710        let names: Vec<&str> = report.checks.iter().map(|c| c.check.as_str()).collect();
711        assert_eq!(
712            names,
713            vec![
714                "declaration",
715                "identity",
716                "udp",
717                "multicast",
718                "broadcast",
719                "protocol",
720                "link",
721                "firewall",
722                "runtime"
723            ]
724        );
725        for check in ["protocol", "link", "firewall", "runtime"] {
726            let c = report.checks.iter().find(|c| c.check == check).unwrap();
727            assert!(c.ok, "{}", c.detail);
728        }
729    }
730
731    #[test]
732    fn a_parsed_declaration_is_summarised_in_the_verdict() {
733        let config: MeshConfig = serde_json::from_value(serde_json::json!({
734            "schema": "mesh/v1", "kind": "mesh-declaration", "id": "docs", "enabled": true
735        }))
736        .unwrap();
737        let report = doctor(Some(Ok(config)));
738        let declaration = report
739            .checks
740            .iter()
741            .find(|c| c.check == "declaration")
742            .unwrap();
743        assert!(declaration.ok);
744        assert!(
745            declaration.detail.contains("enabled=true"),
746            "{}",
747            declaration.detail
748        );
749        assert!(declaration.detail.contains("trust=deny_unknown"));
750        assert!(declaration.detail.contains("cooperation=true"));
751    }
752
753    #[test]
754    fn a_broken_declaration_is_one_failed_check_with_its_impact_and_remedy() {
755        let report = doctor(Some(Err(MeshError::Config("bad".into()))));
756        let declaration = report
757            .checks
758            .iter()
759            .find(|c| c.check == "declaration")
760            .unwrap();
761        assert!(!declaration.ok);
762        assert!(declaration.impact.is_some() && declaration.remediation.is_some());
763        assert!(!report.ok);
764    }
765
766    #[test]
767    fn a_repository_without_an_identity_is_named_with_the_remedy() {
768        let dir = tempfile::tempdir().unwrap();
769        assert!(std::process::Command::new("git")
770            .arg("-C")
771            .arg(dir.path())
772            .args(["init", "-q"])
773            .status()
774            .unwrap()
775            .success());
776        let report = doctor_at(None, Some(dir.path()), None);
777        let repository = report
778            .checks
779            .iter()
780            .find(|c| c.check == "repository")
781            .unwrap();
782        assert!(!repository.ok, "an empty repository has no root commit");
783        assert!(repository
784            .remediation
785            .as_deref()
786            .unwrap_or_default()
787            .contains("cooperation.repository"));
788    }
789
790    fn config(enabled: bool) -> MeshConfig {
791        serde_json::from_value(serde_json::json!({
792            "schema": "mesh/v1", "kind": "mesh-declaration", "id": "docs", "enabled": enabled
793        }))
794        .unwrap()
795    }
796
797    fn check<'a>(report: &'a MeshDoctorReport, name: &str) -> &'a DoctorCheck {
798        report.checks.iter().find(|c| c.check == name).unwrap()
799    }
800
801    #[test]
802    fn an_enabled_declaration_nobody_decided_on_is_an_absence_not_a_failure() {
803        let report = doctor_at(Some(Ok(config(true))), None, None);
804        let runtime = check(&report, "runtime");
805        assert!(runtime.ok, "{}", runtime.detail);
806        assert!(
807            runtime.detail.starts_with("not decided in this process")
808                && runtime.detail.contains("asks the server"),
809            "{}",
810            runtime.detail
811        );
812    }
813
814    #[test]
815    fn an_enabled_declaration_a_server_declined_fails_with_the_servers_reason_and_the_remedy() {
816        let server = super::super::MeshRuntime::new();
817        server.decline("the node identity did not load: permission denied");
818        let report = doctor_at(Some(Ok(config(true))), None, Some(&server.status()));
819        let runtime = check(&report, "runtime");
820        assert!(!runtime.ok);
821        assert_eq!(
822            runtime.detail,
823            "the declaration is enabled and this server's mesh is not active — the node identity did not load: permission denied"
824        );
825        assert!(runtime.impact.is_some());
826        assert!(runtime
827            .remediation
828            .as_deref()
829            .unwrap_or_default()
830            .contains("majordomus serve ensure"));
831        assert!(!report.ok);
832    }
833
834    /// A firewall report as [`firewall::report`] builds one, for a host this suite does
835    /// not run on: ufw on Linux, asked `status`, with the multicast group the one rule an
836    /// enabled declaration with no hub implies. The report is assembled from the module's
837    /// own pure functions, so no firewall is asked and none is needed.
838    fn firewall_report(declared: bool, status: &str) -> firewall::FirewallReport {
839        let config = config(declared);
840        let plan = firewall::plan(Some(&config), &[Ipv4Addr::new(10, 0, 0, 2)], None);
841        let observation = firewall::judge_ufw(status, &plan);
842        let ok = observation.state != firewall::FirewallObservationState::Missing;
843        firewall::FirewallReport {
844            ok,
845            platform: "linux".into(),
846            backend: firewall::Backend::Ufw,
847            executable: None,
848            commands: firewall::render(firewall::Backend::Ufw, &plan, None),
849            plan,
850            verdict: format!("what ufw said: {}", observation.detail),
851            observation,
852            window_seconds: firewall::BLOCK_WINDOW_SECONDS,
853            blocked_recently: None,
854        }
855    }
856
857    #[test]
858    fn a_firewall_that_drops_the_mesh_fails_the_check_with_its_impact_and_the_command_to_run() {
859        let dropping = firewall_report(true, "Status: active\n");
860        let check = firewall_verdict(&dropping);
861        assert_eq!(check.check, "firewall");
862        assert!(!check.ok);
863        assert_eq!(
864            check.detail,
865            "ufw (linux): needs udp/7741 to 239.255.77.77 from any; what ufw said: 1 of 1 rule(s) not admitted: udp/7741 to 239.255.77.77 from any"
866        );
867        let impact = check.impact.as_deref().unwrap_or_default();
868        assert!(
869            impact.contains("never reaches the socket") && impact.contains("every other check"),
870            "{impact}"
871        );
872        let remedy = check.remediation.as_deref().unwrap_or_default();
873        assert!(
874            remedy.contains("`majordomus mesh firewall`")
875                && remedy.contains("`sudo majordomus mesh firewall apply`"),
876            "{remedy}"
877        );
878    }
879
880    #[test]
881    fn a_firewall_that_admits_the_mesh_holds_and_names_the_rules_it_was_asked_about() {
882        let admitting = firewall_report(
883            true,
884            "Status: active\n239.255.77.77 7741/udp     ALLOW IN    Anywhere\n",
885        );
886        let check = firewall_verdict(&admitting);
887        assert!(check.ok, "{}", check.detail);
888        assert_eq!(
889            check.detail,
890            "ufw (linux): needs udp/7741 to 239.255.77.77 from any; what ufw said: every rule of the plan is admitted (1 rule(s))"
891        );
892        assert!(check.impact.is_none() && check.remediation.is_none());
893    }
894
895    #[test]
896    fn a_mesh_that_needs_nothing_admitted_is_reported_by_the_verdict_alone() {
897        // A disabled declaration derives no rule, so there is no backend and no rule to
898        // name beside the verdict: the sentence is the whole detail.
899        let nothing = firewall_report(false, "Status: active\n");
900        assert!(nothing.plan.rules.is_empty());
901        let check = firewall_verdict(&nothing);
902        assert!(check.ok);
903        assert_eq!(check.detail, nothing.verdict);
904        // and the check the doctor runs on this host is that verdict over the real report
905        let here = firewall_check(&Some(Ok(config(false))));
906        assert!(here.ok, "{}", here.detail);
907        assert!(
908            here.detail.contains("needs no inbound admission"),
909            "{}",
910            here.detail
911        );
912    }
913
914    #[test]
915    fn a_disabled_declaration_a_server_declined_is_off_as_declared() {
916        let server = super::super::MeshRuntime::new();
917        server.decline("the mesh declaration is disabled");
918        let report = doctor_at(Some(Ok(config(false))), None, Some(&server.status()));
919        let runtime = check(&report, "runtime");
920        assert!(runtime.ok, "{}", runtime.detail);
921        assert_eq!(
922            runtime.detail,
923            "off, as declared — the mesh declaration is disabled"
924        );
925    }
926
927    #[test]
928    fn a_broken_or_absent_declaration_is_not_the_runtimes_failure() {
929        let server = super::super::MeshRuntime::new();
930        server.decline("the declaration does not parse: bad");
931        let broken = doctor_at(
932            Some(Err(MeshError::Config("bad".into()))),
933            None,
934            Some(&server.status()),
935        );
936        let runtime = check(&broken, "runtime");
937        assert!(
938            runtime.ok,
939            "the declaration check carries that failure, once"
940        );
941        assert!(runtime
942            .detail
943            .starts_with("off: the declaration does not parse"));
944        assert!(!check(&broken, "declaration").ok);
945        let absent = doctor_at(None, None, None);
946        assert!(check(&absent, "runtime")
947            .detail
948            .ends_with("the repository declares no mesh"));
949    }
950
951    #[test]
952    fn a_server_that_activated_its_mesh_is_active_and_every_provider_failing_is_a_failure() {
953        let server = super::super::MeshRuntime::new();
954        let dir = tempfile::tempdir().unwrap();
955        let identity = NodeIdentity::load_or_create(&dir.path().join("node.json")).unwrap();
956        let mut quiet = config(true);
957        quiet.multicast.enabled = false;
958        quiet.trust.allow = vec![identity.public.public_key.clone()];
959        server
960            .activate(&quiet, identity, vec![], vec![], "test")
961            .unwrap();
962        let mut status = server.status();
963        server.stop();
964        let active = doctor_at(Some(Ok(config(true))), None, Some(&status));
965        let runtime = check(&active, "runtime");
966        assert!(runtime.ok, "{}", runtime.detail);
967        assert!(
968            runtime.detail.starts_with("active as ")
969                && runtime.detail.contains(": 0 of 0 provider(s) running;"),
970            "{}",
971            runtime.detail
972        );
973        status.providers.push(super::super::ProviderStatus {
974            id: "udp_multicast".into(),
975            state: MeshProviderState::Failed,
976            detail: Some("address in use".into()),
977            sent: 0,
978            received: 0,
979        });
980        let deaf = doctor_at(Some(Ok(config(true))), None, Some(&status));
981        let runtime = check(&deaf, "runtime");
982        assert!(!runtime.ok);
983        assert!(
984            runtime
985                .detail
986                .contains("failed: udp_multicast (address in use)"),
987            "{}",
988            runtime.detail
989        );
990    }
991}