Files
2026-03-15 23:03:38 +03:00

234 lines
5.6 KiB
Go

package collector
import (
"bee/audit/internal/schema"
"bufio"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
)
// collectCPUs runs dmidecode -t 4 and enriches CPUs with microcode from sysfs.
func collectCPUs() []schema.HardwareCPU {
out, err := runDmidecode("4")
if err != nil {
slog.Warn("cpu: dmidecode type 4 failed", "err", err)
return nil
}
cpus := parseCPUs(out)
if mc := readMicrocode(); mc != "" {
for i := range cpus {
cpus[i].Firmware = &mc
}
}
slog.Info("cpu: collected", "count", len(cpus))
return cpus
}
// parseCPUs splits dmidecode output into per-processor sections and parses each.
func parseCPUs(output string) []schema.HardwareCPU {
sections := splitDMISections(output, "Processor Information")
cpus := make([]schema.HardwareCPU, 0, len(sections))
for _, section := range sections {
cpu, ok := parseCPUSection(section)
if !ok {
continue
}
cpus = append(cpus, cpu)
}
return cpus
}
// parseCPUSection parses one "Processor Information" block into a HardwareCPU.
// Returns false if the socket is unpopulated.
func parseCPUSection(fields map[string]string) (schema.HardwareCPU, bool) {
status := parseCPUStatus(fields["Status"])
if status == statusEmpty {
return schema.HardwareCPU{}, false
}
cpu := schema.HardwareCPU{}
cpu.Status = &status
present := true
cpu.Present = &present
if socket, ok := parseSocketIndex(fields["Socket Designation"]); ok {
cpu.Socket = &socket
}
if v := cleanDMIValue(fields["Version"]); v != "" {
cpu.Model = &v
}
if v := cleanManufacturer(fields["Manufacturer"]); v != "" {
cpu.Manufacturer = &v
}
if v := cleanDMIValue(fields["Serial Number"]); v != "" {
cpu.SerialNumber = &v
}
if v := parseMHz(fields["Max Speed"]); v > 0 {
cpu.MaxFrequencyMHz = &v
}
if v := parseMHz(fields["Current Speed"]); v > 0 {
cpu.FrequencyMHz = &v
}
if v := parseInt(fields["Core Count"]); v > 0 {
cpu.Cores = &v
}
if v := parseInt(fields["Thread Count"]); v > 0 {
cpu.Threads = &v
}
return cpu, true
}
// parseCPUStatus maps dmidecode Status field to our status vocabulary.
func parseCPUStatus(raw string) string {
raw = strings.TrimSpace(raw)
upper := strings.ToUpper(raw)
switch {
case upper == "" || upper == "UNKNOWN":
return statusUnknown
case strings.Contains(upper, "UNPOPULATED") || strings.Contains(upper, "NOT POPULATED"):
return statusEmpty
case strings.Contains(upper, "ENABLED"):
return statusOK
case strings.Contains(upper, "DISABLED"):
return statusWarning
default:
return statusUnknown
}
}
// parseSocketIndex extracts the integer socket index from strings like
// "CPU0", "CPU1", "Processor 1", "Socket 0", etc.
func parseSocketIndex(raw string) (int, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, false
}
// strip leading non-digit prefix and parse the first integer found
digits := ""
for _, r := range raw {
if r >= '0' && r <= '9' {
digits += string(r)
} else if digits != "" {
break
}
}
if digits == "" {
return 0, false
}
n, err := strconv.Atoi(digits)
if err != nil {
return 0, false
}
return n, true
}
// cleanManufacturer normalises CPU manufacturer strings.
// "Intel(R) Corporation" → "Intel", "AMD" → "AMD".
func cleanManufacturer(v string) string {
v = cleanDMIValue(v)
if v == "" {
return ""
}
// strip "(R)" and "(TM)" suffixes, trim "Corporation" / "Inc."
v = strings.ReplaceAll(v, "(R)", "")
v = strings.ReplaceAll(v, "(TM)", "")
v = strings.ReplaceAll(v, " Corporation", "")
v = strings.ReplaceAll(v, " Inc.", "")
v = strings.ReplaceAll(v, " Inc", "")
return strings.TrimSpace(v)
}
// parseMHz parses "4000 MHz" → 4000. Returns 0 on failure.
func parseMHz(v string) int {
v = strings.TrimSpace(v)
v = strings.TrimSuffix(v, " MHz")
v = strings.TrimSpace(v)
n, err := strconv.Atoi(v)
if err != nil {
return 0
}
return n
}
// parseInt parses a plain integer string. Returns 0 on failure or "N/A".
func parseInt(v string) int {
v = strings.TrimSpace(v)
n, err := strconv.Atoi(v)
if err != nil {
return 0
}
return n
}
// readMicrocode reads the CPU microcode revision from sysfs.
// Returns empty string if unavailable.
func readMicrocode() string {
data, err := os.ReadFile(filepath.Join(cpuSysBaseDir, "cpu0", "microcode", "version"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// splitDMISections splits dmidecode output into sections by a given section title.
// Returns a slice of field maps, one per matching section.
func splitDMISections(output, sectionTitle string) []map[string]string {
var sections []map[string]string
var current []string
inSection := false
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == sectionTitle {
if inSection && len(current) > 0 {
sections = append(sections, parseFieldLines(current))
}
current = nil
inSection = true
continue
}
if inSection {
if strings.HasPrefix(line, "Handle ") {
sections = append(sections, parseFieldLines(current))
current = nil
inSection = false
continue
}
current = append(current, line)
}
}
if inSection && len(current) > 0 {
sections = append(sections, parseFieldLines(current))
}
return sections
}
// parseFieldLines converts raw section lines into a key→value map.
// Skips sub-list items (double-tab indented lines).
func parseFieldLines(lines []string) map[string]string {
fields := make(map[string]string)
for _, line := range lines {
if strings.HasPrefix(line, "\t\t") {
continue
}
trimmed := strings.TrimPrefix(line, "\t")
if idx := strings.Index(trimmed, ": "); idx >= 0 {
fields[trimmed[:idx]] = trimmed[idx+2:]
}
}
return fields
}