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 }