using System.Net;
using System.Net.Sockets;
namespace RackPeek.Domain.Discovery;
///
/// Reads an ARP table into ip → MAC, from either format the probe can produce:
/// Linux's /proc/net/arp or BSD/macOS arp -an output. MACs are
/// normalised (lowercase, zero-padded octets) because macOS prints 1:0:5e:…
/// where Linux prints 01:00:5e:… — and the MAC seeds the discovery id, so
/// the same machine must hash the same from every workstation. Pure; never throws.
///
public static class ArpTableParser {
public static IReadOnlyDictionary Parse(string? text) {
var result = new Dictionary(StringComparer.Ordinal);
if (string.IsNullOrWhiteSpace(text))
return result;
foreach (var line in text.Split('\n')) {
(string Ip, string Mac)? entry = ParseLine(line.Trim());
if (entry != null)
result.TryAdd(entry.Value.Ip, entry.Value.Mac);
}
return result;
}
private static (string Ip, string Mac)? ParseLine(string line) {
if (line.Length == 0)
return null;
// BSD/macOS: "? (192.168.1.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]"
var open = line.IndexOf('(');
var close = line.IndexOf(')');
if (open >= 0 && close > open) {
var ip = line[(open + 1)..close];
var at = line.IndexOf(" at ", close, StringComparison.Ordinal);
if (at < 0 || !IsIpv4(ip))
return null;
var rest = line[(at + 4)..];
var end = rest.IndexOf(' ');
var mac = NormaliseMac(end > 0 ? rest[..end] : rest);
return mac == null ? null : (ip, mac);
}
var columns = line.Split(' ', '\t', StringSplitOptions.RemoveEmptyEntries);
if (columns.Length < 2 || !IsIpv4(columns[0]))
return null;
// Linux /proc/net/arp: "192.168.1.1 0x1 0x2 a4:91:b1:4e:3c:20 * eth0"
if (columns.Length >= 4 && columns[1].StartsWith("0x", StringComparison.Ordinal)) {
// Flags 0x0 marks an entry the kernel gave up resolving.
if (columns[2] == "0x0")
return null;
var linuxMac = NormaliseMac(columns[3]);
return linuxMac == null ? null : (columns[0], linuxMac);
}
// Windows arp -a: "192.168.1.1 a4-91-b1-4e-3c-20 dynamic"
var windowsMac = NormaliseMac(columns[1]);
return windowsMac == null ? null : (columns[0], windowsMac);
}
///
/// Lowercase, colon-separated, zero-padded — or null for anything that is not a
/// usable MAC. Accepts Windows' dash separators so the same machine hashes the
/// same from every platform's ARP output.
///
public static string? NormaliseMac(string? raw) {
if (string.IsNullOrWhiteSpace(raw))
return null;
var parts = raw.Trim().Split(':', '-');
if (parts.Length != 6)
return null;
var octets = new string[6];
for (var i = 0; i < 6; i++) {
var part = parts[i];
if (part.Length is 0 or > 2 || !part.All(Uri.IsHexDigit))
return null;
octets[i] = part.Length == 1 ? "0" + char.ToLowerInvariant(part[0]) : part.ToLowerInvariant();
}
var mac = string.Join(':', octets);
// All-zero means the neighbour never answered — no identity there.
return mac == "00:00:00:00:00:00" ? null : mac;
}
private static bool IsIpv4(string value) =>
IPAddress.TryParse(value, out IPAddress? ip) && ip.AddressFamily == AddressFamily.InterNetwork;
}