nd_300/diagnostics/
arp.rs1use 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#[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 #[serde(skip_serializing_if = "Vec::is_empty")]
27 pub duplicate_ip_macs: Vec<DuplicateIpMacs>,
28 pub assessment: String,
29 pub level: String,
30}
31
32pub 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 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 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 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}