Skip to main content

majordomus_cli/mesh/
firewall.rs

1//! The host firewall, as the mesh needs it: what inbound traffic the declaration implies
2//! this machine must admit, which firewall this host runs, the commands that admit it,
3//! what the firewall says now, and what the kernel logged it dropping.
4//!
5//! The declaration says who may link; the host firewall says what reaches the socket at
6//! all, and the two drift silently. On 2026-10-08 lundra, a declared hub, ran ufw with
7//! default deny and no rule for the hub port or the multicast group: every registration
8//! and every advertisement from the LAN was dropped at the kernel (187 of them in a week
9//! of `[UFW BLOCK]` lines) while `mesh doctor` reported every check holding, because every
10//! check it ran was a fact of the process and none was a fact of the host. This module
11//! makes the admission a derived requirement with a verdict — a `firewall` check of the
12//! doctor — instead of a thing an operator remembers.
13//!
14//! Pure where it can be: [`plan`] is a function of the declaration and this machine's
15//! addresses; [`detect`] of the platform and what is on the path; [`render`] of the two;
16//! and every judgement of a firewall's own output is a function of that text. Only
17//! [`observe`], [`kernel_blocks`] and [`apply`] touch the host, and each says when it
18//! could not.
19//!
20//! What this is not: a firewall manager. It admits the ports the mesh declared, from the
21//! private networks the declaration names, and touches no other rule; and it never opens
22//! anything on a public address: no rule is planned without a private network to admit from
23//! (the multicast group excepted), and the declaration case refuses a public address first.
24//!
25//! ```
26//! use std::net::Ipv4Addr;
27//! use majordomus_cli::mesh::firewall::{plan, Role};
28//! use majordomus_cli::mesh::MeshConfig;
29//!
30//! let config: MeshConfig = serde_json::from_value(serde_json::json!({
31//!     "schema": "mesh/v1", "kind": "mesh-declaration", "id": "doc", "enabled": true,
32//!     "rendezvous": { "endpoints": ["http://192.168.7.10:8791", "http://100.64.1.2:8791"] },
33//! })).unwrap();
34//! // this machine is the hub at 192.168.7.10: it must admit the group and the hub port
35//! let p = plan(Some(&config), &[Ipv4Addr::new(192, 168, 7, 10)], None);
36//! assert_eq!(p.networks, ["100.64.0.0/10", "192.168.7.0/24"]);
37//! assert!(p.rules.iter().any(|r| r.role == Role::Multicast && r.port == 7741));
38//! assert!(p.rules.iter().any(|r| r.role == Role::Hub && r.port == 8791));
39//! // another machine of the same fleet is no hub and admits only the group
40//! let p = plan(Some(&config), &[Ipv4Addr::new(192, 168, 7, 11)], None);
41//! assert!(p.rules.iter().all(|r| r.role == Role::Multicast));
42//! ```
43
44use 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/// The transport protocol of a rule: discovery is datagrams, registration and the link
55/// protocol are connections. Serialised as the word a firewall front takes.
56///
57/// ```
58/// use majordomus_cli::mesh::firewall::Protocol;
59///
60/// assert_eq!(serde_json::to_value(Protocol::Udp).unwrap(), "udp");
61/// assert_eq!(serde_json::to_value(Protocol::Tcp).unwrap(), "tcp");
62/// ```
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
64#[serde(rename_all = "snake_case")]
65pub enum Protocol {
66    /// Datagrams: discovery.
67    Udp,
68    /// Connections: the hub's registrations and the link protocol.
69    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/// Why a rule exists: which part of the declaration implied it. The role is what a person
82/// reads beside the rule and what a verdict names, and it is serialised in snake case.
83///
84/// ```
85/// use majordomus_cli::mesh::firewall::Role;
86///
87/// assert_eq!(serde_json::to_value(Role::Hub).unwrap(), "hub");
88/// let back: Role = serde_json::from_value(serde_json::json!("multicast")).unwrap();
89/// assert_eq!(back, Role::Multicast);
90/// ```
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "snake_case")]
93pub enum Role {
94    /// The declared multicast group: every runtime of the segment advertises to it.
95    Multicast,
96    /// The declared broadcast port, when broadcast is a permitted fallback.
97    Broadcast,
98    /// This machine is a declared rendezvous hub: the fleet registers here.
99    Hub,
100    /// This machine's server is dialed by peers on the link protocol.
101    Link,
102}
103
104/// One inbound admission the mesh needs on this host: a protocol and a port, the group it is
105/// addressed to when it is multicast, the networks it may come from, and the sentence that
106/// says why. A rule is derived, never written by hand; [`FirewallRule::label`] is how a
107/// verdict names it.
108///
109/// ```
110/// use majordomus_cli::mesh::firewall::{FirewallRule, Protocol, Role};
111///
112/// let rule = FirewallRule {
113///     role: Role::Hub,
114///     protocol: Protocol::Tcp,
115///     port: 8791,
116///     destination: None,
117///     sources: vec!["192.168.7.0/24".into()],
118///     reason: "this machine is a declared rendezvous hub".into(),
119/// };
120/// assert_eq!(rule.label(), "tcp/8791 from 192.168.7.0/24");
121/// ```
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123pub struct FirewallRule {
124    /// Why.
125    pub role: Role,
126    /// The transport protocol.
127    pub protocol: Protocol,
128    /// The destination port.
129    pub port: u16,
130    /// The destination address when it is a multicast group; `None` for this host's own.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub destination: Option<String>,
133    /// The networks the traffic may come from, as CIDR; empty admits any source, which is
134    /// only ever the case for the multicast group, whose TTL keeps it on the segment.
135    pub sources: Vec<String>,
136    /// The sentence a person reads beside the rule.
137    pub reason: String,
138}
139
140impl FirewallRule {
141    /// `tcp/8791 from 192.168.7.0/24, 100.64.0.0/10` — one line for a verdict: the
142    /// protocol and port, the group when there is one, and the sources or `any`.
143    ///
144    /// ```
145    /// use majordomus_cli::mesh::firewall::{FirewallRule, Protocol, Role};
146    ///
147    /// let group = FirewallRule {
148    ///     role: Role::Multicast,
149    ///     protocol: Protocol::Udp,
150    ///     port: 7741,
151    ///     destination: Some("239.255.77.77".into()),
152    ///     sources: vec![],
153    ///     reason: String::new(),
154    /// };
155    /// assert_eq!(group.label(), "udp/7741 to 239.255.77.77 from any");
156    /// ```
157    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/// What the declaration implies this host must admit: the rules, the networks they admit
171/// from, the hub ports this machine is listed for, and the addresses the plan saw. The
172/// answer of [`plan`]; an empty `rules` is "nothing is needed", which is the answer for no
173/// declaration and for a disabled one.
174///
175/// ```
176/// use majordomus_cli::mesh::firewall::{plan, FirewallPlan};
177///
178/// let nothing: FirewallPlan = plan(None, &[], None);
179/// assert!(nothing.rules.is_empty() && nothing.hub_ports.is_empty() && nothing.networks.is_empty());
180/// ```
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
182pub struct FirewallPlan {
183    /// This machine's addresses, as the plan saw them.
184    pub local_addresses: Vec<String>,
185    /// The private networks peers come from: the enclosing ranges of every address the
186    /// declaration names, or of this machine's own when it names none.
187    pub networks: Vec<String>,
188    /// The ports of the declared hubs this machine is.
189    pub hub_ports: Vec<u16>,
190    /// The admissions, in a fixed order.
191    pub rules: Vec<FirewallRule>,
192}
193
194/// The private range an address belongs to, as the source network of a rule; `None` for a
195/// public address, which the declaration case refuses and no rule is derived for.
196///
197/// ```
198/// use std::net::Ipv4Addr;
199/// use majordomus_cli::mesh::firewall::enclosing;
200///
201/// assert_eq!(enclosing(Ipv4Addr::new(192, 168, 100, 10)).as_deref(), Some("192.168.100.0/24"));
202/// assert_eq!(enclosing(Ipv4Addr::new(100, 92, 246, 32)).as_deref(), Some("100.64.0.0/10"));
203/// assert_eq!(enclosing(Ipv4Addr::new(10, 3, 2, 1)).as_deref(), Some("10.0.0.0/8"));
204/// assert_eq!(enclosing(Ipv4Addr::new(172, 20, 0, 1)).as_deref(), Some("172.16.0.0/12"));
205/// assert_eq!(enclosing(Ipv4Addr::new(8, 8, 8, 8)), None);
206/// ```
207pub 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
218/// `http://192.168.7.10:8791` → the address and the port; a host name, a scheme the mesh
219/// does not speak, or a missing port is `None`.
220///
221/// ```
222/// use std::net::Ipv4Addr;
223/// use majordomus_cli::mesh::firewall::parse_endpoint;
224///
225/// assert_eq!(parse_endpoint("http://192.168.7.10:8791"), Some((Ipv4Addr::new(192, 168, 7, 10), 8791)));
226/// assert_eq!(parse_endpoint("http://192.168.7.10:8791/"), Some((Ipv4Addr::new(192, 168, 7, 10), 8791)));
227/// assert_eq!(parse_endpoint("192.168.7.10:8791"), Some((Ipv4Addr::new(192, 168, 7, 10), 8791)));
228/// assert_eq!(parse_endpoint("http://hub.example:8791"), None);
229/// assert_eq!(parse_endpoint("http://192.168.7.10"), None);
230/// ```
231pub 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
241/// Derive the admissions from the declaration (or its absence), this machine's addresses
242/// and, when its server listens beyond loopback, the server's port.
243///
244/// No declaration, or a disabled one, needs nothing: the plan is empty and that is the
245/// answer. Otherwise: the multicast group's port when multicast is on; the broadcast
246/// port when broadcast is a permitted fallback, from the networks it names; every declared
247/// hub port whose address is this machine's; and the server's port, from the fleet's
248/// networks, when one is given — a server on loopback is dialed by nobody and needs no
249/// rule.
250///
251/// ```
252/// use std::net::Ipv4Addr;
253/// use majordomus_cli::mesh::firewall::{plan, Role};
254/// use majordomus_cli::mesh::MeshConfig;
255///
256/// let config: MeshConfig = serde_json::from_value(serde_json::json!({
257///     "schema": "mesh/v1", "kind": "mesh-declaration", "id": "doc", "enabled": true,
258///     "cooperation": { "seeds": ["http://10.9.8.7:8741"] },
259/// })).unwrap();
260/// // no hub here, a server beyond loopback on 8741: the group's port and the link port
261/// let p = plan(Some(&config), &[Ipv4Addr::new(10, 9, 8, 1)], Some(8741));
262/// let roles: Vec<Role> = p.rules.iter().map(|r| r.role).collect();
263/// assert_eq!(roles, [Role::Multicast, Role::Link]);
264/// assert_eq!(p.rules[1].sources, ["10.0.0.0/8"]);
265/// ```
266pub 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    // The networks peers come from: what the declaration names, else what this machine is on.
282    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    // A set rather than a Vec and a sort: ordered and free of repeats by construction, and
295    // `order-check` counts every sort site in the crate against a baseline it may not exceed.
296    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        // Only the multicast group is admitted from any source. A rule with no source
323        // renders as "from anywhere", and with no private network to name, that is a port
324        // opened to the internet: a decision for the operator, never one derived here.
325        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    // The TCP ports are admitted from the fleet's private networks and from nowhere else.
349    // When none can be derived — a hub declared on a public address, a host with no private
350    // address — no TCP rule is planned: the ports stay in `hub_ports` for a reader, and
351    // opening one to every source stays the operator's act.
352    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/// The firewall front this host runs, as far as the executable can tell without privilege:
389/// decided by [`detect`] from the platform and the path, and the thing [`render`],
390/// [`observe`] and [`apply`] are specific to.
391///
392/// ```
393/// use majordomus_cli::mesh::firewall::Backend;
394///
395/// assert_eq!(serde_json::to_value(Backend::ApplicationFirewall).unwrap(), "application_firewall");
396/// assert_eq!(Backend::None.as_str(), "none");
397/// ```
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
399#[serde(rename_all = "snake_case")]
400pub enum Backend {
401    /// Uncomplicated Firewall, the Debian and Ubuntu front: `ufw` is on the path.
402    Ufw,
403    /// nftables without ufw in front: `nft` is on the path.
404    Nftables,
405    /// macOS: the application firewall (`socketfilterfw`), which admits executables, not ports.
406    ApplicationFirewall,
407    /// No firewall front this executable knows; nothing is observed and nothing is applied.
408    None,
409}
410
411impl Backend {
412    /// The word a person reads in a report or a doctor line, which is the tool's own name
413    /// where there is one and a description where the tool is a system service.
414    ///
415    /// ```
416    /// use majordomus_cli::mesh::firewall::Backend;
417    ///
418    /// assert_eq!(Backend::Ufw.as_str(), "ufw");
419    /// assert_eq!(Backend::Nftables.as_str(), "nftables");
420    /// assert_eq!(Backend::ApplicationFirewall.as_str(), "macOS application firewall");
421    /// ```
422    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
432/// Which backend a platform with these executables on its path runs. Pure: the platform
433/// and the lookup are given, so the decision is testable on any machine.
434///
435/// ```
436/// use majordomus_cli::mesh::firewall::{detect, Backend};
437///
438/// assert_eq!(detect("linux", &|name| name == "ufw" || name == "nft"), Backend::Ufw);
439/// assert_eq!(detect("linux", &|name| name == "nft"), Backend::Nftables);
440/// assert_eq!(detect("linux", &|_| false), Backend::None);
441/// assert_eq!(detect("macos", &|_| false), Backend::ApplicationFirewall);
442/// assert_eq!(detect("windows", &|_| true), Backend::None);
443/// ```
444pub 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
453/// [`detect`] for this process: its platform, and the path it would run the tool from —
454/// `PATH`, and the sbin directories a service manager's path has and a login shell's often
455/// lacks, because `ufw` and `nft` live there.
456///
457/// ```
458/// use majordomus_cli::mesh::firewall::{detect, detect_here, Backend};
459///
460/// let here = detect_here();
461/// // the same decision `detect` makes for this platform with a real path lookup
462/// let looked_up = detect(std::env::consts::OS, &|name| {
463///     std::env::var_os("PATH").map_or(false, |p| {
464///         std::env::split_paths(&p)
465///             .chain(["/usr/sbin", "/sbin", "/usr/local/sbin"].map(std::path::PathBuf::from))
466///             .any(|d| d.join(name).is_file())
467///     })
468/// });
469/// assert_eq!(here, looked_up);
470/// ```
471pub fn detect_here() -> Backend {
472    detect(std::env::consts::OS, &|name| find_tool(name).is_some())
473}
474
475/// Where the tool is, if anywhere.
476fn 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
488/// The macOS application firewall's tool.
489const SOCKETFILTERFW: &str = "/usr/libexec/ApplicationFirewall/socketfilterfw";
490
491/// One command that admits a rule: the rule's label, its argument vector exactly as
492/// [`apply`] executes it, and the same quoted as one line a person pastes into a root
493/// shell. The two are projections of one vector, so what the report shows is what runs.
494///
495/// ```
496/// use std::net::Ipv4Addr;
497/// use majordomus_cli::mesh::firewall::{plan, render, AdmitCommand, Backend};
498/// use majordomus_cli::mesh::MeshConfig;
499///
500/// let config: MeshConfig = serde_json::from_value(serde_json::json!({
501///     "schema": "mesh/v1", "kind": "mesh-declaration", "id": "doc", "enabled": true,
502/// })).unwrap();
503/// let p = plan(Some(&config), &[Ipv4Addr::new(10, 0, 0, 2)], None);
504/// let commands: Vec<AdmitCommand> = render(Backend::Ufw, &p, None);
505/// assert_eq!(commands[0].argv[0], "ufw");
506/// assert_eq!(commands[0].rule, "udp/7741 to 239.255.77.77 from any");
507/// assert_eq!(commands[0].line, "ufw allow in proto udp to 239.255.77.77 port 7741 comment 'majordomus mesh: multicast discovery'");
508/// ```
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
510pub struct AdmitCommand {
511    /// The rule it admits.
512    pub rule: String,
513    /// The program and its arguments, as executed.
514    pub argv: Vec<String>,
515    /// The same, quoted for a shell.
516    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
544/// The comment every rule this module writes carries, so that the rules it admitted can be
545/// told from an operator's own.
546pub 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
558/// The commands that admit the plan on the backend. ufw and nftables take one rule per
559/// source network; the macOS application firewall admits an executable, so it takes the
560/// executable the server runs as, once, whatever the ports — and nothing, when no
561/// executable is given.
562///
563/// ```
564/// use std::net::Ipv4Addr;
565/// use std::path::Path;
566/// use majordomus_cli::mesh::firewall::{plan, render, Backend};
567/// use majordomus_cli::mesh::MeshConfig;
568///
569/// let config: MeshConfig = serde_json::from_value(serde_json::json!({
570///     "schema": "mesh/v1", "kind": "mesh-declaration", "id": "doc", "enabled": true,
571///     "rendezvous": { "endpoints": ["http://192.168.7.10:8791"] },
572/// })).unwrap();
573/// let p = plan(Some(&config), &[Ipv4Addr::new(192, 168, 7, 10)], None);
574/// let lines: Vec<String> = render(Backend::Ufw, &p, None).into_iter().map(|c| c.line).collect();
575/// assert_eq!(lines, [
576///     "ufw allow in proto udp to 239.255.77.77 port 7741 comment 'majordomus mesh: multicast discovery'",
577///     "ufw allow in proto tcp from 192.168.7.0/24 to any port 8791 comment 'majordomus mesh: rendezvous hub'",
578/// ]);
579/// let mac = render(Backend::ApplicationFirewall, &p, Some(Path::new("/opt/majordomus")));
580/// assert_eq!(mac.len(), 2);
581/// assert!(mac[0].line.ends_with("--add /opt/majordomus"));
582/// assert!(mac[1].line.ends_with("--unblockapp /opt/majordomus"));
583/// assert!(render(Backend::None, &p, None).is_empty());
584/// ```
585pub 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                    // nft parses the joined words in its own grammar, where a comment is a
653                    // quoted string: the quotes are part of the word it is handed. Without
654                    // them it answers "syntax error, unexpected string" and adds nothing
655                    // (run on nftables 1.1.3, 2026-10-10).
656                    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/// What the host's firewall says about the plan, as one word. Only `missing` is a failure
687/// on its own; `unobservable` is an absence that the kernel log may still turn into one.
688///
689/// ```
690/// use majordomus_cli::mesh::firewall::FirewallObservationState;
691///
692/// assert_eq!(serde_json::to_value(FirewallObservationState::Unobservable).unwrap(), "unobservable");
693/// ```
694#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
695#[serde(rename_all = "snake_case")]
696pub enum FirewallObservationState {
697    /// No backend to ask.
698    NoBackend,
699    /// The firewall is off: nothing filters, nothing is needed.
700    Inactive,
701    /// Every rule of the plan is admitted.
702    Present,
703    /// At least one rule of the plan is not admitted.
704    Missing,
705    /// The firewall could not be asked from this process — it needs root.
706    Unobservable,
707}
708
709/// The firewall's own word, judged against the plan: the state, the evidence behind it,
710/// and the labels of the rules not admitted when the state is `missing`.
711///
712/// ```
713/// use majordomus_cli::mesh::firewall::{judge_ufw, plan, FirewallObservation, FirewallObservationState};
714///
715/// let o: FirewallObservation = judge_ufw("Status: inactive\n", &plan(None, &[], None));
716/// assert_eq!(o.state, FirewallObservationState::Inactive);
717/// assert!(o.missing.is_empty() && o.detail.contains("inactive"));
718/// ```
719#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
720pub struct FirewallObservation {
721    /// The verdict.
722    pub state: FirewallObservationState,
723    /// The evidence: the status line, the rules found, or the refusal.
724    pub detail: String,
725    /// The rules not admitted, by label, when `state` is `missing`.
726    #[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
740/// Judge `ufw status` output against the plan. Pure.
741///
742/// A rule is admitted when, for each of its sources (or once, for any source), a line
743/// allows its `port/proto` inbound from that network or from anywhere, to its group when
744/// it has one.
745///
746/// ```
747/// use std::net::Ipv4Addr;
748/// use majordomus_cli::mesh::firewall::{judge_ufw, plan, FirewallObservationState};
749/// use majordomus_cli::mesh::MeshConfig;
750///
751/// let config: MeshConfig = serde_json::from_value(serde_json::json!({
752///     "schema": "mesh/v1", "kind": "mesh-declaration", "id": "doc", "enabled": true,
753///     "rendezvous": { "endpoints": ["http://192.168.7.10:8791", "http://100.64.1.2:8791"] },
754/// })).unwrap();
755/// let p = plan(Some(&config), &[Ipv4Addr::new(192, 168, 7, 10)], None);
756///
757/// let before = "Status: active\n\nTo                         Action      From\n--                         ------      ----\n22/tcp                     ALLOW IN    Anywhere\n";
758/// let o = judge_ufw(before, &p);
759/// assert_eq!(o.state, FirewallObservationState::Missing);
760/// assert_eq!(o.missing.len(), 2);
761///
762/// let after = "Status: active\n\nTo                         Action      From\n--                         ------      ----\n22/tcp                     ALLOW IN    Anywhere\n239.255.77.77 7741/udp     ALLOW IN    Anywhere                   # majordomus mesh: multicast discovery\n8791/tcp                   ALLOW IN    192.168.7.0/24             # majordomus mesh: rendezvous hub\n8791/tcp                   ALLOW IN    100.64.0.0/10              # majordomus mesh: rendezvous hub\n";
763/// assert_eq!(judge_ufw(after, &p).state, FirewallObservationState::Present);
764///
765/// // `ufw status` without `verbose` prints the action as `ALLOW`; it is the same rule
766/// let terse = "Status: active\n239.255.77.77 7741/udp     ALLOW       Anywhere\n8791/tcp                   ALLOW       192.168.7.0/24\n8791/tcp                   ALLOW       100.64.0.0/10\n";
767/// assert_eq!(judge_ufw(terse, &p).state, FirewallObservationState::Present);
768///
769/// // a wider admission counts: anywhere covers every network
770/// let wide = "Status: active\n239.255.77.77 7741/udp     ALLOW IN    Anywhere\n8791/tcp                   ALLOW IN    Anywhere\n";
771/// assert_eq!(judge_ufw(wide, &p).state, FirewallObservationState::Present);
772///
773/// assert_eq!(judge_ufw("Status: inactive\n", &p).state, FirewallObservationState::Inactive);
774/// assert_eq!(judge_ufw("ERROR: You need to be root to run this script\n", &p).state, FirewallObservationState::Unobservable);
775/// ```
776pub 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            // `ufw status` prints `ALLOW`; `ufw status verbose` prints `ALLOW IN`. Both
803            // are the same rule; `ALLOW OUT` and `ALLOW FWD` admit nothing inbound.
804            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
842/// Judge `nft list ruleset` output against the plan: a rule is admitted when a line
843/// accepts its `dport` (to its group, when it has one; from each source, when it has any).
844///
845/// ```
846/// use std::net::Ipv4Addr;
847/// use majordomus_cli::mesh::firewall::{judge_nft, plan, FirewallObservationState};
848/// use majordomus_cli::mesh::MeshConfig;
849///
850/// let config: MeshConfig = serde_json::from_value(serde_json::json!({
851///     "schema": "mesh/v1", "kind": "mesh-declaration", "id": "doc", "enabled": true,
852/// })).unwrap();
853/// let p = plan(Some(&config), &[Ipv4Addr::new(10, 0, 0, 5)], None);
854/// assert_eq!(judge_nft("table inet filter {\n chain input {\n  ip daddr 239.255.77.77 udp dport 7741 accept\n }\n}\n", &p).state, FirewallObservationState::Present);
855/// assert_eq!(judge_nft("table inet filter {\n}\n", &p).state, FirewallObservationState::Missing);
856/// assert_eq!(judge_nft("", &p).state, FirewallObservationState::Inactive);
857/// assert_eq!(judge_nft("Operation not permitted", &p).state, FirewallObservationState::Unobservable);
858/// ```
859pub 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
897/// Judge the macOS application firewall against the executable the server runs as: off is
898/// inactive; on, the executable must be listed as allowed.
899///
900/// ```
901/// use std::path::Path;
902/// use majordomus_cli::mesh::firewall::{judge_application_firewall, FirewallObservationState};
903///
904/// let exe = Path::new("/opt/majordomus");
905/// assert_eq!(judge_application_firewall("Firewall is disabled. (State = 0)", "", exe).state, FirewallObservationState::Inactive);
906/// let apps = "ALF: total number of apps = 1 \n\n1 :  /opt/majordomus \n \t ( Allow incoming connections ) \n";
907/// assert_eq!(judge_application_firewall("Firewall is enabled. (State = 1)", apps, exe).state, FirewallObservationState::Present);
908/// let other = "1 :  /opt/other \n \t ( Allow incoming connections ) \n";
909/// assert_eq!(judge_application_firewall("Firewall is enabled. (State = 1)", other, exe).state, FirewallObservationState::Missing);
910/// let blocked = "1 :  /opt/majordomus \n \t ( Block incoming connections ) \n";
911/// assert_eq!(judge_application_firewall("Firewall is enabled. (State = 1)", blocked, exe).state, FirewallObservationState::Missing);
912/// ```
913pub 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
980/// How this module asks a host tool: the program and its arguments in; everything it
981/// printed, or the reason it could not run, out. [`run`] is the one that reaches the host.
982/// The judgement around the asking takes the asker as an argument, so that every branch of
983/// it is exercised by a test that answers from a table — without a firewall, without root
984/// and on any platform — while the public functions pass [`run`] and nothing else.
985type 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
991/// A command run for its effect: whether it exited 0, and what it printed.
992type Exec<'a> = &'a dyn Fn(&str, &[&str]) -> std::result::Result<(bool, String), String>;
993
994/// [`run`], keeping how the command ended. A question is judged by what the tool printed,
995/// which is why [`run`] drops the status; a command that changes the firewall is judged by
996/// its exit, because each tool words a failure differently (`ERROR` from ufw, `Error:` from
997/// nft) and a word looked for in the output misses the one nobody thought of.
998fn 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
1010/// Ask the host's firewall about the plan. The judgement is pure; this is the asking, and
1011/// with no backend there is nothing to ask.
1012///
1013/// ```
1014/// use majordomus_cli::mesh::firewall::{observe, plan, Backend, FirewallObservationState};
1015///
1016/// let o = observe(Backend::None, &plan(None, &[], None), None);
1017/// assert_eq!(o.state, FirewallObservationState::NoBackend);
1018/// // the application firewall admits an executable, and without one it cannot be judged
1019/// let o = observe(Backend::ApplicationFirewall, &plan(None, &[], None), None);
1020/// assert_eq!(o.state, FirewallObservationState::Unobservable);
1021/// ```
1022pub fn observe(
1023    backend: Backend,
1024    plan: &FirewallPlan,
1025    executable: Option<&Path>,
1026) -> FirewallObservation {
1027    observe_with(backend, plan, executable, &run)
1028}
1029
1030/// [`observe`], asking through `run`.
1031fn 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
1068/// Count, per port of the plan, the `[UFW BLOCK]` lines of a kernel log that name it as
1069/// the destination. Pure.
1070///
1071/// ```
1072/// use majordomus_cli::mesh::firewall::count_blocks;
1073///
1074/// let log = "[UFW BLOCK] IN=eth0 SRC=192.168.7.11 DST=239.255.77.77 PROTO=UDP SPT=7741 DPT=7741 LEN=430\n\
1075///            [UFW BLOCK] IN=eth0 SRC=192.168.7.11 DST=192.168.7.10 PROTO=TCP SPT=57168 DPT=8791 WINDOW=65535\n\
1076///            [UFW BLOCK] IN=eth0 SRC=192.168.7.11 DST=192.168.7.10 PROTO=TCP SPT=57169 DPT=8791 WINDOW=65535\n\
1077///            [UFW BLOCK] IN=eth0 SRC=1.2.3.4 DST=192.168.7.10 PROTO=TCP SPT=1 DPT=22 WINDOW=1\n\
1078///            perf: interrupt took too long\n";
1079/// let counts = count_blocks(log, &[7741, 8791]);
1080/// assert_eq!(counts.get("7741"), Some(&1));
1081/// assert_eq!(counts.get("8791"), Some(&2));
1082/// assert_eq!(counts.get("22"), None);
1083/// ```
1084pub 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
1097/// The window [`kernel_blocks`] reads, in seconds: long enough to hold several
1098/// advertisements (every 15 s by default) and registrations (every 30 s) from any peer
1099/// the firewall drops, short enough that a rule just admitted is seen to work within
1100/// minutes instead of an hour.
1101pub const BLOCK_WINDOW_SECONDS: u64 = 300;
1102
1103/// What the kernel logged the firewall dropping toward the plan's ports in the last
1104/// [`BLOCK_WINDOW_SECONDS`], per port; `None` when the log cannot be read from this
1105/// process (no `journalctl`, or no membership of the groups that may read it), which is
1106/// an absence, not a verdict. An empty plan asks nothing.
1107///
1108/// ```
1109/// use majordomus_cli::mesh::firewall::{kernel_blocks, plan};
1110///
1111/// assert!(kernel_blocks(&plan(None, &[], None)).is_none());
1112/// ```
1113pub fn kernel_blocks(plan: &FirewallPlan) -> Option<BTreeMap<String, u64>> {
1114    kernel_blocks_with(plan, std::env::consts::OS, &run)
1115}
1116
1117/// [`kernel_blocks`] for the platform `os`, reading the log through `run`.
1118fn 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/// The whole report: the plan, the backend, the commands, the firewall's word and the
1140/// kernel's, with one verdict sentence and one `ok`. What `mesh.firewall` answers and
1141/// what the doctor's `firewall` check reads.
1142///
1143/// ```
1144/// use majordomus_cli::mesh::firewall::{report, FirewallReport, BLOCK_WINDOW_SECONDS};
1145///
1146/// let r: FirewallReport = report(None, &[], None);
1147/// assert!(r.ok && r.commands.is_empty());
1148/// assert_eq!(r.window_seconds, BLOCK_WINDOW_SECONDS);
1149/// assert_eq!(r.platform, std::env::consts::OS);
1150/// ```
1151#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1152pub struct FirewallReport {
1153    /// Whether the host admits the mesh as far as it can be observed: nothing missing, and
1154    /// nothing logged as dropped.
1155    pub ok: bool,
1156    /// The platform (`linux`, `macos`, …).
1157    pub platform: String,
1158    /// The firewall front found.
1159    pub backend: Backend,
1160    /// The executable the server runs as, which is what the macOS firewall admits.
1161    #[serde(default, skip_serializing_if = "Option::is_none")]
1162    pub executable: Option<String>,
1163    /// What the declaration implies this host must admit.
1164    pub plan: FirewallPlan,
1165    /// The commands that admit it on this backend; what `apply` runs.
1166    pub commands: Vec<AdmitCommand>,
1167    /// The firewall's own word about the plan.
1168    pub observation: FirewallObservation,
1169    /// The window `blocked_recently` covers, in seconds.
1170    pub window_seconds: u64,
1171    /// Drops the kernel logged toward the plan's ports within the window, per port;
1172    /// absent when the log could not be read.
1173    #[serde(default, skip_serializing_if = "Option::is_none")]
1174    pub blocked_recently: Option<BTreeMap<String, u64>>,
1175    /// The sentence a person reads.
1176    pub verdict: String,
1177}
1178
1179/// Build the report for this host from the declaration, the addresses and the server's
1180/// port, asking the firewall and the kernel log — and asking neither when the plan is
1181/// empty, because nothing is needed.
1182///
1183/// ```
1184/// use majordomus_cli::mesh::firewall::{report, FirewallObservationState};
1185///
1186/// let r = report(None, &[], None);
1187/// assert_eq!(r.observation.state, FirewallObservationState::Present);
1188/// assert!(r.blocked_recently.is_none());
1189/// assert!(r.verdict.contains("needs no inbound admission"));
1190/// ```
1191pub 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
1205/// The host a report is built on: its platform, the firewall front found there, the
1206/// executable the server runs as, and how its tools are asked. [`report`] fills it from
1207/// this process; a test fills it with a platform and answers of its own, which is how the
1208/// verdict of every backend and every observation is held on a machine that has neither.
1209struct Host<'a> {
1210    os: &'a str,
1211    backend: Backend,
1212    executable: Option<PathBuf>,
1213    run: Runner<'a>,
1214}
1215
1216/// [`report`] on the host given.
1217fn 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/// One command [`apply`] ran: the line, whether it exited 0 without printing an error,
1290/// and everything it printed, so a refusal by the tool is read in the report and not in
1291/// a log.
1292///
1293/// ```
1294/// use majordomus_cli::mesh::firewall::Applied;
1295///
1296/// let a: Applied = serde_json::from_value(serde_json::json!({
1297///     "line": "ufw allow in proto udp to 239.255.77.77 port 7741", "ok": true, "output": "Rule added",
1298/// })).unwrap();
1299/// assert!(a.ok && a.output == "Rule added");
1300/// ```
1301#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1302pub struct Applied {
1303    /// The line that ran.
1304    pub line: String,
1305    /// Whether it exited 0.
1306    pub ok: bool,
1307    /// What it printed.
1308    pub output: String,
1309}
1310
1311/// What [`apply`] did: the refusal when nothing ran, every command that ran with its
1312/// outcome, the firewall's word afterwards, and the report the commands came from. `ok`
1313/// is the firewall's verdict after the run, never the commands' exit codes alone.
1314///
1315/// ```
1316/// use majordomus_cli::mesh::firewall::{apply, report, FirewallApplyReport};
1317///
1318/// let a: FirewallApplyReport = apply(report(None, &[], None));
1319/// assert!(!a.ok && a.applied.is_empty());
1320/// assert!(a.refused.as_deref().unwrap().contains("nothing to apply"));
1321/// ```
1322#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1323pub struct FirewallApplyReport {
1324    /// Whether every command ran and the firewall admits the plan afterwards.
1325    pub ok: bool,
1326    /// The refusal, when nothing ran: not root, or no backend.
1327    #[serde(default, skip_serializing_if = "Option::is_none")]
1328    pub refused: Option<String>,
1329    /// The commands, in order, with their exit and output.
1330    pub applied: Vec<Applied>,
1331    /// The firewall's word after the commands ran.
1332    pub observation: FirewallObservation,
1333    /// The report the commands came from.
1334    pub before: FirewallReport,
1335}
1336
1337#[cfg(unix)]
1338fn is_root() -> bool {
1339    // SAFETY: geteuid reads the process's effective user id and cannot fail.
1340    unsafe { libc::geteuid() == 0 }
1341}
1342
1343#[cfg(not(unix))]
1344fn is_root() -> bool {
1345    true
1346}
1347
1348/// Run the report's commands on this host. Refuses — runs nothing — without root and
1349/// without a backend; every command that runs is recorded with its exit and its output,
1350/// and the firewall is asked again afterwards so the answer is its word, not the
1351/// commands'.
1352///
1353/// ```
1354/// use majordomus_cli::mesh::firewall::{apply, report};
1355///
1356/// // an empty plan is refused before any privilege is needed, and nothing runs
1357/// let a = apply(report(None, &[], None));
1358/// assert!(a.refused.is_some() && a.applied.is_empty());
1359/// assert_eq!(a.observation, a.before.observation);
1360/// ```
1361pub fn apply(before: FirewallReport) -> FirewallApplyReport {
1362    apply_with(before, is_root(), &run, &run_to_its_end)
1363}
1364
1365/// [`apply`] with the privilege and the asker given: `root` is whether this process may
1366/// change the firewall, and `run` is what runs each command and asks the firewall after.
1367fn 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        // Nothing filters, so nothing is dropped and no rule is owed; and on nftables there
1394        // is then no `inet filter` table or `input` chain for a rule to be added to.
1395        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        // No private network to admit from, so no TCP rule at all: one with no source would
1520        // render as a port opened to every address, and this plan never derives that.
1521        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        // Broadcast is on, it names no network, and the one declared address is public: there
1533        // is nothing to admit from, and a rule without a source would admit everybody.
1534        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        // `render` is faithful to the rule it is handed: one with no source and no group is
1548        // written "to any". That is why `plan` never derives such a rule for a TCP or a
1549        // broadcast port, and this is the only place one exists.
1550        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        // a rule on another port, or outbound, admits nothing
1651        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    // ------------------------------------------------------------ the fixtures below
1666    //
1667    // Everything from here on is held without a firewall: the fleet is the one of the
1668    // incident this module answers, the firewall's words are texts, and the host is a
1669    // table. No test below runs a firewall tool, and none depends on which one — if any —
1670    // the machine running the suite has.
1671
1672    /// The LAN hub of [`fleet`].
1673    const HUB: Ipv4Addr = Ipv4Addr::new(192, 168, 7, 10);
1674
1675    /// Two hubs, one on the LAN and one on the tailnet, and the multicast group on.
1676    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    /// What the LAN hub of [`fleet`] must admit: the group, and its port from both networks.
1684    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    /// ufw on, with nothing of the mesh admitted: the host of the incident.
1692    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    /// ufw on, after `apply`: every rule of [`hub_plan`] admitted, each with its comment.
1701    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    /// What ufw answers a process that is not root.
1716    const UFW_ROOT: &str = "ERROR: You need to be root to run this script\n";
1717
1718    /// An nftables ruleset that admits every rule of [`hub_plan`].
1719    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    /// A kernel log of the window with nothing of the firewall's in it.
1730    const JOURNAL_QUIET: &str = "perf: interrupt took too long\n";
1731
1732    /// A kernel log of the window with one drop toward each port of [`hub_plan`].
1733    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    /// A host that answers from a table. Each entry is a fragment of a command line and
1753    /// what the tool prints for the command carrying it; a command no entry names is a
1754    /// tool this host does not have, answered the way [`run`] answers for one. Every
1755    /// command is recorded, so a test holds what was asked — and that nothing was.
1756    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        /// The same answer with an exit status: the fake tools fail the way the real ones
1782        /// do, by printing their refusal (`ERROR` from ufw, `Error:` from nft).
1783        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    /// The report of [`fleet`]'s LAN hub on the host a test describes.
1798    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    // ------------------------------------------------------------ the plan and its words
1814
1815    #[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        // a rule without a group does not carry the member at all: an absent destination
1830        // is this host's own address, and `null` would read as a group nobody named
1831        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        // the edges of each range, and the public addresses right beside them
1848        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        // a name is not an address this machine can be compared with, and a firewall
1878        // rule is not derived from a resolver's answer
1879        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        // another machine of the same fleet is no hub: the networks are the fleet's all
1907        // the same, because they are what its peers come from
1908        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        // on loopback nobody dials it, and no port is given: no rule
1926        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    // ------------------------------------------------------------ the backend
1990
1991    #[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    // ------------------------------------------------------------ the commands
2030
2031    #[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            // the line is the vector quoted, word for word: what is shown is what runs
2080            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            // The word nft is handed carries its own quotes: nft parses a comment as a quoted
2098            // string, and answers a bare one with "syntax error, unexpected string".
2099            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        // nft words its refusal "Error:", which the old reading — look for "ERROR" — took for
2106        // success, and printed `ok` beside a rule that was never added.
2107        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        // An empty nft ruleset filters nothing and has no `inet filter input` to add to: the
2141        // commands could only fail, and nothing is owed.
2142        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        // nothing to admit, nothing to run — with an executable or without
2200        let nothing = plan(None, &[HUB], None);
2201        assert!(render(Backend::ApplicationFirewall, &nothing, Some(exe)).is_empty());
2202        assert!(render(Backend::ApplicationFirewall, &p, None).is_empty());
2203    }
2204
2205    // ------------------------------------------------------------ the firewall's own word
2206
2207    #[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        // `ufw status` without `verbose` prints the action as `ALLOW`; it is the same rule
2253        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        // a wider admission counts: anywhere covers every network
2264        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        // outbound and forwarded traffic is not what reaches this host's socket, a denial
2271        // is not an admission, and the group's port to another address is not the group's
2272        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        // one network of two is the rule still missing, named whole
2285        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        // an empty plan is admitted by any active firewall: there is nothing to drop
2297        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        // an accept that names no source admits every one of them
2330        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        // one network of two admitted is the rule still missing
2339        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        // the port to another address is not the group, and a drop is not an accept
2347        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        // listed last with no verdict line after it is not listed as allowed either
2371        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    // ------------------------------------------------------------ the asking
2381
2382    #[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        // the application firewall is two questions, and either failing is the same absence
2413        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        // it admits an executable, and without one there is nothing to ask about
2452        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        // The two answers of the real `observe` that run no tool, on any platform.
2468        let nothing = plan(None, &[], None);
2469        let o = observe(Backend::None, &nothing, 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        // git is the one tool every machine that builds this crate has, and asking it for
2478        // its version changes nothing anywhere.
2479        let version = run("git", &["--version"]).unwrap();
2480        assert!(version.starts_with("git version"), "{version}");
2481        // What a tool says on standard error is part of its answer: a refusal is read
2482        // there, and `apply` records it instead of leaving it in a log.
2483        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    // ------------------------------------------------------------ the kernel's word
2493
2494    #[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        // the port as the last word of the line, with and without trailing space, twice
2501        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(&nothing, "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        // the real one, on this platform: an empty plan is never a question
2532        assert!(kernel_blocks(&nothing).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    // ------------------------------------------------------------ the report
2559
2560    #[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        // the commands are the plan's, rendered for the backend found
2578        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        // admitted on paper and dropped all the same: another rule, or another filter
2601        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        // nftables without ufw in front, asked and judged the same way
2638        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        // without an executable it cannot be judged, and with no log either that is said
2737        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    // ------------------------------------------------------------ apply
2749
2750    /// The report of the incident: ufw on, nothing of the mesh admitted.
2751    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        // a refusal changed nothing, so the firewall's word is the one it had
2797        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        // exactly the rendered vectors, in order, and then the question again
2828        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        // The group's command is refused by the tool; the hub's cannot run at all.
2836        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        // a firewall switched off meanwhile filters nothing, and that holds
2874        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        // and one that cannot be asked afterwards has not been seen to admit anything
2888        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        // The real `apply` over the real, empty report: whatever firewall this machine
2952        // runs and whoever runs the suite — root included — there is no command to run.
2953        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}