// Package probes collects hardware facts from a booted Linux system. // // Inventory stage consumers call a sequence of probe functions; each // probe accepts a Logger so it can emit narrative lines (what tool it's // running, what it found, what it skipped) into the run log. Probes are // tolerant of missing tools or sysfs gaps — they log a warn line and // return zero values rather than failing the whole stage. package probes import ( "bufio" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "runtime" "strconv" "strings" "vetting/internal/spec" ) // Logger is the subset of the agent's logForwarder that probes need. // Zero-valued fields are safe to call via log* helpers below — missing // functions are skipped silently so unit tests and older callers (e.g. // Collect) don't have to wire anything up. type Logger struct { Info func(string) Warn func(string) } func (l Logger) info(s string) { if l.Info != nil { l.Info(s) } } func (l Logger) warn(s string) { if l.Warn != nil { l.Warn(s) } } // Collect runs every probe with a no-op logger and returns the merged // inventory. Kept for callers that only want the data without the // narrative; the agent's runner calls the per-probe functions directly. func Collect() (*spec.Inventory, error) { return CollectWithLogger(Logger{}), nil } func CollectWithLogger(log Logger) *spec.Inventory { inv := &spec.Inventory{} inv.CPU = CPU(log) inv.Memory = Memory(log) inv.Disks = Disks(log) inv.NICs = NICs(log) inv.GPUs = GPUs(log) inv.System = System(log) inv.Baseboard = Baseboard(log) inv.PSU = PSU(log) inv.OS = OS(log) return inv } // ----- CPU -------------------------------------------------------------- // CPU reads /proc/cpuinfo. We pull model, vendor, stepping, physical // core count (via "cpu cores" — the per-package count is duplicated on // every logical CPU line, so any occurrence wins), and a filtered flag // subset relevant to virtualization / encryption gates. func CPU(log Logger) spec.CPUSpec { log.info(" CPU: reading /proc/cpuinfo") c := spec.CPUSpec{LogicalCores: runtime.NumCPU()} f, err := os.Open("/proc/cpuinfo") if err != nil { log.warn(" CPU: /proc/cpuinfo unreadable: " + err.Error()) return c } defer func() { _ = f.Close() }() parseCPUInfoInto(f, &c) flagSummary := "none" if len(c.Flags) > 0 { flagSummary = strings.Join(c.Flags, ",") } log.info(fmt.Sprintf(" CPU: model=%q vendor=%s cores=%d threads=%d flags=%s", c.Model, c.Vendor, c.PhysicalCores, c.LogicalCores, flagSummary)) return c } // cpuFlagAllow is the short-list of /proc/cpuinfo flags we care about — // everything else is either ubiquitous or too noisy to keep in a // reportable inventory artifact. var cpuFlagAllow = map[string]bool{ "vmx": true, "svm": true, "aes": true, "sse4_2": true, "avx": true, "avx2": true, "avx512f": true, } func parseCPUInfoInto(r io.Reader, c *spec.CPUSpec) { sc := bufio.NewScanner(r) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) for sc.Scan() { line := sc.Text() k, v, ok := strings.Cut(line, ":") if !ok { continue } key := strings.TrimSpace(k) val := strings.TrimSpace(v) switch key { case "model name": if c.Model == "" { c.Model = val } case "vendor_id": if c.Vendor == "" { c.Vendor = val } case "stepping": if c.Stepping == "" { c.Stepping = val } case "cpu cores": if c.PhysicalCores == 0 { if n, err := strconv.Atoi(val); err == nil { c.PhysicalCores = n } } case "flags", "Features": if len(c.Flags) == 0 { for _, f := range strings.Fields(val) { if cpuFlagAllow[f] { c.Flags = append(c.Flags, f) } } } } } } // ----- Memory ----------------------------------------------------------- // Memory captures total GiB from /proc/meminfo and per-DIMM rows from // dmidecode -t 17. When dmidecode is unavailable or returns nothing the // total still lands via /proc/meminfo so the spec diff for total_gib // stays meaningful. func Memory(log Logger) spec.MemorySpec { m := spec.MemorySpec{} if total, ok := readMemTotalGiB(); ok { m.TotalGiB = total } if _, err := exec.LookPath("dmidecode"); err != nil { log.warn(" Memory: dmidecode not installed; per-DIMM skipped") log.info(fmt.Sprintf(" Memory: %dGiB total (per-slot unavailable)", m.TotalGiB)) return m } log.info(" Memory: dmidecode -t 17") out, err := exec.Command("dmidecode", "-t", "17").Output() if err != nil { log.warn(" Memory: dmidecode -t 17 failed: " + err.Error()) log.info(fmt.Sprintf(" Memory: %dGiB total (per-slot unavailable)", m.TotalGiB)) return m } m.Modules = parseDIMMs(strings.NewReader(string(out)), log) populated := 0 for _, d := range m.Modules { if d.Populated { populated++ } } log.info(fmt.Sprintf(" Memory: %dGiB total, %d of %d slots populated", m.TotalGiB, populated, len(m.Modules))) return m } func readMemTotalGiB() (int, bool) { f, err := os.Open("/proc/meminfo") if err != nil { return 0, false } defer func() { _ = f.Close() }() sc := bufio.NewScanner(f) for sc.Scan() { fields := strings.Fields(sc.Text()) if len(fields) >= 2 && fields[0] == "MemTotal:" { if kb, err := strconv.ParseInt(fields[1], 10, 64); err == nil { return int(kb / 1024 / 1024), true } } } return 0, false } // parseDIMMs reads dmidecode -t 17 output. Each handle is one "Memory // Device"; an empty slot reports Size: "No Module Installed". Slot // locator is Locator (or Bank Locator fallback). func parseDIMMs(r io.Reader, log Logger) []spec.DIMMSpec { blocks := parseDmidecodeAllSections(r, "Memory Device") out := make([]spec.DIMMSpec, 0, len(blocks)) for _, kv := range blocks { slot := strings.TrimSpace(kv["Locator"]) if slot == "" { slot = strings.TrimSpace(kv["Bank Locator"]) } d := spec.DIMMSpec{Slot: slot} size := strings.TrimSpace(kv["Size"]) if size == "" || strings.EqualFold(size, "No Module Installed") || strings.EqualFold(size, "Not Installed") { log.info(" Memory: slot=" + nonEmpty(slot, "?") + " empty") out = append(out, d) continue } d.Populated = true d.SizeGB = parseDmidecodeSize(size) if speed := strings.TrimSpace(kv["Configured Memory Speed"]); speed != "" { d.SpeedMTS = parseMTS(speed) } if d.SpeedMTS == 0 { if speed := strings.TrimSpace(kv["Speed"]); speed != "" { d.SpeedMTS = parseMTS(speed) } } d.Manufacturer = cleanDmidecodeValue(kv["Manufacturer"]) d.PartNumber = cleanDmidecodeValue(kv["Part Number"]) log.info(fmt.Sprintf(" Memory: slot=%s %dGiB %dMT/s %s PN=%s", nonEmpty(slot, "?"), d.SizeGB, d.SpeedMTS, nonEmpty(d.Manufacturer, "?"), nonEmpty(d.PartNumber, "?"))) out = append(out, d) } return out } // parseDmidecodeSize turns "16 GB" / "8192 MB" / "32 GiB" into GB. // Treats MB as MiB-ish by dividing by 1024 (dmidecode reports binary // megabytes even when labeled "MB"). Returns 0 on parse failure. func parseDmidecodeSize(s string) int { parts := strings.Fields(s) if len(parts) < 2 { return 0 } n, err := strconv.Atoi(parts[0]) if err != nil { return 0 } unit := strings.ToLower(parts[1]) // dmidecode reports binary megabytes even when the suffix is "MB"; // accept both "MB" and "MiB" variants. Match on first letter for // unit, which covers "GB"/"GiB"/"G" equally well. switch unit[0] { case 't': return n * 1024 case 'g': return n case 'm': return n / 1024 case 'k': return n / (1024 * 1024) } return 0 } // parseMTS pulls the MT/s integer from "3200 MT/s" or "2400 MHz" — // dmidecode's "Speed" field uses either unit across vendors. func parseMTS(s string) int { parts := strings.Fields(s) if len(parts) == 0 { return 0 } n, err := strconv.Atoi(parts[0]) if err != nil { return 0 } return n } func cleanDmidecodeValue(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } // dmidecode emits literal strings like "Not Specified" / "Unknown" // when the SMBIOS field is blank — suppress them so the inventory // doesn't parrot SMBIOS nulls as real values. low := strings.ToLower(s) if low == "not specified" || low == "unknown" || low == "none" || low == "to be filled by o.e.m." { return "" } return s } func nonEmpty(s, fallback string) string { if s == "" { return fallback } return s } // ----- Disks ------------------------------------------------------------ // Disks walks /sys/class/block and picks out real block devices (no // partitions, no loop/ram). For each it reads size (512B sectors), // serial, model, transport (by inspecting the sysfs symlink target), // and rotational flag. func Disks(log Logger) []spec.DiskSpec { log.info(" Disks: walking /sys/class/block") entries, err := os.ReadDir("/sys/class/block") if err != nil { log.warn(" Disks: /sys/class/block unreadable: " + err.Error()) return nil } var out []spec.DiskSpec for _, e := range entries { name := e.Name() if !isRealDisk(name) { continue } base := filepath.Join("/sys/class/block", name) size := diskSizeGB(base) serial := diskSerial(name) model := diskModel(name) transport := diskTransport(name) rotational := diskRotational(base) if size == 0 && serial == "" && model == "" { continue } out = append(out, spec.DiskSpec{ Serial: serial, SizeGB: size, Model: model, Transport: transport, Rotational: rotational, }) log.info(fmt.Sprintf(" Disks: %s model=%q %dGB %s rotational=%t serial=%s", name, model, size, nonEmpty(transport, "?"), rotational, nonEmpty(serial, "?"))) } return out } func isRealDisk(name string) bool { if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "zram") || strings.HasPrefix(name, "dm-") { return false } partPath := filepath.Join("/sys/class/block", name, "partition") if _, err := os.Stat(partPath); err == nil { return false } return true } func diskSizeGB(base string) int { b, err := os.ReadFile(filepath.Join(base, "size")) if err != nil { return 0 } sectors, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64) if err != nil { return 0 } return int(sectors * 512 / 1_000_000_000) } func diskSerial(name string) string { for _, rel := range []string{ filepath.Join("/sys/block", name, "device", "serial"), filepath.Join("/sys/block", name, "device", "vpd_pg80"), filepath.Join("/sys/block", name, "serial"), } { if b, err := os.ReadFile(rel); err == nil { s := sanitizeASCII(string(b)) if s != "" { return s } } } cmd := exec.Command("udevadm", "info", "--query=property", "--name="+name) out, err := cmd.Output() if err != nil { return "" } for _, line := range strings.Split(string(out), "\n") { if v, ok := strings.CutPrefix(line, "ID_SERIAL_SHORT="); ok { return sanitizeASCII(v) } } return "" } func diskModel(name string) string { for _, rel := range []string{ filepath.Join("/sys/block", name, "device", "model"), filepath.Join("/sys/block", name, "device", "inquiry"), } { if b, err := os.ReadFile(rel); err == nil { s := sanitizeASCII(string(b)) if s != "" { return s } } } return "" } // diskTransport inspects /sys/class/block/'s symlink target. The // path segment after "/devices/" gives the bus: "pci.../nvme/..." → // nvme, "pci.../ata.../host..." → sata, "pci.../virtio..." → virtio, // "pci.../host.../target..." (no "ata") → scsi/sas. func diskTransport(name string) string { target, err := os.Readlink(filepath.Join("/sys/class/block", name)) if err != nil { return "" } t := strings.ToLower(target) switch { case strings.Contains(t, "/nvme/"): return "nvme" case strings.Contains(t, "/usb"): return "usb" case strings.Contains(t, "/virtio"): return "virtio" case strings.Contains(t, "/ata"): return "sata" case strings.Contains(t, "/mmc"): return "mmc" case strings.Contains(t, "/host"): return "scsi" } return "" } func diskRotational(base string) bool { b, err := os.ReadFile(filepath.Join(base, "queue/rotational")) if err != nil { return false } return strings.TrimSpace(string(b)) == "1" } // ----- NICs ------------------------------------------------------------- func NICs(log Logger) []spec.NICSpec { log.info(" NICs: walking /sys/class/net") root := "/sys/class/net" entries, err := os.ReadDir(root) if err != nil { log.warn(" NICs: /sys/class/net unreadable: " + err.Error()) return nil } var out []spec.NICSpec for _, e := range entries { name := e.Name() if name == "lo" { continue } base := filepath.Join(root, name) mac := readLine(filepath.Join(base, "address")) if mac == "" || mac == "00:00:00:00:00:00" { continue } speed := 0 if b, err := os.ReadFile(filepath.Join(base, "speed")); err == nil { if mbps, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil && mbps > 0 { speed = mbps / 1000 } } driver, pci := nicDriverAndPCI(base) n := spec.NICSpec{ MAC: strings.ToLower(mac), SpeedGbps: speed, Driver: driver, PCIAddr: pci, } out = append(out, n) suffix := "" if speed == 0 { suffix = " (no link)" } log.info(fmt.Sprintf(" NICs: %s mac=%s %dGbps driver=%s pci=%s%s", name, n.MAC, speed, nonEmpty(driver, "?"), nonEmpty(pci, "?"), suffix)) } return out } // nicDriverAndPCI reads DRIVER= from the device/uevent file and resolves // the device symlink for the PCI address (last path segment). func nicDriverAndPCI(base string) (driver, pci string) { if b, err := os.ReadFile(filepath.Join(base, "device/uevent")); err == nil { for _, line := range strings.Split(string(b), "\n") { if v, ok := strings.CutPrefix(line, "DRIVER="); ok { driver = strings.TrimSpace(v) break } } } if target, err := os.Readlink(filepath.Join(base, "device")); err == nil { // target looks like "../../../0000:02:00.0" — last element is pci addr. pci = filepath.Base(strings.TrimSpace(target)) } return driver, pci } // ----- GPUs ------------------------------------------------------------- // GPUs leans on lspci for the base list; for each match it parses the // PCI address and runs `lspci -vv -s ` to pull VRAM from the // prefetchable memory region size. Driver comes from the kernel-driver // line lspci emits when verbose. func GPUs(log Logger) []spec.GPUSpec { if _, err := exec.LookPath("lspci"); err != nil { log.warn(" GPUs: lspci not installed; skipped") return nil } log.info(" GPUs: lspci -mm -nn") out, err := exec.Command("lspci", "-D", "-mm", "-nn").Output() if err != nil { log.warn(" GPUs: lspci failed: " + err.Error()) return nil } var gpus []spec.GPUSpec for _, line := range strings.Split(string(out), "\n") { low := strings.ToLower(line) if !strings.Contains(low, "vga compatible controller") && !strings.Contains(low, "3d controller") { continue } // With -D the first quoted field is the PCI address. fields := splitQuoted(line) if len(fields) < 3 { continue } // -D -mm emits the address as the first non-quoted token. // splitQuoted strips unquoted whitespace, so the address is // indexed at 0 and the quoted class/vendor/device follow. // On older lspci the first field is the slot in quotes instead; // cover both: if fields[0] doesn't look like an address, strip // it from the space-separated prefix. addr := "" if pciAddrRE.MatchString(fields[0]) { addr = fields[0] } else if i := strings.IndexByte(line, '"'); i > 0 { prefix := strings.TrimSpace(line[:i]) if pciAddrRE.MatchString(prefix) { addr = prefix } } model := stripPCIID(fields[2]) if !pciAddrRE.MatchString(fields[0]) { // With no -D support the device is at [1], model at [2]: same // as old code path. Keep existing behavior. model = stripPCIID(fields[2]) } model = sanitizeASCII(model) if model == "" { continue } g := spec.GPUSpec{Model: model, PCIAddr: addr} if addr != "" { if vv, err := exec.Command("lspci", "-vv", "-s", addr).Output(); err == nil { g.VRAMGiB, g.Driver = parseLspciVerbose(string(vv)) } } gpus = append(gpus, g) log.info(fmt.Sprintf(" GPUs: %s pci=%s driver=%s vram=%dGiB", model, nonEmpty(addr, "?"), nonEmpty(g.Driver, "?"), g.VRAMGiB)) } return gpus } var pciAddrRE = regexp.MustCompile(`^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$`) // parseLspciVerbose pulls the largest prefetchable memory region (VRAM) // in GiB and the "Kernel driver in use" name from an lspci -vv block. // The memory line looks like: "Memory at ... (64-bit, prefetchable) [size=8G]" func parseLspciVerbose(s string) (vramGiB int, driver string) { sizeRE := regexp.MustCompile(`prefetchable\) \[size=(\d+)([KMGT])`) for _, line := range strings.Split(s, "\n") { trim := strings.TrimSpace(line) if v, ok := strings.CutPrefix(trim, "Kernel driver in use:"); ok { driver = strings.TrimSpace(v) continue } if m := sizeRE.FindStringSubmatch(line); m != nil { n, _ := strconv.Atoi(m[1]) switch m[2] { case "T": n *= 1024 case "G": // already GiB case "M": n /= 1024 case "K": n /= (1024 * 1024) } if n > vramGiB { vramGiB = n } } } return vramGiB, driver } // ----- System / Baseboard ----------------------------------------------- func System(log Logger) spec.SystemSpec { if _, err := exec.LookPath("dmidecode"); err != nil { log.warn(" System: dmidecode not installed; skipped") return spec.SystemSpec{} } log.info(" System: dmidecode -t system") out, err := exec.Command("dmidecode", "-t", "system").Output() if err != nil { log.warn(" System: dmidecode -t system failed: " + err.Error()) return spec.SystemSpec{} } kv := parseDmidecodeSection(strings.NewReader(string(out)), "System Information") if kv == nil { log.warn(" System: no System Information section in dmidecode output") return spec.SystemSpec{} } s := spec.SystemSpec{ Manufacturer: cleanDmidecodeValue(kv["Manufacturer"]), ProductName: cleanDmidecodeValue(kv["Product Name"]), SerialNumber: cleanDmidecodeValue(kv["Serial Number"]), UUID: cleanDmidecodeValue(kv["UUID"]), } log.info(fmt.Sprintf(" System: manufacturer=%s product=%q serial=%s uuid=%s", nonEmpty(s.Manufacturer, "?"), s.ProductName, nonEmpty(s.SerialNumber, "?"), nonEmpty(s.UUID, "?"))) return s } func Baseboard(log Logger) spec.BaseboardSpec { if _, err := exec.LookPath("dmidecode"); err != nil { log.warn(" Baseboard: dmidecode not installed; skipped") return spec.BaseboardSpec{} } log.info(" Baseboard: dmidecode -t baseboard") out, err := exec.Command("dmidecode", "-t", "baseboard").Output() if err != nil { log.warn(" Baseboard: dmidecode -t baseboard failed: " + err.Error()) return spec.BaseboardSpec{} } kv := parseDmidecodeSection(strings.NewReader(string(out)), "Base Board Information") if kv == nil { log.warn(" Baseboard: no Base Board Information section in dmidecode output") return spec.BaseboardSpec{} } b := spec.BaseboardSpec{ Manufacturer: cleanDmidecodeValue(kv["Manufacturer"]), ProductName: cleanDmidecodeValue(kv["Product Name"]), SerialNumber: cleanDmidecodeValue(kv["Serial Number"]), } log.info(fmt.Sprintf(" Baseboard: manufacturer=%s product=%q serial=%s", nonEmpty(b.Manufacturer, "?"), b.ProductName, nonEmpty(b.SerialNumber, "?"))) return b } // ----- PSU -------------------------------------------------------------- func PSU(log Logger) []spec.PowerSupplySpec { if _, err := exec.LookPath("dmidecode"); err != nil { log.warn(" PSU: dmidecode not installed; skipped") return nil } log.info(" PSU: dmidecode -t 39") out, err := exec.Command("dmidecode", "-t", "39").Output() if err != nil { log.warn(" PSU: dmidecode -t 39 failed: " + err.Error()) return nil } blocks := parseDmidecodeAllSections(strings.NewReader(string(out)), "System Power Supply") if len(blocks) == 0 { log.warn(" PSU: dmidecode -t 39 returned no data (mini-PC or SMBIOS lacks type 39)") return nil } var out2 []spec.PowerSupplySpec for _, kv := range blocks { p := spec.PowerSupplySpec{ Slot: cleanDmidecodeValue(kv["Location"]), Manufacturer: cleanDmidecodeValue(kv["Manufacturer"]), Model: cleanDmidecodeValue(kv["Model Part Number"]), Status: cleanDmidecodeValue(kv["Status"]), } if p.Model == "" { p.Model = cleanDmidecodeValue(kv["Name"]) } if watts := cleanDmidecodeValue(kv["Max Power Capacity"]); watts != "" { // "760 W" or "760W" clean := strings.TrimSuffix(strings.TrimSpace(watts), "W") clean = strings.TrimSpace(clean) if n, err := strconv.Atoi(strings.Fields(clean)[0]); err == nil { p.MaxWatts = n } } out2 = append(out2, p) log.info(fmt.Sprintf(" PSU: slot=%s manufacturer=%s model=%q %dW status=%q", nonEmpty(p.Slot, "?"), nonEmpty(p.Manufacturer, "?"), p.Model, p.MaxWatts, p.Status)) } return out2 } // ----- OS --------------------------------------------------------------- func OS(log Logger) spec.OSSpec { o := spec.OSSpec{} // /proc/sys/kernel/osrelease is equivalent to `uname -r` and a plain // file read — keeps the probe buildable on non-linux dev hosts // without syscall.Uname gymnastics. Missing on non-linux; falls back // to running `uname -r`. if b, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil { o.Kernel = strings.TrimSpace(string(b)) } else if out, err := exec.Command("uname", "-r").Output(); err == nil { o.Kernel = strings.TrimSpace(string(out)) } if b, err := os.ReadFile("/etc/os-release"); err == nil { o.Distribution, o.Version = parseOSRelease(string(b)) } log.info(fmt.Sprintf(" OS: kernel=%s distro=%q version=%s", nonEmpty(o.Kernel, "?"), o.Distribution, nonEmpty(o.Version, "?"))) return o } // parseOSRelease returns PRETTY_NAME (distribution) and VERSION_ID // (version). Quotes are stripped per the spec. func parseOSRelease(s string) (dist, version string) { sc := bufio.NewScanner(strings.NewReader(s)) for sc.Scan() { line := sc.Text() k, v, ok := strings.Cut(line, "=") if !ok { continue } v = strings.Trim(strings.TrimSpace(v), `"`) switch strings.TrimSpace(k) { case "PRETTY_NAME": dist = v case "VERSION_ID": version = v } } return dist, version } // ----- shared ----------------------------------------------------------- func splitQuoted(line string) []string { // Extended variant: splits space-separated tokens outside quotes AND // preserves the content inside quotes as single tokens. Needed so // `lspci -D -mm -nn` output (leading PCI address token outside any // quotes) parses cleanly alongside the quoted class/vendor fields. var out []string var cur strings.Builder inQ := false flush := func() { if cur.Len() > 0 { out = append(out, cur.String()) cur.Reset() } } for _, r := range line { switch { case r == '"': if inQ { out = append(out, cur.String()) cur.Reset() } inQ = !inQ case r == ' ' && !inQ: flush() default: cur.WriteRune(r) } } flush() return out } func readLine(path string) string { b, err := os.ReadFile(path) if err != nil { return "" } return strings.TrimSpace(string(b)) } func sanitizeASCII(s string) string { var b strings.Builder b.Grow(len(s)) for i := 0; i < len(s); i++ { c := s[i] if c >= 0x20 && c <= 0x7E { b.WriteByte(c) } } return strings.TrimSpace(b.String()) } var pciIDTail = regexp.MustCompile(` *\[[0-9a-fA-F]{4}\]$`) func stripPCIID(s string) string { return pciIDTail.ReplaceAllString(s, "") }