SystemFactsParser.cs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. namespace RackPeek.Domain.Discovery;
  2. /// <summary>
  3. /// Turns a <see cref="RawSystemSnapshot" /> into <see cref="SystemFacts" />.
  4. /// Pure: no IO, no platform checks, so it runs and is tested identically everywhere.
  5. /// </summary>
  6. public static class SystemFactsParser {
  7. /// <summary>Strings that appear in DMI when the host is a guest rather than real hardware.</summary>
  8. private static readonly string[] _virtualMachineMarkers =
  9. [
  10. "qemu", "kvm", "vmware", "virtualbox", "innotek", "xen", "bochs",
  11. "bhyve", "parallels", "hyper-v", "virtual machine", "openstack"
  12. ];
  13. /// <summary>Kernel-managed devices that are not disks anyone wants in an inventory.</summary>
  14. private static readonly string[] _ignoredBlockDevicePrefixes =
  15. ["loop", "ram", "zram", "sr", "dm-", "fd", "md"];
  16. public static SystemFacts Parse(RawSystemSnapshot raw) {
  17. var type = ParseType(raw);
  18. return new SystemFacts {
  19. Hostname = raw.Hostname,
  20. MachineId = ParseMachineId(raw),
  21. Os = ParseOs(raw),
  22. Cores = raw.Cores > 0 ? raw.Cores : 1,
  23. RamGb = ParseRamGb(raw),
  24. Type = type,
  25. Ip = SelectPrimaryIp(raw.Nics),
  26. Macs = SelectMacs(raw.Nics),
  27. // A container sees the host's block devices through /sys/block. They belong
  28. // to the machine underneath it, so reporting them here would attribute
  29. // someone else's disks to this resource.
  30. Drives = type == "container" ? [] : ParseDrives(raw.BlockDevices)
  31. };
  32. }
  33. internal static string? ParseMachineId(RawSystemSnapshot raw) {
  34. var id = Clean(raw.PlatformUuid) ?? Clean(raw.MachineIdFile);
  35. return string.IsNullOrEmpty(id) ? null : id;
  36. }
  37. internal static string ParseOs(RawSystemSnapshot raw) {
  38. var name = Clean(raw.OsName);
  39. if (!string.IsNullOrEmpty(name))
  40. return name;
  41. var pretty = ReadKeyValue(raw.OsReleaseFile, "PRETTY_NAME");
  42. if (!string.IsNullOrEmpty(pretty))
  43. return pretty;
  44. var id = ReadKeyValue(raw.OsReleaseFile, "NAME");
  45. var version = ReadKeyValue(raw.OsReleaseFile, "VERSION");
  46. if (!string.IsNullOrEmpty(id))
  47. return string.IsNullOrEmpty(version) ? id : $"{id} {version}";
  48. return "Unknown";
  49. }
  50. internal static double ParseRamGb(RawSystemSnapshot raw) {
  51. if (raw.MemoryBytes is > 0)
  52. return DiscoveryUnits.BytesToWholeGb(raw.MemoryBytes.Value);
  53. // MemTotal is in kB, and is a little under the physical total because the
  54. // kernel reserves some. Reported as-is rather than rounded up to a DIMM size.
  55. var memTotal = ReadKeyValue(raw.MemInfoFile, "MemTotal", ':');
  56. if (!string.IsNullOrEmpty(memTotal)) {
  57. var digits = new string(memTotal.TakeWhile(char.IsAsciiDigit).ToArray());
  58. if (long.TryParse(digits, out var kb) && kb > 0)
  59. return DiscoveryUnits.BytesToWholeGb(kb * 1024L);
  60. }
  61. return DiscoveryUnits.BytesToWholeGb(raw.FallbackMemoryBytes);
  62. }
  63. internal static string ParseType(RawSystemSnapshot raw) {
  64. if (raw.DockerEnvPresent || ContainsContainerMarker(raw.CgroupFile))
  65. return "container";
  66. if (raw.HypervisorPresent)
  67. return "vm";
  68. var dmi = $"{raw.DmiVendor} {raw.DmiProduct}".ToLowerInvariant();
  69. if (_virtualMachineMarkers.Any(marker => dmi.Contains(marker, StringComparison.Ordinal)))
  70. return "vm";
  71. return "baremetal";
  72. }
  73. internal static string? SelectPrimaryIp(IReadOnlyList<NicFact> nics) {
  74. var usable = nics
  75. .Where(n => n is { IsUp: true, IsLoopback: false } && !string.IsNullOrWhiteSpace(n.Ipv4))
  76. .ToList();
  77. // An interface holding the default route is the address other machines reach
  78. // this host on. Otherwise prefer anything that is not an obvious virtual bridge.
  79. return usable.FirstOrDefault(n => n.HasGateway)?.Ipv4
  80. ?? usable.FirstOrDefault(n => !IsVirtual(n.Name))?.Ipv4
  81. ?? usable.FirstOrDefault()?.Ipv4;
  82. }
  83. /// <summary>
  84. /// The MACs a network scan could see this machine by: real interfaces only — a
  85. /// docker bridge's MAC never crosses the wire, so recording it could only cause
  86. /// a false unification. Normalised by the same code that reads ARP tables, so
  87. /// both sides of the bridge always agree on the spelling.
  88. /// </summary>
  89. internal static List<string> SelectMacs(IReadOnlyList<NicFact> nics) {
  90. return nics
  91. .Where(n => n is { IsUp: true, IsLoopback: false } && !IsVirtual(n.Name))
  92. .Select(n => ArpTableParser.NormaliseMac(n.Mac))
  93. .Where(mac => mac != null)
  94. .Select(mac => mac!)
  95. .Distinct()
  96. .ToList();
  97. }
  98. internal static bool IsVirtual(string name) {
  99. string[] prefixes = ["docker", "br-", "veth", "virbr", "tailscale", "utun", "tun", "tap", "cni", "flannel"];
  100. return prefixes.Any(p => name.StartsWith(p, StringComparison.OrdinalIgnoreCase));
  101. }
  102. internal static List<DriveFact> ParseDrives(IReadOnlyList<BlockDeviceFact> devices) {
  103. return devices
  104. .Where(d => !_ignoredBlockDevicePrefixes.Any(p =>
  105. d.Name.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
  106. .Where(d => d.SizeBytes > 0)
  107. .Select(d => new DriveFact(DriveType(d), (int)DiscoveryUnits.BytesToWholeGb(d.SizeBytes)))
  108. .ToList();
  109. }
  110. private static string DriveType(BlockDeviceFact device) {
  111. if (device.Name.StartsWith("nvme", StringComparison.OrdinalIgnoreCase))
  112. return "nvme";
  113. if (device.Name.StartsWith("mmcblk", StringComparison.OrdinalIgnoreCase))
  114. return "sdcard";
  115. return device.Rotational ? "hdd" : "ssd";
  116. }
  117. private static bool ContainsContainerMarker(string? cgroup) {
  118. if (string.IsNullOrWhiteSpace(cgroup))
  119. return false;
  120. string[] markers = ["docker", "lxc", "kubepods", "containerd", "podman"];
  121. return markers.Any(m => cgroup.Contains(m, StringComparison.OrdinalIgnoreCase));
  122. }
  123. /// <summary>Reads one entry out of a key=value file such as /etc/os-release.</summary>
  124. private static string? ReadKeyValue(string? contents, string key, char separator = '=') {
  125. if (string.IsNullOrWhiteSpace(contents))
  126. return null;
  127. foreach (var rawLine in contents.Split('\n')) {
  128. var line = rawLine.Trim();
  129. var index = line.IndexOf(separator);
  130. if (index <= 0)
  131. continue;
  132. if (!line[..index].Trim().Equals(key, StringComparison.OrdinalIgnoreCase))
  133. continue;
  134. return line[(index + 1)..].Trim().Trim('"').Trim();
  135. }
  136. return null;
  137. }
  138. private static string? Clean(string? value) =>
  139. string.IsNullOrWhiteSpace(value) ? null : value.Trim();
  140. }