- audit JSON: IPMI sensor readings (ipmitool sensor) merged into hardware.sensors alongside lm-sensors data - audit JSON: IPMI SEL entries (ipmitool sel list) in hardware.event_logs with source "ipmi-sel" - audit JSON: dmesg error/warning lines in hardware.event_logs with source "dmesg" (filtered by error/warn/AER/Xid/NVRM/ECC/panic patterns) - support bundle: added ipmitool-sensor.txt, ipmitool-sel.txt, ipmitool-sel-time.txt to techdump - saa_dmi.go: fix dmiItemRE to accept SHN with parentheses (e.g. PS(4)LC for PSU fields) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
217 lines
5.5 KiB
Go
217 lines
5.5 KiB
Go
package collector
|
|
|
|
import (
|
|
"bee/audit/internal/schema"
|
|
"log/slog"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// collectIPMISensors runs `ipmitool sensor` and returns parsed sensor readings.
|
|
// Returns nil if ipmitool is unavailable or produces no output.
|
|
func collectIPMISensors() *schema.HardwareSensors {
|
|
out, err := exec.Command("ipmitool", "sensor").Output()
|
|
if err != nil || len(out) == 0 {
|
|
return nil
|
|
}
|
|
result := parseIPMISensorOutput(string(out))
|
|
if result == nil {
|
|
return nil
|
|
}
|
|
slog.Info("ipmi sensors: collected",
|
|
"fans", len(result.Fans),
|
|
"temperatures", len(result.Temperatures),
|
|
"power", len(result.Power),
|
|
"other", len(result.Other),
|
|
)
|
|
return result
|
|
}
|
|
|
|
// parseIPMISensorOutput parses `ipmitool sensor` text output.
|
|
// Each line: name | value | unit | status | lnr | lcr | lnc | unc | ucr | unr
|
|
func parseIPMISensorOutput(output string) *schema.HardwareSensors {
|
|
result := &schema.HardwareSensors{}
|
|
seen := map[string]struct{}{}
|
|
|
|
for _, line := range strings.Split(output, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 4 {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(parts[0])
|
|
rawVal := strings.TrimSpace(parts[1])
|
|
unit := strings.TrimSpace(parts[2])
|
|
status := strings.TrimSpace(parts[3])
|
|
|
|
if name == "" || rawVal == "na" || rawVal == "N/A" || rawVal == "" {
|
|
continue
|
|
}
|
|
|
|
value, err := strconv.ParseFloat(rawVal, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
statusStr := normalizeIPMISensorStatus(status)
|
|
|
|
switch {
|
|
case strings.EqualFold(unit, "RPM"):
|
|
if duplicateSensor(seen, "fan", name) {
|
|
continue
|
|
}
|
|
rpm := int(value)
|
|
item := schema.HardwareFanSensor{Name: name, RPM: &rpm}
|
|
if statusStr != "" {
|
|
item.Status = &statusStr
|
|
}
|
|
result.Fans = append(result.Fans, item)
|
|
|
|
case strings.EqualFold(unit, "degrees C") || strings.EqualFold(unit, "C"):
|
|
if duplicateSensor(seen, "temp", name) {
|
|
continue
|
|
}
|
|
item := schema.HardwareTemperatureSensor{Name: name, Celsius: &value}
|
|
if len(parts) >= 9 {
|
|
if unc := parseIPMIThreshold(parts[7]); unc != nil {
|
|
item.ThresholdWarningCelsius = unc
|
|
}
|
|
if ucr := parseIPMIThreshold(parts[8]); ucr != nil {
|
|
item.ThresholdCriticalCelsius = ucr
|
|
}
|
|
}
|
|
if statusStr != "" {
|
|
item.Status = &statusStr
|
|
} else {
|
|
item.Status = deriveTemperatureStatus(item.Celsius, item.ThresholdWarningCelsius, item.ThresholdCriticalCelsius)
|
|
}
|
|
result.Temperatures = append(result.Temperatures, item)
|
|
|
|
case strings.EqualFold(unit, "Volts") || strings.EqualFold(unit, "V"):
|
|
if duplicateSensor(seen, "power", name) {
|
|
continue
|
|
}
|
|
item := schema.HardwarePowerSensor{Name: name, VoltageV: &value}
|
|
if statusStr != "" {
|
|
item.Status = &statusStr
|
|
}
|
|
result.Power = append(result.Power, item)
|
|
|
|
case strings.EqualFold(unit, "Watts") || strings.EqualFold(unit, "W"):
|
|
if duplicateSensor(seen, "power", name) {
|
|
continue
|
|
}
|
|
item := schema.HardwarePowerSensor{Name: name, PowerW: &value}
|
|
if statusStr != "" {
|
|
item.Status = &statusStr
|
|
}
|
|
result.Power = append(result.Power, item)
|
|
|
|
case strings.EqualFold(unit, "Amps") || strings.EqualFold(unit, "A"):
|
|
if duplicateSensor(seen, "power", name) {
|
|
continue
|
|
}
|
|
item := schema.HardwarePowerSensor{Name: name, CurrentA: &value}
|
|
if statusStr != "" {
|
|
item.Status = &statusStr
|
|
}
|
|
result.Power = append(result.Power, item)
|
|
|
|
default:
|
|
if duplicateSensor(seen, "other", name) {
|
|
continue
|
|
}
|
|
item := schema.HardwareOtherSensor{Name: name, Value: &value}
|
|
if unit != "" {
|
|
item.Unit = &unit
|
|
}
|
|
if statusStr != "" {
|
|
item.Status = &statusStr
|
|
}
|
|
result.Other = append(result.Other, item)
|
|
}
|
|
}
|
|
|
|
if len(result.Fans) == 0 && len(result.Temperatures) == 0 && len(result.Power) == 0 && len(result.Other) == 0 {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parseIPMIThreshold(raw string) *float64 {
|
|
s := strings.TrimSpace(raw)
|
|
if s == "" || s == "na" || s == "N/A" {
|
|
return nil
|
|
}
|
|
v, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &v
|
|
}
|
|
|
|
func normalizeIPMISensorStatus(s string) string {
|
|
switch strings.ToLower(s) {
|
|
case "ok":
|
|
return statusOK
|
|
case "cr", "ucr", "lcr":
|
|
return statusCritical
|
|
case "nc", "unc", "lnc", "nr", "unr", "lnr":
|
|
return statusWarning
|
|
case "ns", "na":
|
|
return ""
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// mergeIPMISensors appends IPMI sensor entries into existing, skipping names already present.
|
|
func mergeIPMISensors(existing, ipmi *schema.HardwareSensors) *schema.HardwareSensors {
|
|
if ipmi == nil {
|
|
return existing
|
|
}
|
|
if existing == nil {
|
|
return ipmi
|
|
}
|
|
|
|
existingNames := map[string]struct{}{}
|
|
for _, s := range existing.Fans {
|
|
existingNames["fan\x00"+s.Name] = struct{}{}
|
|
}
|
|
for _, s := range existing.Temperatures {
|
|
existingNames["temp\x00"+s.Name] = struct{}{}
|
|
}
|
|
for _, s := range existing.Power {
|
|
existingNames["power\x00"+s.Name] = struct{}{}
|
|
}
|
|
for _, s := range existing.Other {
|
|
existingNames["other\x00"+s.Name] = struct{}{}
|
|
}
|
|
|
|
for _, s := range ipmi.Fans {
|
|
if _, ok := existingNames["fan\x00"+s.Name]; !ok {
|
|
existing.Fans = append(existing.Fans, s)
|
|
}
|
|
}
|
|
for _, s := range ipmi.Temperatures {
|
|
if _, ok := existingNames["temp\x00"+s.Name]; !ok {
|
|
existing.Temperatures = append(existing.Temperatures, s)
|
|
}
|
|
}
|
|
for _, s := range ipmi.Power {
|
|
if _, ok := existingNames["power\x00"+s.Name]; !ok {
|
|
existing.Power = append(existing.Power, s)
|
|
}
|
|
}
|
|
for _, s := range ipmi.Other {
|
|
if _, ok := existingNames["other\x00"+s.Name]; !ok {
|
|
existing.Other = append(existing.Other, s)
|
|
}
|
|
}
|
|
return existing
|
|
}
|