Skip to main content

nd_300/diagnostics/
arp.rs

1use serde::Serialize;
2
3#[derive(Debug, Clone, Serialize)]
4pub struct ArpEntry {
5    pub ip: String,
6    pub mac: String,
7    pub interface: String,
8    pub entry_type: String,
9}
10
11// ── ARP gateway health (v3.4.0+) ────────────────────────────────────────────
12
13#[derive(Debug, Clone, Serialize)]
14pub struct DuplicateIpMacs {
15    pub ip: String,
16    pub macs: Vec<String>,
17}
18
19#[derive(Debug, Clone, Serialize)]
20pub struct ArpHealth {
21    pub gateway_in_table: bool,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub gateway_mac: Option<String>,
24    /// IPs that appear with more than one distinct MAC — an ARP-spoofing or
25    /// dual-router indicator.
26    #[serde(skip_serializing_if = "Vec::is_empty")]
27    pub duplicate_ip_macs: Vec<DuplicateIpMacs>,
28    pub assessment: String,
29    pub level: String,
30}
31
32/// Pure post-processing over an already-collected ARP table — zero extra
33/// runtime. `None` when there's nothing to assess.
34pub fn assess_health(
35    arp_table: Option<&[ArpEntry]>,
36    gateway_ip: Option<&str>,
37) -> Option<ArpHealth> {
38    let table = arp_table?;
39    if table.is_empty() {
40        return None;
41    }
42
43    let gateway_entry = gateway_ip.and_then(|gw| table.iter().find(|e| e.ip == gw));
44    let gateway_in_table = gateway_entry.is_some();
45    let gateway_mac = gateway_entry.map(|e| e.mac.clone());
46
47    // Group MACs per IP; flag IPs with more than one distinct MAC. Ignore
48    // broadcast/multicast pseudo-entries.
49    let mut per_ip: std::collections::HashMap<&str, Vec<&str>> = std::collections::HashMap::new();
50    for entry in table {
51        if entry.mac.eq_ignore_ascii_case("ff-ff-ff-ff-ff-ff")
52            || entry.mac.eq_ignore_ascii_case("ff:ff:ff:ff:ff:ff")
53        {
54            continue;
55        }
56        let macs = per_ip.entry(entry.ip.as_str()).or_default();
57        if !macs
58            .iter()
59            .any(|m| m.eq_ignore_ascii_case(entry.mac.as_str()))
60        {
61            macs.push(entry.mac.as_str());
62        }
63    }
64    let mut duplicate_ip_macs: Vec<DuplicateIpMacs> = per_ip
65        .into_iter()
66        .filter(|(_, macs)| macs.len() > 1)
67        .map(|(ip, macs)| DuplicateIpMacs {
68            ip: ip.to_string(),
69            macs: macs.into_iter().map(|m| m.to_string()).collect(),
70        })
71        .collect();
72    duplicate_ip_macs.sort_by(|a, b| a.ip.cmp(&b.ip));
73
74    let (assessment, level) = if !duplicate_ip_macs.is_empty() {
75        (
76            "An IP maps to multiple MAC addresses — possible ARP spoofing or a misconfigured second router",
77            "warn",
78        )
79    } else if gateway_ip.is_some() && !gateway_in_table {
80        (
81            "Gateway is missing from the ARP table after a full diagnostic run — possible L2 problem",
82            "warn",
83        )
84    } else {
85        ("ARP table looks healthy", "ok")
86    };
87
88    Some(ArpHealth {
89        gateway_in_table,
90        gateway_mac,
91        duplicate_ip_macs,
92        assessment: assessment.to_string(),
93        level: level.to_string(),
94    })
95}
96
97pub async fn collect() -> Option<Vec<ArpEntry>> {
98    #[cfg(windows)]
99    {
100        collect_windows().await
101    }
102
103    #[cfg(target_os = "macos")]
104    {
105        collect_macos().await
106    }
107
108    #[cfg(target_os = "linux")]
109    {
110        collect_linux().await
111    }
112}
113
114#[cfg(windows)]
115async fn collect_windows() -> Option<Vec<ArpEntry>> {
116    let mut cmd = tokio::process::Command::new("arp");
117    cmd.args(["-a"]);
118    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
119
120    let text = String::from_utf8_lossy(&output.stdout);
121    let mut entries = Vec::new();
122    let mut current_iface = String::new();
123
124    for line in text.lines() {
125        let line = line.trim();
126        if line.starts_with("Interface:") {
127            current_iface = line
128                .split_whitespace()
129                .nth(1)
130                .unwrap_or("unknown")
131                .to_string();
132        } else if !line.is_empty() && !line.starts_with("Internet") {
133            let parts: Vec<&str> = line.split_whitespace().collect();
134            if parts.len() >= 3 {
135                entries.push(ArpEntry {
136                    ip: parts[0].to_string(),
137                    mac: parts[1].to_string(),
138                    interface: current_iface.clone(),
139                    entry_type: parts[2].to_string(),
140                });
141            }
142        }
143    }
144
145    Some(entries)
146}
147
148#[cfg(target_os = "macos")]
149async fn collect_macos() -> Option<Vec<ArpEntry>> {
150    let mut cmd = tokio::process::Command::new("arp");
151    cmd.args(["-a"]);
152    let output = super::util::run_with_timeout(cmd, super::util::QUICK).await?;
153
154    let text = String::from_utf8_lossy(&output.stdout);
155    let mut entries = Vec::new();
156
157    for line in text.lines() {
158        // Format: ? (192.168.1.1) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]
159        let parts: Vec<&str> = line.split_whitespace().collect();
160        if parts.len() >= 6 && parts[1].starts_with('(') {
161            let ip = parts[1].trim_matches(|c| c == '(' || c == ')').to_string();
162            let mac = parts[3].to_string();
163            let iface = parts.get(5).unwrap_or(&"unknown").to_string();
164
165            entries.push(ArpEntry {
166                ip,
167                mac,
168                interface: iface,
169                entry_type: "dynamic".to_string(),
170            });
171        }
172    }
173
174    Some(entries)
175}
176
177#[cfg(target_os = "linux")]
178async fn collect_linux() -> Option<Vec<ArpEntry>> {
179    // Try /proc/net/arp first
180    if let Ok(content) = tokio::fs::read_to_string("/proc/net/arp").await {
181        let mut entries = Vec::new();
182        for line in content.lines().skip(1) {
183            let parts: Vec<&str> = line.split_whitespace().collect();
184            if parts.len() >= 6 {
185                entries.push(ArpEntry {
186                    ip: parts[0].to_string(),
187                    mac: parts[3].to_string(),
188                    interface: parts[5].to_string(),
189                    entry_type: if parts[2] == "0x2" {
190                        "dynamic".to_string()
191                    } else {
192                        "static".to_string()
193                    },
194                });
195            }
196        }
197        return Some(entries);
198    }
199
200    None
201}
202
203#[cfg(test)]
204mod health_tests {
205    use super::*;
206
207    fn entry(ip: &str, mac: &str) -> ArpEntry {
208        ArpEntry {
209            ip: ip.to_string(),
210            mac: mac.to_string(),
211            interface: "eth0".to_string(),
212            entry_type: "dynamic".to_string(),
213        }
214    }
215
216    #[test]
217    fn healthy_table_with_gateway() {
218        let table = [
219            entry("192.168.1.1", "aa-bb-cc-dd-ee-ff"),
220            entry("192.168.1.50", "11-22-33-44-55-66"),
221        ];
222        let h = assess_health(Some(&table), Some("192.168.1.1")).unwrap();
223        assert!(h.gateway_in_table);
224        assert_eq!(h.gateway_mac.as_deref(), Some("aa-bb-cc-dd-ee-ff"));
225        assert_eq!(h.level, "ok");
226    }
227
228    #[test]
229    fn duplicate_macs_flagged() {
230        let table = [
231            entry("192.168.1.1", "aa-bb-cc-dd-ee-ff"),
232            entry("192.168.1.1", "11-22-33-44-55-66"),
233        ];
234        let h = assess_health(Some(&table), Some("192.168.1.1")).unwrap();
235        assert_eq!(h.duplicate_ip_macs.len(), 1);
236        assert_eq!(h.level, "warn");
237        assert!(h.assessment.contains("spoofing"));
238    }
239
240    #[test]
241    fn missing_gateway_flagged() {
242        let table = [entry("192.168.1.50", "11-22-33-44-55-66")];
243        let h = assess_health(Some(&table), Some("192.168.1.1")).unwrap();
244        assert!(!h.gateway_in_table);
245        assert_eq!(h.level, "warn");
246    }
247
248    #[test]
249    fn broadcast_entries_ignored() {
250        let table = [
251            entry("192.168.1.255", "ff-ff-ff-ff-ff-ff"),
252            entry("192.168.1.255", "FF:FF:FF:FF:FF:FF"),
253        ];
254        let h = assess_health(Some(&table), None).unwrap();
255        assert!(h.duplicate_ip_macs.is_empty());
256    }
257
258    #[test]
259    fn empty_or_missing_table_is_none() {
260        assert!(assess_health(None, Some("192.168.1.1")).is_none());
261        assert!(assess_health(Some(&[]), Some("192.168.1.1")).is_none());
262    }
263}