add: memory, storage, pcie collectors (1.4-1.6) — tested on real hardware
This commit is contained in:
@@ -25,7 +25,11 @@ func Run() schema.HardwareIngestRequest {
|
|||||||
snap.CPUs = cpus
|
snap.CPUs = cpus
|
||||||
snap.Firmware = append(snap.Firmware, cpuFW...)
|
snap.Firmware = append(snap.Firmware, cpuFW...)
|
||||||
|
|
||||||
// remaining collectors added in steps 1.4 – 1.10
|
snap.Memory = collectMemory()
|
||||||
|
snap.Storage = collectStorage()
|
||||||
|
snap.PCIeDevices = collectPCIe()
|
||||||
|
|
||||||
|
// remaining collectors added in steps 1.7 – 1.10
|
||||||
|
|
||||||
slog.Info("audit completed", "duration", time.Since(start).Round(time.Millisecond))
|
slog.Info("audit completed", "duration", time.Since(start).Round(time.Millisecond))
|
||||||
|
|
||||||
|
|||||||
98
audit/internal/collector/memory.go
Normal file
98
audit/internal/collector/memory.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bee/audit/internal/schema"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// collectMemory runs dmidecode -t 17 and returns all memory slots.
|
||||||
|
func collectMemory() []schema.HardwareMemory {
|
||||||
|
out, err := runDmidecode("17")
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("memory: dmidecode type 17 failed", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dimms := parseMemory(out)
|
||||||
|
slog.Info("memory: collected", "count", len(dimms))
|
||||||
|
return dimms
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMemory(output string) []schema.HardwareMemory {
|
||||||
|
sections := splitDMISections(output, "Memory Device")
|
||||||
|
dimms := make([]schema.HardwareMemory, 0, len(sections))
|
||||||
|
for _, fields := range sections {
|
||||||
|
dimm := parseMemorySection(fields)
|
||||||
|
dimms = append(dimms, dimm)
|
||||||
|
}
|
||||||
|
return dimms
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMemorySection(fields map[string]string) schema.HardwareMemory {
|
||||||
|
dimm := schema.HardwareMemory{}
|
||||||
|
|
||||||
|
if v := cleanDMIValue(fields["Locator"]); v != "" {
|
||||||
|
dimm.Slot = &v
|
||||||
|
}
|
||||||
|
if v := cleanDMIValue(fields["Bank Locator"]); v != "" {
|
||||||
|
dimm.Location = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// presence: "No Module Installed" or size == 0
|
||||||
|
present := true
|
||||||
|
rawSize := fields["Size"]
|
||||||
|
if strings.Contains(strings.ToLower(rawSize), "no module") || rawSize == "0" || rawSize == "" {
|
||||||
|
present = false
|
||||||
|
}
|
||||||
|
dimm.Present = &present
|
||||||
|
|
||||||
|
if !present {
|
||||||
|
status := "EMPTY"
|
||||||
|
dimm.Status = &status
|
||||||
|
return dimm
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "OK"
|
||||||
|
dimm.Status = &status
|
||||||
|
|
||||||
|
if mb := parseMemorySizeMB(rawSize); mb > 0 {
|
||||||
|
dimm.SizeMB = &mb
|
||||||
|
}
|
||||||
|
if v := cleanDMIValue(fields["Type"]); v != "" && v != "Unknown" {
|
||||||
|
dimm.Type = &v
|
||||||
|
}
|
||||||
|
if mhz := parseInt(strings.TrimSuffix(fields["Speed"], " MT/s")); mhz > 0 {
|
||||||
|
dimm.MaxSpeedMHz = &mhz
|
||||||
|
}
|
||||||
|
if mhz := parseInt(strings.TrimSuffix(fields["Configured Memory Speed"], " MT/s")); mhz > 0 {
|
||||||
|
dimm.CurrentSpeedMHz = &mhz
|
||||||
|
}
|
||||||
|
if v := cleanDMIValue(fields["Manufacturer"]); v != "" {
|
||||||
|
dimm.Manufacturer = &v
|
||||||
|
}
|
||||||
|
if v := cleanDMIValue(fields["Serial Number"]); v != "" {
|
||||||
|
dimm.SerialNumber = &v
|
||||||
|
}
|
||||||
|
if v := cleanDMIValue(fields["Part Number"]); v != "" {
|
||||||
|
p := strings.TrimSpace(v)
|
||||||
|
dimm.PartNumber = &p
|
||||||
|
}
|
||||||
|
|
||||||
|
return dimm
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMemorySizeMB parses DMI size strings: "8 GB", "2048 MB", "No Module Installed".
|
||||||
|
func parseMemorySizeMB(s string) int {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if strings.Contains(strings.ToLower(s), "no module") {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(s, " GB") {
|
||||||
|
n := parseInt(strings.TrimSuffix(s, " GB"))
|
||||||
|
return n * 1024
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(s, " MB") {
|
||||||
|
return parseInt(strings.TrimSuffix(s, " MB"))
|
||||||
|
}
|
||||||
|
return parseInt(s)
|
||||||
|
}
|
||||||
101
audit/internal/collector/pcie.go
Normal file
101
audit/internal/collector/pcie.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bee/audit/internal/schema"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func collectPCIe() []schema.HardwarePCIeDevice {
|
||||||
|
out, err := exec.Command("lspci", "-vmm", "-D").Output()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("pcie: lspci failed", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
devs := parseLspci(string(out))
|
||||||
|
slog.Info("pcie: collected", "count", len(devs))
|
||||||
|
return devs
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLspci(output string) []schema.HardwarePCIeDevice {
|
||||||
|
// lspci -vmm -D outputs blank-line separated records, each field is "Key:\tValue"
|
||||||
|
var devs []schema.HardwarePCIeDevice
|
||||||
|
for _, block := range strings.Split(output, "\n\n") {
|
||||||
|
block = strings.TrimSpace(block)
|
||||||
|
if block == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := map[string]string{}
|
||||||
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
idx := strings.Index(line, ":\t")
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(line[:idx])
|
||||||
|
val := strings.TrimSpace(line[idx+2:])
|
||||||
|
fields[key] = val
|
||||||
|
}
|
||||||
|
dev := parseLspciDevice(fields)
|
||||||
|
devs = append(devs, dev)
|
||||||
|
}
|
||||||
|
return devs
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLspciDevice(fields map[string]string) schema.HardwarePCIeDevice {
|
||||||
|
dev := schema.HardwarePCIeDevice{}
|
||||||
|
present := true
|
||||||
|
dev.Present = &present
|
||||||
|
status := "OK"
|
||||||
|
dev.Status = &status
|
||||||
|
|
||||||
|
// Slot is the BDF: "0000:00:02.0"
|
||||||
|
if bdf := fields["Slot"]; bdf != "" {
|
||||||
|
dev.BDF = &bdf
|
||||||
|
// parse vendor_id and device_id from sysfs
|
||||||
|
vendorID, deviceID := readPCIIDs(bdf)
|
||||||
|
if vendorID != 0 {
|
||||||
|
dev.VendorID = &vendorID
|
||||||
|
}
|
||||||
|
if deviceID != 0 {
|
||||||
|
dev.DeviceID = &deviceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := fields["Class"]; v != "" {
|
||||||
|
dev.DeviceClass = &v
|
||||||
|
}
|
||||||
|
if v := fields["Vendor"]; v != "" {
|
||||||
|
dev.Manufacturer = &v
|
||||||
|
}
|
||||||
|
if v := fields["Device"]; v != "" {
|
||||||
|
dev.Model = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVendor/SDevice available but not in schema — skip
|
||||||
|
|
||||||
|
return dev
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPCIIDs reads vendor and device IDs from sysfs for a given BDF.
|
||||||
|
func readPCIIDs(bdf string) (vendorID, deviceID int) {
|
||||||
|
base := "/sys/bus/pci/devices/" + bdf
|
||||||
|
if v, err := readHexFile(base + "/vendor"); err == nil {
|
||||||
|
vendorID = v
|
||||||
|
}
|
||||||
|
if v, err := readHexFile(base + "/device"); err == nil {
|
||||||
|
deviceID = v
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHexFile(path string) (int, error) {
|
||||||
|
out, err := exec.Command("cat", path).Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(strings.TrimPrefix(string(out), "0x"))
|
||||||
|
n, err := strconv.ParseInt(s, 16, 64)
|
||||||
|
return int(n), err
|
||||||
|
}
|
||||||
177
audit/internal/collector/storage.go
Normal file
177
audit/internal/collector/storage.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bee/audit/internal/schema"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func collectStorage() []schema.HardwareStorage {
|
||||||
|
devs := lsblkDevices()
|
||||||
|
result := make([]schema.HardwareStorage, 0, len(devs))
|
||||||
|
for _, dev := range devs {
|
||||||
|
s := enrichWithSmartctl(dev)
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
slog.Info("storage: collected", "count", len(result))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// lsblkDevice is a minimal lsblk JSON record.
|
||||||
|
type lsblkDevice struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Tran string `json:"tran"`
|
||||||
|
Hctl string `json:"hctl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lsblkRoot struct {
|
||||||
|
Blockdevices []lsblkDevice `json:"blockdevices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func lsblkDevices() []lsblkDevice {
|
||||||
|
out, err := exec.Command("lsblk", "-J", "-d",
|
||||||
|
"-o", "NAME,TYPE,SIZE,SERIAL,MODEL,TRAN,HCTL").Output()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("storage: lsblk failed", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var root lsblkRoot
|
||||||
|
if err := json.Unmarshal(out, &root); err != nil {
|
||||||
|
slog.Warn("storage: lsblk parse failed", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var disks []lsblkDevice
|
||||||
|
for _, d := range root.Blockdevices {
|
||||||
|
if d.Type == "disk" {
|
||||||
|
disks = append(disks, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return disks
|
||||||
|
}
|
||||||
|
|
||||||
|
// smartctlInfo is the subset of smartctl -j -a output we care about.
|
||||||
|
type smartctlInfo struct {
|
||||||
|
ModelFamily string `json:"model_family"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
FirmwareVer string `json:"firmware_version"`
|
||||||
|
RotationRate int `json:"rotation_rate"`
|
||||||
|
UserCapacity struct {
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
} `json:"user_capacity"`
|
||||||
|
AtaSmartAttributes struct {
|
||||||
|
Table []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Raw struct{ Value int64 `json:"value"` } `json:"raw"`
|
||||||
|
} `json:"table"`
|
||||||
|
} `json:"ata_smart_attributes"`
|
||||||
|
PowerOnTime struct {
|
||||||
|
Hours int `json:"hours"`
|
||||||
|
} `json:"power_on_time"`
|
||||||
|
PowerCycleCount int `json:"power_cycle_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichWithSmartctl(dev lsblkDevice) schema.HardwareStorage {
|
||||||
|
present := true
|
||||||
|
s := schema.HardwareStorage{Present: &present}
|
||||||
|
|
||||||
|
tran := strings.ToLower(dev.Tran)
|
||||||
|
devPath := "/dev/" + dev.Name
|
||||||
|
|
||||||
|
// determine device type (refined by smartctl rotation_rate below)
|
||||||
|
var devType string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(dev.Name, "nvme"):
|
||||||
|
devType = "NVMe"
|
||||||
|
case tran == "usb":
|
||||||
|
devType = "USB"
|
||||||
|
case tran == "sata" || tran == "sas":
|
||||||
|
devType = "HDD" // refined to SSD below if rotation_rate==0
|
||||||
|
default:
|
||||||
|
devType = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
iface := strings.ToUpper(tran)
|
||||||
|
if iface != "" {
|
||||||
|
s.Interface = &iface
|
||||||
|
}
|
||||||
|
|
||||||
|
// slot from HCTL (host:channel:target:lun)
|
||||||
|
if dev.Hctl != "" {
|
||||||
|
s.Slot = &dev.Hctl
|
||||||
|
}
|
||||||
|
|
||||||
|
// run smartctl
|
||||||
|
out, err := exec.Command("smartctl", "-j", "-a", devPath).Output()
|
||||||
|
if err != nil {
|
||||||
|
// still fill what lsblk gave us
|
||||||
|
if v := strings.TrimSpace(dev.Model); v != "" {
|
||||||
|
s.Model = &v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(dev.Serial); v != "" {
|
||||||
|
s.SerialNumber = &v
|
||||||
|
}
|
||||||
|
s.Type = &devType
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var info smartctlInfo
|
||||||
|
if err := json.Unmarshal(out, &info); err == nil {
|
||||||
|
if v := cleanDMIValue(info.ModelName); v != "" {
|
||||||
|
s.Model = &v
|
||||||
|
}
|
||||||
|
if v := cleanDMIValue(info.SerialNumber); v != "" {
|
||||||
|
s.SerialNumber = &v
|
||||||
|
}
|
||||||
|
if v := cleanDMIValue(info.FirmwareVer); v != "" {
|
||||||
|
s.Firmware = &v
|
||||||
|
}
|
||||||
|
if info.UserCapacity.Bytes > 0 {
|
||||||
|
gb := int(info.UserCapacity.Bytes / 1_000_000_000)
|
||||||
|
s.SizeGB = &gb
|
||||||
|
}
|
||||||
|
|
||||||
|
// refine type from rotation_rate
|
||||||
|
if info.RotationRate == 0 && devType != "NVMe" && devType != "USB" {
|
||||||
|
devType = "SSD"
|
||||||
|
} else if info.RotationRate > 0 {
|
||||||
|
devType = "HDD"
|
||||||
|
}
|
||||||
|
|
||||||
|
// telemetry
|
||||||
|
tel := map[string]any{}
|
||||||
|
if info.PowerOnTime.Hours > 0 {
|
||||||
|
tel["power_on_hours"] = info.PowerOnTime.Hours
|
||||||
|
}
|
||||||
|
if info.PowerCycleCount > 0 {
|
||||||
|
tel["power_cycles"] = info.PowerCycleCount
|
||||||
|
}
|
||||||
|
for _, attr := range info.AtaSmartAttributes.Table {
|
||||||
|
switch attr.ID {
|
||||||
|
case 5:
|
||||||
|
tel["reallocated_sectors"] = attr.Raw.Value
|
||||||
|
case 177:
|
||||||
|
tel["wear_leveling_pct"] = attr.Raw.Value
|
||||||
|
case 231:
|
||||||
|
tel["life_remaining_pct"] = attr.Raw.Value
|
||||||
|
case 241:
|
||||||
|
tel["total_lba_written"] = attr.Raw.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tel) > 0 {
|
||||||
|
s.Telemetry = tel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Type = &devType
|
||||||
|
status := "OK"
|
||||||
|
s.Status = &status
|
||||||
|
return s
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user