homelab/services/device-inventory/src/client/discovery.cpp
Dan V 6e1e4b4134 feat(device-inventory): hardware discovery pipeline natural key deduplication
- protocol.h: add K_LOCATOR, K_SOCKET, K_DEVICE_NAME natural key constants
- models.h: add locator/socket_designation/device_name fields to structs
- database.cpp: apply_*, serialize_*, save_nolock, load all updated;
  upsert_part gains natural key fallback for memory/cpu/disk
- discovery.cpp: emit K_LOCATOR for memory sticks/slots; fix CPU filter
  to skip phantom iLO entries; emit K_SOCKET for cpu/cpu_slot;
  emit K_DEVICE_NAME for disks with serial collision fix; replace NIC
  blocklist with /sys/class/net/<if>/device symlink check + lspci enrichment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 22:37:17 +02:00

1196 lines
49 KiB
C++

#include "client/discovery.h"
#include "common/models.h"
#include "common/protocol.h"
#include <algorithm>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <sstream>
#include <sys/stat.h>
// ═════════════════════════════════════════════════════════════════════════════
// Low-level helpers
// ═════════════════════════════════════════════════════════════════════════════
// Run a command with popen, return trimmed stdout (at most 64 KB).
std::string Discovery::run_cmd(const std::string& cmd) {
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) return "";
std::string result;
char buf[4096];
while (fgets(buf, sizeof(buf), pipe)) {
result += buf;
if (result.size() >= 65536) break;
}
pclose(pipe);
// Trim trailing whitespace
while (!result.empty() && std::isspace((unsigned char)result.back()))
result.pop_back();
return result;
}
// Parse dmidecode -t <type_num> output into a vector of key→value maps.
// Each map corresponds to one DMI block (e.g. one "Memory Device").
std::vector<std::map<std::string, std::string>>
Discovery::parse_dmi(const std::string& type_num) {
#ifdef __APPLE__
(void)type_num;
return {}; // dmidecode not available on macOS; callers check __APPLE__
#else
std::string output = run_cmd("dmidecode -t " + type_num + " 2>/dev/null");
if (output.empty()) return {};
std::vector<std::map<std::string, std::string>> result;
std::map<std::string, std::string> current;
std::istringstream ss(output);
std::string line;
while (std::getline(ss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
// Handle empty — signals end of a block
if (line.empty()) {
if (!current.empty()) {
result.push_back(std::move(current));
current.clear();
}
continue;
}
// "Handle 0x…" starts a new block
if (line.size() >= 6 && line.substr(0, 6) == "Handle") {
if (!current.empty()) {
result.push_back(std::move(current));
current.clear();
}
continue;
}
// Tab-indented: " Key: Value" or just " Tag"
if (line[0] == '\t') {
std::string kv = line.substr(1);
size_t colon = kv.find(": ");
if (colon != std::string::npos) {
std::string k = kv.substr(0, colon);
std::string v = kv.substr(colon + 2);
while (!v.empty() && std::isspace((unsigned char)v.back()))
v.pop_back();
current[k] = v;
} else {
// Line with no colon is the block type header
while (!kv.empty() && std::isspace((unsigned char)kv.back()))
kv.pop_back();
if (!kv.empty()) current["__type__"] = kv;
}
} else if (line[0] != '#') {
// Non-indented, non-comment: block type header
while (!line.empty() && std::isspace((unsigned char)line.back()))
line.pop_back();
if (!line.empty()) current["__type__"] = line;
}
}
if (!current.empty()) result.push_back(std::move(current));
return result;
#endif
}
// ── Numeric / size helpers ────────────────────────────────────────────────────
namespace {
// "16384 MB", "16 GB", "No Module Installed", "" → MB as uint64_t
static uint64_t dmi_size_to_mb(const std::string& s) {
if (s.empty() || s.find("No Module") != std::string::npos) return 0;
size_t i = 0;
while (i < s.size() && (std::isdigit((unsigned char)s[i]) || s[i] == '.')) ++i;
if (i == 0) return 0;
double val = std::stod(s.substr(0, i));
std::string suffix = s.substr(i);
// trim leading whitespace from suffix
while (!suffix.empty() && std::isspace((unsigned char)suffix[0]))
suffix = suffix.substr(1);
if (suffix == "GB" || suffix == "GB" || suffix[0] == 'G') return static_cast<uint64_t>(val * 1024);
if (suffix == "TB" || suffix[0] == 'T') return static_cast<uint64_t>(val * 1024 * 1024);
return static_cast<uint64_t>(val); // assume MB
}
#ifndef __APPLE__
// "500G", "2T", "500M", "128K", raw bytes → GB as uint64_t
static uint64_t size_to_gb(const std::string& s) {
if (s.empty()) return 0;
size_t i = 0;
while (i < s.size() && (std::isdigit((unsigned char)s[i]) || s[i] == '.')) ++i;
if (i == 0) return 0;
double val = std::stod(s.substr(0, i));
std::string suffix = s.substr(i);
while (!suffix.empty() && std::isspace((unsigned char)suffix[0]))
suffix = suffix.substr(1);
char u = suffix.empty() ? '\0' : std::toupper((unsigned char)suffix[0]);
if (u == 'T') return static_cast<uint64_t>(val * 1024.0);
if (u == 'G') return static_cast<uint64_t>(val);
if (u == 'M') return static_cast<uint64_t>(val / 1024.0);
if (u == 'K') return static_cast<uint64_t>(val / (1024.0 * 1024.0));
// Assume raw bytes
return static_cast<uint64_t>(val / 1e9);
}
#endif // !__APPLE__
// Extract integer prefix from a string like "3600 MHz" → 3600
static uint64_t leading_uint(const std::string& s) {
size_t i = 0;
while (i < s.size() && std::isdigit((unsigned char)s[i])) ++i;
if (i == 0) return 0;
return std::stoull(s.substr(0, i));
}
#ifndef __APPLE__
// file-exists helper (Linux dhcp detection)
static bool file_exists(const std::string& path) {
struct stat st;
return stat(path.c_str(), &st) == 0;
}
#endif // !__APPLE__
// ── Minimal JSON helpers for lsblk / ip -j / system_profiler output ──────────
#ifndef __APPLE__
// Extract the value of a JSON string or scalar field by key.
// Returns "" for null/missing.
static std::string json_str(const std::string& json, const std::string& key) {
// Accept both "key":"v" and "key": "v"
for (auto sep : {std::string("\"") + key + "\":\"",
std::string("\"") + key + "\": \""}) {
size_t pos = json.find(sep);
if (pos == std::string::npos) continue;
pos += sep.size();
std::string result;
while (pos < json.size() && json[pos] != '"') {
if (json[pos] == '\\') {
++pos;
if (pos < json.size()) {
char c = json[pos];
if (c == 'n') result += '\n';
else if (c == 't') result += '\t';
else if (c == '\\') result += '\\';
else if (c == '"') result += '"';
else result += c;
}
} else {
result += json[pos];
}
++pos;
}
return result;
}
// Try null / numeric / bool (no quotes)
for (auto sep : {std::string("\"") + key + "\":",
std::string("\"") + key + "\": "}) {
size_t pos = json.find(sep);
if (pos == std::string::npos) continue;
pos += sep.size();
while (pos < json.size() && std::isspace((unsigned char)json[pos])) ++pos;
if (pos >= json.size() || json[pos] == '"') continue; // quoted handled above
if (json[pos] == 'n') return ""; // null
std::string result;
while (pos < json.size() && json[pos] != ',' && json[pos] != '}' &&
json[pos] != ']' && !std::isspace((unsigned char)json[pos]))
result += json[pos++];
return result;
}
return "";
}
// Split a JSON array of objects into individual object strings (outer array only).
static std::vector<std::string> json_array_objects(const std::string& json,
const std::string& array_key) {
std::string search = "\"" + array_key + "\":";
size_t pos = json.find(search);
if (pos == std::string::npos) {
search = "\"" + array_key + "\": ";
pos = json.find(search);
}
if (pos == std::string::npos) return {};
pos = json.find('[', pos);
if (pos == std::string::npos) return {};
++pos; // skip '['
std::vector<std::string> result;
int depth = 0;
size_t obj_start = std::string::npos;
bool in_string = false;
char prev = '\0';
for (size_t i = pos; i < json.size(); ++i) {
char c = json[i];
// Track string boundaries to avoid treating braces inside strings
if (c == '"' && prev != '\\') { in_string = !in_string; }
if (!in_string) {
if (c == '{') {
if (depth == 0) obj_start = i;
++depth;
} else if (c == '}') {
--depth;
if (depth == 0 && obj_start != std::string::npos) {
result.push_back(json.substr(obj_start, i - obj_start + 1));
obj_start = std::string::npos;
}
} else if (c == ']' && depth == 0) {
break;
}
}
prev = c;
}
return result;
}
// Extract all string values from a JSON array field (flat, one level).
static std::vector<std::string> json_array_strings(const std::string& json,
const std::string& key) {
std::string search = "\"" + key + "\":[";
size_t pos = json.find(search);
if (pos == std::string::npos) {
search = "\"" + key + "\": [";
pos = json.find(search);
}
if (pos == std::string::npos) return {};
pos = json.find('[', pos) + 1;
std::vector<std::string> result;
while (pos < json.size()) {
while (pos < json.size() && std::isspace((unsigned char)json[pos])) ++pos;
if (pos >= json.size() || json[pos] == ']') break;
if (json[pos] == '"') {
++pos;
std::string v;
while (pos < json.size() && json[pos] != '"') v += json[pos++];
if (pos < json.size()) ++pos; // skip closing '"'
result.push_back(v);
} else {
++pos; // skip other chars
}
}
return result;
}
#endif // !__APPLE__ (json_array_objects)
// Trim leading/trailing whitespace from a string.
static std::string trim(const std::string& s) {
size_t a = 0, b = s.size();
while (a < b && std::isspace((unsigned char)s[a])) ++a;
while (b > a && std::isspace((unsigned char)s[b-1])) --b;
return s.substr(a, b - a);
}
} // anonymous namespace
// ═════════════════════════════════════════════════════════════════════════════
// Public interface
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_all() {
std::vector<DiscoveredPart> all;
for (auto& v : {discover_memory_sticks(), discover_memory_slots(),
discover_cpus(), discover_cpu_slots(),
discover_disks(), discover_nics()}) {
all.insert(all.end(), v.begin(), v.end());
}
return all;
}
std::vector<DiscoveredPart> Discovery::discover(const std::string& type_name) {
if (type_name == PTYPE_MEMORY_STICK) return discover_memory_sticks();
if (type_name == PTYPE_MEMORY_SLOT) return discover_memory_slots();
if (type_name == PTYPE_CPU) return discover_cpus();
if (type_name == PTYPE_CPU_SLOT) return discover_cpu_slots();
if (type_name == PTYPE_DISK) return discover_disks();
if (type_name == PTYPE_NIC) return discover_nics();
std::cerr << "discover: unknown type '" << type_name << "'\n";
return {};
}
// ═════════════════════════════════════════════════════════════════════════════
// Memory sticks
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_memory_sticks() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
// ── macOS: system_profiler SPMemoryDataType ───────────────────────────────
std::string out = run_cmd("system_profiler SPMemoryDataType 2>/dev/null");
if (out.empty()) {
std::cerr << "warning: system_profiler SPMemoryDataType returned no data\n";
return result;
}
// Each "BANK X/..." section is a slot; skip lines until we see "Size:"
// Parse simple indented key: value text
std::map<std::string, std::string> block;
std::string bank_name;
auto flush_block = [&]() {
if (block.empty()) return;
auto sit = block.find("Size");
// Skip empty/missing slots
if (sit == block.end() || sit->second == "Empty" || sit->second.empty()) {
block.clear(); return;
}
DiscoveredPart p;
p.type_name = PTYPE_MEMORY_STICK;
p.kv[K_SIZE_MB] = std::to_string(dmi_size_to_mb(sit->second));
if (block.count("Speed")) p.kv[K_SPEED_MHZ] = std::to_string(leading_uint(block["Speed"]));
if (block.count("Manufacturer")) p.kv[K_MANUFACTURER] = block["Manufacturer"];
if (block.count("Serial Number") && block["Serial Number"] != "-")
p.kv[K_SERIAL] = block["Serial Number"];
if (block.count("Type"))
p.kv[K_OTHER_INFO] = block["Type"];
// channel_config heuristic from bank name
if (!bank_name.empty()) {
std::string lname = bank_name;
std::transform(lname.begin(), lname.end(), lname.begin(), ::tolower);
if (lname.find("channel") != std::string::npos)
p.kv[K_CHANNEL_CONFIG] = "dual";
}
result.push_back(std::move(p));
block.clear();
};
std::istringstream ss(out);
std::string line;
while (std::getline(ss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
std::string t = trim(line);
if (t.empty()) continue;
// Bank header: e.g. " BANK 0/ChannelA-DIMM0:"
if (t.back() == ':' && t.find('/') != std::string::npos) {
flush_block();
bank_name = t.substr(0, t.size() - 1);
continue;
}
size_t colon = t.find(": ");
if (colon != std::string::npos)
block[t.substr(0, colon)] = t.substr(colon + 2);
}
flush_block();
#else
// ── Linux: dmidecode -t 17 ────────────────────────────────────────────────
auto blocks = parse_dmi("17");
if (blocks.empty()) {
std::cerr << "warning: dmidecode returned no Memory Device data "
"(may need root)\n";
return result;
}
for (auto& b : blocks) {
// Skip empty slots
if (!b.count("Size")) continue;
if (b["Size"] == "No Module Installed" || b["Size"].empty() ||
dmi_size_to_mb(b["Size"]) == 0) continue;
DiscoveredPart p;
p.type_name = PTYPE_MEMORY_STICK;
if (b.count("Serial Number") && b["Serial Number"] != "Not Specified" &&
b["Serial Number"] != "Unknown")
p.kv[K_SERIAL] = b["Serial Number"];
p.kv[K_SIZE_MB] = std::to_string(dmi_size_to_mb(b["Size"]));
if (b.count("Speed"))
p.kv[K_SPEED_MHZ] = std::to_string(leading_uint(b["Speed"]));
if (b.count("Manufacturer") && b["Manufacturer"] != "Not Specified")
p.kv[K_MANUFACTURER] = b["Manufacturer"];
// Channel config heuristic from Bank Locator
if (b.count("Bank Locator")) {
std::string bl = b["Bank Locator"];
std::transform(bl.begin(), bl.end(), bl.begin(), ::tolower);
if (bl.find("channel") != std::string::npos)
p.kv[K_CHANNEL_CONFIG] = "dual";
}
// other_info: Type + Form Factor
std::string other;
if (b.count("Type") && b["Type"] != "Unknown") other += b["Type"];
if (b.count("Form Factor") && b["Form Factor"] != "Unknown") {
if (!other.empty()) other += " ";
other += b["Form Factor"];
}
if (!other.empty()) p.kv[K_OTHER_INFO] = other;
if (b.count("Locator") && !b["Locator"].empty())
p.kv[K_LOCATOR] = b["Locator"];
result.push_back(std::move(p));
}
#endif
return result;
}
// ═════════════════════════════════════════════════════════════════════════════
// Memory slots
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_memory_slots() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
// On macOS, use system_profiler to infer slot count from bank info
std::string out = run_cmd("system_profiler SPMemoryDataType 2>/dev/null");
if (out.empty()) return result;
uint64_t allowed_speed = 0;
int slot_count = 0;
std::istringstream ss(out);
std::string line;
while (std::getline(ss, line)) {
std::string t = trim(line);
if (t.back() == ':' && t.find('/') != std::string::npos) {
++slot_count;
}
if (t.rfind("Speed:", 0) == 0 && allowed_speed == 0) {
allowed_speed = leading_uint(t.substr(7));
}
}
for (int s = 0; s < slot_count; ++s) {
DiscoveredPart p;
p.type_name = PTYPE_MEMORY_SLOT;
if (allowed_speed) p.kv[K_ALLOWED_SPEED] = std::to_string(allowed_speed);
p.kv[K_INSTALLED_STICK] = "NULL";
result.push_back(std::move(p));
}
#else
// ── Linux: dmidecode -t 17 (all slots) + -t 16 for max capacity ───────────
auto blocks17 = parse_dmi("17");
if (blocks17.empty()) {
std::cerr << "warning: dmidecode returned no Memory Device data "
"(may need root)\n";
return result;
}
// Get max allowed size from dmidecode -t 16 (Physical Memory Array)
uint64_t max_size_mb = 0;
{
auto blocks16 = parse_dmi("16");
for (auto& b : blocks16) {
if (b.count("Maximum Capacity")) {
uint64_t mc = dmi_size_to_mb(b["Maximum Capacity"]);
if (mc > max_size_mb) max_size_mb = mc;
}
}
}
for (auto& b : blocks17) {
DiscoveredPart p;
p.type_name = PTYPE_MEMORY_SLOT;
// Allowed speed
uint64_t spd = 0;
if (b.count("Configured Memory Speed"))
spd = leading_uint(b["Configured Memory Speed"]);
if (!spd && b.count("Speed"))
spd = leading_uint(b["Speed"]);
if (spd) p.kv[K_ALLOWED_SPEED] = std::to_string(spd);
// Allowed size (per-slot approximation: total max / slot count)
if (max_size_mb > 0 && !blocks17.empty())
p.kv[K_ALLOWED_SIZE] = std::to_string(
max_size_mb / static_cast<uint64_t>(blocks17.size()));
p.kv[K_INSTALLED_STICK] = "NULL";
if (b.count("Locator") && !b["Locator"].empty())
p.kv[K_LOCATOR] = b["Locator"];
result.push_back(std::move(p));
}
#endif
return result;
}
// ═════════════════════════════════════════════════════════════════════════════
// CPUs
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_cpus() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
std::string brand = run_cmd("sysctl -n machdep.cpu.brand_string 2>/dev/null");
std::string phys = run_cmd("sysctl -n hw.physicalcpu 2>/dev/null");
std::string logi = run_cmd("sysctl -n hw.logicalcpu 2>/dev/null");
std::string freq = run_cmd("sysctl -n hw.cpufrequency 2>/dev/null");
if (brand.empty()) {
std::cerr << "warning: sysctl cpu info unavailable\n";
return result;
}
DiscoveredPart p;
p.type_name = PTYPE_CPU;
p.kv[K_NAME] = brand;
// Try to extract speed from brand string e.g. "@ 2.60GHz"
double speed_ghz = 0.0;
auto at_pos = brand.find(" @ ");
if (at_pos != std::string::npos) {
std::string sp = brand.substr(at_pos + 3);
try { speed_ghz = std::stod(sp); } catch (...) {}
}
if (speed_ghz == 0.0 && !freq.empty()) {
try { speed_ghz = std::stoull(freq) / 1e9; } catch (...) {}
}
if (speed_ghz > 0.0) {
char buf[32];
snprintf(buf, sizeof(buf), "%.2f", speed_ghz);
p.kv[K_SPEED_GHZ] = buf;
}
if (!phys.empty()) p.kv[K_CORES] = phys;
if (!logi.empty()) p.kv[K_THREADS] = logi;
// Manufacturer heuristic
std::string lower_brand = brand;
std::transform(lower_brand.begin(), lower_brand.end(), lower_brand.begin(), ::tolower);
if (lower_brand.find("intel") != std::string::npos) p.kv[K_MANUFACTURER] = "Intel";
else if (lower_brand.find("amd") != std::string::npos) p.kv[K_MANUFACTURER] = "AMD";
else if (lower_brand.find("apple") != std::string::npos) p.kv[K_MANUFACTURER] = "Apple";
result.push_back(std::move(p));
#else
// ── Linux: dmidecode -t 4 ─────────────────────────────────────────────────
auto blocks = parse_dmi("4");
if (blocks.empty()) {
std::cerr << "warning: dmidecode returned no Processor data "
"(may need root)\n";
return result;
}
for (auto& b : blocks) {
// Skip phantom entries (iLO processor, "Other" type, absent Status)
if (!b.count("Status") || b["Status"].find("Populated") == std::string::npos) continue;
if (b.count("Type") && b["Type"].find("Central Processor") == std::string::npos) continue;
DiscoveredPart p;
p.type_name = PTYPE_CPU;
if (b.count("ID") && b["ID"] != "Not Specified")
p.kv[K_SERIAL] = b["ID"];
if (b.count("Version") && b["Version"] != "Not Specified")
p.kv[K_NAME] = b["Version"];
if (b.count("Manufacturer") && b["Manufacturer"] != "Not Specified")
p.kv[K_MANUFACTURER] = b["Manufacturer"];
if (b.count("Current Speed")) {
double mhz = static_cast<double>(leading_uint(b["Current Speed"]));
if (mhz > 0) {
char buf[32];
snprintf(buf, sizeof(buf), "%.3f", mhz / 1000.0);
p.kv[K_SPEED_GHZ] = buf;
}
}
if (b.count("Core Count")) p.kv[K_CORES] = b["Core Count"];
if (b.count("Thread Count")) p.kv[K_THREADS] = b["Thread Count"];
if (b.count("Socket Designation") && b["Socket Designation"] != "Not Specified")
p.kv[K_SOCKET] = b["Socket Designation"];
result.push_back(std::move(p));
}
#endif
return result;
}
// ═════════════════════════════════════════════════════════════════════════════
// CPU slots
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_cpu_slots() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
// On macOS we can only infer 1 slot from sysctl
if (run_cmd("sysctl -n machdep.cpu.brand_string 2>/dev/null").empty())
return result;
DiscoveredPart p;
p.type_name = PTYPE_CPU_SLOT;
p.kv[K_FORM_FACTOR] = "unknown";
p.kv[K_INSTALLED_CPU] = "NULL";
result.push_back(std::move(p));
#else
auto blocks = parse_dmi("4");
if (blocks.empty()) {
std::cerr << "warning: dmidecode returned no Processor data "
"(may need root)\n";
return result;
}
for (auto& b : blocks) {
// Only real CPU sockets: must have a recognizable Status (Populated or Unpopulated)
// and must be of type Central Processor
if (!b.count("Status")) continue;
const std::string& status = b["Status"];
bool is_populated = status.find("Populated") != std::string::npos;
bool is_unpopulated = status.find("Unpopulated") != std::string::npos;
if (!is_populated && !is_unpopulated) continue; // Skip "Other", empty, or phantom entries
if (b.count("Type") && b["Type"].find("Central Processor") == std::string::npos) continue;
DiscoveredPart p;
p.type_name = PTYPE_CPU_SLOT;
if (b.count("Upgrade")) p.kv[K_FORM_FACTOR] = b["Upgrade"];
else p.kv[K_FORM_FACTOR] = "unknown";
p.kv[K_INSTALLED_CPU] = "NULL";
if (b.count("Socket Designation") && b["Socket Designation"] != "Not Specified")
p.kv[K_SOCKET] = b["Socket Designation"];
result.push_back(std::move(p));
}
#endif
return result;
}
// ═════════════════════════════════════════════════════════════════════════════
// Disks
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_disks() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
// ── macOS: diskutil info for each disk ────────────────────────────────────
// Enumerate whole disks via XML plist from diskutil list -plist
std::vector<std::string> all_disks;
{
std::string plist_out = run_cmd("diskutil list -plist 2>/dev/null");
if (!plist_out.empty()) {
// Parse <key>WholeDisks</key><array><string>disk0</string>...</array>
size_t key_pos = plist_out.find("<key>WholeDisks</key>");
if (key_pos != std::string::npos) {
size_t arr_start = plist_out.find("<array>", key_pos);
size_t arr_end = plist_out.find("</array>", key_pos);
if (arr_start != std::string::npos && arr_end != std::string::npos) {
std::string arr_content = plist_out.substr(arr_start, arr_end - arr_start);
size_t p = 0;
while ((p = arr_content.find("<string>", p)) != std::string::npos) {
p += 8;
size_t e = arr_content.find("</string>", p);
if (e == std::string::npos) break;
all_disks.push_back(arr_content.substr(p, e - p));
p = e + 9;
}
}
}
}
if (all_disks.empty()) {
// fall back: text diskutil list output
std::string txt = run_cmd("diskutil list 2>/dev/null");
std::istringstream ss(txt);
std::string line;
while (std::getline(ss, line)) {
std::string t = trim(line);
if (t.rfind("/dev/disk", 0) == 0) {
size_t sp = t.find(' ');
std::string dev = (sp != std::string::npos) ? t.substr(5, sp - 5) : t.substr(5);
if (dev.find('s') == std::string::npos) all_disks.push_back(dev);
}
}
}
if (all_disks.empty()) {
std::cerr << "warning: could not enumerate disks via diskutil\n";
return result;
}
}
for (auto& disk_name : all_disks) {
std::string info = run_cmd("diskutil info -plist /dev/" + disk_name + " 2>/dev/null");
if (info.empty()) continue;
// Parse plist-style XML (very simplified)
// Extract key/value pairs from <key>K</key><string>V</string>
std::map<std::string, std::string> plist;
std::istringstream ss(info);
std::string line;
std::string cur_key;
while (std::getline(ss, line)) {
std::string t = trim(line);
auto tag_val = [&](const std::string& tag) -> std::string {
std::string open = "<" + tag + ">";
std::string close = "</" + tag + ">";
size_t p0 = t.find(open);
if (p0 == std::string::npos) return "";
p0 += open.size();
size_t p1 = t.find(close, p0);
if (p1 == std::string::npos) return "";
return t.substr(p0, p1 - p0);
};
std::string k = tag_val("key");
if (!k.empty()) { cur_key = k; continue; }
if (!cur_key.empty()) {
for (auto& vtag : {"string", "integer", "real"}) {
std::string v = tag_val(vtag);
if (!v.empty()) { plist[cur_key] = v; cur_key = ""; break; }
}
if (t == "<true/>") { plist[cur_key] = "true"; cur_key = ""; }
if (t == "<false/>") { plist[cur_key] = "false"; cur_key = ""; }
}
}
// Skip virtual disks
auto ct = plist.count("VirtualOrPhysical") ? plist["VirtualOrPhysical"] : "";
if (ct == "Virtual") continue;
DiscoveredPart p;
p.type_name = PTYPE_DISK;
if (plist.count("MediaName")) p.kv[K_MODEL] = plist["MediaName"];
if (plist.count("IORegistryEntryName") && !p.kv.count(K_MODEL))
p.kv[K_MODEL] = plist["IORegistryEntryName"];
// Size
if (plist.count("TotalSize"))
p.kv[K_DISK_SIZE] = std::to_string(
static_cast<uint64_t>(std::stoull(plist["TotalSize"]) / 1000000000ULL));
// Type
bool solid = false;
if (plist.count("SolidState")) solid = (plist["SolidState"] == "true");
std::string protocol = plist.count("BusProtocol") ? plist["BusProtocol"] : "";
std::string conn = protocol;
std::string dtype;
std::string lower_proto = protocol;
std::transform(lower_proto.begin(), lower_proto.end(), lower_proto.begin(), ::tolower);
if (lower_proto.find("nvme") != std::string::npos ||
lower_proto.find("pcie") != std::string::npos) {
dtype = "nvme"; conn = "NVMe";
} else if (solid) {
dtype = "ssd";
} else {
dtype = "hdd";
}
if (!dtype.empty()) p.kv[K_DISK_TYPE] = dtype;
if (!conn.empty()) p.kv[K_CONN_TYPE] = conn;
// Speed heuristic
uint64_t conn_speed = 0;
if (lower_proto.find("nvme") != std::string::npos) conn_speed = 3500;
else if (lower_proto.find("sata") != std::string::npos) conn_speed = 600;
else if (lower_proto.find("usb") != std::string::npos) conn_speed = 480;
if (conn_speed) p.kv[K_CONN_SPEED] = std::to_string(conn_speed);
p.kv[K_DISK_SPEED] = "0";
p.kv[K_AGE_YEARS] = "-1";
// Partitions — use diskutil list <disk> text output for partition names
std::string parts_txt = run_cmd("diskutil list /dev/" + disk_name + " 2>/dev/null");
std::vector<std::string> part_ids, part_sizes;
{
std::istringstream ps(parts_txt);
std::string pline;
while (std::getline(ps, pline)) {
std::string pt = trim(pline);
// lines containing "disk0s1" etc.
if (pt.find(disk_name + "s") != std::string::npos) {
// Extract partition dev name (last token-ish)
std::istringstream ts(pt);
std::string tok, last_tok;
while (ts >> tok) last_tok = tok;
if (!last_tok.empty() && last_tok.find(disk_name) != std::string::npos)
part_ids.push_back(last_tok);
}
}
}
if (!part_ids.empty()) {
std::string pid_str, psz_str;
for (size_t ki = 0; ki < part_ids.size(); ++ki) {
if (ki) { pid_str += LS; psz_str += LS; }
pid_str += part_ids[ki];
psz_str += "0";
}
p.kv[K_PARTITION_IDS] = pid_str;
p.kv[K_PARTITION_SIZES] = psz_str;
}
p.kv[K_VM_HOSTNAMES] = "";
p.kv[K_VM_SERVER_IDS] = "";
result.push_back(std::move(p));
}
#else
// ── Linux: lsblk -J ──────────────────────────────────────────────────────
std::string out = run_cmd(
"lsblk -J -o NAME,SIZE,TYPE,ROTA,SERIAL,MODEL,VENDOR,TRAN,"
"LOG-SEC,PHY-SEC,MOUNTPOINTS 2>/dev/null");
if (out.empty()) {
std::cerr << "warning: lsblk returned no data\n";
return result;
}
auto devices = json_array_objects(out, "blockdevices");
for (auto& dev : devices) {
// Only top-level disks
std::string type = json_str(dev, "type");
if (type != "disk") continue;
std::string dev_name = json_str(dev, "name");
DiscoveredPart p;
p.type_name = PTYPE_DISK;
std::string serial = json_str(dev, "serial");
std::string model = trim(json_str(dev, "model"));
std::string vendor = trim(json_str(dev, "vendor"));
std::string rota = json_str(dev, "rota");
std::string tran = json_str(dev, "tran");
std::string size_str = json_str(dev, "size");
if (!serial.empty() && serial != "null") p.kv[K_SERIAL] = serial;
if (!model.empty()) p.kv[K_MODEL] = model;
if (!vendor.empty()) p.kv[K_MANUFACTURER] = vendor;
// disk_type
std::string lower_tran = tran;
std::transform(lower_tran.begin(), lower_tran.end(), lower_tran.begin(), ::tolower);
std::string dtype;
if (lower_tran.find("nvme") != std::string::npos) dtype = "nvme";
else if (rota == "0") dtype = "ssd";
else dtype = "hdd";
p.kv[K_DISK_TYPE] = dtype;
p.kv[K_CONN_TYPE] = tran.empty() ? "unknown" : tran;
// connection speed heuristic
uint64_t conn_speed = 0;
if (lower_tran.find("nvme") != std::string::npos) conn_speed = 3500;
else if (lower_tran.find("sata") != std::string::npos) conn_speed = 600;
else if (lower_tran.find("usb") != std::string::npos) conn_speed = 480;
else if (lower_tran.find("sas") != std::string::npos) conn_speed = 1200;
p.kv[K_CONN_SPEED] = std::to_string(conn_speed);
p.kv[K_DISK_SPEED] = "0";
p.kv[K_DISK_SIZE] = std::to_string(size_to_gb(size_str));
p.kv[K_AGE_YEARS] = "-1";
// Partitions from "children" array
auto children = json_array_objects(dev, "children");
std::vector<std::string> part_ids, part_sizes;
for (auto& child : children) {
if (json_str(child, "type") == "part") {
part_ids.push_back(json_str(child, "name"));
part_sizes.push_back(std::to_string(size_to_gb(json_str(child, "size"))));
}
}
if (!part_ids.empty()) {
std::string pid_str, psz_str;
for (size_t ki = 0; ki < part_ids.size(); ++ki) {
if (ki) { pid_str += LS; psz_str += LS; }
pid_str += part_ids[ki];
psz_str += part_sizes[ki];
}
p.kv[K_PARTITION_IDS] = pid_str;
p.kv[K_PARTITION_SIZES] = psz_str;
}
p.kv[K_VM_HOSTNAMES] = "";
p.kv[K_VM_SERVER_IDS] = "";
// Handle serial collisions: if another disk in result already has the same serial,
// differentiate both by appending the device name to the serial.
if (!dev_name.empty()) {
p.kv[K_DEVICE_NAME] = dev_name;
auto ser_it = p.kv.find(K_SERIAL);
if (ser_it != p.kv.end()) {
for (auto& prev : result) {
auto prev_ser = prev.kv.find(K_SERIAL);
if (prev_ser != prev.kv.end() && prev_ser->second == ser_it->second) {
// Collision: suffix both with their device names
auto prev_dev = prev.kv.find(K_DEVICE_NAME);
std::string prev_name = (prev_dev != prev.kv.end()) ? prev_dev->second : "";
if (!prev_name.empty())
prev_ser->second += ":" + prev_name;
ser_it->second += ":" + dev_name;
break;
}
}
}
}
result.push_back(std::move(p));
}
#endif
return result;
}
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_nics() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
// ── macOS: networksetup + ifconfig ────────────────────────────────────────
// networksetup -listallhardwareports gives:
// Hardware Port: Ethernet
// Device: en0
// Ethernet Address: xx:xx:xx:xx:xx:xx
std::string ns_out = run_cmd("networksetup -listallhardwareports 2>/dev/null");
if (ns_out.empty()) {
std::cerr << "warning: networksetup not available\n";
return result;
}
struct NsPort { std::string port, device, mac; };
std::vector<NsPort> ports;
{
NsPort cur;
std::istringstream ss(ns_out);
std::string line;
while (std::getline(ss, line)) {
std::string t = trim(line);
auto extract = [&](const std::string& prefix) -> std::string {
if (t.rfind(prefix, 0) == 0) return trim(t.substr(prefix.size()));
return "";
};
auto pv = extract("Hardware Port: ");
if (!pv.empty()) { if (!cur.device.empty()) ports.push_back(cur); cur = {pv, "", ""}; continue; }
auto dv = extract("Device: ");
if (!dv.empty()) { cur.device = dv; continue; }
auto mv = extract("Ethernet Address: ");
if (!mv.empty()) { cur.mac = mv; continue; }
}
if (!cur.device.empty()) ports.push_back(cur);
}
for (auto& pt : ports) {
if (pt.device.empty()) continue;
// Skip loopback
if (pt.device == "lo0" || pt.device.rfind("lo", 0) == 0) continue;
DiscoveredPart p;
p.type_name = PTYPE_NIC;
p.kv[K_MAC] = pt.mac;
p.kv[K_MODEL] = pt.port;
// connection type
std::string lower_port = pt.port;
std::transform(lower_port.begin(), lower_port.end(), lower_port.begin(), ::tolower);
if (lower_port.find("wi-fi") != std::string::npos ||
lower_port.find("wifi") != std::string::npos ||
lower_port.find("wlan") != std::string::npos ||
lower_port.find("airport") != std::string::npos)
p.kv[K_CONN_TYPE] = "wifi";
else
p.kv[K_CONN_TYPE] = "ethernet";
// IPs via ifconfig
std::string ic_out = run_cmd("ifconfig " + pt.device + " 2>/dev/null");
std::vector<std::string> ips;
{
std::istringstream ss(ic_out);
std::string line;
while (std::getline(ss, line)) {
std::string t = trim(line);
if (t.rfind("inet ", 0) == 0 || t.rfind("inet6 ", 0) == 0) {
bool v6 = t[4] == '6';
std::string rest = t.substr(v6 ? 6 : 5);
// Extract addr and prefix/netmask
std::istringstream ts(rest);
std::string addr;
ts >> addr;
// Skip link-local for IPv6 unless it's the only address
if (v6 && addr.rfind("fe80", 0) == 0) continue;
std::string prefix_str;
std::string tok;
while (ts >> tok) {
if (tok == "prefixlen" || tok == "netmask") {
ts >> prefix_str;
break;
}
}
if (!prefix_str.empty() && prefix_str.rfind("0x", 0) == 0) {
// Convert hex netmask to prefix length
uint32_t mask = static_cast<uint32_t>(std::stoul(prefix_str, nullptr, 16));
int bits = 0;
while (mask) { bits += (mask & 1); mask >>= 1; }
prefix_str = std::to_string(bits);
}
std::string entry = addr;
if (!prefix_str.empty()) entry += "/" + prefix_str;
ips.push_back(entry);
}
}
}
if (!ips.empty()) {
std::string ips_str;
for (size_t ki = 0; ki < ips.size(); ++ki) {
if (ki) ips_str += LS;
ips_str += ips[ki];
}
p.kv[K_IPS] = ips_str;
}
p.kv[K_CONN_SPEED] = "0";
p.kv[K_AGE_YEARS] = "-1";
p.kv[K_DHCP] = "false";
result.push_back(std::move(p));
}
#else
// ── Linux: ip -j link show + ip -j addr show ──────────────────────────────
std::string link_out = run_cmd("ip -j link show 2>/dev/null");
if (link_out.empty()) {
std::cerr << "warning: ip -j link show failed\n";
return result;
}
auto ifaces = json_array_objects(link_out, ""); // top-level array
// ip -j link show returns a JSON array at the root level, not under a key
// Re-parse: split the root array
{
// json_array_objects looks for a named key; for root arrays we parse directly
ifaces.clear();
int depth = 0;
size_t obj_start = std::string::npos;
bool in_string = false;
char prev = '\0';
for (size_t i = 0; i < link_out.size(); ++i) {
char c = link_out[i];
if (c == '"' && prev != '\\') in_string = !in_string;
if (!in_string) {
if (c == '{') { if (depth == 0) obj_start = i; ++depth; }
else if (c == '}') {
--depth;
if (depth == 0 && obj_start != std::string::npos) {
ifaces.push_back(link_out.substr(obj_start, i - obj_start + 1));
obj_start = std::string::npos;
}
}
}
prev = c;
}
}
for (auto& iface : ifaces) {
std::string ifname = json_str(iface, "ifname");
std::string mac = json_str(iface, "address");
std::string link_type= json_str(iface, "link_type");
std::string flags_str= json_str(iface, "flags"); // may be array
if (ifname.empty()) continue;
// Only include physical NICs: those with a /device symlink pointing to a PCI entry.
// Virtual interfaces (bridges, veth, flannel, fwbr, vmbr, tap, etc.) have no device symlink.
{
std::string dev_path = "/sys/class/net/" + ifname + "/device";
struct stat st;
if (lstat(dev_path.c_str(), &st) != 0 || !S_ISLNK(st.st_mode)) continue;
}
// Enrich with PCI device description via lspci
std::string pci_model;
std::string pci_vendor;
{
std::string link = run_cmd("readlink /sys/class/net/" + ifname + "/device 2>/dev/null");
// link is like "../../../0000:01:00.0" — extract last path component
size_t last_slash = link.rfind('/');
if (last_slash != std::string::npos) link = link.substr(last_slash + 1);
// trim whitespace
while (!link.empty() && std::isspace((unsigned char)link.back())) link.pop_back();
if (!link.empty()) {
std::string lspci_out = run_cmd("lspci -s " + link + " 2>/dev/null");
// Format: "0000:01:00.0 Ethernet controller: Broadcom Corporation NetXtreme II..."
// Find first ": " after the address
size_t colon = lspci_out.find(": ");
if (colon != std::string::npos) {
std::string desc = lspci_out.substr(colon + 2);
while (!desc.empty() && std::isspace((unsigned char)desc.back())) desc.pop_back();
// Try to split "VendorName Description" — look for another ": " for sub-vendor
size_t sub = desc.find(": ");
if (sub != std::string::npos) {
pci_vendor = desc.substr(0, sub);
pci_model = desc.substr(sub + 2);
} else {
pci_model = desc;
}
}
}
}
DiscoveredPart p;
p.type_name = PTYPE_NIC;
p.kv[K_MAC] = mac;
p.kv[K_MODEL] = pci_model.empty() ? link_type : pci_model;
if (!pci_vendor.empty()) p.kv[K_MANUFACTURER] = pci_vendor;
// connection type
std::string lower_ifname = ifname;
std::transform(lower_ifname.begin(), lower_ifname.end(),
lower_ifname.begin(), ::tolower);
std::string conn_type = "ethernet";
if (lower_ifname.find("wl") != std::string::npos ||
lower_ifname.find("wifi") != std::string::npos)
conn_type = "wifi";
p.kv[K_CONN_TYPE] = conn_type;
// IPs
std::string addr_out = run_cmd("ip -j addr show " + ifname + " 2>/dev/null");
std::vector<std::string> ips;
if (!addr_out.empty()) {
// find addr_info array inside the single interface object
std::string search = "\"addr_info\":";
size_t ai_pos = addr_out.find(search);
if (ai_pos != std::string::npos) {
std::string addr_slice = addr_out.substr(ai_pos);
auto addr_objs = json_array_objects(addr_slice, "addr_info");
for (auto& ao : addr_objs) {
std::string local = json_str(ao, "local");
std::string prefixlen = json_str(ao, "prefixlen");
if (local.empty()) continue;
// skip link-local IPv6
std::string ll = local;
std::transform(ll.begin(), ll.end(), ll.begin(), ::tolower);
if (ll.rfind("fe80", 0) == 0) continue;
std::string entry = local;
if (!prefixlen.empty()) entry += "/" + prefixlen;
ips.push_back(entry);
}
}
}
if (!ips.empty()) {
std::string ips_str;
for (size_t ki = 0; ki < ips.size(); ++ki) {
if (ki) ips_str += LS;
ips_str += ips[ki];
}
p.kv[K_IPS] = ips_str;
}
// DHCP detection
bool dhcp = file_exists("/run/dhclient." + ifname + ".pid") ||
file_exists("/run/dhcp-lease-" + ifname) ||
file_exists("/var/lib/dhcp/dhclient." + ifname + ".leases");
p.kv[K_DHCP] = dhcp ? "true" : "false";
// Link speed via sysfs
uint64_t speed = 0;
{
std::string speed_path = "/sys/class/net/" + ifname + "/speed";
FILE* sf = fopen(speed_path.c_str(), "r");
if (sf) {
int raw = 0;
if (fscanf(sf, "%d", &raw) == 1 && raw > 0) speed = static_cast<uint64_t>(raw);
fclose(sf);
}
}
p.kv[K_CONN_SPEED] = std::to_string(speed);
p.kv[K_AGE_YEARS] = "-1";
result.push_back(std::move(p));
}
#endif
return result;
}