ArpTableParser.cs 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. using System.Net;
  2. using System.Net.Sockets;
  3. namespace RackPeek.Domain.Discovery;
  4. /// <summary>
  5. /// Reads an ARP table into ip → MAC, from either format the probe can produce:
  6. /// Linux's <c>/proc/net/arp</c> or BSD/macOS <c>arp -an</c> output. MACs are
  7. /// normalised (lowercase, zero-padded octets) because macOS prints <c>1:0:5e:…</c>
  8. /// where Linux prints <c>01:00:5e:…</c> — and the MAC seeds the discovery id, so
  9. /// the same machine must hash the same from every workstation. Pure; never throws.
  10. /// </summary>
  11. public static class ArpTableParser {
  12. public static IReadOnlyDictionary<string, string> Parse(string? text) {
  13. var result = new Dictionary<string, string>(StringComparer.Ordinal);
  14. if (string.IsNullOrWhiteSpace(text))
  15. return result;
  16. foreach (var line in text.Split('\n')) {
  17. (string Ip, string Mac)? entry = ParseLine(line.Trim());
  18. if (entry != null)
  19. result.TryAdd(entry.Value.Ip, entry.Value.Mac);
  20. }
  21. return result;
  22. }
  23. private static (string Ip, string Mac)? ParseLine(string line) {
  24. if (line.Length == 0)
  25. return null;
  26. // BSD/macOS: "? (192.168.1.1) at a4:91:b1:4e:3c:20 on en0 ifscope [ethernet]"
  27. var open = line.IndexOf('(');
  28. var close = line.IndexOf(')');
  29. if (open >= 0 && close > open) {
  30. var ip = line[(open + 1)..close];
  31. var at = line.IndexOf(" at ", close, StringComparison.Ordinal);
  32. if (at < 0 || !IsIpv4(ip))
  33. return null;
  34. var rest = line[(at + 4)..];
  35. var end = rest.IndexOf(' ');
  36. var mac = NormaliseMac(end > 0 ? rest[..end] : rest);
  37. return mac == null ? null : (ip, mac);
  38. }
  39. var columns = line.Split(' ', '\t', StringSplitOptions.RemoveEmptyEntries);
  40. if (columns.Length < 2 || !IsIpv4(columns[0]))
  41. return null;
  42. // Linux /proc/net/arp: "192.168.1.1 0x1 0x2 a4:91:b1:4e:3c:20 * eth0"
  43. if (columns.Length >= 4 && columns[1].StartsWith("0x", StringComparison.Ordinal)) {
  44. // Flags 0x0 marks an entry the kernel gave up resolving.
  45. if (columns[2] == "0x0")
  46. return null;
  47. var linuxMac = NormaliseMac(columns[3]);
  48. return linuxMac == null ? null : (columns[0], linuxMac);
  49. }
  50. // Windows arp -a: "192.168.1.1 a4-91-b1-4e-3c-20 dynamic"
  51. var windowsMac = NormaliseMac(columns[1]);
  52. return windowsMac == null ? null : (columns[0], windowsMac);
  53. }
  54. /// <summary>
  55. /// Lowercase, colon-separated, zero-padded — or null for anything that is not a
  56. /// usable MAC. Accepts Windows' dash separators so the same machine hashes the
  57. /// same from every platform's ARP output.
  58. /// </summary>
  59. public static string? NormaliseMac(string? raw) {
  60. if (string.IsNullOrWhiteSpace(raw))
  61. return null;
  62. var parts = raw.Trim().Split(':', '-');
  63. if (parts.Length != 6)
  64. return null;
  65. var octets = new string[6];
  66. for (var i = 0; i < 6; i++) {
  67. var part = parts[i];
  68. if (part.Length is 0 or > 2 || !part.All(Uri.IsHexDigit))
  69. return null;
  70. octets[i] = part.Length == 1 ? "0" + char.ToLowerInvariant(part[0]) : part.ToLowerInvariant();
  71. }
  72. var mac = string.Join(':', octets);
  73. // All-zero means the neighbour never answered — no identity there.
  74. return mac == "00:00:00:00:00:00" ? null : mac;
  75. }
  76. private static bool IsIpv4(string value) =>
  77. IPAddress.TryParse(value, out IPAddress? ip) && ip.AddressFamily == AddressFamily.InterNetwork;
  78. }