From e1a482c500fa0966c8047c3c0e11a930fce1796e Mon Sep 17 00:00:00 2001 From: Dan V Date: Tue, 24 Mar 2026 14:58:36 +0100 Subject: [PATCH] feat(device-inventory): add hooks system and DNS updater hook - Add Hook interface (filter + execute contract) in server/hooks/hook.h - Add HookRunner in server/hooks/hook_runner.h: spawns a detached thread per matching hook, with try/catch protection against crashes - Add DnsUpdaterHook in server/hooks/dns_updater_hook.{h,cpp}: triggers on server name changes, logs in to Technitium, deletes the old A record (ignores 404), and adds the new A record Config via env vars: TECHNITIUM_HOST/PORT/USER/PASS/ZONE, DNS_TTL - Add Database::get_nics_for_server() to resolve a server's IPv4 address - Wire HookRunner into InventoryServer; cmd_edit_server now fires hooks with before/after Server snapshots - Update CMakeLists.txt to include dns_updater_hook.cpp - Document env vars in Dockerfile Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- services/device-inventory/CMakeLists.txt | 33 + services/device-inventory/Dockerfile | 25 + services/device-inventory/Dockerfile.cli | 15 + services/device-inventory/README.md | 138 ++ services/device-inventory/build-and-load.sh | 34 + services/device-inventory/instructions.md | 19 + .../device-inventory/src/client/client.cpp | 218 ++++ services/device-inventory/src/client/client.h | 27 + .../device-inventory/src/client/discovery.cpp | 1127 +++++++++++++++++ .../device-inventory/src/client/discovery.h | 41 + services/device-inventory/src/client/main.cpp | 286 +++++ services/device-inventory/src/common/models.h | 131 ++ .../device-inventory/src/common/protocol.h | 166 +++ .../device-inventory/src/server/database.cpp | 915 +++++++++++++ .../device-inventory/src/server/database.h | 98 ++ .../src/server/hooks/dns_updater_hook.cpp | 257 ++++ .../src/server/hooks/dns_updater_hook.h | 79 ++ .../device-inventory/src/server/hooks/hook.h | 25 + .../src/server/hooks/hook_runner.h | 55 + services/device-inventory/src/server/main.cpp | 57 + .../device-inventory/src/server/server.cpp | 278 ++++ services/device-inventory/src/server/server.h | 55 + .../device-inventory/src/test/inv_test.sh | 70 + services/device-inventory/web-ui/Dockerfile | 11 + services/device-inventory/web-ui/main.go | 437 +++++++ 25 files changed, 4597 insertions(+) create mode 100644 services/device-inventory/CMakeLists.txt create mode 100644 services/device-inventory/Dockerfile create mode 100644 services/device-inventory/Dockerfile.cli create mode 100644 services/device-inventory/README.md create mode 100755 services/device-inventory/build-and-load.sh create mode 100644 services/device-inventory/instructions.md create mode 100644 services/device-inventory/src/client/client.cpp create mode 100644 services/device-inventory/src/client/client.h create mode 100644 services/device-inventory/src/client/discovery.cpp create mode 100644 services/device-inventory/src/client/discovery.h create mode 100644 services/device-inventory/src/client/main.cpp create mode 100644 services/device-inventory/src/common/models.h create mode 100644 services/device-inventory/src/common/protocol.h create mode 100644 services/device-inventory/src/server/database.cpp create mode 100644 services/device-inventory/src/server/database.h create mode 100644 services/device-inventory/src/server/hooks/dns_updater_hook.cpp create mode 100644 services/device-inventory/src/server/hooks/dns_updater_hook.h create mode 100644 services/device-inventory/src/server/hooks/hook.h create mode 100644 services/device-inventory/src/server/hooks/hook_runner.h create mode 100644 services/device-inventory/src/server/main.cpp create mode 100644 services/device-inventory/src/server/server.cpp create mode 100644 services/device-inventory/src/server/server.h create mode 100755 services/device-inventory/src/test/inv_test.sh create mode 100644 services/device-inventory/web-ui/Dockerfile create mode 100644 services/device-inventory/web-ui/main.go diff --git a/services/device-inventory/CMakeLists.txt b/services/device-inventory/CMakeLists.txt new file mode 100644 index 0000000..dd4bf9a --- /dev/null +++ b/services/device-inventory/CMakeLists.txt @@ -0,0 +1,33 @@ +cmake_minimum_required(VERSION 3.16) +project(device-inventory VERSION 1.0.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Compiler warnings +if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +# Server executable +add_executable(inventory-server + src/server/main.cpp + src/server/database.cpp + src/server/server.cpp + src/server/hooks/dns_updater_hook.cpp +) +target_include_directories(inventory-server PRIVATE src) +target_link_libraries(inventory-server PRIVATE pthread) + +# Client executable +add_executable(inventory-cli + src/client/main.cpp + src/client/client.cpp + src/client/discovery.cpp +) +target_include_directories(inventory-cli PRIVATE src) + +install(TARGETS inventory-server inventory-cli + RUNTIME DESTINATION bin +) diff --git a/services/device-inventory/Dockerfile b/services/device-inventory/Dockerfile new file mode 100644 index 0000000..d0d1712 --- /dev/null +++ b/services/device-inventory/Dockerfile @@ -0,0 +1,25 @@ +FROM ubuntu:24.04 AS builder +RUN apt-get update && apt-get install -y --no-install-recommends \ + cmake make g++ && \ + rm -rf /var/lib/apt/lists/* +WORKDIR /src +COPY . . +RUN rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ + cmake --build build --parallel + +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + libstdc++6 && \ + rm -rf /var/lib/apt/lists/* && \ + mkdir -p /var/lib/inventory +COPY --from=builder /src/build/inventory-server /usr/local/bin/ +EXPOSE 9876 +VOLUME ["/var/lib/inventory"] +# DNS updater hook env vars (set at runtime – do not bake secrets into the image): +# TECHNITIUM_HOST (default: 192.168.2.193) +# TECHNITIUM_PORT (default: 5380) +# TECHNITIUM_USER (default: admin) +# TECHNITIUM_PASS (required for DNS updates) +# TECHNITIUM_ZONE (default: homelab) +# DNS_TTL (default: 300) +CMD ["inventory-server", "--port", "9876", "--db", "/var/lib/inventory/data.db"] diff --git a/services/device-inventory/Dockerfile.cli b/services/device-inventory/Dockerfile.cli new file mode 100644 index 0000000..6a1e347 --- /dev/null +++ b/services/device-inventory/Dockerfile.cli @@ -0,0 +1,15 @@ +FROM ubuntu:24.04 AS builder +RUN apt-get update && apt-get install -y --no-install-recommends \ + cmake make g++ && \ + rm -rf /var/lib/apt/lists/* +WORKDIR /src +COPY . . +RUN rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ + cmake --build build --parallel + +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + dmidecode iproute2 util-linux pciutils libstdc++6 && \ + rm -rf /var/lib/apt/lists/* +COPY --from=builder /src/build/inventory-cli /usr/local/bin/ +ENTRYPOINT ["inventory-cli"] diff --git a/services/device-inventory/README.md b/services/device-inventory/README.md new file mode 100644 index 0000000..70696de --- /dev/null +++ b/services/device-inventory/README.md @@ -0,0 +1,138 @@ +# device-inventory + +A lightweight hardware inventory tracker built in C++17. +Uses a **client/server** model: the server owns the file-backed database and +exposes a TCP interface on localhost; the CLI client connects to it to run +commands. + +--- + +## Architecture + +``` +┌──────────────┐ TCP (localhost:9876) ┌─────────────────────┐ +│ inventory-cli│ ─────────────────────► │ inventory-server │ +│ (CLI client)│ ◄───────────────────── │ database ► file.db │ +└──────────────┘ └─────────────────────┘ +``` + +* **inventory-server** – loads the inventory file on start, accepts one + command per connection, and flushes changes to disk after every mutation. +* **inventory-cli** – a thin CLI that translates subcommands into protocol + messages, connects to the server, and pretty-prints the response. + +--- + +## Build + +Requires CMake ≥ 3.16 and a C++17-capable compiler. + +```bash +cd device-inventory +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build +``` + +Binaries end up in `build/inventory-server` and `build/inventory-cli`. + +--- + +## Running + +### Start the server + +```bash +./build/inventory-server # uses inventory.db in CWD +./build/inventory-server --port 9876 --db /var/lib/inventory/data.db +``` + +The server reloads the file every time it restarts. On the very first run the +file is created automatically. + +### Use the CLI + +```bash +# Shorthand for the examples below +cli="./build/inventory-cli" +``` + +--- + +## Commands + +### Servers + +```bash +# Add a server +$cli add-server "web01" "192.168.1.10" "Rack A" "Primary web server" + +# List all servers +$cli list-servers + +# Edit a field (fields: name | hostname | location | description) +$cli edit-server 1 hostname "10.0.0.1" +$cli edit-server 1 description "Retired – do not use" +``` + +### Part types + +```bash +# Add a part type +$cli add-part-type "RAM" "Memory modules" +$cli add-part-type "SSD" "Solid-state storage" + +# List all part types +$cli list-part-types + +# Edit a field (fields: name | description) +$cli edit-part-type 1 description "DDR4/DDR5 memory" +``` + +### Parts + +```bash +# Add a part to a server +# add-part +$cli add-part 1 1 "16 GB DDR4" "SN-MEM-001" "DIMM slot A1" +$cli add-part 1 2 "Samsung 870 EVO 1 TB" "SN-SSD-004" "Primary OS drive" + +# List parts of a server +$cli list-parts 1 + +# Edit a field (fields: name | serial | description | server_id | part_type_id) +$cli edit-part 2 serial "SN-SSD-005" +$cli edit-part 2 server_id 3 +``` + +--- + +## Database file format + +Plain text, one record per line – safe to inspect or diff with standard tools. + +``` +# device-inventory database +META||| +S||||| +PT||| +P|||||| +``` + +Pipe characters and backslashes inside field values are escaped with `\|` +and `\\` respectively. + +--- + +## Wire protocol + +Commands are newline-terminated lines; fields are separated by ASCII SOH +(`0x01`). Each connection carries exactly one request/response pair. + +``` +Client → Server: COMMAND\x01arg1\x01arg2\n +Server → Client: OK\n + field1\x01field2\x01...\n ← 0 or more data rows + END\n + -- or -- + ERR \n +``` diff --git a/services/device-inventory/build-and-load.sh b/services/device-inventory/build-and-load.sh new file mode 100755 index 0000000..9ddbecd --- /dev/null +++ b/services/device-inventory/build-and-load.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Build all device-inventory images and load them onto the required k8s nodes. +# Run this from any machine with Docker and SSH access to the nodes. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +NODE2_IP="192.168.2.195" # kube-node-2 (server + web-ui + cli CronJob) +NODE3_IP="192.168.2.196" # kube-node-3 (cli CronJob only) + +echo "=== Building inventory-server:latest ===" +docker build -t inventory-server:latest -f "$SCRIPT_DIR/Dockerfile" "$SCRIPT_DIR" + +echo "=== Building inventory-cli:latest ===" +docker build -t inventory-cli:latest -f "$SCRIPT_DIR/Dockerfile.cli" "$SCRIPT_DIR" + +echo "=== Building inventory-web-ui:latest ===" +docker build -t inventory-web-ui:latest -f "$SCRIPT_DIR/web-ui/Dockerfile" "$SCRIPT_DIR/web-ui" + +echo "=== Loading images onto kube-node-2 ($NODE2_IP) ===" +for img in inventory-server:latest inventory-web-ui:latest inventory-cli:latest; do + echo " → $img" + docker save "$img" | ssh "dan@$NODE2_IP" "sudo ctr --namespace k8s.io images import -" +done + +echo "=== Loading images onto kube-node-3 ($NODE3_IP) ===" +for img in inventory-server:latest inventory-web-ui:latest inventory-cli:latest; do + echo " → $img" + docker save "$img" | ssh "dan@$NODE3_IP" "sudo ctr --namespace k8s.io images import -" +done + +echo "" +echo "=== All images loaded. Apply the manifest with: ===" +echo " kubectl apply -f /home/dan/homelab/deployment/infrastructure/device-inventory.yaml" diff --git a/services/device-inventory/instructions.md b/services/device-inventory/instructions.md new file mode 100644 index 0000000..d2edf23 --- /dev/null +++ b/services/device-inventory/instructions.md @@ -0,0 +1,19 @@ +need the following changes: +models: create a template class for parts. +All parts have a partId, nullable serialnumber, last-updated (unix ts in seconds) +Specialized classes: +Memory stick - speed, size, manufacturer, single/dual/quadruple channel, others +Memory slot - allowed speed, allowed size, installed memory stick +cpu - name, manufacturer, speed, cores, threads +cpu slot - form factor, installed cpu +disk - manufacturer, age (since production if possible), generation, model, connection type, connection speed, disk speed, disk size, type (ssd, hdd, virtual, etc), current partitions sizes, partition ids (like sda1 for /dev/sda), and which vms or servers have access to it, by hostname and id. +network card - manufacturer, model, age, connection type, connection speed, mac address, ip address(es), whether it's manual or dhcp + +The cli should: +1. be able to extract all this information and submit to server. Must also be able to edit any of the fields. Must be able to add/remove parts from servers. +2. be able to add/edit/remove servers and part types (like "memory stick", "cpu", etc) +3. be able to list all servers, part types, and parts of a server. +4. be able to just discover and update one part at a time, or all at once. Like `./inventory-cli discover --part-type memory` -> reports back to server all data about memory sticks, memory slots, etc. + +deployment: +this will be deployed on all servers, and it will need to report back every day. \ No newline at end of file diff --git a/services/device-inventory/src/client/client.cpp b/services/device-inventory/src/client/client.cpp new file mode 100644 index 0000000..ed0fc0d --- /dev/null +++ b/services/device-inventory/src/client/client.cpp @@ -0,0 +1,218 @@ +#include "client/client.h" +#include "common/protocol.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Client::Client(std::string host, uint16_t port) + : host_(std::move(host)), port_(port) {} + +// ── transact ────────────────────────────────────────────────────────────────── +bool Client::transact(const std::string& line, std::string& response) { + struct addrinfo hints{}, *res = nullptr; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + std::string port_str = std::to_string(port_); + if (::getaddrinfo(host_.c_str(), port_str.c_str(), &hints, &res) != 0) { + std::cerr << "error: cannot resolve " << host_ << "\n"; + return false; + } + + int fd = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol); + if (fd < 0) { ::freeaddrinfo(res); perror("socket"); return false; } + if (::connect(fd, res->ai_addr, res->ai_addrlen) < 0) { + ::freeaddrinfo(res); ::close(fd); perror("connect"); return false; + } + ::freeaddrinfo(res); + + std::string data = line + "\n"; + if (::send(fd, data.c_str(), data.size(), 0) < 0) { + perror("send"); ::close(fd); return false; + } + + response.clear(); + char buf[4096]; + while (true) { + ssize_t n = ::recv(fd, buf, sizeof(buf), 0); + if (n <= 0) break; + response.append(buf, static_cast(n)); + } + ::close(fd); + return true; +} + +// ── send ────────────────────────────────────────────────────────────────────── +int Client::send(const std::vector& tokens) { + if (tokens.empty()) { std::cerr << "error: empty command\n"; return 1; } + + std::string line; + for (size_t i = 0; i < tokens.size(); ++i) { + if (i) line += FS; + line += tokens[i]; + } + + std::string response; + if (!transact(line, response)) { + std::cerr << "error: could not reach server at " + << host_ << ":" << port_ << "\n"; + return 1; + } + + std::istringstream ss(response); + std::string status_line; + if (!std::getline(ss, status_line)) { + std::cerr << "error: empty response from server\n"; + return 1; + } + if (!status_line.empty() && status_line.back() == '\r') + status_line.pop_back(); + + const std::string& cmd = tokens[0]; + + if (status_line == RESP_OK) { + std::string data_line; + int row_count = 0; + + static const std::set silent_ok = { + CMD_EDIT_SERVER, CMD_REMOVE_SERVER, + CMD_EDIT_PART_TYPE, CMD_REMOVE_PART_TYPE, + CMD_EDIT_PART, CMD_REMOVE_PART + }; + + while (std::getline(ss, data_line)) { + if (!data_line.empty() && data_line.back() == '\r') + data_line.pop_back(); + if (data_line == RESP_END) break; + auto fields = split(data_line, FS); + print_row(cmd, fields); + ++row_count; + } + + if (silent_ok.count(cmd)) { + std::cout << "Done.\n"; + } else if (row_count == 0 && + (cmd == CMD_LIST_SERVERS || + cmd == CMD_LIST_PARTS || + cmd == CMD_LIST_PART_TYPES)) { + std::cout << "(none)\n"; + } + return 0; + + } else if (status_line.size() >= 3 && + status_line.substr(0, 3) == RESP_ERR) { + std::string msg = status_line.size() > 4 ? status_line.substr(4) : ""; + std::cerr << "server error: " << msg << "\n"; + return 1; + } + + std::cerr << "error: unexpected response: " << status_line << "\n"; + return 1; +} + +// ── helpers ─────────────────────────────────────────────────────────────────── +namespace { + +static const std::set kListFields = { + K_IPS, K_PARTITION_IDS, K_PARTITION_SIZES, K_VM_HOSTNAMES, K_VM_SERVER_IDS +}; + +// Convert unix-seconds string to "YYYY-MM-DD HH:MM UTC" +static std::string fmt_time(const std::string& unix_s) { + if (unix_s.empty() || unix_s == "0") return "unknown"; + time_t t = static_cast(std::stoll(unix_s)); + struct tm* tm = gmtime(&t); + if (!tm) return unix_s; + char buf[32]; + strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M UTC", tm); + return buf; +} + +// Join an LS-encoded field as "a, b, c" +static std::string fmt_list(const std::string& val) { + auto parts = split(val, LS); + std::string out; + for (size_t i = 0; i < parts.size(); ++i) { + if (i) out += ", "; + out += parts[i]; + } + return out; +} + +} // anonymous namespace + +// ── print_row ───────────────────────────────────────────────────────────────── +void print_row(const std::string& cmd, const std::vector& f) { + + // ── Servers ─────────────────────────────────────────────────────────────── + if (cmd == CMD_LIST_SERVERS || cmd == CMD_ADD_SERVER) { + // fields: id | name | hostname | location | description + if (f.size() < 5) { + for (auto& x : f) std::cout << x << " "; + std::cout << "\n"; + return; + } + std::cout << "[" << f[0] << "] " << f[1] + << " (" << f[2] << ")" + << " loc: " << f[3] + << "\n " << f[4] << "\n"; + return; + } + + // ── Part types ──────────────────────────────────────────────────────────── + if (cmd == CMD_LIST_PART_TYPES || cmd == CMD_ADD_PART_TYPE) { + // fields: id | name | description + if (f.size() < 3) { + for (auto& x : f) std::cout << x << " "; + std::cout << "\n"; + return; + } + std::cout << "[" << f[0] << "] " << f[1] + << " \u2014 " << f[2] << "\n"; + return; + } + + // ── Parts ───────────────────────────────────────────────────────────────── + // Row: type | part_id | server_id | serial | last_updated | k1 | v1 | k2 | v2 ... + if (cmd == CMD_LIST_PARTS || cmd == CMD_ADD_PART || + cmd == CMD_UPSERT_PART || cmd == CMD_GET_PART) { + if (f.size() < 5) { + for (auto& x : f) std::cout << x << " "; + std::cout << "\n"; + return; + } + const std::string& part_type = f[0]; + const std::string& part_id = f[1]; + const std::string& server_id = f[2]; + const std::string& serial = f[3]; + const std::string& last_updated = f[4]; + + std::cout << "[" << part_id << "] " << part_type + << " server:#" << server_id + << " serial: " << (serial.empty() || serial == "NULL" ? "(none)" : serial) + << " updated: " << fmt_time(last_updated) << "\n"; + + for (size_t i = 5; i + 1 < f.size(); i += 2) { + const std::string& key = f[i]; + const std::string& val = f[i + 1]; + if (kListFields.count(key)) { + std::cout << " " << key << ": " << fmt_list(val) << "\n"; + } else { + std::cout << " " << key << ": " << val << "\n"; + } + } + return; + } + + // ── Fallback ────────────────────────────────────────────────────────────── + for (size_t i = 0; i < f.size(); ++i) { + if (i) std::cout << " "; + std::cout << f[i]; + } + std::cout << "\n"; +} diff --git a/services/device-inventory/src/client/client.h b/services/device-inventory/src/client/client.h new file mode 100644 index 0000000..96f4fed --- /dev/null +++ b/services/device-inventory/src/client/client.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include + +// ── Client ──────────────────────────────────────────────────────────────────── +// Connects to the inventory server, sends one command, pretty-prints the response. + +class Client { +public: + Client(std::string host, uint16_t port); + + // Join tokens with FS, send to server, parse and print the response. + // Returns 0 on success, 1 on error. + int send(const std::vector& tokens); + + // Raw round-trip: send line, receive full response string. + // Returns false on network error. + bool transact(const std::string& line, std::string& response); + +private: + std::string host_; + uint16_t port_; +}; + +// Pretty-print one data row from the server, keyed by originating command. +void print_row(const std::string& cmd, const std::vector& fields); diff --git a/services/device-inventory/src/client/discovery.cpp b/services/device-inventory/src/client/discovery.cpp new file mode 100644 index 0000000..f083334 --- /dev/null +++ b/services/device-inventory/src/client/discovery.cpp @@ -0,0 +1,1127 @@ +#include "client/discovery.h" +#include "common/models.h" +#include "common/protocol.h" +#include +#include +#include +#include +#include +#include +#include + +// ═════════════════════════════════════════════════════════════════════════════ +// 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 output into a vector of key→value maps. +// Each map corresponds to one DMI block (e.g. one "Memory Device"). +std::vector> +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> result; + std::map 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(val * 1024); + if (suffix == "TB" || suffix[0] == 'T') return static_cast(val * 1024 * 1024); + return static_cast(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(val * 1024.0); + if (u == 'G') return static_cast(val); + if (u == 'M') return static_cast(val / 1024.0); + if (u == 'K') return static_cast(val / (1024.0 * 1024.0)); + // Assume raw bytes + return static_cast(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 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 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 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 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 Discovery::discover_all() { + std::vector 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 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 Discovery::discover_memory_sticks() { + std::vector 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 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; + + result.push_back(std::move(p)); + } +#endif + + return result; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Memory slots +// ═════════════════════════════════════════════════════════════════════════════ + +std::vector Discovery::discover_memory_slots() { + std::vector 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(blocks17.size())); + + p.kv[K_INSTALLED_STICK] = "NULL"; + result.push_back(std::move(p)); + } +#endif + + return result; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// CPUs +// ═════════════════════════════════════════════════════════════════════════════ + +std::vector Discovery::discover_cpus() { + std::vector 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 unpopulated sockets + if (b.count("Status") && b["Status"].find("Unpopulated") != 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(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"]; + + result.push_back(std::move(p)); + } +#endif + + return result; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// CPU slots +// ═════════════════════════════════════════════════════════════════════════════ + +std::vector Discovery::discover_cpu_slots() { + std::vector 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) { + 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"; + result.push_back(std::move(p)); + } +#endif + + return result; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Disks +// ═════════════════════════════════════════════════════════════════════════════ + +std::vector Discovery::discover_disks() { + std::vector result; + +#ifdef __APPLE__ + // ── macOS: diskutil info for each disk ──────────────────────────────────── + // Enumerate whole disks via XML plist from diskutil list -plist + std::vector all_disks; + { + std::string plist_out = run_cmd("diskutil list -plist 2>/dev/null"); + if (!plist_out.empty()) { + // Parse WholeDisksdisk0... + size_t key_pos = plist_out.find("WholeDisks"); + if (key_pos != std::string::npos) { + size_t arr_start = plist_out.find("", key_pos); + size_t arr_end = plist_out.find("", 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("", p)) != std::string::npos) { + p += 8; + size_t e = arr_content.find("", 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 KV + std::map 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 = ""; + 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 == "") { plist[cur_key] = "true"; cur_key = ""; } + if (t == "") { 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(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 text output for partition names + std::string parts_txt = run_cmd("diskutil list /dev/" + disk_name + " 2>/dev/null"); + std::vector 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; + + 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 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] = ""; + + result.push_back(std::move(p)); + } +#endif + + return result; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// NICs +// ═════════════════════════════════════════════════════════════════════════════ + +std::vector Discovery::discover_nics() { + std::vector 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 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 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(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; + // Skip loopback and virtual interfaces + if (ifname == "lo") continue; + if (ifname.rfind("docker", 0) == 0 || + ifname.rfind("veth", 0) == 0 || + ifname.rfind("virbr", 0) == 0 || + ifname.rfind("br-", 0) == 0 || + ifname.rfind("tun", 0) == 0 || + ifname.rfind("tap", 0) == 0) continue; + + DiscoveredPart p; + p.type_name = PTYPE_NIC; + p.kv[K_MAC] = mac; + p.kv[K_MODEL] = link_type; + + // 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 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(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; +} diff --git a/services/device-inventory/src/client/discovery.h b/services/device-inventory/src/client/discovery.h new file mode 100644 index 0000000..0b98a13 --- /dev/null +++ b/services/device-inventory/src/client/discovery.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include + +// ── DiscoveredPart ──────────────────────────────────────────────────────────── +// One hardware component detected on the local machine, ready to send to the +// inventory server via CMD_UPSERT_PART. + +struct DiscoveredPart { + std::string type_name; // PTYPE_* constant, e.g. "memory_stick" + std::map kv; // field-key → value (list fields use LS) +}; + +// ── Discovery ───────────────────────────────────────────────────────────────── +// Queries OS tools (dmidecode, lsblk, ip, sysctl, …) to detect hardware. +// All methods are non-throwing: failures produce empty results + stderr warnings. + +class Discovery { +public: + // Detect all supported part types. + std::vector discover_all(); + + // Detect parts of a single type (pass a PTYPE_* constant). + std::vector discover(const std::string& type_name); + +private: + std::vector discover_memory_sticks(); + std::vector discover_memory_slots(); + std::vector discover_cpus(); + std::vector discover_cpu_slots(); + std::vector discover_disks(); + std::vector discover_nics(); + + // Run a shell command, return trimmed stdout (≤64 KB). "" on failure. + static std::string run_cmd(const std::string& cmd); + + // Parse dmidecode -t output into a vector of field-map blocks. + static std::vector> + parse_dmi(const std::string& type_num); +}; diff --git a/services/device-inventory/src/client/main.cpp b/services/device-inventory/src/client/main.cpp new file mode 100644 index 0000000..3bffa82 --- /dev/null +++ b/services/device-inventory/src/client/main.cpp @@ -0,0 +1,286 @@ +#include "client/client.h" +#include "client/discovery.h" +#include "common/models.h" +#include "common/protocol.h" +#include +#include +#include +#include +#include +#include + +// ── usage ───────────────────────────────────────────────────────────────────── +static void usage() { + std::cout << +R"(Usage: inventory-cli [--host HOST] [--port PORT] [args...] + +Global options: + --host HOST Server hostname (default: 127.0.0.1) + --port PORT Server port (default: 9876) + +Server management: + add-server + list-servers + edit-server + fields: name | hostname | location | description + remove-server + +Part type management: + add-part-type + list-part-types + edit-part-type + fields: name | description + remove-part-type + +Part management (manual): + add-part [key=value ...] + upsert-part [key=value ...] + edit-part [key=value ...] + remove-part + list-parts [--type ] + get-part + + Types: memory_stick | memory_slot | cpu | cpu_slot | disk | nic + Keys: any K_* field from the protocol (e.g. serial, name, manufacturer, ...) + Example: add-part cpu 1 name="Intel Xeon" cores=8 threads=16 speed_ghz=3.2 + +Discovery: + discover [--type ] [--dry-run] + Auto-detect hardware on this machine and register with server. + --type Only discover one part type (memory_stick|memory_slot|cpu|cpu_slot|disk|nic) + --dry-run Print discovered parts without sending to server +)"; +} + +// ── parse key=value args into token pairs ───────────────────────────────────── +// Returns false and prints an error if any arg is malformed. +static bool parse_kv_args(int argc, char* argv[], int start, + std::vector& tokens) { + // Use ordered map to preserve insertion order (use vector of pairs) + std::vector> kv_seen; + std::map kv_index; // key → index in kv_seen + + for (int j = start; j < argc; ++j) { + std::string arg = argv[j]; + size_t eq = arg.find('='); + if (eq == std::string::npos) { + std::cerr << "error: expected key=value, got: " << arg << "\n"; + return false; + } + std::string k = arg.substr(0, eq); + std::string v = arg.substr(eq + 1); + + auto it = kv_index.find(k); + if (it != kv_index.end()) { + // Append to existing value with LS for list fields + auto& existing = kv_seen[it->second].second; + existing += LS; + existing += v; + } else { + kv_index[k] = kv_seen.size(); + kv_seen.emplace_back(k, v); + } + } + + for (auto& [k, v] : kv_seen) { + tokens.push_back(k); + tokens.push_back(v); + } + return true; +} + +// ── discover subcommand ─────────────────────────────────────────────────────── +static int cmd_discover(Client& client, const std::string& server_id, + const std::string& filter_type, bool dry_run) { + Discovery disc; + std::vector parts; + + if (filter_type.empty()) { + parts = disc.discover_all(); + } else { + parts = disc.discover(filter_type); + } + + int submitted = 0; + + for (auto& p : parts) { + if (dry_run) { + std::cout << "[dry-run] type: " << p.type_name; + auto sit = p.kv.find(K_SERIAL); + if (sit != p.kv.end() && !sit->second.empty() && sit->second != "NULL") + std::cout << " serial: " << sit->second; + std::cout << "\n"; + for (auto& [k, v] : p.kv) { + std::cout << " " << k << ": " << v << "\n"; + } + } else { + std::vector tokens; + tokens.push_back(CMD_UPSERT_PART); + tokens.push_back(p.type_name); + tokens.push_back(server_id); + for (auto& [k, v] : p.kv) { + tokens.push_back(k); + tokens.push_back(v); + } + int rc = client.send(tokens); + if (rc == 0) ++submitted; + } + } + + std::cout << "Discovered " << parts.size() << " parts"; + if (!dry_run) + std::cout << ", submitted " << submitted << " to server"; + std::cout << "\n"; + + return 0; +} + +// ── main ────────────────────────────────────────────────────────────────────── +int main(int argc, char* argv[]) { + std::string host = "127.0.0.1"; + uint16_t port = DEFAULT_PORT; + + int i = 1; + for (; i < argc; ++i) { + std::string arg = argv[i]; + if ((arg == "--host" || arg == "-H") && i + 1 < argc) { + host = argv[++i]; + } else if ((arg == "--port" || arg == "-p") && i + 1 < argc) { + port = static_cast(std::stoul(argv[++i])); + } else if (arg == "--help" || arg == "-h") { + usage(); return 0; + } else { + break; + } + } + + if (i >= argc) { usage(); return 1; } + + std::string sub = argv[i++]; + + // ── Server management commands ──────────────────────────────────────────── + if (sub == "add-server") { + if (i + 3 >= argc) { + std::cerr << "usage: add-server \n"; + return 1; + } + std::vector tokens = { + CMD_ADD_SERVER, argv[i], argv[i+1], argv[i+2], argv[i+3] + }; + return Client(host, port).send(tokens); + } + + if (sub == "list-servers") { + return Client(host, port).send({CMD_LIST_SERVERS}); + } + + if (sub == "edit-server") { + if (i + 2 >= argc) { + std::cerr << "usage: edit-server \n"; return 1; + } + return Client(host, port).send({CMD_EDIT_SERVER, argv[i], argv[i+1], argv[i+2]}); + } + + if (sub == "remove-server") { + if (i >= argc) { std::cerr << "usage: remove-server \n"; return 1; } + return Client(host, port).send({CMD_REMOVE_SERVER, argv[i]}); + } + + // ── Part type commands ──────────────────────────────────────────────────── + if (sub == "add-part-type") { + if (i + 1 >= argc) { + std::cerr << "usage: add-part-type \n"; return 1; + } + return Client(host, port).send({CMD_ADD_PART_TYPE, argv[i], argv[i+1]}); + } + + if (sub == "list-part-types") { + return Client(host, port).send({CMD_LIST_PART_TYPES}); + } + + if (sub == "edit-part-type") { + if (i + 2 >= argc) { + std::cerr << "usage: edit-part-type \n"; return 1; + } + return Client(host, port).send({CMD_EDIT_PART_TYPE, argv[i], argv[i+1], argv[i+2]}); + } + + if (sub == "remove-part-type") { + if (i >= argc) { std::cerr << "usage: remove-part-type \n"; return 1; } + return Client(host, port).send({CMD_REMOVE_PART_TYPE, argv[i]}); + } + + // ── Part commands ───────────────────────────────────────────────────────── + if (sub == "add-part" || sub == "upsert-part") { + const std::string proto_cmd = (sub == "add-part") ? CMD_ADD_PART : CMD_UPSERT_PART; + if (i + 1 >= argc) { + std::cerr << "usage: " << sub << " [key=value ...]\n"; + return 1; + } + std::vector tokens = {proto_cmd, argv[i], argv[i+1]}; + if (!parse_kv_args(argc, argv, i + 2, tokens)) return 1; + return Client(host, port).send(tokens); + } + + if (sub == "edit-part") { + if (i >= argc) { + std::cerr << "usage: edit-part [key=value ...]\n"; return 1; + } + std::vector tokens = {CMD_EDIT_PART, argv[i]}; + if (!parse_kv_args(argc, argv, i + 1, tokens)) return 1; + return Client(host, port).send(tokens); + } + + if (sub == "remove-part") { + if (i >= argc) { std::cerr << "usage: remove-part \n"; return 1; } + return Client(host, port).send({CMD_REMOVE_PART, argv[i]}); + } + + if (sub == "list-parts") { + if (i >= argc) { std::cerr << "usage: list-parts [--type ]\n"; return 1; } + std::string server_id = argv[i++]; + std::string type_filter; + while (i < argc) { + std::string a = argv[i++]; + if (a == "--type" && i < argc) type_filter = argv[i++]; + } + std::vector tokens = {CMD_LIST_PARTS, server_id}; + if (!type_filter.empty()) tokens.push_back(type_filter); + return Client(host, port).send(tokens); + } + + if (sub == "get-part") { + if (i >= argc) { std::cerr << "usage: get-part \n"; return 1; } + return Client(host, port).send({CMD_GET_PART, argv[i]}); + } + + // ── Discovery ───────────────────────────────────────────────────────────── + if (sub == "discover") { + if (i >= argc) { + std::cerr << "usage: discover [--type ] [--dry-run]\n"; + return 1; + } + std::string server_id = argv[i++]; + std::string filter_type; + bool dry_run = false; + + while (i < argc) { + std::string a = argv[i++]; + if (a == "--type" && i < argc) { + filter_type = argv[i++]; + } else if (a == "--dry-run") { + dry_run = true; + } else { + std::cerr << "error: unknown discover option: " << a << "\n"; + return 1; + } + } + + Client client(host, port); + return cmd_discover(client, server_id, filter_type, dry_run); + } + + std::cerr << "error: unknown command '" << sub << "'\n\n"; + usage(); + return 1; +} diff --git a/services/device-inventory/src/common/models.h b/services/device-inventory/src/common/models.h new file mode 100644 index 0000000..e6fd427 --- /dev/null +++ b/services/device-inventory/src/common/models.h @@ -0,0 +1,131 @@ +#pragma once +#include +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── +// Server +// ───────────────────────────────────────────────────────────────────────────── + +struct Server { + uint32_t id = 0; + std::string name; + std::string hostname; + std::string location; + std::string description; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// PartType – user-defined label / category, e.g. "memory_stick", "cpu", … +// ───────────────────────────────────────────────────────────────────────────── + +struct PartType { + uint32_t id = 0; + std::string name; // slug used in protocol, e.g. "memory_stick" + std::string description; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// PartBase – common fields for every specialized part +// ───────────────────────────────────────────────────────────────────────────── + +struct PartBase { + uint32_t part_id = 0; + uint32_t server_id = 0; + std::optional serial_number; // nullable + int64_t last_updated = 0; // unix seconds +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Specialized part structs +// ───────────────────────────────────────────────────────────────────────────── + +// channel_config: "single" | "dual" | "quad" +struct MemoryStick : PartBase { + uint32_t speed_mhz = 0; + uint64_t size_mb = 0; + std::string manufacturer; + std::string channel_config; + std::string other_info; +}; + +struct MemorySlot : PartBase { + uint32_t allowed_speed_mhz = 0; + uint64_t allowed_size_mb = 0; + std::optional installed_stick_id; // ref to MemoryStick.part_id +}; + +struct CPU : PartBase { + std::string name; + std::string manufacturer; + double speed_ghz = 0.0; + uint32_t cores = 0; + uint32_t threads = 0; +}; + +struct CPUSlot : PartBase { + std::string form_factor; // "LGA1700", "AM5", … + std::optional installed_cpu_id; // ref to CPU.part_id +}; + +// connection_type: "SATA" | "NVMe" | "SAS" | "USB" | "virtual" | … +// disk_type: "ssd" | "hdd" | "nvme" | "virtual" | … +struct Disk : PartBase { + std::string manufacturer; + std::string model; + std::string generation; // e.g. "PCIe 4.0" + std::string connection_type; + uint64_t connection_speed_mbps = 0; + uint64_t disk_speed_mbps = 0; + uint64_t disk_size_gb = 0; + std::string disk_type; + int32_t age_years = -1; // -1 = unknown + std::vector partition_ids; // ["sda1","sda2",…] + std::vector partition_sizes_gb; + std::vector vm_hostnames; // hosts/VMs with disk access + std::vector vm_server_ids; // inventory server ids +}; + +// connection_type: "ethernet" | "wifi" | "infiniband" | … +struct NetworkCard : PartBase { + std::string manufacturer; + std::string model; + int32_t age_years = -1; // -1 = unknown + std::string connection_type; + uint64_t connection_speed_mbps = 0; + std::string mac_address; + std::vector ip_addresses; // ["192.168.1.2/24",…] + bool dhcp = false; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// In-memory inventory +// ───────────────────────────────────────────────────────────────────────────── + +struct Inventory { + std::vector servers; + std::vector part_types; + + std::vector memory_sticks; + std::vector memory_slots; + std::vector cpus; + std::vector cpu_slots; + std::vector disks; + std::vector network_cards; + + uint32_t next_server_id = 1; + uint32_t next_part_type_id = 1; + uint32_t next_part_id = 1; // shared counter across all part types +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Part-type name constants (used in protocol and CLI) +// ───────────────────────────────────────────────────────────────────────────── + +constexpr const char* PTYPE_MEMORY_STICK = "memory_stick"; +constexpr const char* PTYPE_MEMORY_SLOT = "memory_slot"; +constexpr const char* PTYPE_CPU = "cpu"; +constexpr const char* PTYPE_CPU_SLOT = "cpu_slot"; +constexpr const char* PTYPE_DISK = "disk"; +constexpr const char* PTYPE_NIC = "nic"; diff --git a/services/device-inventory/src/common/protocol.h b/services/device-inventory/src/common/protocol.h new file mode 100644 index 0000000..475052a --- /dev/null +++ b/services/device-inventory/src/common/protocol.h @@ -0,0 +1,166 @@ +#pragma once +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── +// Network +// ───────────────────────────────────────────────────────────────────────────── + +constexpr uint16_t DEFAULT_PORT = 9876; + +// ───────────────────────────────────────────────────────────────────────────── +// Wire-format separators +// ───────────────────────────────────────────────────────────────────────────── + +// FS (field separator) – SOH 0x01 – splits top-level tokens in a command line +constexpr char FS = '\x01'; + +// LS (list separator) – used to encode lists inside a single field value +// e.g. ip_addresses field: "192.168.1.2/24\x02fe80::1/64" +constexpr char LS = '\x02'; + +// ───────────────────────────────────────────────────────────────────────────── +// Wire format +// +// Client → Server (one line per request): +// COMMANDarg1arg2… +// +// Server → Client: +// OK +// ← 0 or more rows +// END +// – or – +// ERR +// +// Key-value part commands: +// ADD_PART server_idkey1val1key2val2… +// UPSERT_PART server_idkey1val1… +// (insert or update matching an existing part by serial/mac) +// EDIT_PART key1val1key2val2… +// REMOVE_PART +// LIST_PARTS [optional: ] +// GET_PART +// +// List field values are encoded as val1val2val3 +// (empty string encodes an empty list) +// +// Null / absent optional fields are sent as the literal string "NULL" +// ───────────────────────────────────────────────────────────────────────────── + +// Server management +constexpr const char* CMD_ADD_SERVER = "ADD_SERVER"; +constexpr const char* CMD_LIST_SERVERS = "LIST_SERVERS"; +constexpr const char* CMD_EDIT_SERVER = "EDIT_SERVER"; +constexpr const char* CMD_REMOVE_SERVER = "REMOVE_SERVER"; + +// Part type (label) management +constexpr const char* CMD_ADD_PART_TYPE = "ADD_PART_TYPE"; +constexpr const char* CMD_LIST_PART_TYPES = "LIST_PART_TYPES"; +constexpr const char* CMD_EDIT_PART_TYPE = "EDIT_PART_TYPE"; +constexpr const char* CMD_REMOVE_PART_TYPE = "REMOVE_PART_TYPE"; + +// Typed part management +constexpr const char* CMD_ADD_PART = "ADD_PART"; +constexpr const char* CMD_UPSERT_PART = "UPSERT_PART"; // for discovery +constexpr const char* CMD_EDIT_PART = "EDIT_PART"; +constexpr const char* CMD_REMOVE_PART = "REMOVE_PART"; +constexpr const char* CMD_LIST_PARTS = "LIST_PARTS"; +constexpr const char* CMD_GET_PART = "GET_PART"; + +// Response sentinels +constexpr const char* RESP_OK = "OK"; +constexpr const char* RESP_ERR = "ERR"; +constexpr const char* RESP_END = "END"; + +// ───────────────────────────────────────────────────────────────────────────── +// Field keys for each specialized part type +// (shared between client serializer and server deserializer) +// ───────────────────────────────────────────────────────────────────────────── + +// PartBase common keys +constexpr const char* K_SERIAL = "serial"; +constexpr const char* K_LAST_UPDATED = "last_updated"; + +// MemoryStick +constexpr const char* K_SPEED_MHZ = "speed_mhz"; +constexpr const char* K_SIZE_MB = "size_mb"; +constexpr const char* K_MANUFACTURER = "manufacturer"; +constexpr const char* K_CHANNEL_CONFIG = "channel_config"; +constexpr const char* K_OTHER_INFO = "other_info"; + +// MemorySlot +constexpr const char* K_ALLOWED_SPEED = "allowed_speed_mhz"; +constexpr const char* K_ALLOWED_SIZE = "allowed_size_mb"; +constexpr const char* K_INSTALLED_STICK = "installed_stick_id"; + +// CPU +constexpr const char* K_NAME = "name"; +constexpr const char* K_SPEED_GHZ = "speed_ghz"; +constexpr const char* K_CORES = "cores"; +constexpr const char* K_THREADS = "threads"; + +// CPUSlot +constexpr const char* K_FORM_FACTOR = "form_factor"; +constexpr const char* K_INSTALLED_CPU = "installed_cpu_id"; + +// Disk +constexpr const char* K_MODEL = "model"; +constexpr const char* K_GENERATION = "generation"; +constexpr const char* K_CONN_TYPE = "connection_type"; +constexpr const char* K_CONN_SPEED = "connection_speed_mbps"; +constexpr const char* K_DISK_SPEED = "disk_speed_mbps"; +constexpr const char* K_DISK_SIZE = "disk_size_gb"; +constexpr const char* K_DISK_TYPE = "disk_type"; +constexpr const char* K_AGE_YEARS = "age_years"; +constexpr const char* K_PARTITION_IDS = "partition_ids"; // LS-separated +constexpr const char* K_PARTITION_SIZES = "partition_sizes_gb"; // LS-separated +constexpr const char* K_VM_HOSTNAMES = "vm_hostnames"; // LS-separated +constexpr const char* K_VM_SERVER_IDS = "vm_server_ids"; // LS-separated + +// NetworkCard +constexpr const char* K_AGE_YEARS_NIC = "age_years"; // same key, different struct +constexpr const char* K_SPEED_MBPS = "connection_speed_mbps"; +constexpr const char* K_MAC = "mac_address"; +constexpr const char* K_IPS = "ip_addresses"; // LS-separated +constexpr const char* K_DHCP = "dhcp"; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +inline std::vector split(const std::string& s, char delim) { + std::vector parts; + std::stringstream ss(s); + std::string token; + while (std::getline(ss, token, delim)) + parts.push_back(token); + return parts; +} + +inline std::string join(const std::vector& v, char delim) { + std::string out; + for (size_t i = 0; i < v.size(); ++i) { + if (i) out += delim; + out += v[i]; + } + return out; +} + +inline std::string join_u64(const std::vector& v, char delim) { + std::string out; + for (size_t i = 0; i < v.size(); ++i) { + if (i) out += delim; + out += std::to_string(v[i]); + } + return out; +} + +inline std::string join_u32(const std::vector& v, char delim) { + std::string out; + for (size_t i = 0; i < v.size(); ++i) { + if (i) out += delim; + out += std::to_string(v[i]); + } + return out; +} diff --git a/services/device-inventory/src/server/database.cpp b/services/device-inventory/src/server/database.cpp new file mode 100644 index 0000000..d944c1c --- /dev/null +++ b/services/device-inventory/src/server/database.cpp @@ -0,0 +1,915 @@ +#include "server/database.h" +#include "common/protocol.h" +#include +#include +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── +// Escape / unescape: protect '|', '\n', '\' in pipe-delimited fields +// ───────────────────────────────────────────────────────────────────────────── +std::string Database::escape(const std::string& s) { + std::string out; + out.reserve(s.size() + 4); + for (char c : s) { + if (c == '\\') out += "\\\\"; + else if (c == '|') out += "\\|"; + else if (c == '\n') out += "\\n"; + else out += c; + } + return out; +} + +std::string Database::unescape(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] == '\\' && i + 1 < s.size()) { + ++i; + if (s[i] == '\\') out += '\\'; + else if (s[i] == '|') out += '|'; + else if (s[i] == 'n') out += '\n'; + else { out += '\\'; out += s[i]; } + } else { + out += s[i]; + } + } + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// split_pipe: split on '|' respecting backslash escapes +// ───────────────────────────────────────────────────────────────────────────── +static std::vector split_pipe(const std::string& line) { + std::vector fields; + std::string cur; + for (size_t i = 0; i < line.size(); ++i) { + if (line[i] == '\\' && i + 1 < line.size()) { + cur += line[i]; + cur += line[++i]; + } else if (line[i] == '|') { + fields.push_back(cur); + cur.clear(); + } else { + cur += line[i]; + } + } + fields.push_back(cur); + return fields; +} + +// ───────────────────────────────────────────────────────────────────────────── +// List helpers (LS separator) +// ───────────────────────────────────────────────────────────────────────────── +static std::string ls_join_u64(const std::vector& v) { + return join_u64(v, LS); +} +static std::string ls_join_u32(const std::vector& v) { + return join_u32(v, LS); +} +static std::vector ls_split(const std::string& s) { + if (s.empty()) return {}; + return split(s, LS); +} +static std::vector ls_split_u64(const std::string& s) { + std::vector out; + for (const auto& p : split(s, LS)) + if (!p.empty()) out.push_back(std::stoull(p)); + return out; +} +static std::vector ls_split_u32(const std::string& s) { + std::vector out; + for (const auto& p : split(s, LS)) + if (!p.empty()) out.push_back(static_cast(std::stoul(p))); + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// PartBase helpers +// ───────────────────────────────────────────────────────────────────────────── +static std::string base_serial(const PartBase& p) { + return p.serial_number.has_value() ? p.serial_number.value() : "NULL"; +} + +static void apply_base(PartBase& p, const std::map& kv) { + if (auto it = kv.find(K_SERIAL); it != kv.end()) { + if (it->second == "NULL") p.serial_number = std::nullopt; + else p.serial_number = it->second; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Per-type apply helpers (static free functions) +// ───────────────────────────────────────────────────────────────────────────── +static void apply_memory_stick(MemoryStick& m, const std::map& kv) { + apply_base(m, kv); + if (auto it = kv.find(K_SPEED_MHZ); it != kv.end()) m.speed_mhz = static_cast(std::stoul(it->second)); + if (auto it = kv.find(K_SIZE_MB); it != kv.end()) m.size_mb = std::stoull(it->second); + if (auto it = kv.find(K_MANUFACTURER); it != kv.end()) m.manufacturer = it->second; + if (auto it = kv.find(K_CHANNEL_CONFIG); it != kv.end()) m.channel_config = it->second; + if (auto it = kv.find(K_OTHER_INFO); it != kv.end()) m.other_info = it->second; +} + +static void apply_memory_slot(MemorySlot& m, const std::map& kv) { + apply_base(m, kv); + if (auto it = kv.find(K_ALLOWED_SPEED); it != kv.end()) m.allowed_speed_mhz = static_cast(std::stoul(it->second)); + if (auto it = kv.find(K_ALLOWED_SIZE); it != kv.end()) m.allowed_size_mb = std::stoull(it->second); + if (auto it = kv.find(K_INSTALLED_STICK); it != kv.end()) { + if (it->second == "NULL") m.installed_stick_id = std::nullopt; + else m.installed_stick_id = static_cast(std::stoul(it->second)); + } +} + +static void apply_cpu(CPU& c, const std::map& kv) { + apply_base(c, kv); + if (auto it = kv.find(K_NAME); it != kv.end()) c.name = it->second; + if (auto it = kv.find(K_MANUFACTURER); it != kv.end()) c.manufacturer = it->second; + if (auto it = kv.find(K_SPEED_GHZ); it != kv.end()) c.speed_ghz = std::stod(it->second); + if (auto it = kv.find(K_CORES); it != kv.end()) c.cores = static_cast(std::stoul(it->second)); + if (auto it = kv.find(K_THREADS); it != kv.end()) c.threads = static_cast(std::stoul(it->second)); +} + +static void apply_cpu_slot(CPUSlot& c, const std::map& kv) { + apply_base(c, kv); + if (auto it = kv.find(K_FORM_FACTOR); it != kv.end()) c.form_factor = it->second; + if (auto it = kv.find(K_INSTALLED_CPU); it != kv.end()) { + if (it->second == "NULL") c.installed_cpu_id = std::nullopt; + else c.installed_cpu_id = static_cast(std::stoul(it->second)); + } +} + +static void apply_disk(Disk& d, const std::map& kv) { + apply_base(d, kv); + if (auto it = kv.find(K_MANUFACTURER); it != kv.end()) d.manufacturer = it->second; + if (auto it = kv.find(K_MODEL); it != kv.end()) d.model = it->second; + if (auto it = kv.find(K_GENERATION); it != kv.end()) d.generation = it->second; + if (auto it = kv.find(K_CONN_TYPE); it != kv.end()) d.connection_type = it->second; + if (auto it = kv.find(K_CONN_SPEED); it != kv.end()) d.connection_speed_mbps = std::stoull(it->second); + if (auto it = kv.find(K_DISK_SPEED); it != kv.end()) d.disk_speed_mbps = std::stoull(it->second); + if (auto it = kv.find(K_DISK_SIZE); it != kv.end()) d.disk_size_gb = std::stoull(it->second); + if (auto it = kv.find(K_DISK_TYPE); it != kv.end()) d.disk_type = it->second; + if (auto it = kv.find(K_AGE_YEARS); it != kv.end()) d.age_years = std::stoi(it->second); + if (auto it = kv.find(K_PARTITION_IDS); it != kv.end()) d.partition_ids = ls_split(it->second); + if (auto it = kv.find(K_PARTITION_SIZES); it != kv.end()) d.partition_sizes_gb = ls_split_u64(it->second); + if (auto it = kv.find(K_VM_HOSTNAMES); it != kv.end()) d.vm_hostnames = ls_split(it->second); + if (auto it = kv.find(K_VM_SERVER_IDS); it != kv.end()) d.vm_server_ids = ls_split_u32(it->second); +} + +static void apply_nic(NetworkCard& n, const std::map& kv) { + apply_base(n, kv); + if (auto it = kv.find(K_MANUFACTURER); it != kv.end()) n.manufacturer = it->second; + if (auto it = kv.find(K_MODEL); it != kv.end()) n.model = it->second; + if (auto it = kv.find(K_AGE_YEARS); it != kv.end()) n.age_years = std::stoi(it->second); + if (auto it = kv.find(K_CONN_TYPE); it != kv.end()) n.connection_type = it->second; + if (auto it = kv.find(K_CONN_SPEED); it != kv.end()) n.connection_speed_mbps = std::stoull(it->second); + if (auto it = kv.find(K_MAC); it != kv.end()) n.mac_address = it->second; + if (auto it = kv.find(K_IPS); it != kv.end()) n.ip_addresses = ls_split(it->second); + if (auto it = kv.find(K_DHCP); it != kv.end()) n.dhcp = (it->second == "1" || it->second == "true"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Wire-row serializers +// type_namepart_idserver_idserial_or_NULLlast_updatedk1v1... +// ───────────────────────────────────────────────────────────────────────────── +static std::string base_prefix(const char* type_name, const PartBase& p) { + return std::string(type_name) + + FS + std::to_string(p.part_id) + + FS + std::to_string(p.server_id) + + FS + base_serial(p) + + FS + std::to_string(p.last_updated); +} + +std::string Database::serialize_memory_stick(const MemoryStick& m) const { + return base_prefix(PTYPE_MEMORY_STICK, m) + + FS + K_SPEED_MHZ + FS + std::to_string(m.speed_mhz) + + FS + K_SIZE_MB + FS + std::to_string(m.size_mb) + + FS + K_MANUFACTURER + FS + m.manufacturer + + FS + K_CHANNEL_CONFIG + FS + m.channel_config + + FS + K_OTHER_INFO + FS + m.other_info + + "\n"; +} + +std::string Database::serialize_memory_slot(const MemorySlot& m) const { + std::string installed = m.installed_stick_id.has_value() + ? std::to_string(m.installed_stick_id.value()) : "NULL"; + return base_prefix(PTYPE_MEMORY_SLOT, m) + + FS + K_ALLOWED_SPEED + FS + std::to_string(m.allowed_speed_mhz) + + FS + K_ALLOWED_SIZE + FS + std::to_string(m.allowed_size_mb) + + FS + K_INSTALLED_STICK + FS + installed + + "\n"; +} + +std::string Database::serialize_cpu(const CPU& c) const { + return base_prefix(PTYPE_CPU, c) + + FS + K_NAME + FS + c.name + + FS + K_MANUFACTURER + FS + c.manufacturer + + FS + K_SPEED_GHZ + FS + std::to_string(c.speed_ghz) + + FS + K_CORES + FS + std::to_string(c.cores) + + FS + K_THREADS + FS + std::to_string(c.threads) + + "\n"; +} + +std::string Database::serialize_cpu_slot(const CPUSlot& c) const { + std::string installed = c.installed_cpu_id.has_value() + ? std::to_string(c.installed_cpu_id.value()) : "NULL"; + return base_prefix(PTYPE_CPU_SLOT, c) + + FS + K_FORM_FACTOR + FS + c.form_factor + + FS + K_INSTALLED_CPU + FS + installed + + "\n"; +} + +std::string Database::serialize_disk(const Disk& d) const { + // Build per-list string: escape each element, join with LS + auto esc_join = [](const std::vector& v) -> std::string { + std::string out; + for (size_t i = 0; i < v.size(); ++i) { + if (i) out += LS; + // escape element (pipe/backslash could appear in partition ids or hostnames) + for (char c : v[i]) { + if (c == '\\') out += "\\\\"; + else if (c == '|') out += "\\|"; + else out += c; + } + } + return out; + }; + return base_prefix(PTYPE_DISK, d) + + FS + K_MANUFACTURER + FS + d.manufacturer + + FS + K_MODEL + FS + d.model + + FS + K_GENERATION + FS + d.generation + + FS + K_CONN_TYPE + FS + d.connection_type + + FS + K_CONN_SPEED + FS + std::to_string(d.connection_speed_mbps) + + FS + K_DISK_SPEED + FS + std::to_string(d.disk_speed_mbps) + + FS + K_DISK_SIZE + FS + std::to_string(d.disk_size_gb) + + FS + K_DISK_TYPE + FS + d.disk_type + + FS + K_AGE_YEARS + FS + std::to_string(d.age_years) + + FS + K_PARTITION_IDS + FS + esc_join(d.partition_ids) + + FS + K_PARTITION_SIZES + FS + ls_join_u64(d.partition_sizes_gb) + + FS + K_VM_HOSTNAMES + FS + esc_join(d.vm_hostnames) + + FS + K_VM_SERVER_IDS + FS + ls_join_u32(d.vm_server_ids) + + "\n"; +} + +std::string Database::serialize_nic(const NetworkCard& n) const { + auto esc_join = [](const std::vector& v) -> std::string { + std::string out; + for (size_t i = 0; i < v.size(); ++i) { + if (i) out += LS; + for (char c : v[i]) { + if (c == '\\') out += "\\\\"; + else if (c == '|') out += "\\|"; + else out += c; + } + } + return out; + }; + return base_prefix(PTYPE_NIC, n) + + FS + K_MANUFACTURER + FS + n.manufacturer + + FS + K_MODEL + FS + n.model + + FS + K_AGE_YEARS + FS + std::to_string(n.age_years) + + FS + K_CONN_TYPE + FS + n.connection_type + + FS + K_CONN_SPEED + FS + std::to_string(n.connection_speed_mbps) + + FS + K_MAC + FS + n.mac_address + + FS + K_IPS + FS + esc_join(n.ip_addresses) + + FS + K_DHCP + FS + (n.dhcp ? "1" : "0") + + "\n"; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Constructor +// ───────────────────────────────────────────────────────────────────────────── +Database::Database(std::string path) : path_(std::move(path)) {} + +// ───────────────────────────────────────────────────────────────────────────── +// alloc_part_id — caller must hold mu_ +// ───────────────────────────────────────────────────────────────────────────── +uint32_t Database::alloc_part_id() { + return inv_.next_part_id++; +} + +// ───────────────────────────────────────────────────────────────────────────── +// save_nolock / save +// ───────────────────────────────────────────────────────────────────────────── +bool Database::save_nolock() { + std::ofstream f(path_, std::ios::trunc); + if (!f.is_open()) { + std::cerr << "[db] cannot open " << path_ << " for writing\n"; + return false; + } + f << "# device-inventory database\n"; + f << "META|" << inv_.next_server_id << "|" + << inv_.next_part_type_id << "|" + << inv_.next_part_id << "\n"; + + for (const auto& s : inv_.servers) { + f << "S|" << s.id + << "|" << escape(s.name) + << "|" << escape(s.hostname) + << "|" << escape(s.location) + << "|" << escape(s.description) << "\n"; + } + for (const auto& pt : inv_.part_types) { + f << "PT|" << pt.id + << "|" << escape(pt.name) + << "|" << escape(pt.description) << "\n"; + } + + // Helper lambda: write LS-joined list of escaped strings as a single pipe field + auto write_str_list = [](std::ofstream& out, const std::vector& v) { + for (size_t i = 0; i < v.size(); ++i) { + if (i) out << static_cast(LS); + // escape individual elements (protect | and \ within each element) + for (char c : v[i]) { + if (c == '\\') out << "\\\\"; + else if (c == '|') out << "\\|"; + else out << c; + } + } + }; + + for (const auto& m : inv_.memory_sticks) { + f << "MS|" << m.part_id << "|" << m.server_id + << "|" << escape(base_serial(m)) + << "|" << m.last_updated + << "|" << m.speed_mhz + << "|" << m.size_mb + << "|" << escape(m.manufacturer) + << "|" << escape(m.channel_config) + << "|" << escape(m.other_info) << "\n"; + } + for (const auto& m : inv_.memory_slots) { + std::string inst = m.installed_stick_id.has_value() + ? std::to_string(m.installed_stick_id.value()) : "NULL"; + f << "MSLOT|" << m.part_id << "|" << m.server_id + << "|" << escape(base_serial(m)) + << "|" << m.last_updated + << "|" << m.allowed_speed_mhz + << "|" << m.allowed_size_mb + << "|" << inst << "\n"; + } + for (const auto& c : inv_.cpus) { + f << "CPU|" << c.part_id << "|" << c.server_id + << "|" << escape(base_serial(c)) + << "|" << c.last_updated + << "|" << escape(c.name) + << "|" << escape(c.manufacturer) + << "|" << c.speed_ghz + << "|" << c.cores + << "|" << c.threads << "\n"; + } + for (const auto& c : inv_.cpu_slots) { + std::string inst = c.installed_cpu_id.has_value() + ? std::to_string(c.installed_cpu_id.value()) : "NULL"; + f << "CPUSLOT|" << c.part_id << "|" << c.server_id + << "|" << escape(base_serial(c)) + << "|" << c.last_updated + << "|" << escape(c.form_factor) + << "|" << inst << "\n"; + } + for (const auto& d : inv_.disks) { + f << "DISK|" << d.part_id << "|" << d.server_id + << "|" << escape(base_serial(d)) + << "|" << d.last_updated + << "|" << escape(d.manufacturer) + << "|" << escape(d.model) + << "|" << escape(d.generation) + << "|" << escape(d.connection_type) + << "|" << d.connection_speed_mbps + << "|" << d.disk_speed_mbps + << "|" << d.disk_size_gb + << "|" << escape(d.disk_type) + << "|" << d.age_years << "|"; + write_str_list(f, d.partition_ids); + f << "|"; + // partition_sizes_gb: numeric, no escape needed + for (size_t i = 0; i < d.partition_sizes_gb.size(); ++i) { + if (i) f << static_cast(LS); + f << d.partition_sizes_gb[i]; + } + f << "|"; + write_str_list(f, d.vm_hostnames); + f << "|"; + for (size_t i = 0; i < d.vm_server_ids.size(); ++i) { + if (i) f << static_cast(LS); + f << d.vm_server_ids[i]; + } + f << "\n"; + } + for (const auto& n : inv_.network_cards) { + f << "NIC|" << n.part_id << "|" << n.server_id + << "|" << escape(base_serial(n)) + << "|" << n.last_updated + << "|" << escape(n.manufacturer) + << "|" << escape(n.model) + << "|" << n.age_years + << "|" << escape(n.connection_type) + << "|" << n.connection_speed_mbps + << "|" << escape(n.mac_address) << "|"; + write_str_list(f, n.ip_addresses); + f << "|" << (n.dhcp ? 1 : 0) << "\n"; + } + return true; +} + +bool Database::save() { + std::lock_guard lk(mu_); + return save_nolock(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// load +// ───────────────────────────────────────────────────────────────────────────── +bool Database::load() { + std::lock_guard lk(mu_); + std::ifstream f(path_); + if (!f.is_open()) { + inv_ = Inventory{}; + return true; + } + + Inventory fresh; + std::string line; + while (std::getline(f, line)) { + if (line.empty() || line[0] == '#') continue; + auto flds = split_pipe(line); + if (flds.empty()) continue; + const auto& rec = flds[0]; + + auto get = [&](size_t i) -> std::string { + return i < flds.size() ? unescape(flds[i]) : ""; + }; + auto getu32 = [&](size_t i) -> uint32_t { + return i < flds.size() ? static_cast(std::stoul(flds[i])) : 0; + }; + auto getu64 = [&](size_t i) -> uint64_t { + return i < flds.size() ? std::stoull(flds[i]) : 0ULL; + }; + auto geti64 = [&](size_t i) -> int64_t { + return i < flds.size() ? std::stoll(flds[i]) : 0LL; + }; + // serial field: raw flds[i] (not unescaped via get) but compare to "NULL" + auto get_serial = [&](size_t i) -> std::optional { + if (i >= flds.size() || flds[i] == "NULL") return std::nullopt; + return unescape(flds[i]); + }; + auto get_opt_u32 = [&](size_t i) -> std::optional { + if (i >= flds.size() || flds[i] == "NULL") return std::nullopt; + return static_cast(std::stoul(flds[i])); + }; + + if (rec == "META" && flds.size() >= 4) { + fresh.next_server_id = getu32(1); + fresh.next_part_type_id = getu32(2); + fresh.next_part_id = getu32(3); + } else if (rec == "S" && flds.size() >= 6) { + Server s; + s.id = getu32(1); + s.name = get(2); + s.hostname = get(3); + s.location = get(4); + s.description = get(5); + fresh.servers.push_back(std::move(s)); + } else if (rec == "PT" && flds.size() >= 4) { + PartType pt; + pt.id = getu32(1); + pt.name = get(2); + pt.description = get(3); + fresh.part_types.push_back(std::move(pt)); + } else if (rec == "MS" && flds.size() >= 10) { + MemoryStick m; + m.part_id = getu32(1); + m.server_id = getu32(2); + m.serial_number = get_serial(3); + m.last_updated = geti64(4); + m.speed_mhz = getu32(5); + m.size_mb = getu64(6); + m.manufacturer = get(7); + m.channel_config = get(8); + m.other_info = get(9); + fresh.memory_sticks.push_back(std::move(m)); + } else if (rec == "MSLOT" && flds.size() >= 8) { + MemorySlot m; + m.part_id = getu32(1); + m.server_id = getu32(2); + m.serial_number = get_serial(3); + m.last_updated = geti64(4); + m.allowed_speed_mhz = getu32(5); + m.allowed_size_mb = getu64(6); + m.installed_stick_id = get_opt_u32(7); + fresh.memory_slots.push_back(std::move(m)); + } else if (rec == "CPU" && flds.size() >= 10) { + CPU c; + c.part_id = getu32(1); + c.server_id = getu32(2); + c.serial_number = get_serial(3); + c.last_updated = geti64(4); + c.name = get(5); + c.manufacturer = get(6); + c.speed_ghz = flds.size() > 7 ? std::stod(flds[7]) : 0.0; + c.cores = getu32(8); + c.threads = getu32(9); + fresh.cpus.push_back(std::move(c)); + } else if (rec == "CPUSLOT" && flds.size() >= 7) { + CPUSlot c; + c.part_id = getu32(1); + c.server_id = getu32(2); + c.serial_number = get_serial(3); + c.last_updated = geti64(4); + c.form_factor = get(5); + c.installed_cpu_id = get_opt_u32(6); + fresh.cpu_slots.push_back(std::move(c)); + } else if (rec == "DISK" && flds.size() >= 18) { + Disk d; + d.part_id = getu32(1); + d.server_id = getu32(2); + d.serial_number = get_serial(3); + d.last_updated = geti64(4); + d.manufacturer = get(5); + d.model = get(6); + d.generation = get(7); + d.connection_type = get(8); + d.connection_speed_mbps = getu64(9); + d.disk_speed_mbps = getu64(10); + d.disk_size_gb = getu64(11); + d.disk_type = get(12); + d.age_years = flds.size() > 13 ? std::stoi(flds[13]) : -1; + // Note: flds[14..17] have NOT been through unescape() yet (we used raw flds) + // Re-split using the raw (escaped) field and unescape each element + auto uesc_split_raw = [](const std::string& raw) -> std::vector { + if (raw.empty()) return {}; + std::vector out; + for (const auto& elem : split(raw, LS)) { + std::string u; + for (size_t i = 0; i < elem.size(); ++i) { + if (elem[i] == '\\' && i+1 < elem.size()) { + ++i; + if (elem[i] == '\\') u += '\\'; + else if (elem[i] == '|') u += '|'; + else if (elem[i] == 'n') u += '\n'; + else { u += '\\'; u += elem[i]; } + } else u += elem[i]; + } + out.push_back(u); + } + return out; + }; + d.partition_ids = uesc_split_raw(flds.size() > 14 ? flds[14] : ""); + d.partition_sizes_gb = ls_split_u64(flds.size() > 15 ? flds[15] : ""); + d.vm_hostnames = uesc_split_raw(flds.size() > 16 ? flds[16] : ""); + d.vm_server_ids = ls_split_u32(flds.size() > 17 ? flds[17] : ""); + fresh.disks.push_back(std::move(d)); + } else if (rec == "NIC" && flds.size() >= 13) { + NetworkCard n; + n.part_id = getu32(1); + n.server_id = getu32(2); + n.serial_number = get_serial(3); + n.last_updated = geti64(4); + n.manufacturer = get(5); + n.model = get(6); + n.age_years = flds.size() > 7 ? std::stoi(flds[7]) : -1; + n.connection_type = get(8); + n.connection_speed_mbps= getu64(9); + n.mac_address = get(10); + // ip_addresses: LS separated in flds[11] + { + auto& raw = flds[11]; + for (auto& p : split(raw, LS)) + n.ip_addresses.push_back(unescape(p)); + } + n.dhcp = (flds.size() > 12 && flds[12] == "1"); + fresh.network_cards.push_back(std::move(n)); + } + } + inv_ = std::move(fresh); + return true; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Servers +// ───────────────────────────────────────────────────────────────────────────── +Server Database::add_server(std::string name, std::string hostname, + std::string location, std::string description) { + std::lock_guard lk(mu_); + Server s; + s.id = inv_.next_server_id++; + s.name = std::move(name); + s.hostname = std::move(hostname); + s.location = std::move(location); + s.description = std::move(description); + inv_.servers.push_back(s); + save_nolock(); + return s; +} + +std::vector Database::list_servers() const { + std::lock_guard lk(mu_); + return inv_.servers; +} + +std::optional Database::get_server(uint32_t id) const { + std::lock_guard lk(mu_); + for (const auto& s : inv_.servers) + if (s.id == id) return s; + return std::nullopt; +} + +bool Database::edit_server(uint32_t id, const std::string& field, const std::string& value) { + std::lock_guard lk(mu_); + for (auto& s : inv_.servers) { + if (s.id != id) continue; + if (field == "name") s.name = value; + else if (field == "hostname") s.hostname = value; + else if (field == "location") s.location = value; + else if (field == "description") s.description = value; + else return false; + save_nolock(); + return true; + } + return false; +} + +bool Database::remove_server(uint32_t id) { + std::lock_guard lk(mu_); + auto& v = inv_.servers; + auto it = std::find_if(v.begin(), v.end(), [id](const Server& s){ return s.id == id; }); + if (it == v.end()) return false; + v.erase(it); + save_nolock(); + return true; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Part types +// ───────────────────────────────────────────────────────────────────────────── +PartType Database::add_part_type(std::string name, std::string description) { + std::lock_guard lk(mu_); + PartType pt; + pt.id = inv_.next_part_type_id++; + pt.name = std::move(name); + pt.description = std::move(description); + inv_.part_types.push_back(pt); + save_nolock(); + return pt; +} + +std::vector Database::list_part_types() const { + std::lock_guard lk(mu_); + return inv_.part_types; +} + +std::optional Database::get_part_type(uint32_t id) const { + std::lock_guard lk(mu_); + for (const auto& pt : inv_.part_types) + if (pt.id == id) return pt; + return std::nullopt; +} + +bool Database::edit_part_type(uint32_t id, const std::string& field, const std::string& value) { + std::lock_guard lk(mu_); + for (auto& pt : inv_.part_types) { + if (pt.id != id) continue; + if (field == "name") pt.name = value; + else if (field == "description") pt.description = value; + else return false; + save_nolock(); + return true; + } + return false; +} + +bool Database::remove_part_type(uint32_t id) { + std::lock_guard lk(mu_); + auto& v = inv_.part_types; + auto it = std::find_if(v.begin(), v.end(), [id](const PartType& pt){ return pt.id == id; }); + if (it == v.end()) return false; + v.erase(it); + save_nolock(); + return true; +} + +// ───────────────────────────────────────────────────────────────────────────── +// add_part_nolock — caller must hold mu_ +// ───────────────────────────────────────────────────────────────────────────── +uint32_t Database::add_part_nolock(const std::string& type_name, uint32_t server_id, + const std::map& kv) { + uint32_t pid = alloc_part_id(); + int64_t now = static_cast(time(nullptr)); + + if (type_name == PTYPE_MEMORY_STICK) { + MemoryStick m; m.part_id = pid; m.server_id = server_id; m.last_updated = now; + apply_memory_stick(m, kv); + inv_.memory_sticks.push_back(std::move(m)); + } else if (type_name == PTYPE_MEMORY_SLOT) { + MemorySlot m; m.part_id = pid; m.server_id = server_id; m.last_updated = now; + apply_memory_slot(m, kv); + inv_.memory_slots.push_back(std::move(m)); + } else if (type_name == PTYPE_CPU) { + CPU c; c.part_id = pid; c.server_id = server_id; c.last_updated = now; + apply_cpu(c, kv); + inv_.cpus.push_back(std::move(c)); + } else if (type_name == PTYPE_CPU_SLOT) { + CPUSlot c; c.part_id = pid; c.server_id = server_id; c.last_updated = now; + apply_cpu_slot(c, kv); + inv_.cpu_slots.push_back(std::move(c)); + } else if (type_name == PTYPE_DISK) { + Disk d; d.part_id = pid; d.server_id = server_id; d.last_updated = now; + apply_disk(d, kv); + inv_.disks.push_back(std::move(d)); + } else if (type_name == PTYPE_NIC) { + NetworkCard n; n.part_id = pid; n.server_id = server_id; n.last_updated = now; + apply_nic(n, kv); + inv_.network_cards.push_back(std::move(n)); + } else { + // Unknown type — no-op, reclaim the id by decrementing + --inv_.next_part_id; + return 0; + } + save_nolock(); + return pid; +} + +// ───────────────────────────────────────────────────────────────────────────── +// add_part (public, locks) +// ───────────────────────────────────────────────────────────────────────────── +uint32_t Database::add_part(const std::string& type_name, uint32_t server_id, + const std::map& kv) { + std::lock_guard lk(mu_); + return add_part_nolock(type_name, server_id, kv); +} + +// ───────────────────────────────────────────────────────────────────────────── +// upsert_part +// ───────────────────────────────────────────────────────────────────────────── +uint32_t Database::upsert_part(const std::string& type_name, uint32_t server_id, + const std::map& kv) { + std::lock_guard lk(mu_); + int64_t now = static_cast(time(nullptr)); + + if (type_name == PTYPE_NIC) { + auto mac_it = kv.find(K_MAC); + if (mac_it != kv.end() && !mac_it->second.empty()) { + for (auto& n : inv_.network_cards) { + if (n.server_id == server_id && n.mac_address == mac_it->second) { + apply_nic(n, kv); + n.last_updated = now; + save_nolock(); + return n.part_id; + } + } + } + } else { + auto ser_it = kv.find(K_SERIAL); + if (ser_it != kv.end() && ser_it->second != "NULL" && !ser_it->second.empty()) { + const std::string& serial = ser_it->second; + // Search in the collection matching type_name + auto try_update = [&](auto& vec) -> uint32_t { + for (auto& p : vec) { + if (p.server_id == server_id && p.serial_number.has_value() + && p.serial_number.value() == serial) { + if constexpr (std::is_same_v, MemoryStick>) + apply_memory_stick(p, kv); + else if constexpr (std::is_same_v, MemorySlot>) + apply_memory_slot(p, kv); + else if constexpr (std::is_same_v, CPU>) + apply_cpu(p, kv); + else if constexpr (std::is_same_v, CPUSlot>) + apply_cpu_slot(p, kv); + else if constexpr (std::is_same_v, Disk>) + apply_disk(p, kv); + p.last_updated = now; + save_nolock(); + return p.part_id; + } + } + return 0u; + }; + + uint32_t found = 0; + if (type_name == PTYPE_MEMORY_STICK) found = try_update(inv_.memory_sticks); + else if (type_name == PTYPE_MEMORY_SLOT) found = try_update(inv_.memory_slots); + else if (type_name == PTYPE_CPU) found = try_update(inv_.cpus); + else if (type_name == PTYPE_CPU_SLOT) found = try_update(inv_.cpu_slots); + else if (type_name == PTYPE_DISK) found = try_update(inv_.disks); + if (found) return found; + } + } + // Not found — insert + return add_part_nolock(type_name, server_id, kv); +} + +// ───────────────────────────────────────────────────────────────────────────── +// edit_part +// ───────────────────────────────────────────────────────────────────────────── +bool Database::edit_part(uint32_t part_id, const std::map& kv) { + std::lock_guard lk(mu_); + int64_t now = static_cast(time(nullptr)); + + auto try_edit = [&](auto& vec, auto apply_fn) -> bool { + for (auto& p : vec) { + if (p.part_id != part_id) continue; + apply_fn(p, kv); + p.last_updated = now; + save_nolock(); + return true; + } + return false; + }; + + if (try_edit(inv_.memory_sticks, apply_memory_stick)) return true; + if (try_edit(inv_.memory_slots, apply_memory_slot)) return true; + if (try_edit(inv_.cpus, apply_cpu)) return true; + if (try_edit(inv_.cpu_slots, apply_cpu_slot)) return true; + if (try_edit(inv_.disks, apply_disk)) return true; + if (try_edit(inv_.network_cards, apply_nic)) return true; + return false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// remove_part +// ───────────────────────────────────────────────────────────────────────────── +bool Database::remove_part(uint32_t part_id) { + std::lock_guard lk(mu_); + + auto try_remove = [&](auto& vec) -> bool { + auto it = std::find_if(vec.begin(), vec.end(), + [part_id](const auto& p){ return p.part_id == part_id; }); + if (it == vec.end()) return false; + vec.erase(it); + return true; + }; + + bool removed = try_remove(inv_.memory_sticks) + || try_remove(inv_.memory_slots) + || try_remove(inv_.cpus) + || try_remove(inv_.cpu_slots) + || try_remove(inv_.disks) + || try_remove(inv_.network_cards); + if (removed) save_nolock(); + return removed; +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_parts / get_part_row +// ───────────────────────────────────────────────────────────────────────────── +std::vector Database::list_parts(uint32_t server_id, + const std::string& type_filter) const { + std::lock_guard lk(mu_); + std::vector rows; + bool all = type_filter.empty(); + + if (all || type_filter == PTYPE_MEMORY_STICK) + for (const auto& m : inv_.memory_sticks) + if (m.server_id == server_id) rows.push_back(serialize_memory_stick(m)); + + if (all || type_filter == PTYPE_MEMORY_SLOT) + for (const auto& m : inv_.memory_slots) + if (m.server_id == server_id) rows.push_back(serialize_memory_slot(m)); + + if (all || type_filter == PTYPE_CPU) + for (const auto& c : inv_.cpus) + if (c.server_id == server_id) rows.push_back(serialize_cpu(c)); + + if (all || type_filter == PTYPE_CPU_SLOT) + for (const auto& c : inv_.cpu_slots) + if (c.server_id == server_id) rows.push_back(serialize_cpu_slot(c)); + + if (all || type_filter == PTYPE_DISK) + for (const auto& d : inv_.disks) + if (d.server_id == server_id) rows.push_back(serialize_disk(d)); + + if (all || type_filter == PTYPE_NIC) + for (const auto& n : inv_.network_cards) + if (n.server_id == server_id) rows.push_back(serialize_nic(n)); + + return rows; +} + +std::string Database::get_part_row(uint32_t part_id) const { + std::lock_guard lk(mu_); + + for (const auto& m : inv_.memory_sticks) + if (m.part_id == part_id) return serialize_memory_stick(m); + for (const auto& m : inv_.memory_slots) + if (m.part_id == part_id) return serialize_memory_slot(m); + for (const auto& c : inv_.cpus) + if (c.part_id == part_id) return serialize_cpu(c); + for (const auto& c : inv_.cpu_slots) + if (c.part_id == part_id) return serialize_cpu_slot(c); + for (const auto& d : inv_.disks) + if (d.part_id == part_id) return serialize_disk(d); + for (const auto& n : inv_.network_cards) + if (n.part_id == part_id) return serialize_nic(n); + return ""; +} + +// ───────────────────────────────────────────────────────────────────────────── +// get_nics_for_server +// ───────────────────────────────────────────────────────────────────────────── +std::vector Database::get_nics_for_server(uint32_t server_id) const { + std::lock_guard lk(mu_); + std::vector result; + for (const auto& n : inv_.network_cards) + if (n.server_id == server_id) result.push_back(n); + return result; +} diff --git a/services/device-inventory/src/server/database.h b/services/device-inventory/src/server/database.h new file mode 100644 index 0000000..d0fb7c2 --- /dev/null +++ b/services/device-inventory/src/server/database.h @@ -0,0 +1,98 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include "common/models.h" + +// ───────────────────────────────────────────────────────────────────────────── +// Database +// Thread-safe, file-backed inventory store. +// +// File format (text, one record per line): +// # device-inventory database +// META|next_server_id|next_part_type_id|next_part_id +// S|id|name|hostname|location|description +// PT|id|name|description +// MS|part_id|server_id|serial_or_NULL|last_updated|speed_mhz|size_mb|manufacturer|channel_config|other_info +// MSLOT|part_id|server_id|serial_or_NULL|last_updated|allowed_speed_mhz|allowed_size_mb|installed_stick_id_or_NULL +// CPU|part_id|server_id|serial_or_NULL|last_updated|name|manufacturer|speed_ghz|cores|threads +// CPUSLOT|part_id|server_id|serial_or_NULL|last_updated|form_factor|installed_cpu_id_or_NULL +// DISK|part_id|server_id|serial_or_NULL|last_updated|manufacturer|model|generation|conn_type|conn_speed|disk_speed|disk_size|disk_type|age_years|partition_ids_LS|partition_sizes_LS|vm_hostnames_LS|vm_server_ids_LS +// NIC|part_id|server_id|serial_or_NULL|last_updated|manufacturer|model|age_years|conn_type|conn_speed|mac|ips_LS|dhcp +// +// Lists within a field use LS ('\x02'). Pipe and backslash in text fields are +// escaped via escape()/unescape(). +// ───────────────────────────────────────────────────────────────────────────── + +class Database { +public: + explicit Database(std::string path); + + // Persist current inventory to disk (acquires lock) + bool save(); + // Load (or reload) inventory from disk + bool load(); + + // ── Servers ─────────────────────────────────────────────────────────────── + Server add_server(std::string name, std::string hostname, + std::string location, std::string description); + std::vector list_servers() const; + std::optional get_server(uint32_t id) const; + bool edit_server(uint32_t id, const std::string& field, + const std::string& value); + bool remove_server(uint32_t id); + + // ── Part types (labels) ─────────────────────────────────────────────────── + PartType add_part_type(std::string name, std::string description); + std::vector list_part_types() const; + std::optional get_part_type(uint32_t id) const; + bool edit_part_type(uint32_t id, const std::string& field, + const std::string& value); + bool remove_part_type(uint32_t id); + + // ── Typed parts ─────────────────────────────────────────────────────────── + // kv is a map of field-key → field-value strings (K_* constants from protocol.h) + uint32_t add_part(const std::string& type_name, uint32_t server_id, + const std::map& kv); + // Insert or update: for nic match by mac, for others by serial + uint32_t upsert_part(const std::string& type_name, uint32_t server_id, + const std::map& kv); + bool edit_part(uint32_t part_id, const std::map& kv); + bool remove_part(uint32_t part_id); + + // Wire-row format: type_namepart_idserver_idserial_or_NULLlast_updatedk1v1... + std::vector list_parts(uint32_t server_id, + const std::string& type_filter = "") const; + std::string get_part_row(uint32_t part_id) const; // empty = not found + + // Returns all NetworkCard records belonging to the given server. + std::vector get_nics_for_server(uint32_t server_id) const; + +private: + std::string path_; + Inventory inv_; + mutable std::mutex mu_; + + // Internal save without acquiring lock (caller must hold mu_) + bool save_nolock(); + // Internal add_part without acquiring lock + uint32_t add_part_nolock(const std::string& type_name, uint32_t server_id, + const std::map& kv); + + uint32_t alloc_part_id(); // call while holding mu_ + + static std::string escape(const std::string& s); + static std::string unescape(const std::string& s); + static void apply_base(PartBase& p, const std::map& kv); + + // Per-type wire-row serializers + std::string serialize_memory_stick(const MemoryStick& m) const; + std::string serialize_memory_slot (const MemorySlot& m) const; + std::string serialize_cpu (const CPU& c) const; + std::string serialize_cpu_slot (const CPUSlot& c) const; + std::string serialize_disk (const Disk& d) const; + std::string serialize_nic (const NetworkCard& n) const; +}; diff --git a/services/device-inventory/src/server/hooks/dns_updater_hook.cpp b/services/device-inventory/src/server/hooks/dns_updater_hook.cpp new file mode 100644 index 0000000..3b34faa --- /dev/null +++ b/services/device-inventory/src/server/hooks/dns_updater_hook.cpp @@ -0,0 +1,257 @@ +#include "server/hooks/dns_updater_hook.h" +#include "server/database.h" +#include +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── +// Config +// ───────────────────────────────────────────────────────────────────────────── + +DnsUpdaterHook::Config DnsUpdaterHook::load_config() { + auto env = [](const char* key, const char* def) -> std::string { + const char* v = std::getenv(key); + return (v && *v) ? v : def; + }; + Config c; + c.host = env("TECHNITIUM_HOST", "192.168.2.193"); + c.port = std::stoi(env("TECHNITIUM_PORT", "5380")); + c.user = env("TECHNITIUM_USER", "admin"); + c.pass = env("TECHNITIUM_PASS", ""); + c.zone = env("TECHNITIUM_ZONE", "homelab"); + c.ttl = std::stoi(env("DNS_TTL", "300")); + return c; +} + +// ───────────────────────────────────────────────────────────────────────────── +// execute +// ───────────────────────────────────────────────────────────────────────────── + +void DnsUpdaterHook::execute(const Server& old_server, const Server& new_server) { + Config cfg = load_config(); + + if (cfg.pass.empty()) { + std::cerr << "[dns-updater] TECHNITIUM_PASS is not set – skipping\n"; + return; + } + + // Resolve the server's IP address from its NICs. + std::string ip; + { + auto nics = db_.get_nics_for_server(new_server.id); + for (const auto& nic : nics) { + for (const auto& addr : nic.ip_addresses) { + // Strip CIDR suffix if present (e.g. "192.168.2.100/24" → "192.168.2.100") + auto slash = addr.find('/'); + std::string candidate = (slash != std::string::npos) + ? addr.substr(0, slash) : addr; + // Accept only IPv4 + struct in_addr dummy{}; + if (::inet_pton(AF_INET, candidate.c_str(), &dummy) == 1) { + ip = candidate; + break; + } + } + if (!ip.empty()) break; + } + } + + if (ip.empty()) { + std::cerr << "[dns-updater] No IPv4 address found for server '" + << new_server.name << "' – skipping DNS update\n"; + return; + } + + // ── 1. Login ────────────────────────────────────────────────────────────── + std::string login_path = "/api/user/login?user=" + url_encode(cfg.user) + + "&pass=" + url_encode(cfg.pass); + std::string login_resp = http_get(cfg.host, cfg.port, login_path); + + std::string status = json_get(login_resp, "status"); + if (status != "ok") { + std::cerr << "[dns-updater] Login failed: " << login_resp << "\n"; + return; + } + std::string token = json_get(login_resp, "token"); + if (token.empty()) { + std::cerr << "[dns-updater] No token in login response: " << login_resp << "\n"; + return; + } + std::cout << "[dns-updater] Logged in to Technitium\n"; + + // ── 2. Delete old record (ignore errors – record may not exist) ─────────── + std::string old_domain = old_server.name + "." + cfg.zone; + std::string del_body = "token=" + url_encode(token) + + "&domain=" + url_encode(old_domain) + + "&zone=" + url_encode(cfg.zone) + + "&type=A" + + "&ipAddress=" + url_encode(ip); + std::string del_resp = http_post(cfg.host, cfg.port, + "/api/zones/records/delete", del_body); + std::string del_status = json_get(del_resp, "status"); + if (del_status != "ok") { + // Log but continue – record may simply not exist yet + std::cerr << "[dns-updater] Delete old record returned: " << del_resp << "\n"; + } else { + std::cout << "[dns-updater] Deleted DNS record: " << old_domain << "\n"; + } + + // ── 3. Add new record ───────────────────────────────────────────────────── + std::string new_domain = new_server.name + "." + cfg.zone; + std::string add_body = "token=" + url_encode(token) + + "&domain=" + url_encode(new_domain) + + "&zone=" + url_encode(cfg.zone) + + "&type=A" + + "&ttl=" + std::to_string(cfg.ttl) + + "&ipAddress=" + url_encode(ip); + std::string add_resp = http_post(cfg.host, cfg.port, + "/api/zones/records/add", add_body); + std::string add_status = json_get(add_resp, "status"); + if (add_status != "ok") { + std::cerr << "[dns-updater] Add new record failed: " << add_resp << "\n"; + return; + } + std::cout << "[dns-updater] Added DNS record: " << new_domain + << " -> " << ip << "\n"; +} + +// ───────────────────────────────────────────────────────────────────────────── +// HTTP helpers +// ───────────────────────────────────────────────────────────────────────────── + +int DnsUpdaterHook::connect_to(const std::string& host, int port) { + addrinfo hints{}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + addrinfo* res = nullptr; + std::string port_str = std::to_string(port); + int rc = ::getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res); + if (rc != 0) + throw std::runtime_error(std::string("getaddrinfo: ") + ::gai_strerror(rc)); + + int fd = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol); + if (fd < 0) { + ::freeaddrinfo(res); + throw std::runtime_error("socket() failed"); + } + if (::connect(fd, res->ai_addr, res->ai_addrlen) < 0) { + ::close(fd); + ::freeaddrinfo(res); + throw std::runtime_error("connect() to " + host + ":" + port_str + " failed"); + } + ::freeaddrinfo(res); + return fd; +} + +static std::string read_http_response(int fd) { + std::string raw; + char buf[4096]; + ssize_t n; + while ((n = ::recv(fd, buf, sizeof(buf), 0)) > 0) + raw.append(buf, static_cast(n)); + + // Return only the body (after the blank line separating headers from body) + auto pos = raw.find("\r\n\r\n"); + if (pos != std::string::npos) return raw.substr(pos + 4); + pos = raw.find("\n\n"); + if (pos != std::string::npos) return raw.substr(pos + 2); + return raw; +} + +std::string DnsUpdaterHook::http_get(const std::string& host, int port, + const std::string& path) { + int fd = connect_to(host, port); + std::ostringstream req; + req << "GET " << path << " HTTP/1.0\r\n" + << "Host: " << host << ":" << port << "\r\n" + << "Connection: close\r\n" + << "\r\n"; + std::string r = req.str(); + ::send(fd, r.c_str(), r.size(), 0); + std::string body = read_http_response(fd); + ::close(fd); + return body; +} + +std::string DnsUpdaterHook::http_post(const std::string& host, int port, + const std::string& path, + const std::string& body) { + int fd = connect_to(host, port); + std::ostringstream req; + req << "POST " << path << " HTTP/1.0\r\n" + << "Host: " << host << ":" << port << "\r\n" + << "Content-Type: application/x-www-form-urlencoded\r\n" + << "Content-Length: " << body.size() << "\r\n" + << "Connection: close\r\n" + << "\r\n" + << body; + std::string r = req.str(); + ::send(fd, r.c_str(), r.size(), 0); + std::string resp = read_http_response(fd); + ::close(fd); + return resp; +} + +// ───────────────────────────────────────────────────────────────────────────── +// url_encode +// ───────────────────────────────────────────────────────────────────────────── + +std::string DnsUpdaterHook::url_encode(const std::string& s) { + static const char hex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(s.size()); + for (unsigned char c : s) { + if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + out += static_cast(c); + } else { + out += '%'; + out += hex[c >> 4]; + out += hex[c & 0xf]; + } + } + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// json_get – naive but sufficient for Technitium's flat responses +// ───────────────────────────────────────────────────────────────────────────── + +std::string DnsUpdaterHook::json_get(const std::string& json, const std::string& key) { + // Look for "key":"value" or "key": "value" + std::string needle = "\"" + key + "\""; + auto pos = json.find(needle); + if (pos == std::string::npos) return {}; + + pos = json.find(':', pos + needle.size()); + if (pos == std::string::npos) return {}; + + // Skip whitespace + ++pos; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) ++pos; + + if (pos >= json.size()) return {}; + + if (json[pos] == '"') { + // Quoted string value + ++pos; + std::string val; + while (pos < json.size() && json[pos] != '"') { + if (json[pos] == '\\' && pos + 1 < json.size()) ++pos; // skip escape + val += json[pos++]; + } + return val; + } + + // Unquoted value (number, bool, null) – read until delimiter + std::string val; + while (pos < json.size() && json[pos] != ',' && json[pos] != '}' && + json[pos] != '\n' && json[pos] != '\r') { + val += json[pos++]; + } + // Trim trailing whitespace + while (!val.empty() && (val.back() == ' ' || val.back() == '\t')) + val.pop_back(); + return val; +} diff --git a/services/device-inventory/src/server/hooks/dns_updater_hook.h b/services/device-inventory/src/server/hooks/dns_updater_hook.h new file mode 100644 index 0000000..155ed87 --- /dev/null +++ b/services/device-inventory/src/server/hooks/dns_updater_hook.h @@ -0,0 +1,79 @@ +#pragma once +#include "server/hooks/hook.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── +// DnsUpdaterHook +// +// Fires whenever a server's *name* changes. Updates Technitium DNS by: +// 1. Logging in → GET /api/user/login?user=…&pass=… → token +// 2. Deleting the old A record (404 errors are silently ignored) +// 3. Adding the new A record +// +// The server's IP is derived from the first NIC ip_address that belongs to the +// server record. This hook receives the old and new Server structs; to look up +// IP addresses it needs access to the Database – pass a const ref in the ctor. +// +// Config is supplied via environment variables so no secrets land in source: +// TECHNITIUM_HOST (default: 192.168.2.193) +// TECHNITIUM_PORT (default: 5380) +// TECHNITIUM_USER (default: admin) +// TECHNITIUM_PASS (required – hook logs an error and skips if unset) +// TECHNITIUM_ZONE (default: homelab) +// DNS_TTL (default: 300) +// ───────────────────────────────────────────────────────────────────────────── + +class Database; // forward declaration – full header included in .cpp + +class DnsUpdaterHook : public Hook { +public: + explicit DnsUpdaterHook(const Database& db) : db_(db) {} + + // Trigger only when the server name changes. + bool filter(const Server& old_server, const Server& new_server) override { + return old_server.name != new_server.name; + } + + void execute(const Server& old_server, const Server& new_server) override; + +private: + const Database& db_; + + // Resolve config from environment, falling back to defaults. + struct Config { + std::string host; + int port; + std::string user; + std::string pass; + std::string zone; + int ttl; + }; + static Config load_config(); + + // Percent-encode a string for use in a URL query parameter. + static std::string url_encode(const std::string& s); + + // Open a TCP connection; throws on failure. + static int connect_to(const std::string& host, int port); + + // Send an HTTP/1.0 GET request and return the full response body. + static std::string http_get(const std::string& host, int port, + const std::string& path); + + // Send an HTTP/1.0 POST request (application/x-www-form-urlencoded body) + // and return the full response body. + static std::string http_post(const std::string& host, int port, + const std::string& path, + const std::string& body); + + // Minimal JSON value extractor – finds "key":"value" or "key":value. + static std::string json_get(const std::string& json, const std::string& key); +}; diff --git a/services/device-inventory/src/server/hooks/hook.h b/services/device-inventory/src/server/hooks/hook.h new file mode 100644 index 0000000..1a90d25 --- /dev/null +++ b/services/device-inventory/src/server/hooks/hook.h @@ -0,0 +1,25 @@ +#pragma once +#include "common/models.h" + +// ───────────────────────────────────────────────────────────────────────────── +// Hook interface +// +// Each hook decides whether it needs to fire (filter) and then performs its +// side-effect (execute). Both functions receive the state of the Server record +// before and after the mutation. +// +// Hooks are invoked from HookRunner::fire(), which spawns a detached thread per +// hook and wraps execute() in a try/catch so a crashing hook cannot affect the +// server. +// ───────────────────────────────────────────────────────────────────────────── + +struct Hook { + virtual ~Hook() = default; + + // Return true if this hook should run for the given change. + virtual bool filter(const Server& old_server, const Server& new_server) = 0; + + // Perform the side-effect. Called in a dedicated thread; exceptions are + // caught and logged by HookRunner. + virtual void execute(const Server& old_server, const Server& new_server) = 0; +}; diff --git a/services/device-inventory/src/server/hooks/hook_runner.h b/services/device-inventory/src/server/hooks/hook_runner.h new file mode 100644 index 0000000..6c0397e --- /dev/null +++ b/services/device-inventory/src/server/hooks/hook_runner.h @@ -0,0 +1,55 @@ +#pragma once +#include "server/hooks/hook.h" +#include +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── +// HookRunner +// +// Owns a list of Hook instances. Call fire() after every server mutation to +// run every matching hook in its own detached thread. +// ───────────────────────────────────────────────────────────────────────────── + +class HookRunner { +public: + void add(std::unique_ptr hook) { + hooks_.push_back(std::move(hook)); + } + + // Call after a server record is mutated. For each registered hook: + // 1. Calls filter() on the calling thread (cheap, no I/O). + // 2. If filter returns true, spawns a detached thread that calls + // execute() and catches any exception. + void fire(const Server& old_server, const Server& new_server) { + for (auto& hook : hooks_) { + bool should_run = false; + try { + should_run = hook->filter(old_server, new_server); + } catch (const std::exception& e) { + std::cerr << "[hooks] filter() threw: " << e.what() << "\n"; + } catch (...) { + std::cerr << "[hooks] filter() threw unknown exception\n"; + } + + if (!should_run) continue; + + // Capture raw pointer – hook lifetime is tied to HookRunner which + // outlives the server process, so this is safe. + Hook* raw = hook.get(); + std::thread([raw, old_server, new_server]() { + try { + raw->execute(old_server, new_server); + } catch (const std::exception& e) { + std::cerr << "[hooks] execute() threw: " << e.what() << "\n"; + } catch (...) { + std::cerr << "[hooks] execute() threw unknown exception\n"; + } + }).detach(); + } + } + +private: + std::vector> hooks_; +}; diff --git a/services/device-inventory/src/server/main.cpp b/services/device-inventory/src/server/main.cpp new file mode 100644 index 0000000..679dace --- /dev/null +++ b/services/device-inventory/src/server/main.cpp @@ -0,0 +1,57 @@ +#include "server/server.h" +#include "server/hooks/dns_updater_hook.h" +#include "common/protocol.h" +#include +#include +#include +#include +#include + +static constexpr const char* VERSION = "1.0.0"; + +static InventoryServer* g_server = nullptr; + +static void on_signal(int) { + std::cout << "\n[server] Shutting down...\n"; + if (g_server) g_server->stop(); +} + +int main(int argc, char* argv[]) { + uint16_t port = DEFAULT_PORT; + std::string db_path = "inventory.db"; + + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if ((arg == "--port" || arg == "-p") && i + 1 < argc) + port = static_cast(std::stoul(argv[++i])); + else if ((arg == "--db" || arg == "-d") && i + 1 < argc) + db_path = argv[++i]; + else if (arg == "--help" || arg == "-h") { + std::cout << "inventory-server v" << VERSION << "\n" + << "Usage: inventory-server [options]\n\n" + << "Options:\n" + << " --port, -p PORT TCP port to listen on (default: " + << DEFAULT_PORT << ")\n" + << " --db, -d PATH Path to the inventory database file" + " (default: inventory.db)\n" + << " --help, -h Show this help\n"; + return 0; + } + } + + std::signal(SIGINT, on_signal); + std::signal(SIGTERM, on_signal); + + std::cout << "inventory-server v" << VERSION << "\n" + << "[server] Database: " << db_path << "\n"; + + InventoryServer server(port, db_path); + g_server = &server; + + server.hooks().add(std::make_unique(server.db())); + + server.run(); + std::cout << "[server] Bye\n"; + return 0; +} + diff --git a/services/device-inventory/src/server/server.cpp b/services/device-inventory/src/server/server.cpp new file mode 100644 index 0000000..07cc068 --- /dev/null +++ b/services/device-inventory/src/server/server.cpp @@ -0,0 +1,278 @@ +#include "server/server.h" +#include "common/protocol.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── +// Constructor / Destructor +// ───────────────────────────────────────────────────────────────────────────── +InventoryServer::InventoryServer(uint16_t port, std::string db_path) + : port_(port), db_(std::move(db_path)) {} + +InventoryServer::~InventoryServer() { stop(); } + +// ───────────────────────────────────────────────────────────────────────────── +// run +// ───────────────────────────────────────────────────────────────────────────── +void InventoryServer::run() { + if (!db_.load()) { + std::cerr << "[server] Failed to load database – aborting\n"; + return; + } + std::cout << "[server] Inventory loaded\n"; + + listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0); + if (listen_fd_ < 0) { perror("socket"); return; } + + int opt = 1; + ::setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons(port_); + + if (::bind(listen_fd_, reinterpret_cast(&addr), sizeof(addr)) < 0) { + perror("bind"); ::close(listen_fd_); listen_fd_ = -1; return; + } + if (::listen(listen_fd_, 32) < 0) { + perror("listen"); ::close(listen_fd_); listen_fd_ = -1; return; + } + + running_ = true; + std::cout << "[server] Listening on 127.0.0.1:" << port_ << "\n"; + + while (running_) { + sockaddr_in client_addr{}; + socklen_t len = sizeof(client_addr); + int client_fd = ::accept(listen_fd_, + reinterpret_cast(&client_addr), &len); + if (client_fd < 0) { + if (running_) perror("accept"); + break; + } + std::thread([this, client_fd]() { handle_client(client_fd); }).detach(); + } +} + +void InventoryServer::stop() { + running_ = false; + if (listen_fd_ >= 0) { ::close(listen_fd_); listen_fd_ = -1; } +} + +// ───────────────────────────────────────────────────────────────────────────── +// handle_client +// ───────────────────────────────────────────────────────────────────────────── +void InventoryServer::handle_client(int fd) { + std::string line; + while (true) { + char buf[4096]; + ssize_t n = ::recv(fd, buf, sizeof(buf), 0); + if (n <= 0) break; + line.append(buf, static_cast(n)); + if (line.find('\n') != std::string::npos) break; + } + while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) + line.pop_back(); + + std::vector tokens = split(line, FS); + std::string response = dispatch(tokens); + ::send(fd, response.c_str(), response.size(), 0); + ::close(fd); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Response helpers +// ───────────────────────────────────────────────────────────────────────────── +static std::string ok(const std::string& body = "") { + if (body.empty()) + return std::string(RESP_OK) + "\n" + RESP_END + "\n"; + return std::string(RESP_OK) + "\n" + body + RESP_END + "\n"; +} +static std::string err(const std::string& msg) { + return std::string(RESP_ERR) + " " + msg + "\n"; +} + +// ───────────────────────────────────────────────────────────────────────────── +// dispatch +// ───────────────────────────────────────────────────────────────────────────── +std::string InventoryServer::dispatch(const std::vector& t) { + if (t.empty()) return err("empty command"); + const std::string& cmd = t[0]; + + if (cmd == CMD_ADD_SERVER) return cmd_add_server(t); + else if (cmd == CMD_LIST_SERVERS) return cmd_list_servers(t); + else if (cmd == CMD_EDIT_SERVER) return cmd_edit_server(t); + else if (cmd == CMD_REMOVE_SERVER) return cmd_remove_server(t); + else if (cmd == CMD_ADD_PART_TYPE) return cmd_add_part_type(t); + else if (cmd == CMD_LIST_PART_TYPES) return cmd_list_part_types(t); + else if (cmd == CMD_EDIT_PART_TYPE) return cmd_edit_part_type(t); + else if (cmd == CMD_REMOVE_PART_TYPE) return cmd_remove_part_type(t); + else if (cmd == CMD_ADD_PART) return cmd_add_part(t); + else if (cmd == CMD_UPSERT_PART) return cmd_upsert_part(t); + else if (cmd == CMD_EDIT_PART) return cmd_edit_part(t); + else if (cmd == CMD_REMOVE_PART) return cmd_remove_part(t); + else if (cmd == CMD_LIST_PARTS) return cmd_list_parts(t); + else if (cmd == CMD_GET_PART) return cmd_get_part(t); + return err("unknown command: " + cmd); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Server handlers +// ───────────────────────────────────────────────────────────────────────────── +std::string InventoryServer::cmd_add_server(const std::vector& t) { + if (t.size() < 5) return err("usage: ADD_SERVER "); + auto s = db_.add_server(t[1], t[2], t[3], t[4]); + std::ostringstream ss; + ss << s.id << FS << s.name << FS << s.hostname << FS + << s.location << FS << s.description << "\n"; + return ok(ss.str()); +} + +std::string InventoryServer::cmd_list_servers(const std::vector&) { + auto servers = db_.list_servers(); + std::ostringstream ss; + for (const auto& s : servers) + ss << s.id << FS << s.name << FS << s.hostname << FS + << s.location << FS << s.description << "\n"; + return ok(ss.str()); +} + +std::string InventoryServer::cmd_edit_server(const std::vector& t) { + if (t.size() < 4) return err("usage: EDIT_SERVER "); + uint32_t id = static_cast(std::stoul(t[1])); + auto before = db_.get_server(id); + if (!before) return err("server not found or invalid field"); + if (!db_.edit_server(id, t[2], t[3])) + return err("server not found or invalid field"); + auto after = db_.get_server(id); + if (after) hooks_.fire(*before, *after); + return ok(); +} + +std::string InventoryServer::cmd_remove_server(const std::vector& t) { + if (t.size() < 2) return err("usage: REMOVE_SERVER "); + uint32_t id = static_cast(std::stoul(t[1])); + if (!db_.remove_server(id)) + return err("server not found"); + return ok(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Part type handlers +// ───────────────────────────────────────────────────────────────────────────── +std::string InventoryServer::cmd_add_part_type(const std::vector& t) { + if (t.size() < 3) return err("usage: ADD_PART_TYPE "); + auto pt = db_.add_part_type(t[1], t[2]); + std::ostringstream ss; + ss << pt.id << FS << pt.name << FS << pt.description << "\n"; + return ok(ss.str()); +} + +std::string InventoryServer::cmd_list_part_types(const std::vector&) { + auto pts = db_.list_part_types(); + std::ostringstream ss; + for (const auto& pt : pts) + ss << pt.id << FS << pt.name << FS << pt.description << "\n"; + return ok(ss.str()); +} + +std::string InventoryServer::cmd_edit_part_type(const std::vector& t) { + if (t.size() < 4) return err("usage: EDIT_PART_TYPE "); + uint32_t id = static_cast(std::stoul(t[1])); + if (!db_.edit_part_type(id, t[2], t[3])) + return err("part type not found or invalid field"); + return ok(); +} + +std::string InventoryServer::cmd_remove_part_type(const std::vector& t) { + if (t.size() < 2) return err("usage: REMOVE_PART_TYPE "); + uint32_t id = static_cast(std::stoul(t[1])); + if (!db_.remove_part_type(id)) + return err("part type not found"); + return ok(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Part handlers — helpers +// ───────────────────────────────────────────────────────────────────────────── +// Parse key/value pairs from tokens starting at offset `start` +static std::map parse_kv(const std::vector& t, + size_t start) { + std::map kv; + for (size_t i = start; i + 1 < t.size(); i += 2) + kv[t[i]] = t[i + 1]; + return kv; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Part handlers +// ───────────────────────────────────────────────────────────────────────────── +std::string InventoryServer::cmd_add_part(const std::vector& t) { + // ADD_PART key1 val1 key2 val2 … + if (t.size() < 3) return err("usage: ADD_PART [key val ...]"); + const std::string& type_name = t[1]; + uint32_t server_id = static_cast(std::stoul(t[2])); + auto kv = parse_kv(t, 3); + uint32_t pid = db_.add_part(type_name, server_id, kv); + if (pid == 0) return err("unknown part type: " + type_name); + std::string row = db_.get_part_row(pid); + return ok(row.empty() ? "" : row); +} + +std::string InventoryServer::cmd_upsert_part(const std::vector& t) { + // UPSERT_PART key1 val1 key2 val2 … + if (t.size() < 3) return err("usage: UPSERT_PART [key val ...]"); + const std::string& type_name = t[1]; + uint32_t server_id = static_cast(std::stoul(t[2])); + auto kv = parse_kv(t, 3); + uint32_t pid = db_.upsert_part(type_name, server_id, kv); + if (pid == 0) return err("unknown part type: " + type_name); + std::string row = db_.get_part_row(pid); + return ok(row.empty() ? "" : row); +} + +std::string InventoryServer::cmd_edit_part(const std::vector& t) { + // EDIT_PART key1 val1 key2 val2 … + if (t.size() < 4) return err("usage: EDIT_PART key val [key val ...]"); + uint32_t part_id = static_cast(std::stoul(t[1])); + auto kv = parse_kv(t, 2); + if (!db_.edit_part(part_id, kv)) + return err("part not found"); + return ok(); +} + +std::string InventoryServer::cmd_remove_part(const std::vector& t) { + if (t.size() < 2) return err("usage: REMOVE_PART "); + uint32_t part_id = static_cast(std::stoul(t[1])); + if (!db_.remove_part(part_id)) + return err("part not found"); + return ok(); +} + +std::string InventoryServer::cmd_list_parts(const std::vector& t) { + // LIST_PARTS [type_filter] + if (t.size() < 2) return err("usage: LIST_PARTS [type]"); + uint32_t server_id = static_cast(std::stoul(t[1])); + std::string type_filter = (t.size() >= 3) ? t[2] : ""; + auto rows = db_.list_parts(server_id, type_filter); + std::ostringstream ss; + for (const auto& row : rows) ss << row; + return ok(ss.str()); +} + +std::string InventoryServer::cmd_get_part(const std::vector& t) { + if (t.size() < 2) return err("usage: GET_PART "); + uint32_t part_id = static_cast(std::stoul(t[1])); + std::string row = db_.get_part_row(part_id); + if (row.empty()) return err("part not found"); + return ok(row); +} diff --git a/services/device-inventory/src/server/server.h b/services/device-inventory/src/server/server.h new file mode 100644 index 0000000..d846466 --- /dev/null +++ b/services/device-inventory/src/server/server.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include +#include "server/database.h" +#include "server/hooks/hook_runner.h" + +// ───────────────────────────────────────────────────────────────────────────── +// InventoryServer +// Listens on TCP 127.0.0.1:, spawns a thread per connection. +// Each connection reads one command line, writes one response, then closes. +// ───────────────────────────────────────────────────────────────────────────── + +class InventoryServer { +public: + InventoryServer(uint16_t port, std::string db_path); + ~InventoryServer(); + + void run(); // blocking – returns only when stop() is called + void stop(); + + HookRunner& hooks() { return hooks_; } + const Database& db() const { return db_; } + +private: + uint16_t port_; + Database db_; + HookRunner hooks_; + int listen_fd_ = -1; + std::atomic running_{false}; + + void handle_client(int client_fd); + std::string dispatch(const std::vector& tokens); + + // ── Server handlers ─────────────────────────────────────────────────────── + std::string cmd_add_server (const std::vector& t); + std::string cmd_list_servers (const std::vector& t); + std::string cmd_edit_server (const std::vector& t); + std::string cmd_remove_server (const std::vector& t); + + // ── Part type handlers ──────────────────────────────────────────────────── + std::string cmd_add_part_type (const std::vector& t); + std::string cmd_list_part_types (const std::vector& t); + std::string cmd_edit_part_type (const std::vector& t); + std::string cmd_remove_part_type (const std::vector& t); + + // ── Part handlers ───────────────────────────────────────────────────────── + std::string cmd_add_part (const std::vector& t); + std::string cmd_upsert_part (const std::vector& t); + std::string cmd_edit_part (const std::vector& t); + std::string cmd_remove_part (const std::vector& t); + std::string cmd_list_parts (const std::vector& t); + std::string cmd_get_part (const std::vector& t); +}; + diff --git a/services/device-inventory/src/test/inv_test.sh b/services/device-inventory/src/test/inv_test.sh new file mode 100755 index 0000000..97bdb90 --- /dev/null +++ b/services/device-inventory/src/test/inv_test.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -e +cd /Users/dan/work/homelab/services/device-inventory + +lsof -ti :9876 | xargs kill -9 2>/dev/null || true +rm -f /tmp/inv2.db +./build/inventory-server --db /tmp/inv2.db --port 9876 & +SRV=$! +sleep 0.4 + +CLI="./build/inventory-cli" + +echo "=== add server ===" +$CLI add-server "web01" "192.168.1.10" "Rack A" "Primary web server" + +echo "=== list servers ===" +$CLI list-servers + +echo "=== add part types ===" +$CLI add-part-type "cpu" "CPU processors" +$CLI add-part-type "nic" "Network interface cards" +$CLI add-part-type "disk" "Storage disks" + +echo "=== list part types ===" +$CLI list-part-types + +echo "=== add cpu ===" +$CLI add-part cpu 1 name="Intel Xeon E5-2690" manufacturer=Intel speed_ghz=3.2 cores=8 threads=16 + +echo "=== add nic (multiple IPs) ===" +$CLI add-part nic 1 manufacturer=Intel model="I210" connection_type=ethernet \ + connection_speed_mbps=1000 mac_address=aa:bb:cc:dd:ee:ff \ + ip_addresses=192.168.1.10/24 ip_addresses=10.0.0.1/24 dhcp=false + +echo "=== add disk ===" +$CLI add-part disk 1 manufacturer=Samsung model="870 EVO" disk_type=ssd \ + connection_type=SATA disk_size_gb=500 serial=S4EVNX0R123456 \ + partition_ids=sda1 partition_ids=sda2 partition_sizes_gb=50 partition_sizes_gb=450 + +echo "=== list all parts ===" +$CLI list-parts 1 + +echo "=== list only cpu ===" +$CLI list-parts 1 --type cpu + +echo "=== edit part 1 (cpu speed) ===" +$CLI edit-part 1 speed_ghz=3.5 + +echo "=== get part 1 ===" +$CLI get-part 1 + +echo "=== edit server ===" +$CLI edit-server 1 hostname "10.0.0.100" + +echo "=== remove nic (part 2) ===" +$CLI remove-part 2 + +echo "=== list parts after remove ===" +$CLI list-parts 1 + +echo "=== discover dry-run ===" +$CLI discover 1 --dry-run 2>&1 | head -40 + +echo "" +echo "=== DB file ===" +cat /tmp/inv2.db + +kill $SRV 2>/dev/null +echo "" +echo "=== DONE ===" diff --git a/services/device-inventory/web-ui/Dockerfile b/services/device-inventory/web-ui/Dockerfile new file mode 100644 index 0000000..8a262d8 --- /dev/null +++ b/services/device-inventory/web-ui/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.22-alpine AS builder +WORKDIR /src +COPY main.go . +RUN go mod init inventory-web-ui && \ + go build -o inventory-web-ui . + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +COPY --from=builder /src/inventory-web-ui /usr/local/bin/ +EXPOSE 8080 +CMD ["inventory-web-ui"] diff --git a/services/device-inventory/web-ui/main.go b/services/device-inventory/web-ui/main.go new file mode 100644 index 0000000..b858c91 --- /dev/null +++ b/services/device-inventory/web-ui/main.go @@ -0,0 +1,437 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "strings" + "time" +) + +const ( + fieldSep = "\x01" + listSep = "\x02" +) + +var serverAddr string + +func init() { + host := os.Getenv("INVENTORY_HOST") + if host == "" { + host = "inventory-server" + } + port := os.Getenv("INVENTORY_PORT") + if port == "" { + port = "9876" + } + serverAddr = host + ":" + port +} + +// sendCommand sends a protocol command and returns the response rows. +func sendCommand(tokens []string) ([][]string, error) { + conn, err := net.DialTimeout("tcp", serverAddr, 5*time.Second) + if err != nil { + return nil, fmt.Errorf("connect to %s: %w", serverAddr, err) + } + defer conn.Close() + + conn.SetDeadline(time.Now().Add(15 * time.Second)) + + line := strings.Join(tokens, fieldSep) + "\n" + if _, err := conn.Write([]byte(line)); err != nil { + return nil, fmt.Errorf("write: %w", err) + } + + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + + if !scanner.Scan() { + return nil, fmt.Errorf("no response from server") + } + status := scanner.Text() + + if strings.HasPrefix(status, "ERR") { + msg := "" + if len(status) > 4 { + msg = status[4:] + } + return nil, fmt.Errorf("server: %s", msg) + } + if status != "OK" { + return nil, fmt.Errorf("unexpected status: %s", status) + } + + var rows [][]string + for scanner.Scan() { + row := scanner.Text() + if row == "END" { + break + } + rows = append(rows, strings.Split(row, fieldSep)) + } + return rows, scanner.Err() +} + +// ── Domain types ───────────────────────────────────────────────────────────── + +type Server struct { + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + Location string `json:"location"` + Description string `json:"description"` +} + +type PartType struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type Part struct { + ID string `json:"id"` + Type string `json:"type"` + ServerID string `json:"server_id"` + Serial string `json:"serial"` + LastUpdated int64 `json:"last_updated"` + Fields map[string]string `json:"fields"` +} + +// ── Query helpers ───────────────────────────────────────────────────────────── + +func listServers() ([]Server, error) { + rows, err := sendCommand([]string{"LIST_SERVERS"}) + if err != nil { + return nil, err + } + var out []Server + for _, f := range rows { + if len(f) < 5 { + continue + } + out = append(out, Server{ID: f[0], Name: f[1], Hostname: f[2], Location: f[3], Description: f[4]}) + } + return out, nil +} + +func listPartTypes() ([]PartType, error) { + rows, err := sendCommand([]string{"LIST_PART_TYPES"}) + if err != nil { + return nil, err + } + var out []PartType + for _, f := range rows { + if len(f) < 3 { + continue + } + out = append(out, PartType{ID: f[0], Name: f[1], Description: f[2]}) + } + return out, nil +} + +func listParts(serverID string) ([]Part, error) { + rows, err := sendCommand([]string{"LIST_PARTS", serverID}) + if err != nil { + return nil, err + } + var out []Part + for _, f := range rows { + if len(f) < 5 { + continue + } + fields := make(map[string]string) + for i := 5; i+1 < len(f); i += 2 { + val := strings.ReplaceAll(f[i+1], listSep, ", ") + fields[f[i]] = val + } + var ts int64 + fmt.Sscanf(f[4], "%d", &ts) + out = append(out, Part{Type: f[0], ID: f[1], ServerID: f[2], Serial: f[3], LastUpdated: ts, Fields: fields}) + } + return out, nil +} + +// ── HTTP handlers ───────────────────────────────────────────────────────────── + +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func main() { + mux := http.NewServeMux() + + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "ok") + }) + + mux.HandleFunc("/api/servers", func(w http.ResponseWriter, r *http.Request) { + servers, err := listServers() + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + if servers == nil { + servers = []Server{} + } + writeJSON(w, servers) + }) + + mux.HandleFunc("/api/part-types", func(w http.ResponseWriter, r *http.Request) { + pts, err := listPartTypes() + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + if pts == nil { + pts = []PartType{} + } + writeJSON(w, pts) + }) + + // /api/servers/{id}/parts + mux.HandleFunc("/api/servers/", func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + // parts = ["api", "servers", "{id}", "parts"] + if len(parts) != 4 || parts[3] != "parts" { + http.NotFound(w, r) + return + } + serverID := parts[2] + plist, err := listParts(serverID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + if plist == nil { + plist = []Part{} + } + writeJSON(w, plist) + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, dashboardHTML) + }) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + log.Printf("inventory-web-ui listening on :%s, backend: %s", port, serverAddr) + if err := http.ListenAndServe(":"+port, mux); err != nil { + log.Fatal(err) + } +} + +// ── Embedded dashboard ──────────────────────────────────────────────────────── + +const dashboardHTML = ` + + + + +Device Inventory + + + +
+

Device Inventory

+ HOMELAB + +
+
+ +
+
← Select a server to view its hardware inventory
+
+
+ + + +`