1use 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
45pub struct DoctorCheck {
46 pub check: String,
48 pub ok: bool,
50 pub detail: String,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub impact: Option<String>,
55 #[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
82pub 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
126pub 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
168pub struct MeshDoctorReport {
169 pub ok: bool,
171 pub checks: Vec<DoctorCheck>,
173}
174
175pub fn doctor(declaration: Option<Result<MeshConfig, MeshError>>) -> MeshDoctorReport {
179 doctor_at(declaration, None, None)
180}
181
182pub 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 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 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 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 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 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 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 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 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 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 checks.push(firewall_check(&declaration));
425
426 checks.push(runtime_check(declared(&declaration), runtime));
430
431 MeshDoctorReport {
432 ok: checks.iter().all(|c| c.ok),
433 checks,
434 }
435}
436
437fn 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
452fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488enum Declared {
489 Absent,
491 Broken,
493 Disabled,
495 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
508pub 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 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 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 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 let nothing = firewall_report(false, "Status: active\n");
900 assert!(nothing.plan.rules.is_empty());
901 let check = firewall_verdict(¬hing);
902 assert!(check.ok);
903 assert_eq!(check.detail, nothing.verdict);
904 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}