1use std::collections::BTreeMap;
45use std::net::Ipv4Addr;
46use std::path::{Path, PathBuf};
47use std::process::Command;
48
49use schemars::JsonSchema;
50use serde::{Deserialize, Serialize};
51
52use super::config::{BroadcastMode, MeshConfig};
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
64#[serde(rename_all = "snake_case")]
65pub enum Protocol {
66 Udp,
68 Tcp,
70}
71
72impl Protocol {
73 fn as_str(self) -> &'static str {
74 match self {
75 Protocol::Udp => "udp",
76 Protocol::Tcp => "tcp",
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "snake_case")]
93pub enum Role {
94 Multicast,
96 Broadcast,
98 Hub,
100 Link,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123pub struct FirewallRule {
124 pub role: Role,
126 pub protocol: Protocol,
128 pub port: u16,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub destination: Option<String>,
133 pub sources: Vec<String>,
136 pub reason: String,
138}
139
140impl FirewallRule {
141 pub fn label(&self) -> String {
158 let to = match &self.destination {
159 Some(group) => format!("{}/{} to {group}", self.protocol.as_str(), self.port),
160 None => format!("{}/{}", self.protocol.as_str(), self.port),
161 };
162 if self.sources.is_empty() {
163 format!("{to} from any")
164 } else {
165 format!("{to} from {}", self.sources.join(", "))
166 }
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
182pub struct FirewallPlan {
183 pub local_addresses: Vec<String>,
185 pub networks: Vec<String>,
188 pub hub_ports: Vec<u16>,
190 pub rules: Vec<FirewallRule>,
192}
193
194pub fn enclosing(ip: Ipv4Addr) -> Option<String> {
208 let [a, b, c, _] = ip.octets();
209 match (a, b) {
210 (10, _) => Some("10.0.0.0/8".into()),
211 (172, 16..=31) => Some("172.16.0.0/12".into()),
212 (192, 168) => Some(format!("192.168.{c}.0/24")),
213 (100, 64..=127) => Some("100.64.0.0/10".into()),
214 _ => None,
215 }
216}
217
218pub fn parse_endpoint(endpoint: &str) -> Option<(Ipv4Addr, u16)> {
232 let rest = endpoint
233 .strip_prefix("http://")
234 .or_else(|| endpoint.strip_prefix("https://"))
235 .unwrap_or(endpoint);
236 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
237 let (host, port) = authority.rsplit_once(':')?;
238 Some((host.parse().ok()?, port.parse().ok()?))
239}
240
241pub fn plan(
267 config: Option<&MeshConfig>,
268 local: &[Ipv4Addr],
269 server_port: Option<u16>,
270) -> FirewallPlan {
271 let local_addresses: Vec<String> = local.iter().map(ToString::to_string).collect();
272 let Some(config) = config.filter(|c| c.enabled) else {
273 return FirewallPlan {
274 local_addresses,
275 networks: Vec::new(),
276 hub_ports: Vec::new(),
277 rules: Vec::new(),
278 };
279 };
280
281 let declared: Vec<Ipv4Addr> = config
283 .rendezvous
284 .endpoints
285 .iter()
286 .chain(config.cooperation.seeds.iter())
287 .filter_map(|e| parse_endpoint(e).map(|(ip, _)| ip))
288 .collect();
289 let basis: &[Ipv4Addr] = if declared.is_empty() {
290 local
291 } else {
292 &declared
293 };
294 let networks: Vec<String> = basis
297 .iter()
298 .filter_map(|ip| enclosing(*ip))
299 .collect::<std::collections::BTreeSet<String>>()
300 .into_iter()
301 .collect();
302
303 let mut rules = Vec::new();
304 if config.multicast.enabled {
305 rules.push(FirewallRule {
306 role: Role::Multicast,
307 protocol: Protocol::Udp,
308 port: config.multicast.port,
309 destination: Some(config.multicast.group.clone()),
310 sources: Vec::new(),
311 reason: format!(
312 "every runtime of the segment advertises to {} on UDP port {}; a datagram to the group that the firewall drops is a peer this machine never hears",
313 config.multicast.group, config.multicast.port
314 ),
315 });
316 }
317 if config.broadcast.mode != BroadcastMode::Disabled {
318 let mut sources = config.broadcast.networks.clone();
319 if sources.is_empty() {
320 sources.clone_from(&networks);
321 }
322 if !sources.is_empty() {
326 rules.push(FirewallRule {
327 role: Role::Broadcast,
328 protocol: Protocol::Udp,
329 port: config.broadcast.port,
330 destination: None,
331 sources,
332 reason: format!(
333 "broadcast is a permitted fallback on UDP port {}",
334 config.broadcast.port
335 ),
336 });
337 }
338 }
339
340 let hub_ports: std::collections::BTreeSet<u16> = config
341 .rendezvous
342 .endpoints
343 .iter()
344 .filter_map(|e| parse_endpoint(e))
345 .filter(|(ip, _)| local.contains(ip))
346 .map(|(_, port)| port)
347 .collect();
348 let private = !networks.is_empty();
353 for port in hub_ports.iter().filter(|_| private) {
354 rules.push(FirewallRule {
355 role: Role::Hub,
356 protocol: Protocol::Tcp,
357 port: *port,
358 destination: None,
359 sources: networks.clone(),
360 reason: format!(
361 "this machine is a declared rendezvous hub on TCP port {port}: every server of the fleet registers here, and the hub is how machines on different segments find each other"
362 ),
363 });
364 }
365 if let Some(port) = server_port.filter(|_| private) {
366 if !hub_ports.contains(&port) {
367 rules.push(FirewallRule {
368 role: Role::Link,
369 protocol: Protocol::Tcp,
370 port,
371 destination: None,
372 sources: networks.clone(),
373 reason: format!(
374 "this checkout's server listens beyond loopback on TCP port {port}: a peer dials it there on the link protocol"
375 ),
376 });
377 }
378 }
379
380 FirewallPlan {
381 local_addresses,
382 networks,
383 hub_ports: hub_ports.into_iter().collect(),
384 rules,
385 }
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
399#[serde(rename_all = "snake_case")]
400pub enum Backend {
401 Ufw,
403 Nftables,
405 ApplicationFirewall,
407 None,
409}
410
411impl Backend {
412 pub fn as_str(self) -> &'static str {
423 match self {
424 Backend::Ufw => "ufw",
425 Backend::Nftables => "nftables",
426 Backend::ApplicationFirewall => "macOS application firewall",
427 Backend::None => "none",
428 }
429 }
430}
431
432pub fn detect(os: &str, on_path: &dyn Fn(&str) -> bool) -> Backend {
445 match os {
446 "macos" => Backend::ApplicationFirewall,
447 "linux" if on_path("ufw") => Backend::Ufw,
448 "linux" if on_path("nft") => Backend::Nftables,
449 _ => Backend::None,
450 }
451}
452
453pub fn detect_here() -> Backend {
472 detect(std::env::consts::OS, &|name| find_tool(name).is_some())
473}
474
475fn find_tool(name: &str) -> Option<PathBuf> {
477 let mut dirs: Vec<PathBuf> = std::env::var_os("PATH")
478 .map(|p| std::env::split_paths(&p).collect())
479 .unwrap_or_default();
480 for extra in ["/usr/sbin", "/sbin", "/usr/local/sbin"] {
481 dirs.push(PathBuf::from(extra));
482 }
483 dirs.into_iter()
484 .map(|d| d.join(name))
485 .find(|candidate| candidate.is_file())
486}
487
488const SOCKETFILTERFW: &str = "/usr/libexec/ApplicationFirewall/socketfilterfw";
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
510pub struct AdmitCommand {
511 pub rule: String,
513 pub argv: Vec<String>,
515 pub line: String,
517}
518
519fn shell_quote(word: &str) -> String {
520 if !word.is_empty()
521 && word
522 .chars()
523 .all(|c| c.is_ascii_alphanumeric() || "-_./:=@%,".contains(c))
524 {
525 word.to_string()
526 } else {
527 format!("'{}'", word.replace('\'', "'\\''"))
528 }
529}
530
531fn command(rule: &FirewallRule, argv: Vec<String>) -> AdmitCommand {
532 let line = argv
533 .iter()
534 .map(|w| shell_quote(w))
535 .collect::<Vec<_>>()
536 .join(" ");
537 AdmitCommand {
538 rule: rule.label(),
539 argv,
540 line,
541 }
542}
543
544pub const COMMENT_PREFIX: &str = "majordomus mesh";
547
548fn comment(rule: &FirewallRule) -> String {
549 let what = match rule.role {
550 Role::Multicast => "multicast discovery",
551 Role::Broadcast => "broadcast discovery",
552 Role::Hub => "rendezvous hub",
553 Role::Link => "link protocol",
554 };
555 format!("{COMMENT_PREFIX}: {what}")
556}
557
558pub fn render(
586 backend: Backend,
587 plan: &FirewallPlan,
588 executable: Option<&Path>,
589) -> Vec<AdmitCommand> {
590 let mut out = Vec::new();
591 match backend {
592 Backend::Ufw => {
593 for rule in &plan.rules {
594 let proto = rule.protocol.as_str().to_string();
595 let port = rule.port.to_string();
596 let note = comment(rule);
597 if rule.sources.is_empty() {
598 let mut argv = vec![
599 "ufw".into(),
600 "allow".into(),
601 "in".into(),
602 "proto".into(),
603 proto,
604 ];
605 argv.push("to".into());
606 argv.push(rule.destination.clone().unwrap_or_else(|| "any".into()));
607 argv.extend(["port".into(), port, "comment".into(), note]);
608 out.push(command(rule, argv));
609 } else {
610 for source in &rule.sources {
611 let argv = vec![
612 "ufw".into(),
613 "allow".into(),
614 "in".into(),
615 "proto".into(),
616 proto.clone(),
617 "from".into(),
618 source.clone(),
619 "to".into(),
620 rule.destination.clone().unwrap_or_else(|| "any".into()),
621 "port".into(),
622 port.clone(),
623 "comment".into(),
624 note.clone(),
625 ];
626 out.push(command(rule, argv));
627 }
628 }
629 }
630 }
631 Backend::Nftables => {
632 for rule in &plan.rules {
633 let proto = rule.protocol.as_str().to_string();
634 let port = rule.port.to_string();
635 let note = comment(rule);
636 let sources: Vec<Option<&String>> = if rule.sources.is_empty() {
637 vec![None]
638 } else {
639 rule.sources.iter().map(Some).collect()
640 };
641 for source in sources {
642 let mut argv: Vec<String> = ["nft", "add", "rule", "inet", "filter", "input"]
643 .iter()
644 .map(|w| w.to_string())
645 .collect();
646 if let Some(source) = source {
647 argv.extend(["ip".into(), "saddr".into(), source.clone()]);
648 }
649 if let Some(group) = &rule.destination {
650 argv.extend(["ip".into(), "daddr".into(), group.clone()]);
651 }
652 argv.extend([
657 proto.clone(),
658 "dport".into(),
659 port.clone(),
660 "accept".into(),
661 "comment".into(),
662 format!("\"{note}\""),
663 ]);
664 out.push(command(rule, argv));
665 }
666 }
667 }
668 Backend::ApplicationFirewall => {
669 if let (Some(exe), Some(rule)) = (executable, plan.rules.first()) {
670 let exe = exe.display().to_string();
671 out.push(command(
672 rule,
673 vec![SOCKETFILTERFW.into(), "--add".into(), exe.clone()],
674 ));
675 out.push(command(
676 rule,
677 vec![SOCKETFILTERFW.into(), "--unblockapp".into(), exe],
678 ));
679 }
680 }
681 Backend::None => {}
682 }
683 out
684}
685
686#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
695#[serde(rename_all = "snake_case")]
696pub enum FirewallObservationState {
697 NoBackend,
699 Inactive,
701 Present,
703 Missing,
705 Unobservable,
707}
708
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
720pub struct FirewallObservation {
721 pub state: FirewallObservationState,
723 pub detail: String,
725 #[serde(default, skip_serializing_if = "Vec::is_empty")]
727 pub missing: Vec<String>,
728}
729
730impl FirewallObservation {
731 fn of(state: FirewallObservationState, detail: impl Into<String>) -> Self {
732 FirewallObservation {
733 state,
734 detail: detail.into(),
735 missing: Vec::new(),
736 }
737 }
738}
739
740pub fn judge_ufw(status: &str, plan: &FirewallPlan) -> FirewallObservation {
777 let lines: Vec<&str> = status.lines().map(str::trim).collect();
778 if lines.iter().any(|l| l.contains("need to be root")) {
779 return FirewallObservation::of(
780 FirewallObservationState::Unobservable,
781 "ufw status needs root; run `sudo majordomus mesh firewall` to observe the rules",
782 );
783 }
784 if lines.iter().any(|l| l.starts_with("Status: inactive")) {
785 return FirewallObservation::of(
786 FirewallObservationState::Inactive,
787 "ufw is inactive: nothing filters inbound traffic on this host",
788 );
789 }
790 if !lines.iter().any(|l| l.starts_with("Status: active")) {
791 return FirewallObservation::of(
792 FirewallObservationState::Unobservable,
793 format!(
794 "ufw status was not understood: {}",
795 lines.first().copied().unwrap_or("(empty)")
796 ),
797 );
798 }
799 let allows: Vec<(String, String)> = lines
800 .iter()
801 .filter_map(|l| {
802 let (to, rest) = l.split_once("ALLOW IN").or_else(|| {
805 l.split_once("ALLOW ")
806 .filter(|(_, r)| !r.starts_with("OUT") && !r.starts_with("FWD"))
807 })?;
808 let from = rest
809 .split('#')
810 .next()
811 .unwrap_or("")
812 .split_whitespace()
813 .next()
814 .unwrap_or("")
815 .to_string();
816 Some((to.split_whitespace().collect::<Vec<_>>().join(" "), from))
817 })
818 .collect();
819 let admitted = |rule: &FirewallRule, source: Option<&str>| {
820 let token = format!("{}/{}", rule.port, rule.protocol.as_str());
821 allows.iter().any(|(to, from)| {
822 let to_matches = match &rule.destination {
823 Some(group) => *to == format!("{group} {token}"),
824 None => *to == token,
825 };
826 let from_matches = match source {
827 None => true,
828 Some(network) => from == "Anywhere" || from == network,
829 };
830 to_matches && from_matches
831 })
832 };
833 judge_with(plan, |rule| {
834 if rule.sources.is_empty() {
835 admitted(rule, None)
836 } else {
837 rule.sources.iter().all(|s| admitted(rule, Some(s)))
838 }
839 })
840}
841
842pub fn judge_nft(ruleset: &str, plan: &FirewallPlan) -> FirewallObservation {
860 if ruleset.contains("not permitted") || ruleset.contains("Permission denied") {
861 return FirewallObservation::of(
862 FirewallObservationState::Unobservable,
863 "nft list ruleset needs root; run `sudo majordomus mesh firewall` to observe the rules",
864 );
865 }
866 if ruleset.trim().is_empty() {
867 return FirewallObservation::of(
868 FirewallObservationState::Inactive,
869 "the nftables ruleset is empty: nothing filters inbound traffic on this host",
870 );
871 }
872 let accepts: Vec<&str> = ruleset
873 .lines()
874 .map(str::trim)
875 .filter(|l| l.contains("accept"))
876 .collect();
877 judge_with(plan, |rule| {
878 let dport = format!("{} dport {}", rule.protocol.as_str(), rule.port);
879 let line_admits = |l: &&str, source: Option<&str>| {
880 l.contains(&dport)
881 && rule
882 .destination
883 .as_ref()
884 .is_none_or(|g| l.contains(&format!("daddr {g}")))
885 && source.is_none_or(|s| !l.contains("saddr") || l.contains(s))
886 };
887 if rule.sources.is_empty() {
888 accepts.iter().any(|l| line_admits(l, None))
889 } else {
890 rule.sources
891 .iter()
892 .all(|s| accepts.iter().any(|l| line_admits(l, Some(s))))
893 }
894 })
895}
896
897pub fn judge_application_firewall(
914 global_state: &str,
915 apps: &str,
916 executable: &Path,
917) -> FirewallObservation {
918 if global_state.contains("disabled") || global_state.contains("State = 0") {
919 return FirewallObservation::of(
920 FirewallObservationState::Inactive,
921 "the application firewall is off: nothing filters inbound connections on this host",
922 );
923 }
924 let exe = executable.display().to_string();
925 let lines: Vec<&str> = apps.lines().map(str::trim).collect();
926 let allowed = lines.iter().enumerate().any(|(i, l)| {
927 l.ends_with(&exe)
928 && lines
929 .get(i + 1)
930 .is_some_and(|next| next.contains("Allow incoming"))
931 });
932 if allowed {
933 FirewallObservation::of(
934 FirewallObservationState::Present,
935 format!("the application firewall allows incoming connections to {exe}"),
936 )
937 } else {
938 FirewallObservation {
939 state: FirewallObservationState::Missing,
940 detail: format!(
941 "the application firewall is on and does not allow incoming connections to {exe}"
942 ),
943 missing: vec![format!("incoming connections to {exe}")],
944 }
945 }
946}
947
948fn judge_with(
949 plan: &FirewallPlan,
950 admitted: impl Fn(&FirewallRule) -> bool,
951) -> FirewallObservation {
952 let missing: Vec<String> = plan
953 .rules
954 .iter()
955 .filter(|r| !admitted(r))
956 .map(FirewallRule::label)
957 .collect();
958 if missing.is_empty() {
959 FirewallObservation::of(
960 FirewallObservationState::Present,
961 format!(
962 "every rule of the plan is admitted ({} rule(s))",
963 plan.rules.len()
964 ),
965 )
966 } else {
967 FirewallObservation {
968 state: FirewallObservationState::Missing,
969 detail: format!(
970 "{} of {} rule(s) not admitted: {}",
971 missing.len(),
972 plan.rules.len(),
973 missing.join("; ")
974 ),
975 missing,
976 }
977 }
978}
979
980type Runner<'a> = &'a dyn Fn(&str, &[&str]) -> std::result::Result<String, String>;
986
987fn run(program: &str, args: &[&str]) -> std::result::Result<String, String> {
988 run_to_its_end(program, args).map(|(_, text)| text)
989}
990
991type Exec<'a> = &'a dyn Fn(&str, &[&str]) -> std::result::Result<(bool, String), String>;
993
994fn run_to_its_end(program: &str, args: &[&str]) -> std::result::Result<(bool, String), String> {
999 let program = find_tool(program).unwrap_or_else(|| PathBuf::from(program));
1000 match Command::new(&program).args(args).output() {
1001 Ok(output) => {
1002 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
1003 text.push_str(&String::from_utf8_lossy(&output.stderr));
1004 Ok((output.status.success(), text))
1005 }
1006 Err(e) => Err(format!("{} could not run: {e}", program.display())),
1007 }
1008}
1009
1010pub fn observe(
1023 backend: Backend,
1024 plan: &FirewallPlan,
1025 executable: Option<&Path>,
1026) -> FirewallObservation {
1027 observe_with(backend, plan, executable, &run)
1028}
1029
1030fn observe_with(
1032 backend: Backend,
1033 plan: &FirewallPlan,
1034 executable: Option<&Path>,
1035 run: Runner<'_>,
1036) -> FirewallObservation {
1037 match backend {
1038 Backend::None => FirewallObservation::of(
1039 FirewallObservationState::NoBackend,
1040 "no firewall front this executable knows (ufw, nft, the macOS application firewall) is on this host; nothing is observed",
1041 ),
1042 Backend::Ufw => match run("ufw", &["status"]) {
1043 Ok(text) => judge_ufw(&text, plan),
1044 Err(e) => FirewallObservation::of(FirewallObservationState::Unobservable, e),
1045 },
1046 Backend::Nftables => match run("nft", &["list", "ruleset"]) {
1047 Ok(text) => judge_nft(&text, plan),
1048 Err(e) => FirewallObservation::of(FirewallObservationState::Unobservable, e),
1049 },
1050 Backend::ApplicationFirewall => {
1051 let Some(exe) = executable else {
1052 return FirewallObservation::of(
1053 FirewallObservationState::Unobservable,
1054 "the application firewall admits an executable, and none was given",
1055 );
1056 };
1057 match (
1058 run(SOCKETFILTERFW, &["--getglobalstate"]),
1059 run(SOCKETFILTERFW, &["--listapps"]),
1060 ) {
1061 (Ok(state), Ok(apps)) => judge_application_firewall(&state, &apps, exe),
1062 (Err(e), _) | (_, Err(e)) => FirewallObservation::of(FirewallObservationState::Unobservable, e),
1063 }
1064 }
1065 }
1066}
1067
1068pub fn count_blocks(log: &str, ports: &[u16]) -> BTreeMap<String, u64> {
1085 let mut counts: BTreeMap<String, u64> = ports.iter().map(|p| (p.to_string(), 0)).collect();
1086 for line in log.lines().filter(|l| l.contains("[UFW BLOCK]")) {
1087 for port in ports {
1088 let token = format!("DPT={port} ");
1089 if line.contains(&token) || line.trim_end().ends_with(&format!("DPT={port}")) {
1090 *counts.entry(port.to_string()).or_insert(0) += 1;
1091 }
1092 }
1093 }
1094 counts
1095}
1096
1097pub const BLOCK_WINDOW_SECONDS: u64 = 300;
1102
1103pub fn kernel_blocks(plan: &FirewallPlan) -> Option<BTreeMap<String, u64>> {
1114 kernel_blocks_with(plan, std::env::consts::OS, &run)
1115}
1116
1117fn kernel_blocks_with(
1119 plan: &FirewallPlan,
1120 os: &str,
1121 run: Runner<'_>,
1122) -> Option<BTreeMap<String, u64>> {
1123 if plan.rules.is_empty() || os != "linux" {
1124 return None;
1125 }
1126 let ports: Vec<u16> = plan.rules.iter().map(|r| r.port).collect();
1127 let since = format!("-{BLOCK_WINDOW_SECONDS}s");
1128 let text = run(
1129 "journalctl",
1130 &["-k", "--since", &since, "-o", "cat", "-q", "--no-pager"],
1131 )
1132 .ok()?;
1133 if text.contains("No journal files") || text.contains("Permission denied") {
1134 return None;
1135 }
1136 Some(count_blocks(&text, &ports))
1137}
1138
1139#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1152pub struct FirewallReport {
1153 pub ok: bool,
1156 pub platform: String,
1158 pub backend: Backend,
1160 #[serde(default, skip_serializing_if = "Option::is_none")]
1162 pub executable: Option<String>,
1163 pub plan: FirewallPlan,
1165 pub commands: Vec<AdmitCommand>,
1167 pub observation: FirewallObservation,
1169 pub window_seconds: u64,
1171 #[serde(default, skip_serializing_if = "Option::is_none")]
1174 pub blocked_recently: Option<BTreeMap<String, u64>>,
1175 pub verdict: String,
1177}
1178
1179pub fn report(
1192 config: Option<&MeshConfig>,
1193 local: &[Ipv4Addr],
1194 server_port: Option<u16>,
1195) -> FirewallReport {
1196 let host = Host {
1197 os: std::env::consts::OS,
1198 backend: detect_here(),
1199 executable: std::env::current_exe().ok(),
1200 run: &run,
1201 };
1202 report_with(config, local, server_port, host)
1203}
1204
1205struct Host<'a> {
1210 os: &'a str,
1211 backend: Backend,
1212 executable: Option<PathBuf>,
1213 run: Runner<'a>,
1214}
1215
1216fn report_with(
1218 config: Option<&MeshConfig>,
1219 local: &[Ipv4Addr],
1220 server_port: Option<u16>,
1221 host: Host<'_>,
1222) -> FirewallReport {
1223 let Host {
1224 os,
1225 backend,
1226 executable,
1227 run,
1228 } = host;
1229 let plan = plan(config, local, server_port);
1230 let commands = render(backend, &plan, executable.as_deref());
1231 let observation = if plan.rules.is_empty() {
1232 FirewallObservation::of(
1233 FirewallObservationState::Present,
1234 "the mesh needs no inbound admission on this host: no enabled declaration, or nothing declared that reaches this machine",
1235 )
1236 } else {
1237 observe_with(backend, &plan, executable.as_deref(), run)
1238 };
1239 let blocked_recently = kernel_blocks_with(&plan, os, run);
1240 let dropped: u64 = blocked_recently
1241 .as_ref()
1242 .map(|m| m.values().sum())
1243 .unwrap_or(0);
1244 let minutes = BLOCK_WINDOW_SECONDS / 60;
1245 let ok = observation.state != FirewallObservationState::Missing && dropped == 0;
1246 let verdict = match (observation.state, dropped) {
1247 (FirewallObservationState::Missing, _) => format!(
1248 "the host firewall does not admit the mesh: {}; run `sudo majordomus mesh firewall apply`",
1249 observation.detail
1250 ),
1251 (FirewallObservationState::Unobservable, n) if n > 0 => format!(
1252 "the kernel logged {n} inbound datagram(s) or connection(s) toward the mesh's ports dropped in the last {minutes} minutes, and the firewall could not be asked without root: run `sudo majordomus mesh firewall apply`"
1253 ),
1254 (_, n) if n > 0 => format!(
1255 "the kernel logged {n} inbound datagram(s) or connection(s) toward the mesh's ports dropped in the last {minutes} minutes although the firewall admits the mesh on paper: a rule above the mesh's, or another filter, still drops them"
1256 ),
1257 (FirewallObservationState::Present, _) if plan.rules.is_empty() => observation.detail.clone(),
1258 (FirewallObservationState::Present, _) => format!(
1259 "the host firewall admits the mesh: {}",
1260 observation.detail
1261 ),
1262 (FirewallObservationState::Inactive, _) => observation.detail.clone(),
1263 (FirewallObservationState::NoBackend, _) => observation.detail.clone(),
1264 (FirewallObservationState::Unobservable, _) => format!(
1265 "not observed: {}; {}",
1266 observation.detail,
1267 match &blocked_recently {
1268 Some(_) => format!(
1269 "the kernel log shows nothing dropped toward the mesh's ports in the last {minutes} minutes"
1270 ),
1271 None => "the kernel log could not be read either".to_string(),
1272 }
1273 ),
1274 };
1275 FirewallReport {
1276 ok,
1277 platform: os.to_string(),
1278 backend,
1279 executable: executable.map(|p| p.display().to_string()),
1280 plan,
1281 commands,
1282 observation,
1283 window_seconds: BLOCK_WINDOW_SECONDS,
1284 blocked_recently,
1285 verdict,
1286 }
1287}
1288
1289#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1302pub struct Applied {
1303 pub line: String,
1305 pub ok: bool,
1307 pub output: String,
1309}
1310
1311#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1323pub struct FirewallApplyReport {
1324 pub ok: bool,
1326 #[serde(default, skip_serializing_if = "Option::is_none")]
1328 pub refused: Option<String>,
1329 pub applied: Vec<Applied>,
1331 pub observation: FirewallObservation,
1333 pub before: FirewallReport,
1335}
1336
1337#[cfg(unix)]
1338fn is_root() -> bool {
1339 unsafe { libc::geteuid() == 0 }
1341}
1342
1343#[cfg(not(unix))]
1344fn is_root() -> bool {
1345 true
1346}
1347
1348pub fn apply(before: FirewallReport) -> FirewallApplyReport {
1362 apply_with(before, is_root(), &run, &run_to_its_end)
1363}
1364
1365fn apply_with(
1368 before: FirewallReport,
1369 root: bool,
1370 run: Runner<'_>,
1371 exec: Exec<'_>,
1372) -> FirewallApplyReport {
1373 let refuse = |before: FirewallReport, reason: String| FirewallApplyReport {
1374 ok: false,
1375 refused: Some(reason),
1376 applied: Vec::new(),
1377 observation: before.observation.clone(),
1378 before,
1379 };
1380 if before.backend == Backend::None {
1381 return refuse(
1382 before,
1383 "no firewall front this executable knows is on this host; nothing to apply".into(),
1384 );
1385 }
1386 if before.commands.is_empty() {
1387 return refuse(
1388 before,
1389 "the plan needs no admission on this host; nothing to apply".into(),
1390 );
1391 }
1392 if before.observation.state == FirewallObservationState::Inactive {
1393 return refuse(
1396 before,
1397 "the host firewall is inactive: nothing filters, so there is nothing to apply".into(),
1398 );
1399 }
1400 if !root {
1401 return refuse(
1402 before,
1403 "applying firewall rules needs root: run `sudo majordomus mesh firewall apply` (sudo -E keeps the repository and state paths of the invoking user)".into(),
1404 );
1405 }
1406 let mut applied = Vec::new();
1407 let mut all_ok = true;
1408 for command in &before.commands {
1409 let program = command.argv.first().cloned().unwrap_or_default();
1410 let args: Vec<&str> = command.argv.iter().skip(1).map(String::as_str).collect();
1411 let (ok, output) = match exec(&program, &args) {
1412 Ok((succeeded, text)) => (succeeded, text),
1413 Err(e) => (false, e),
1414 };
1415 all_ok &= ok;
1416 applied.push(Applied {
1417 line: command.line.clone(),
1418 ok,
1419 output: output.trim().to_string(),
1420 });
1421 }
1422 let exe = before.executable.as_deref().map(Path::new);
1423 let observation = observe_with(before.backend, &before.plan, exe, run);
1424 let ok = all_ok
1425 && matches!(
1426 observation.state,
1427 FirewallObservationState::Present | FirewallObservationState::Inactive
1428 );
1429 FirewallApplyReport {
1430 ok,
1431 refused: None,
1432 applied,
1433 observation,
1434 before,
1435 }
1436}
1437
1438#[cfg(test)]
1439mod tests {
1440 use super::*;
1441
1442 fn config(json: serde_json::Value) -> MeshConfig {
1443 serde_json::from_value(json).unwrap()
1444 }
1445
1446 #[test]
1447 fn the_backend_here_is_the_one_detect_finds_with_the_path_lookup() {
1448 assert_eq!(
1449 detect_here(),
1450 detect(std::env::consts::OS, &|name| find_tool(name).is_some())
1451 );
1452 }
1453
1454 #[test]
1455 fn no_declaration_and_a_disabled_one_need_nothing() {
1456 let p = plan(None, &[Ipv4Addr::new(192, 168, 1, 2)], Some(8741));
1457 assert!(p.rules.is_empty() && p.networks.is_empty());
1458 let off = config(serde_json::json!({
1459 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": false,
1460 "rendezvous": { "endpoints": ["http://192.168.1.2:8791"] },
1461 }));
1462 let p = plan(Some(&off), &[Ipv4Addr::new(192, 168, 1, 2)], Some(8741));
1463 assert!(p.rules.is_empty(), "a disabled mesh opens nothing: {p:?}");
1464 }
1465
1466 #[test]
1467 fn the_networks_come_from_the_declaration_and_fall_back_to_this_machine() {
1468 let declared = config(serde_json::json!({
1469 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
1470 "rendezvous": { "endpoints": ["http://10.1.2.3:8791", "http://100.65.22.118:8791"] },
1471 "cooperation": { "seeds": ["http://192.168.50.4:8741"] },
1472 }));
1473 let p = plan(Some(&declared), &[Ipv4Addr::new(172, 17, 0, 1)], None);
1474 assert_eq!(
1475 p.networks,
1476 ["10.0.0.0/8", "100.64.0.0/10", "192.168.50.0/24"]
1477 );
1478 let bare = config(serde_json::json!({
1479 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
1480 }));
1481 let p = plan(
1482 Some(&bare),
1483 &[Ipv4Addr::new(172, 17, 0, 1), Ipv4Addr::new(192, 168, 9, 9)],
1484 Some(8741),
1485 );
1486 assert_eq!(p.networks, ["172.16.0.0/12", "192.168.9.0/24"]);
1487 let link = p
1488 .rules
1489 .iter()
1490 .find(|r| r.role == Role::Link)
1491 .expect("a server beyond loopback is dialed");
1492 assert_eq!((link.protocol, link.port), (Protocol::Tcp, 8741));
1493 assert_eq!(link.sources, p.networks);
1494 }
1495
1496 #[test]
1497 fn a_hub_port_that_is_also_the_server_port_is_one_rule() {
1498 let c = config(serde_json::json!({
1499 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
1500 "multicast": { "enabled": false },
1501 "rendezvous": { "endpoints": ["http://192.168.1.2:8791", "http://192.168.1.2:8791"] },
1502 }));
1503 let p = plan(Some(&c), &[Ipv4Addr::new(192, 168, 1, 2)], Some(8791));
1504 assert_eq!(p.hub_ports, [8791]);
1505 assert_eq!(p.rules.len(), 1);
1506 assert_eq!(p.rules[0].role, Role::Hub);
1507 assert_eq!(p.rules[0].label(), "tcp/8791 from 192.168.1.0/24");
1508 }
1509
1510 #[test]
1511 fn a_public_hub_address_derives_no_network() {
1512 let c = config(serde_json::json!({
1513 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
1514 "rendezvous": { "endpoints": ["http://203.0.113.5:8791"] },
1515 }));
1516 let p = plan(Some(&c), &[Ipv4Addr::new(203, 0, 113, 5)], Some(8741));
1517 assert!(p.networks.is_empty());
1518 assert_eq!(p.hub_ports, [8791], "the port is still named for a reader");
1519 assert!(
1522 p.rules
1523 .iter()
1524 .all(|r| r.role == Role::Multicast && r.protocol == Protocol::Udp),
1525 "only the group's own port is admitted from any source: {:?}",
1526 p.rules
1527 );
1528 }
1529
1530 #[test]
1531 fn broadcast_with_no_network_to_admit_from_is_not_planned() {
1532 let c = config(serde_json::json!({
1535 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
1536 "multicast": { "enabled": false },
1537 "broadcast": { "mode": "explicit", "port": 7742 },
1538 "rendezvous": { "endpoints": ["http://203.0.113.5:8791"] },
1539 }));
1540 let p = plan(Some(&c), &[Ipv4Addr::new(203, 0, 113, 5)], Some(8741));
1541 assert!(p.networks.is_empty());
1542 assert!(p.rules.is_empty(), "nothing is derived: {:?}", p.rules);
1543 }
1544
1545 #[test]
1546 fn a_rule_is_rendered_as_it_is_given_so_the_plan_is_what_keeps_a_port_closed() {
1547 let open = FirewallPlan {
1551 local_addresses: Vec::new(),
1552 networks: Vec::new(),
1553 hub_ports: vec![8791],
1554 rules: vec![FirewallRule {
1555 role: Role::Hub,
1556 protocol: Protocol::Tcp,
1557 port: 8791,
1558 destination: None,
1559 sources: Vec::new(),
1560 reason: "hand-built".into(),
1561 }],
1562 };
1563 let ufw = render(Backend::Ufw, &open, None);
1564 assert_eq!(ufw.len(), 1, "{ufw:#?}");
1565 assert!(
1566 ufw[0]
1567 .line
1568 .starts_with("ufw allow in proto tcp to any port 8791 comment"),
1569 "{}",
1570 ufw[0].line
1571 );
1572 }
1573
1574 #[test]
1575 fn broadcast_takes_its_own_networks_or_the_fleets() {
1576 let own = config(serde_json::json!({
1577 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
1578 "multicast": { "enabled": false },
1579 "broadcast": { "mode": "explicit", "port": 7742, "networks": ["192.168.1.0/24"] },
1580 }));
1581 let p = plan(Some(&own), &[Ipv4Addr::new(10, 0, 0, 1)], None);
1582 let b = &p.rules[0];
1583 assert_eq!(
1584 (b.role, b.protocol, b.port),
1585 (Role::Broadcast, Protocol::Udp, 7742)
1586 );
1587 assert_eq!(b.sources, ["192.168.1.0/24"]);
1588 }
1589
1590 #[test]
1591 fn the_rendered_commands_quote_their_comments_and_name_every_source() {
1592 let c = config(serde_json::json!({
1593 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
1594 "rendezvous": { "endpoints": ["http://192.168.1.2:8791", "http://100.64.0.9:8791"] },
1595 }));
1596 let p = plan(Some(&c), &[Ipv4Addr::new(192, 168, 1, 2)], None);
1597 let ufw = render(Backend::Ufw, &p, None);
1598 assert_eq!(ufw.len(), 3, "{ufw:#?}");
1599 assert!(ufw
1600 .iter()
1601 .all(|c| c.argv.last().unwrap().starts_with(COMMENT_PREFIX)));
1602 assert!(
1603 ufw[1].line.contains("from 100.64.0.0/10")
1604 && ufw[2].line.contains("from 192.168.1.0/24")
1605 );
1606 let nft = render(Backend::Nftables, &p, None);
1607 assert_eq!(nft.len(), 3);
1608 assert!(nft[0].line.starts_with(
1609 "nft add rule inet filter input ip daddr 239.255.77.77 udp dport 7741 accept comment"
1610 ));
1611 assert!(nft[1]
1612 .line
1613 .contains("ip saddr 100.64.0.0/10 tcp dport 8791 accept"));
1614 assert!(
1615 render(Backend::ApplicationFirewall, &p, None).is_empty(),
1616 "no executable, nothing to admit"
1617 );
1618 }
1619
1620 #[test]
1621 fn the_report_of_an_empty_plan_is_ok_without_asking_anything() {
1622 let r = report(None, &[], None);
1623 assert!(r.ok);
1624 assert_eq!(r.observation.state, FirewallObservationState::Present);
1625 assert!(r.commands.is_empty());
1626 assert!(r.blocked_recently.is_none());
1627 }
1628
1629 #[test]
1630 fn apply_refuses_before_it_runs_anything_it_should_not() {
1631 let r = report(None, &[], None);
1632 let a = apply(r);
1633 assert!(!a.ok && a.applied.is_empty());
1634 assert!(a.refused.is_some());
1635 }
1636
1637 #[test]
1638 fn ufw_judgement_requires_each_source_unless_anywhere_covers_it() {
1639 let c = config(serde_json::json!({
1640 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
1641 "multicast": { "enabled": false },
1642 "rendezvous": { "endpoints": ["http://192.168.1.2:8791", "http://100.64.0.9:8791"] },
1643 }));
1644 let p = plan(Some(&c), &[Ipv4Addr::new(192, 168, 1, 2)], None);
1645 let one = "Status: active\n8791/tcp ALLOW IN 192.168.1.0/24\n";
1646 let o = judge_ufw(one, &p);
1647 assert_eq!(o.state, FirewallObservationState::Missing, "{o:?}");
1648 let both = "Status: active\n8791/tcp ALLOW IN 192.168.1.0/24\n8791/tcp ALLOW IN 100.64.0.0/10\n";
1649 assert_eq!(judge_ufw(both, &p).state, FirewallObservationState::Present);
1650 let other = "Status: active\n8790/tcp ALLOW IN Anywhere\n8791/tcp ALLOW OUT Anywhere\n";
1652 assert_eq!(
1653 judge_ufw(other, &p).state,
1654 FirewallObservationState::Missing
1655 );
1656 }
1657
1658 #[test]
1659 fn blocks_are_counted_only_toward_the_plans_ports() {
1660 let log = "[UFW BLOCK] IN=e SRC=1.1.1.1 DST=2.2.2.2 PROTO=TCP DPT=8791\n[UFW BLOCK] IN=e DPT=87911 LEN=1\n";
1661 let counts = count_blocks(log, &[8791]);
1662 assert_eq!(counts["8791"], 1);
1663 }
1664
1665 const HUB: Ipv4Addr = Ipv4Addr::new(192, 168, 7, 10);
1674
1675 fn fleet() -> MeshConfig {
1677 config(serde_json::json!({
1678 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
1679 "rendezvous": { "endpoints": ["http://192.168.7.10:8791", "http://100.64.1.2:8791"] },
1680 }))
1681 }
1682
1683 fn hub_plan() -> FirewallPlan {
1685 plan(Some(&fleet()), &[HUB], None)
1686 }
1687
1688 const GROUP_LABEL: &str = "udp/7741 to 239.255.77.77 from any";
1689 const HUB_LABEL: &str = "tcp/8791 from 100.64.0.0/10, 192.168.7.0/24";
1690
1691 const UFW_BARE: &str = concat!(
1693 "Status: active\n",
1694 "\n",
1695 "To Action From\n",
1696 "-- ------ ----\n",
1697 "22/tcp ALLOW IN Anywhere\n",
1698 );
1699
1700 const UFW_ADMITTED: &str = concat!(
1702 "Status: active\n",
1703 "\n",
1704 "To Action From\n",
1705 "-- ------ ----\n",
1706 "22/tcp ALLOW IN Anywhere\n",
1707 "239.255.77.77 7741/udp ALLOW IN Anywhere ",
1708 "# majordomus mesh: multicast discovery\n",
1709 "8791/tcp ALLOW IN 192.168.7.0/24 ",
1710 "# majordomus mesh: rendezvous hub\n",
1711 "8791/tcp ALLOW IN 100.64.0.0/10 ",
1712 "# majordomus mesh: rendezvous hub\n",
1713 );
1714
1715 const UFW_ROOT: &str = "ERROR: You need to be root to run this script\n";
1717
1718 const NFT_ADMITTED: &str = concat!(
1720 "table inet filter {\n",
1721 " chain input {\n",
1722 " ip daddr 239.255.77.77 udp dport 7741 accept\n",
1723 " ip saddr 192.168.7.0/24 tcp dport 8791 accept\n",
1724 " ip saddr 100.64.0.0/10 tcp dport 8791 accept\n",
1725 " }\n",
1726 "}\n",
1727 );
1728
1729 const JOURNAL_QUIET: &str = "perf: interrupt took too long\n";
1731
1732 const JOURNAL_DROPS: &str = concat!(
1734 "[UFW BLOCK] IN=eth0 OUT= SRC=192.168.7.11 DST=239.255.77.77 ",
1735 "PROTO=UDP SPT=7741 DPT=7741 LEN=430\n",
1736 "[UFW BLOCK] IN=eth0 OUT= SRC=192.168.7.11 DST=192.168.7.10 ",
1737 "PROTO=TCP SPT=57168 DPT=8791 WINDOW=65535\n",
1738 "[UFW BLOCK] IN=eth0 OUT= SRC=203.0.113.9 DST=192.168.7.10 ",
1739 "PROTO=TCP SPT=40000 DPT=22 WINDOW=1024\n",
1740 );
1741
1742 const FIREWALL_ON: &str = "Firewall is enabled. (State = 1)";
1743 const EXECUTABLE: &str = "/opt/majordomus";
1744 const APPS_ALLOWED: &str = concat!(
1745 "ALF: total number of apps = 1 \n",
1746 "\n",
1747 "1 : /opt/majordomus \n",
1748 " \t ( Allow incoming connections ) \n",
1749 );
1750 const APPS_OTHER: &str = "1 : /opt/other \n \t ( Allow incoming connections ) \n";
1751
1752 struct Script {
1757 answers: Vec<(&'static str, &'static str)>,
1758 asked: std::cell::RefCell<Vec<String>>,
1759 }
1760
1761 impl Script {
1762 fn new(answers: &[(&'static str, &'static str)]) -> Self {
1763 Script {
1764 answers: answers.to_vec(),
1765 asked: std::cell::RefCell::new(Vec::new()),
1766 }
1767 }
1768
1769 fn run(&self, program: &str, args: &[&str]) -> std::result::Result<String, String> {
1770 let mut words = vec![program];
1771 words.extend_from_slice(args);
1772 let line = words.join(" ");
1773 self.asked.borrow_mut().push(line.clone());
1774 self.answers
1775 .iter()
1776 .find(|(fragment, _)| line.contains(fragment))
1777 .map(|(_, printed)| printed.to_string())
1778 .ok_or_else(|| format!("{program} could not run: no such tool on this host"))
1779 }
1780
1781 fn exec(
1784 &self,
1785 program: &str,
1786 args: &[&str],
1787 ) -> std::result::Result<(bool, String), String> {
1788 self.run(program, args)
1789 .map(|text| (!(text.contains("ERROR") || text.contains("Error:")), text))
1790 }
1791
1792 fn asked(&self) -> Vec<String> {
1793 self.asked.borrow().clone()
1794 }
1795 }
1796
1797 fn hub_report(
1799 os: &str,
1800 backend: Backend,
1801 executable: Option<&str>,
1802 host: &Script,
1803 ) -> FirewallReport {
1804 let host = Host {
1805 os,
1806 backend,
1807 executable: executable.map(PathBuf::from),
1808 run: &|program, args| host.run(program, args),
1809 };
1810 report_with(Some(&fleet()), &[HUB], None, host)
1811 }
1812
1813 #[test]
1816 fn a_rule_is_labelled_by_its_protocol_its_port_its_group_and_its_sources() {
1817 let p = hub_plan();
1818 assert_eq!(p.rules[0].label(), GROUP_LABEL);
1819 assert_eq!(p.rules[1].label(), HUB_LABEL);
1820 let lone = FirewallRule {
1821 role: Role::Link,
1822 protocol: Protocol::Tcp,
1823 port: 8741,
1824 destination: None,
1825 sources: Vec::new(),
1826 reason: String::new(),
1827 };
1828 assert_eq!(lone.label(), "tcp/8741 from any");
1829 let wire = serde_json::to_value(&lone).unwrap();
1832 assert!(wire.get("destination").is_none(), "{wire}");
1833 assert_eq!(wire["role"], "link");
1834 assert_eq!(serde_json::from_value::<FirewallRule>(wire).unwrap(), lone);
1835 }
1836
1837 #[test]
1838 fn every_private_range_encloses_its_addresses_and_a_public_address_has_none() {
1839 let range = |a, b, c, d| enclosing(Ipv4Addr::new(a, b, c, d));
1840 assert_eq!(
1841 range(192, 168, 100, 10).as_deref(),
1842 Some("192.168.100.0/24")
1843 );
1844 assert_eq!(range(100, 92, 246, 32).as_deref(), Some("100.64.0.0/10"));
1845 assert_eq!(range(10, 3, 2, 1).as_deref(), Some("10.0.0.0/8"));
1846 assert_eq!(range(172, 20, 0, 1).as_deref(), Some("172.16.0.0/12"));
1847 assert_eq!(range(172, 16, 0, 1).as_deref(), Some("172.16.0.0/12"));
1849 assert_eq!(range(172, 31, 255, 254).as_deref(), Some("172.16.0.0/12"));
1850 assert_eq!(range(100, 64, 0, 1).as_deref(), Some("100.64.0.0/10"));
1851 assert_eq!(range(100, 127, 255, 254).as_deref(), Some("100.64.0.0/10"));
1852 for public in [
1853 Ipv4Addr::new(8, 8, 8, 8),
1854 Ipv4Addr::new(172, 15, 0, 1),
1855 Ipv4Addr::new(172, 32, 0, 1),
1856 Ipv4Addr::new(100, 63, 0, 1),
1857 Ipv4Addr::new(100, 128, 0, 1),
1858 Ipv4Addr::new(192, 169, 0, 1),
1859 ] {
1860 assert_eq!(enclosing(public), None, "{public} is not a private address");
1861 }
1862 }
1863
1864 #[test]
1865 fn an_endpoint_is_an_address_and_a_port_whatever_scheme_or_path_surrounds_them() {
1866 let hub = Some((HUB, 8791));
1867 assert_eq!(parse_endpoint("http://192.168.7.10:8791"), hub);
1868 assert_eq!(parse_endpoint("https://192.168.7.10:8791"), hub);
1869 assert_eq!(parse_endpoint("192.168.7.10:8791"), hub);
1870 assert_eq!(parse_endpoint("http://192.168.7.10:8791/"), hub);
1871 assert_eq!(
1872 parse_endpoint("http://192.168.7.10:8791/api/v1?x=1#top"),
1873 hub
1874 );
1875 assert_eq!(parse_endpoint("http://192.168.7.10:8791?x=1"), hub);
1876 assert_eq!(parse_endpoint("http://192.168.7.10:8791#top"), hub);
1877 assert_eq!(parse_endpoint("http://hub.example:8791"), None);
1880 assert_eq!(parse_endpoint("http://192.168.7.10"), None);
1881 assert_eq!(parse_endpoint("http://192.168.7.10:http"), None);
1882 assert_eq!(parse_endpoint("http://192.168.7.10:99999"), None);
1883 assert_eq!(parse_endpoint(""), None);
1884 }
1885
1886 #[test]
1887 fn a_declared_hub_admits_the_group_and_its_port_and_every_other_machine_only_the_group() {
1888 let p = hub_plan();
1889 assert_eq!(p.local_addresses, ["192.168.7.10"]);
1890 assert_eq!(p.networks, ["100.64.0.0/10", "192.168.7.0/24"]);
1891 assert_eq!(p.hub_ports, [8791]);
1892 let group = &p.rules[0];
1893 assert_eq!(
1894 (group.role, group.protocol, group.port),
1895 (Role::Multicast, Protocol::Udp, 7741)
1896 );
1897 assert_eq!(group.destination.as_deref(), Some("239.255.77.77"));
1898 assert!(group.sources.is_empty() && group.reason.contains("239.255.77.77"));
1899 let hub = &p.rules[1];
1900 assert_eq!(
1901 (hub.role, hub.protocol, hub.port),
1902 (Role::Hub, Protocol::Tcp, 8791)
1903 );
1904 assert_eq!(hub.sources, p.networks);
1905 assert!(hub.reason.contains("TCP port 8791"), "{}", hub.reason);
1906 let other = plan(Some(&fleet()), &[Ipv4Addr::new(192, 168, 7, 11)], None);
1909 assert!(other.hub_ports.is_empty());
1910 assert!(other.rules.iter().all(|r| r.role == Role::Multicast));
1911 assert_eq!(other.networks, p.networks);
1912 }
1913
1914 #[test]
1915 fn a_server_beyond_loopback_is_admitted_on_its_port_from_the_networks_of_the_seeds() {
1916 let seeded = config(serde_json::json!({
1917 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
1918 "cooperation": { "seeds": ["http://10.9.8.7:8741"] },
1919 }));
1920 let p = plan(Some(&seeded), &[Ipv4Addr::new(10, 9, 8, 1)], Some(8741));
1921 let roles: Vec<Role> = p.rules.iter().map(|r| r.role).collect();
1922 assert_eq!(roles, [Role::Multicast, Role::Link]);
1923 assert_eq!(p.rules[1].sources, ["10.0.0.0/8"]);
1924 assert!(p.rules[1].reason.contains("TCP port 8741"));
1925 let quiet = plan(Some(&seeded), &[Ipv4Addr::new(10, 9, 8, 1)], None);
1927 assert!(quiet.rules.iter().all(|r| r.role != Role::Link));
1928 }
1929
1930 #[test]
1931 fn broadcast_that_names_no_network_of_its_own_is_admitted_from_the_fleets() {
1932 let auto = config(serde_json::json!({
1933 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
1934 "multicast": { "enabled": false },
1935 "broadcast": { "mode": "auto" },
1936 "rendezvous": { "endpoints": ["http://192.168.7.10:8791"] },
1937 }));
1938 let p = plan(Some(&auto), &[Ipv4Addr::new(192, 168, 7, 11)], None);
1939 assert_eq!(p.rules.len(), 1, "{p:?}");
1940 let b = &p.rules[0];
1941 assert_eq!(
1942 (b.role, b.protocol, b.port),
1943 (Role::Broadcast, Protocol::Udp, 7741)
1944 );
1945 assert_eq!(b.destination, None);
1946 assert_eq!(b.sources, ["192.168.7.0/24"]);
1947 assert!(b.reason.contains("UDP port 7741"), "{}", b.reason);
1948 }
1949
1950 #[test]
1951 fn every_word_a_report_carries_is_the_one_a_reader_of_the_json_matches_on() {
1952 let word = |v: serde_json::Value| v.as_str().map(str::to_string);
1953 assert_eq!(
1954 word(serde_json::json!(Protocol::Udp)).as_deref(),
1955 Some("udp")
1956 );
1957 assert_eq!(
1958 word(serde_json::json!(Protocol::Tcp)).as_deref(),
1959 Some("tcp")
1960 );
1961 assert_eq!(word(serde_json::json!(Role::Hub)).as_deref(), Some("hub"));
1962 assert_eq!(
1963 word(serde_json::json!(Role::Broadcast)).as_deref(),
1964 Some("broadcast")
1965 );
1966 let back: Role = serde_json::from_value(serde_json::json!("multicast")).unwrap();
1967 assert_eq!(back, Role::Multicast);
1968 assert_eq!(
1969 word(serde_json::json!(Backend::ApplicationFirewall)).as_deref(),
1970 Some("application_firewall")
1971 );
1972 assert_eq!(
1973 word(serde_json::json!(FirewallObservationState::NoBackend)).as_deref(),
1974 Some("no_backend")
1975 );
1976 assert_eq!(
1977 word(serde_json::json!(FirewallObservationState::Unobservable)).as_deref(),
1978 Some("unobservable")
1979 );
1980 let ran: Applied = serde_json::from_value(serde_json::json!({
1981 "line": "ufw allow in proto udp to 239.255.77.77 port 7741",
1982 "ok": true,
1983 "output": "Rule added",
1984 }))
1985 .unwrap();
1986 assert!(ran.ok && ran.output == "Rule added" && ran.line.starts_with("ufw allow"));
1987 }
1988
1989 #[test]
1992 fn a_backend_is_named_by_its_tool_or_described_where_it_is_a_system_service() {
1993 assert_eq!(Backend::Ufw.as_str(), "ufw");
1994 assert_eq!(Backend::Nftables.as_str(), "nftables");
1995 assert_eq!(
1996 Backend::ApplicationFirewall.as_str(),
1997 "macOS application firewall"
1998 );
1999 assert_eq!(Backend::None.as_str(), "none");
2000 }
2001
2002 #[test]
2003 fn the_backend_is_decided_by_the_platform_and_by_what_is_on_the_path() {
2004 assert_eq!(
2005 detect("linux", &|name| name == "ufw" || name == "nft"),
2006 Backend::Ufw,
2007 "ufw is a front of nftables: where both are, ufw is the one to ask"
2008 );
2009 assert_eq!(detect("linux", &|name| name == "ufw"), Backend::Ufw);
2010 assert_eq!(detect("linux", &|name| name == "nft"), Backend::Nftables);
2011 assert_eq!(detect("linux", &|_| false), Backend::None);
2012 assert_eq!(
2013 detect("macos", &|_| false),
2014 Backend::ApplicationFirewall,
2015 "the application firewall is part of the system, not a tool on the path"
2016 );
2017 assert_eq!(detect("macos", &|_| true), Backend::ApplicationFirewall);
2018 assert_eq!(detect("windows", &|_| true), Backend::None);
2019 assert_eq!(detect("freebsd", &|_| true), Backend::None);
2020 }
2021
2022 #[test]
2023 fn this_process_detects_what_its_platform_and_its_path_decide() {
2024 let looked_up = detect(std::env::consts::OS, &|name| find_tool(name).is_some());
2025 assert_eq!(detect_here(), looked_up);
2026 assert_eq!(find_tool("majordomus-no-such-firewall-tool"), None);
2027 }
2028
2029 #[test]
2032 fn a_word_is_quoted_for_a_shell_only_when_the_shell_would_read_it_otherwise() {
2033 assert_eq!(shell_quote("ufw"), "ufw");
2034 assert_eq!(shell_quote("192.168.7.0/24"), "192.168.7.0/24");
2035 assert_eq!(shell_quote(SOCKETFILTERFW), SOCKETFILTERFW);
2036 assert_eq!(
2037 shell_quote("majordomus mesh: rendezvous hub"),
2038 "'majordomus mesh: rendezvous hub'"
2039 );
2040 assert_eq!(shell_quote(""), "''");
2041 assert_eq!(shell_quote("it's"), "'it'\\''s'");
2042 assert_eq!(shell_quote("$(reboot)"), "'$(reboot)'");
2043 }
2044
2045 #[test]
2046 fn every_role_is_admitted_with_the_comment_that_names_it_on_ufw_and_on_nftables() {
2047 let all = config(serde_json::json!({
2048 "schema": "mesh/v1", "kind": "mesh-declaration", "id": "t", "enabled": true,
2049 "broadcast": { "mode": "auto" },
2050 "rendezvous": { "endpoints": ["http://192.168.7.10:8791"] },
2051 }));
2052 let p = plan(Some(&all), &[HUB], Some(8741));
2053 let roles: Vec<Role> = p.rules.iter().map(|r| r.role).collect();
2054 assert_eq!(
2055 roles,
2056 [Role::Multicast, Role::Broadcast, Role::Hub, Role::Link]
2057 );
2058 let comments = [
2059 "majordomus mesh: multicast discovery",
2060 "majordomus mesh: broadcast discovery",
2061 "majordomus mesh: rendezvous hub",
2062 "majordomus mesh: link protocol",
2063 ];
2064
2065 let ufw = render(Backend::Ufw, &p, None);
2066 let lines: Vec<&str> = ufw.iter().map(|c| c.line.as_str()).collect();
2067 assert_eq!(
2068 lines,
2069 [
2070 "ufw allow in proto udp to 239.255.77.77 port 7741 comment 'majordomus mesh: multicast discovery'",
2071 "ufw allow in proto udp from 192.168.7.0/24 to any port 7741 comment 'majordomus mesh: broadcast discovery'",
2072 "ufw allow in proto tcp from 192.168.7.0/24 to any port 8791 comment 'majordomus mesh: rendezvous hub'",
2073 "ufw allow in proto tcp from 192.168.7.0/24 to any port 8741 comment 'majordomus mesh: link protocol'",
2074 ]
2075 );
2076 for (command, (rule, comment)) in ufw.iter().zip(p.rules.iter().zip(comments)) {
2077 assert_eq!(command.rule, rule.label());
2078 assert_eq!(command.argv.last().map(String::as_str), Some(comment));
2079 let quoted: Vec<String> = command.argv.iter().map(|w| shell_quote(w)).collect();
2081 assert_eq!(command.line, quoted.join(" "));
2082 }
2083
2084 let nft = render(Backend::Nftables, &p, None);
2085 assert_eq!(nft.len(), 4, "{nft:#?}");
2086 let heads = [
2087 "nft add rule inet filter input ip daddr 239.255.77.77 udp dport 7741 accept comment ",
2088 "nft add rule inet filter input ip saddr 192.168.7.0/24 udp dport 7741 accept comment ",
2089 "nft add rule inet filter input ip saddr 192.168.7.0/24 tcp dport 8791 accept comment ",
2090 "nft add rule inet filter input ip saddr 192.168.7.0/24 tcp dport 8741 accept comment ",
2091 ];
2092 for ((command, head), (rule, comment)) in
2093 nft.iter().zip(heads).zip(p.rules.iter().zip(comments))
2094 {
2095 assert!(command.line.starts_with(head), "{}", command.line);
2096 assert_eq!(command.rule, rule.label());
2097 assert_eq!(command.argv.last().cloned(), Some(format!("\"{comment}\"")));
2100 }
2101 }
2102
2103 #[test]
2104 fn a_command_is_ok_by_its_exit_and_never_by_a_word_in_what_it_printed() {
2105 let bare = Script::new(&[
2108 (
2109 "nft list ruleset",
2110 "table inet filter {\n chain input {\n }\n}\n",
2111 ),
2112 ("journalctl", JOURNAL_QUIET),
2113 ]);
2114 let before = hub_report("linux", Backend::Nftables, None, &bare);
2115 assert_eq!(before.observation.state, FirewallObservationState::Missing);
2116 assert!(!before.commands.is_empty());
2117 let refusing = Script::new(&[
2118 ("nft add rule", "Error: syntax error, unexpected string\n"),
2119 (
2120 "nft list ruleset",
2121 "table inet filter {\n chain input {\n }\n}\n",
2122 ),
2123 ]);
2124 let a = apply_with(
2125 before,
2126 true,
2127 &|program, args| refusing.run(program, args),
2128 &|program, args| refusing.exec(program, args),
2129 );
2130 assert!(!a.ok && a.refused.is_none());
2131 assert!(
2132 a.applied.iter().all(|c| !c.ok),
2133 "a command nft refused was recorded ok: {:#?}",
2134 a.applied
2135 );
2136 }
2137
2138 #[test]
2139 fn an_inactive_firewall_is_not_applied_to_because_nothing_filters() {
2140 let empty = Script::new(&[("nft list ruleset", ""), ("journalctl", JOURNAL_QUIET)]);
2143 let before = hub_report("linux", Backend::Nftables, None, &empty);
2144 assert_eq!(before.observation.state, FirewallObservationState::Inactive);
2145 assert!(!before.commands.is_empty(), "the plan still has rules");
2146 let untouched = Script::new(&[]);
2147 let a = apply_with(
2148 before,
2149 true,
2150 &|program, args| untouched.run(program, args),
2151 &|program, args| untouched.exec(program, args),
2152 );
2153 assert!(a.refused.is_some() && a.applied.is_empty());
2154 assert!(untouched.asked().is_empty(), "{:?}", untouched.asked());
2155 }
2156
2157 #[test]
2158 fn a_rule_from_two_networks_is_one_command_per_network_and_no_backend_is_no_command() {
2159 let p = hub_plan();
2160 let lines: Vec<String> = render(Backend::Ufw, &p, None)
2161 .into_iter()
2162 .map(|c| c.line)
2163 .collect();
2164 assert_eq!(
2165 lines,
2166 [
2167 "ufw allow in proto udp to 239.255.77.77 port 7741 comment 'majordomus mesh: multicast discovery'",
2168 "ufw allow in proto tcp from 100.64.0.0/10 to any port 8791 comment 'majordomus mesh: rendezvous hub'",
2169 "ufw allow in proto tcp from 192.168.7.0/24 to any port 8791 comment 'majordomus mesh: rendezvous hub'",
2170 ]
2171 );
2172 let nft = render(Backend::Nftables, &p, None);
2173 assert_eq!(nft.len(), 3);
2174 assert!(nft[1].argv.contains(&"100.64.0.0/10".to_string()));
2175 assert!(nft[2].argv.contains(&"192.168.7.0/24".to_string()));
2176 assert!(render(Backend::None, &p, None).is_empty());
2177 assert!(render(Backend::None, &p, Some(Path::new(EXECUTABLE))).is_empty());
2178 }
2179
2180 #[test]
2181 fn the_application_firewall_admits_the_executable_once_whatever_the_ports() {
2182 let p = hub_plan();
2183 let exe = Path::new(EXECUTABLE);
2184 let mac = render(Backend::ApplicationFirewall, &p, Some(exe));
2185 let argv: Vec<Vec<&str>> = mac
2186 .iter()
2187 .map(|c| c.argv.iter().map(String::as_str).collect())
2188 .collect();
2189 assert_eq!(
2190 argv,
2191 [
2192 [SOCKETFILTERFW, "--add", EXECUTABLE],
2193 [SOCKETFILTERFW, "--unblockapp", EXECUTABLE],
2194 ]
2195 );
2196 assert!(mac[0].line.ends_with("--add /opt/majordomus"));
2197 assert!(mac[1].line.ends_with("--unblockapp /opt/majordomus"));
2198 assert!(mac.iter().all(|c| c.rule == GROUP_LABEL));
2199 let nothing = plan(None, &[HUB], None);
2201 assert!(render(Backend::ApplicationFirewall, ¬hing, Some(exe)).is_empty());
2202 assert!(render(Backend::ApplicationFirewall, &p, None).is_empty());
2203 }
2204
2205 #[test]
2208 fn ufw_is_judged_present_missing_inactive_or_unobservable_from_its_status_alone() {
2209 let p = hub_plan();
2210 let before = judge_ufw(UFW_BARE, &p);
2211 assert_eq!(before.state, FirewallObservationState::Missing);
2212 assert_eq!(before.missing, [GROUP_LABEL, HUB_LABEL]);
2213 assert_eq!(
2214 before.detail,
2215 format!("2 of 2 rule(s) not admitted: {GROUP_LABEL}; {HUB_LABEL}")
2216 );
2217
2218 let after = judge_ufw(UFW_ADMITTED, &p);
2219 assert_eq!(after.state, FirewallObservationState::Present);
2220 assert!(after.missing.is_empty());
2221 assert_eq!(
2222 after.detail,
2223 "every rule of the plan is admitted (2 rule(s))"
2224 );
2225
2226 let off = judge_ufw("Status: inactive\n", &p);
2227 assert_eq!(off.state, FirewallObservationState::Inactive);
2228 assert!(off.missing.is_empty() && off.detail.contains("inactive"));
2229
2230 let unasked = judge_ufw(UFW_ROOT, &p);
2231 assert_eq!(unasked.state, FirewallObservationState::Unobservable);
2232 assert!(unasked.detail.contains("sudo majordomus mesh firewall"));
2233 }
2234
2235 #[test]
2236 fn a_ufw_status_nobody_understands_is_unobservable_and_quotes_what_it_said() {
2237 let p = hub_plan();
2238 let odd = judge_ufw("ufw: command not found\nsecond line\n", &p);
2239 assert_eq!(odd.state, FirewallObservationState::Unobservable);
2240 assert_eq!(
2241 odd.detail,
2242 "ufw status was not understood: ufw: command not found"
2243 );
2244 let silent = judge_ufw("", &p);
2245 assert_eq!(silent.state, FirewallObservationState::Unobservable);
2246 assert_eq!(silent.detail, "ufw status was not understood: (empty)");
2247 }
2248
2249 #[test]
2250 fn ufw_admits_by_the_terse_action_and_by_anywhere_and_never_by_an_outbound_rule() {
2251 let p = hub_plan();
2252 let terse = concat!(
2254 "Status: active\n",
2255 "239.255.77.77 7741/udp ALLOW Anywhere\n",
2256 "8791/tcp ALLOW 192.168.7.0/24\n",
2257 "8791/tcp ALLOW 100.64.0.0/10\n",
2258 );
2259 assert_eq!(
2260 judge_ufw(terse, &p).state,
2261 FirewallObservationState::Present
2262 );
2263 let wide = concat!(
2265 "Status: active\n",
2266 "239.255.77.77 7741/udp ALLOW IN Anywhere\n",
2267 "8791/tcp ALLOW IN Anywhere\n",
2268 );
2269 assert_eq!(judge_ufw(wide, &p).state, FirewallObservationState::Present);
2270 let elsewhere = concat!(
2273 "Status: active\n",
2274 "7741/udp ALLOW IN Anywhere\n",
2275 "239.255.77.77 7741/udp ALLOW OUT Anywhere\n",
2276 "239.255.77.77 7741/udp DENY IN Anywhere\n",
2277 "8791/tcp ALLOW OUT Anywhere\n",
2278 "8791/tcp ALLOW FWD Anywhere\n",
2279 "8791/tcp DENY 192.168.7.0/24\n",
2280 );
2281 let o = judge_ufw(elsewhere, &p);
2282 assert_eq!(o.state, FirewallObservationState::Missing);
2283 assert_eq!(o.missing, [GROUP_LABEL, HUB_LABEL]);
2284 let half = concat!(
2286 "Status: active\n",
2287 "239.255.77.77 7741/udp ALLOW IN Anywhere\n",
2288 "8791/tcp ALLOW IN 192.168.7.0/24\n",
2289 );
2290 let o = judge_ufw(half, &p);
2291 assert_eq!(o.missing, [HUB_LABEL]);
2292 assert_eq!(
2293 o.detail,
2294 format!("1 of 2 rule(s) not admitted: {HUB_LABEL}")
2295 );
2296 let nothing = judge_ufw(UFW_BARE, &plan(None, &[], None));
2298 assert_eq!(nothing.state, FirewallObservationState::Present);
2299 }
2300
2301 #[test]
2302 fn nftables_is_judged_present_missing_inactive_or_unobservable_from_its_ruleset_alone() {
2303 let p = hub_plan();
2304 assert_eq!(
2305 judge_nft(NFT_ADMITTED, &p).state,
2306 FirewallObservationState::Present
2307 );
2308 let empty_table = judge_nft("table inet filter {\n}\n", &p);
2309 assert_eq!(empty_table.state, FirewallObservationState::Missing);
2310 assert_eq!(empty_table.missing, [GROUP_LABEL, HUB_LABEL]);
2311 for nothing in ["", " \n\n"] {
2312 let o = judge_nft(nothing, &p);
2313 assert_eq!(o.state, FirewallObservationState::Inactive);
2314 assert!(o.detail.contains("ruleset is empty"), "{}", o.detail);
2315 }
2316 for refusal in [
2317 "Operation not permitted (you must be root)",
2318 "netlink: Error: cache initialization failed: Permission denied",
2319 ] {
2320 let o = judge_nft(refusal, &p);
2321 assert_eq!(o.state, FirewallObservationState::Unobservable);
2322 assert!(o.detail.contains("sudo majordomus mesh firewall"));
2323 }
2324 }
2325
2326 #[test]
2327 fn an_nftables_accept_covers_a_source_it_names_or_every_source_when_it_names_none() {
2328 let p = hub_plan();
2329 let any_source = concat!(
2331 " ip daddr 239.255.77.77 udp dport 7741 accept\n",
2332 " tcp dport 8791 accept\n",
2333 );
2334 assert_eq!(
2335 judge_nft(any_source, &p).state,
2336 FirewallObservationState::Present
2337 );
2338 let half = concat!(
2340 " ip daddr 239.255.77.77 udp dport 7741 accept\n",
2341 " ip saddr 192.168.7.0/24 tcp dport 8791 accept\n",
2342 );
2343 let o = judge_nft(half, &p);
2344 assert_eq!(o.state, FirewallObservationState::Missing);
2345 assert_eq!(o.missing, [HUB_LABEL]);
2346 let elsewhere = concat!(
2348 " udp dport 7741 accept\n",
2349 " ip saddr 192.168.7.0/24 tcp dport 8791 drop\n",
2350 " ip saddr 100.64.0.0/10 tcp dport 8791 drop\n",
2351 " tcp dport 22 accept\n",
2352 );
2353 let o = judge_nft(elsewhere, &p);
2354 assert_eq!(o.missing, [GROUP_LABEL, HUB_LABEL]);
2355 }
2356
2357 #[test]
2358 fn the_application_firewall_is_judged_by_whether_the_executable_is_allowed() {
2359 let exe = Path::new(EXECUTABLE);
2360 for off in ["Firewall is disabled. (State = 0)", "(State = 0)"] {
2361 let o = judge_application_firewall(off, "", exe);
2362 assert_eq!(o.state, FirewallObservationState::Inactive, "{off}");
2363 assert!(o.missing.is_empty());
2364 }
2365 let allowed = judge_application_firewall(FIREWALL_ON, APPS_ALLOWED, exe);
2366 assert_eq!(allowed.state, FirewallObservationState::Present);
2367 assert!(allowed.detail.ends_with(EXECUTABLE), "{}", allowed.detail);
2368
2369 let blocked = "1 : /opt/majordomus \n \t ( Block incoming connections ) \n";
2370 let cut_short = "1 : /opt/majordomus";
2372 for apps in [APPS_OTHER, blocked, cut_short, ""] {
2373 let o = judge_application_firewall(FIREWALL_ON, apps, exe);
2374 assert_eq!(o.state, FirewallObservationState::Missing, "{apps:?}");
2375 assert_eq!(o.missing, ["incoming connections to /opt/majordomus"]);
2376 assert!(o.detail.contains("does not allow"), "{}", o.detail);
2377 }
2378 }
2379
2380 #[test]
2383 fn each_backend_is_asked_its_own_question_and_judged_by_its_own_answer() {
2384 let p = hub_plan();
2385 let host = Script::new(&[
2386 ("ufw status", UFW_ADMITTED),
2387 ("nft list ruleset", "table inet filter {\n}\n"),
2388 ]);
2389 let ufw = observe_with(Backend::Ufw, &p, None, &|program, args| {
2390 host.run(program, args)
2391 });
2392 assert_eq!(ufw.state, FirewallObservationState::Present);
2393 let nft = observe_with(Backend::Nftables, &p, None, &|program, args| {
2394 host.run(program, args)
2395 });
2396 assert_eq!(nft.state, FirewallObservationState::Missing);
2397 assert_eq!(host.asked(), ["ufw status", "nft list ruleset"]);
2398 }
2399
2400 #[test]
2401 fn a_firewall_tool_that_cannot_run_is_unobservable_and_the_reason_is_the_evidence() {
2402 let p = hub_plan();
2403 let host = Script::new(&[]);
2404 for (backend, tool) in [(Backend::Ufw, "ufw"), (Backend::Nftables, "nft")] {
2405 let o = observe_with(backend, &p, None, &|program, args| host.run(program, args));
2406 assert_eq!(o.state, FirewallObservationState::Unobservable);
2407 assert_eq!(
2408 o.detail,
2409 format!("{tool} could not run: no such tool on this host")
2410 );
2411 }
2412 let exe = Path::new(EXECUTABLE);
2414 for answered in [
2415 ("--getglobalstate", FIREWALL_ON),
2416 ("--listapps", APPS_ALLOWED),
2417 ] {
2418 let host = Script::new(&[answered]);
2419 let o = observe_with(
2420 Backend::ApplicationFirewall,
2421 &p,
2422 Some(exe),
2423 &|program, args| host.run(program, args),
2424 );
2425 assert_eq!(o.state, FirewallObservationState::Unobservable, "{o:?}");
2426 assert!(o.detail.contains("could not run"), "{}", o.detail);
2427 }
2428 }
2429
2430 #[test]
2431 fn the_application_firewall_is_asked_its_state_and_its_list_for_the_executable_given() {
2432 let p = hub_plan();
2433 let host = Script::new(&[
2434 ("--getglobalstate", FIREWALL_ON),
2435 ("--listapps", APPS_ALLOWED),
2436 ]);
2437 let o = observe_with(
2438 Backend::ApplicationFirewall,
2439 &p,
2440 Some(Path::new(EXECUTABLE)),
2441 &|program, args| host.run(program, args),
2442 );
2443 assert_eq!(o.state, FirewallObservationState::Present, "{o:?}");
2444 assert_eq!(
2445 host.asked(),
2446 [
2447 format!("{SOCKETFILTERFW} --getglobalstate"),
2448 format!("{SOCKETFILTERFW} --listapps"),
2449 ]
2450 );
2451 let unasked = Script::new(&[]);
2453 let o = observe_with(Backend::ApplicationFirewall, &p, None, &|program, args| {
2454 unasked.run(program, args)
2455 });
2456 assert_eq!(o.state, FirewallObservationState::Unobservable);
2457 assert!(o.detail.contains("none was given"), "{}", o.detail);
2458 let o = observe_with(Backend::None, &p, None, &|program, args| {
2459 unasked.run(program, args)
2460 });
2461 assert_eq!(o.state, FirewallObservationState::NoBackend);
2462 assert!(unasked.asked().is_empty(), "{:?}", unasked.asked());
2463 }
2464
2465 #[test]
2466 fn observing_without_a_backend_or_without_an_executable_asks_nothing_of_this_host() {
2467 let nothing = plan(None, &[], None);
2469 let o = observe(Backend::None, ¬hing, None);
2470 assert_eq!(o.state, FirewallObservationState::NoBackend);
2471 let o = observe(Backend::ApplicationFirewall, &hub_plan(), None);
2472 assert_eq!(o.state, FirewallObservationState::Unobservable);
2473 }
2474
2475 #[test]
2476 fn a_host_tool_answers_with_everything_it_printed_and_a_missing_one_with_the_reason() {
2477 let version = run("git", &["--version"]).unwrap();
2480 assert!(version.starts_with("git version"), "{version}");
2481 let refused = run("git", &["--majordomus-no-such-option"]).unwrap();
2484 assert!(refused.contains("--majordomus-no-such-option"), "{refused}");
2485 let absent = run("majordomus-no-such-firewall-tool", &[]).unwrap_err();
2486 assert!(
2487 absent.starts_with("majordomus-no-such-firewall-tool could not run: "),
2488 "{absent}"
2489 );
2490 }
2491
2492 #[test]
2495 fn a_drop_is_counted_for_the_port_it_names_wherever_the_port_stands_in_the_line() {
2496 let counts = count_blocks(JOURNAL_DROPS, &[7741, 8791]);
2497 assert_eq!(counts.get("7741"), Some(&1));
2498 assert_eq!(counts.get("8791"), Some(&1));
2499 assert_eq!(counts.get("22"), None, "port 22 is not the mesh's");
2500 let tail = "[UFW BLOCK] IN=e DPT=8791\n[UFW BLOCK] IN=e DPT=8791 \nDPT=8791 unrelated\n";
2502 let counts = count_blocks(tail, &[7741, 8791]);
2503 assert_eq!(counts["8791"], 2);
2504 assert_eq!(
2505 counts["7741"], 0,
2506 "a port nothing was dropped toward counts zero"
2507 );
2508 assert!(count_blocks(JOURNAL_DROPS, &[]).is_empty());
2509 }
2510
2511 #[test]
2512 fn the_kernel_log_is_read_on_linux_for_a_plan_with_rules_and_nowhere_else() {
2513 let host = Script::new(&[("journalctl", JOURNAL_DROPS)]);
2514 let nothing = plan(None, &[], None);
2515 let read = |plan: &FirewallPlan, os: &str| {
2516 kernel_blocks_with(plan, os, &|program, args| host.run(program, args))
2517 };
2518 assert_eq!(read(¬hing, "linux"), None, "an empty plan asks nothing");
2519 assert_eq!(read(&hub_plan(), "macos"), None);
2520 assert!(host.asked().is_empty(), "{:?}", host.asked());
2521
2522 let counts = read(&hub_plan(), "linux").unwrap();
2523 assert_eq!(counts.len(), 2);
2524 assert_eq!((counts["7741"], counts["8791"]), (1, 1));
2525 assert_eq!(
2526 host.asked(),
2527 [format!(
2528 "journalctl -k --since -{BLOCK_WINDOW_SECONDS}s -o cat -q --no-pager"
2529 )]
2530 );
2531 assert!(kernel_blocks(¬hing).is_none());
2533 }
2534
2535 #[test]
2536 fn a_kernel_log_that_cannot_be_read_is_an_absence_and_never_a_count_of_zero() {
2537 let p = hub_plan();
2538 let absent = Script::new(&[]);
2539 let none = kernel_blocks_with(&p, "linux", &|program, args| absent.run(program, args));
2540 assert_eq!(none, None, "no journalctl on this host");
2541 for refusal in [
2542 "No journal files were found.\n",
2543 "Failed to open journal: Permission denied\n",
2544 ] {
2545 let host = Script::new(&[("journalctl", refusal)]);
2546 let none = kernel_blocks_with(&p, "linux", &|program, args| host.run(program, args));
2547 assert_eq!(none, None, "{refusal}");
2548 }
2549 let quiet = Script::new(&[("journalctl", JOURNAL_QUIET)]);
2550 let zero = kernel_blocks_with(&p, "linux", &|program, args| quiet.run(program, args));
2551 assert_eq!(
2552 zero.map(|m| m.values().sum::<u64>()),
2553 Some(0),
2554 "a log that was read and holds no drop is a count, and the count is zero"
2555 );
2556 }
2557
2558 #[test]
2561 fn a_rule_observed_missing_fails_the_report_and_the_verdict_says_how_to_admit_it() {
2562 let host = Script::new(&[("ufw status", UFW_BARE), ("journalctl", JOURNAL_QUIET)]);
2563 let r = hub_report("linux", Backend::Ufw, None, &host);
2564 assert!(!r.ok);
2565 assert_eq!((r.platform.as_str(), r.backend), ("linux", Backend::Ufw));
2566 assert_eq!(r.executable, None);
2567 assert_eq!(r.window_seconds, BLOCK_WINDOW_SECONDS);
2568 assert_eq!(r.observation.state, FirewallObservationState::Missing);
2569 assert_eq!(r.observation.missing, [GROUP_LABEL, HUB_LABEL]);
2570 assert_eq!(
2571 r.verdict,
2572 format!(
2573 "the host firewall does not admit the mesh: {}; run `sudo majordomus mesh firewall apply`",
2574 r.observation.detail
2575 )
2576 );
2577 assert_eq!(r.commands, render(Backend::Ufw, &r.plan, None));
2579 assert_eq!(r.commands.len(), 3);
2580 let dropped = r.blocked_recently.as_ref().unwrap();
2581 assert_eq!((dropped["7741"], dropped["8791"]), (0, 0));
2582 }
2583
2584 #[test]
2585 fn a_logged_drop_fails_the_report_whatever_the_firewall_says_or_cannot_say() {
2586 let unasked = Script::new(&[("ufw status", UFW_ROOT), ("journalctl", JOURNAL_DROPS)]);
2587 let r = hub_report("linux", Backend::Ufw, None, &unasked);
2588 assert!(!r.ok);
2589 assert_eq!(r.observation.state, FirewallObservationState::Unobservable);
2590 assert!(
2591 r.verdict.starts_with("the kernel logged 2 inbound")
2592 && r.verdict.contains("in the last 5 minutes")
2593 && r.verdict.contains("could not be asked without root")
2594 && r.verdict
2595 .ends_with("run `sudo majordomus mesh firewall apply`"),
2596 "{}",
2597 r.verdict
2598 );
2599
2600 let on_paper = Script::new(&[("ufw status", UFW_ADMITTED), ("journalctl", JOURNAL_DROPS)]);
2602 let r = hub_report("linux", Backend::Ufw, None, &on_paper);
2603 assert!(!r.ok);
2604 assert_eq!(r.observation.state, FirewallObservationState::Present);
2605 assert!(
2606 r.verdict.starts_with("the kernel logged 2 inbound")
2607 && r.verdict
2608 .contains("although the firewall admits the mesh on paper"),
2609 "{}",
2610 r.verdict
2611 );
2612 }
2613
2614 #[test]
2615 fn a_firewall_that_admits_the_plan_or_filters_nothing_holds_with_its_own_words() {
2616 let admitted = Script::new(&[("ufw status", UFW_ADMITTED), ("journalctl", JOURNAL_QUIET)]);
2617 let r = hub_report("linux", Backend::Ufw, None, &admitted);
2618 assert!(r.ok);
2619 assert_eq!(
2620 r.verdict,
2621 "the host firewall admits the mesh: every rule of the plan is admitted (2 rule(s))"
2622 );
2623 assert_eq!(
2624 admitted.asked().len(),
2625 2,
2626 "the firewall once, the log once: {:?}",
2627 admitted.asked()
2628 );
2629
2630 let off = Script::new(&[("ufw status", "Status: inactive\n")]);
2631 let r = hub_report("linux", Backend::Ufw, None, &off);
2632 assert!(r.ok);
2633 assert_eq!(r.observation.state, FirewallObservationState::Inactive);
2634 assert_eq!(r.verdict, r.observation.detail);
2635 assert!(r.verdict.contains("inactive"), "{}", r.verdict);
2636
2637 let nft = Script::new(&[("nft list ruleset", NFT_ADMITTED)]);
2639 let r = hub_report("linux", Backend::Nftables, None, &nft);
2640 assert!(r.ok);
2641 assert_eq!(r.commands, render(Backend::Nftables, &r.plan, None));
2642 }
2643
2644 #[test]
2645 fn a_host_without_a_known_firewall_holds_and_says_that_nothing_was_observed() {
2646 let host = Script::new(&[]);
2647 let r = hub_report("freebsd", Backend::None, None, &host);
2648 assert!(r.ok);
2649 assert_eq!(r.platform, "freebsd");
2650 assert_eq!(r.observation.state, FirewallObservationState::NoBackend);
2651 assert_eq!(r.verdict, r.observation.detail);
2652 assert!(r.verdict.contains("nothing is observed"), "{}", r.verdict);
2653 assert!(r.commands.is_empty() && r.blocked_recently.is_none());
2654 assert_eq!(r.plan, hub_plan(), "the plan is derived all the same");
2655 assert!(host.asked().is_empty(), "{:?}", host.asked());
2656 }
2657
2658 #[test]
2659 fn a_firewall_that_cannot_be_asked_holds_and_says_what_the_kernel_log_showed() {
2660 let quiet = Script::new(&[("ufw status", UFW_ROOT), ("journalctl", JOURNAL_QUIET)]);
2661 let r = hub_report("linux", Backend::Ufw, None, &quiet);
2662 assert!(r.ok, "an absence is not a verdict: {}", r.verdict);
2663 assert!(
2664 r.verdict.starts_with("not observed: ufw status needs root")
2665 && r.verdict.ends_with(
2666 "the kernel log shows nothing dropped toward the mesh's ports in the last 5 minutes"
2667 ),
2668 "{}",
2669 r.verdict
2670 );
2671
2672 let blind = Script::new(&[("ufw status", UFW_ROOT)]);
2673 let r = hub_report("linux", Backend::Ufw, None, &blind);
2674 assert!(r.ok);
2675 assert!(r.blocked_recently.is_none());
2676 assert!(
2677 r.verdict
2678 .ends_with("the kernel log could not be read either"),
2679 "{}",
2680 r.verdict
2681 );
2682 }
2683
2684 #[test]
2685 fn an_empty_plan_is_reported_as_needing_nothing_on_any_host_and_asks_none_of_them() {
2686 let host = Script::new(&[]);
2687 for (os, backend) in [
2688 ("linux", Backend::Ufw),
2689 ("linux", Backend::Nftables),
2690 ("macos", Backend::ApplicationFirewall),
2691 ("freebsd", Backend::None),
2692 ] {
2693 let on = Host {
2694 os,
2695 backend,
2696 executable: Some(PathBuf::from(EXECUTABLE)),
2697 run: &|program, args| host.run(program, args),
2698 };
2699 let r = report_with(None, &[HUB], Some(8741), on);
2700 assert!(r.ok, "{os}: {}", r.verdict);
2701 assert_eq!(r.plan.local_addresses, ["192.168.7.10"]);
2702 assert!(r.plan.rules.is_empty() && r.commands.is_empty());
2703 assert_eq!(r.observation.state, FirewallObservationState::Present);
2704 assert_eq!(r.verdict, r.observation.detail);
2705 assert!(r.verdict.contains("needs no inbound admission"));
2706 assert!(r.blocked_recently.is_none());
2707 assert_eq!(r.executable.as_deref(), Some(EXECUTABLE));
2708 }
2709 assert!(host.asked().is_empty(), "{:?}", host.asked());
2710 }
2711
2712 #[test]
2713 fn the_report_of_a_mac_names_the_executable_it_asked_the_application_firewall_about() {
2714 let host = Script::new(&[
2715 ("--getglobalstate", FIREWALL_ON),
2716 ("--listapps", APPS_ALLOWED),
2717 ]);
2718 let r = hub_report(
2719 "macos",
2720 Backend::ApplicationFirewall,
2721 Some(EXECUTABLE),
2722 &host,
2723 );
2724 assert!(r.ok, "{}", r.verdict);
2725 assert_eq!(r.executable.as_deref(), Some(EXECUTABLE));
2726 assert_eq!(r.observation.state, FirewallObservationState::Present);
2727 assert_eq!(r.commands.len(), 2);
2728 assert!(r.blocked_recently.is_none(), "there is no ufw log on a Mac");
2729 assert_eq!(
2730 host.asked().len(),
2731 2,
2732 "its state and its list, and no kernel log: {:?}",
2733 host.asked()
2734 );
2735
2736 let unasked = Script::new(&[]);
2738 let r = hub_report("macos", Backend::ApplicationFirewall, None, &unasked);
2739 assert!(r.ok);
2740 assert!(r.commands.is_empty());
2741 assert_eq!(
2742 r.verdict,
2743 "not observed: the application firewall admits an executable, and none was given; the kernel log could not be read either"
2744 );
2745 assert!(unasked.asked().is_empty(), "{:?}", unasked.asked());
2746 }
2747
2748 fn dropping() -> FirewallReport {
2752 let host = Script::new(&[("ufw status", UFW_BARE), ("journalctl", JOURNAL_QUIET)]);
2753 hub_report("linux", Backend::Ufw, None, &host)
2754 }
2755
2756 #[test]
2757 fn apply_refuses_without_a_backend_without_commands_and_without_root_and_runs_nothing() {
2758 let host = Script::new(&[("ufw", "Rule added\n")]);
2759 let apply_as = |before: FirewallReport, root: bool| {
2760 apply_with(
2761 before,
2762 root,
2763 &|program, args| host.run(program, args),
2764 &|program, args| host.exec(program, args),
2765 )
2766 };
2767
2768 let nowhere = hub_report("freebsd", Backend::None, None, &Script::new(&[]));
2769 let a = apply_as(nowhere, true);
2770 assert!(!a.ok && a.applied.is_empty());
2771 assert_eq!(
2772 a.refused.as_deref(),
2773 Some("no firewall front this executable knows is on this host; nothing to apply")
2774 );
2775
2776 let on = Host {
2777 os: "linux",
2778 backend: Backend::Ufw,
2779 executable: None,
2780 run: &|program, args| host.run(program, args),
2781 };
2782 let a = apply_as(report_with(None, &[HUB], None, on), true);
2783 assert!(!a.ok && a.applied.is_empty());
2784 assert_eq!(
2785 a.refused.as_deref(),
2786 Some("the plan needs no admission on this host; nothing to apply")
2787 );
2788
2789 let a = apply_as(dropping(), false);
2790 assert!(!a.ok && a.applied.is_empty());
2791 let refusal = a.refused.as_deref().unwrap();
2792 assert!(
2793 refusal.starts_with("applying firewall rules needs root"),
2794 "{refusal}"
2795 );
2796 assert_eq!(a.observation, a.before.observation);
2798 assert_eq!(a.observation.state, FirewallObservationState::Missing);
2799
2800 assert!(host.asked().is_empty(), "{:?}", host.asked());
2801 }
2802
2803 #[test]
2804 fn as_root_every_command_runs_as_rendered_and_the_verdict_is_the_firewalls_afterwards() {
2805 let before = dropping();
2806 let rendered: Vec<String> = before.commands.iter().map(|c| c.argv.join(" ")).collect();
2807 let lines: Vec<String> = before.commands.iter().map(|c| c.line.clone()).collect();
2808 let host = Script::new(&[("ufw allow", "Rule added\n"), ("ufw status", UFW_ADMITTED)]);
2809 let a = apply_with(
2810 before,
2811 true,
2812 &|program, args| host.run(program, args),
2813 &|program, args| host.exec(program, args),
2814 );
2815 assert!(a.ok);
2816 assert_eq!(a.refused, None);
2817 assert_eq!(a.applied.len(), 3);
2818 assert!(a.applied.iter().all(|c| c.ok && c.output == "Rule added"));
2819 let ran: Vec<&str> = a.applied.iter().map(|c| c.line.as_str()).collect();
2820 assert_eq!(ran, lines);
2821 assert_eq!(a.observation.state, FirewallObservationState::Present);
2822 assert_eq!(
2823 a.before.observation.state,
2824 FirewallObservationState::Missing,
2825 "the report the commands came from is kept as it was"
2826 );
2827 let mut expected = rendered;
2829 expected.push("ufw status".to_string());
2830 assert_eq!(host.asked(), expected);
2831 }
2832
2833 #[test]
2834 fn a_command_the_tool_refuses_fails_the_run_even_when_the_firewall_then_admits_the_plan() {
2835 let host = Script::new(&[
2837 ("proto udp", "ERROR: Invalid syntax\n"),
2838 ("ufw status", UFW_ADMITTED),
2839 ]);
2840 let a = apply_with(
2841 dropping(),
2842 true,
2843 &|program, args| host.run(program, args),
2844 &|program, args| host.exec(program, args),
2845 );
2846 assert!(!a.ok, "a refused command is never a run that held");
2847 assert_eq!(a.refused, None, "it ran: that is not a refusal to run");
2848 assert_eq!(a.observation.state, FirewallObservationState::Present);
2849 assert!(!a.applied[0].ok);
2850 assert_eq!(a.applied[0].output, "ERROR: Invalid syntax");
2851 for failed in &a.applied[1..] {
2852 assert!(!failed.ok);
2853 assert_eq!(
2854 failed.output,
2855 "ufw could not run: no such tool on this host"
2856 );
2857 }
2858 }
2859
2860 #[test]
2861 fn commands_that_all_ran_do_not_hold_while_the_firewall_still_drops_the_mesh() {
2862 let still = Script::new(&[("ufw allow", "Rule added\n"), ("ufw status", UFW_BARE)]);
2863 let a = apply_with(
2864 dropping(),
2865 true,
2866 &|program, args| still.run(program, args),
2867 &|program, args| still.exec(program, args),
2868 );
2869 assert!(a.applied.iter().all(|c| c.ok));
2870 assert_eq!(a.observation.state, FirewallObservationState::Missing);
2871 assert!(!a.ok, "the verdict is the firewall's, not the exit codes'");
2872
2873 let off = Script::new(&[
2875 ("ufw allow", "Rules updated\n"),
2876 ("ufw status", "Status: inactive\n"),
2877 ]);
2878 let a = apply_with(
2879 dropping(),
2880 true,
2881 &|program, args| off.run(program, args),
2882 &|program, args| off.exec(program, args),
2883 );
2884 assert_eq!(a.observation.state, FirewallObservationState::Inactive);
2885 assert!(a.ok);
2886
2887 let blind = Script::new(&[("ufw allow", "Rule added\n"), ("ufw status", UFW_ROOT)]);
2889 let a = apply_with(
2890 dropping(),
2891 true,
2892 &|program, args| blind.run(program, args),
2893 &|program, args| blind.exec(program, args),
2894 );
2895 assert_eq!(a.observation.state, FirewallObservationState::Unobservable);
2896 assert!(!a.ok);
2897 }
2898
2899 #[test]
2900 fn the_application_firewall_is_applied_for_the_executable_the_report_names() {
2901 let listed_other = Script::new(&[
2902 ("--getglobalstate", FIREWALL_ON),
2903 ("--listapps", APPS_OTHER),
2904 ]);
2905 let before = hub_report(
2906 "macos",
2907 Backend::ApplicationFirewall,
2908 Some(EXECUTABLE),
2909 &listed_other,
2910 );
2911 assert!(!before.ok);
2912 assert_eq!(
2913 before.observation.missing,
2914 ["incoming connections to /opt/majordomus"]
2915 );
2916
2917 let host = Script::new(&[
2918 (
2919 "--add",
2920 "Application at path ( /opt/majordomus ) added to firewall\n",
2921 ),
2922 (
2923 "--unblockapp",
2924 "Incoming connection to the application is permitted\n",
2925 ),
2926 ("--getglobalstate", FIREWALL_ON),
2927 ("--listapps", APPS_ALLOWED),
2928 ]);
2929 let a = apply_with(
2930 before,
2931 true,
2932 &|program, args| host.run(program, args),
2933 &|program, args| host.exec(program, args),
2934 );
2935 assert!(a.ok, "{a:?}");
2936 assert_eq!(a.applied.len(), 2);
2937 assert_eq!(a.observation.state, FirewallObservationState::Present);
2938 assert_eq!(
2939 host.asked(),
2940 [
2941 format!("{SOCKETFILTERFW} --add {EXECUTABLE}"),
2942 format!("{SOCKETFILTERFW} --unblockapp {EXECUTABLE}"),
2943 format!("{SOCKETFILTERFW} --getglobalstate"),
2944 format!("{SOCKETFILTERFW} --listapps"),
2945 ]
2946 );
2947 }
2948
2949 #[test]
2950 fn applying_an_empty_plan_on_this_host_is_refused_before_anything_could_run() {
2951 let a = apply(report(None, &[], None));
2954 assert!(!a.ok && a.applied.is_empty());
2955 let refusal = a.refused.as_deref().unwrap();
2956 assert!(refusal.ends_with("nothing to apply"), "{refusal}");
2957 assert_eq!(a.observation, a.before.observation);
2958 assert_eq!(a.before.platform, std::env::consts::OS);
2959 assert_eq!(a.before.backend, detect_here());
2960 }
2961}