diff --git a/docs/collector.dmi.md b/docs/collector.dmi.md new file mode 100644 index 00000000..8fdc36ba --- /dev/null +++ b/docs/collector.dmi.md @@ -0,0 +1,73 @@ +# dmi collector + +The dmi collector exposes Desktop Management Interface (DMI) / SMBIOS system +information, compatible with node_exporter's +[`dmi` collector](https://github.com/prometheus/node_exporter/blob/master/collector/dmi.go). + +||| +-|- +Metric name prefix | `dmi` +Data source | [`GetSystemFirmwareTable`](https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getsystemfirmwaretable) (SMBIOS) +Enabled by default? | No + +## Flags + +None + +## Metrics + +| Name | Description | Type | Labels | +|---------------------|-----------------------------------------------------------------------------------------------------|-------|----------------------------------------------------------------------------| +| `windows_dmi_info` | A metric with a constant '1' value labeled by SMBIOS Type 1 (System Information) fields. | gauge | `product_name`, `product_serial`, `product_uuid`, `product_version`, `system_vendor` | + +On VMs the labels reflect the hypervisor-assigned identity; on physical hosts +they reflect the hardware SMBIOS data. If SMBIOS data cannot be read (e.g. in +some container environments), the collector will fail to build. + +### Example metric + +``` +# HELP windows_dmi_info A metric with a constant '1' value labeled by product_name, product_serial, product_uuid, product_version, system_vendor from SMBIOS Type 1 (System Information). +# TYPE windows_dmi_info gauge +windows_dmi_info{product_name="Standard PC (Q35 + ICH9, 2009)",product_serial="Not Specified",product_uuid="12345678-1234-1234-1234-123456789abc",product_version="pc-q35-9.2",system_vendor="QEMU"} 1 +``` + +## Useful queries + +### Cross-exporter compatibility + +The label names match node_exporter's `node_dmi_info` metric, so dashboards +and alerts work across Linux and Windows guests: + +```promql +# Works for both Linux and Windows guests +{__name__=~"node_dmi_info|windows_dmi_info"} +``` + +### Correlate with libvirt_exporter + +With [prometheus-libvirt-exporter](https://github.com/inovex/prometheus-libvirt-exporter), +the domain UUID is exposed on `libvirt_domain_info_meta{uuid="..."}`. This UUID +matches the SMBIOS UUID passed to the guest by QEMU/KVM. + +Add the `product_uuid` label to any guest metric: + +```promql +windows_memory_physical_free_bytes + * on(job, instance) group_left(product_uuid) +windows_dmi_info +``` + +Join a host-side libvirt metric with the guest-identified UUID (full guest→host chain): + +```promql +libvirt_domain_info_memory_usage_bytes + * on(job, instance, domain) group_left(uuid) +libvirt_domain_info_meta + * on(uuid) group_left() +label_replace(windows_dmi_info, "uuid", "$1", "product_uuid", "(.+)") +``` + +## Alerting examples + +_This collector does not yet have alerting examples, we would appreciate your help adding them!_ diff --git a/docs/collector.os.md b/docs/collector.os.md index c172d2fa..07e756a9 100644 --- a/docs/collector.os.md +++ b/docs/collector.os.md @@ -5,7 +5,7 @@ The os collector exposes metrics about the operating system ||| -|- Metric name prefix | `os` -Classes | [`Win32_OperatingSystem`](https://msdn.microsoft.com/en-us/library/aa394239) +Data sources | Registry Enabled by default? | Yes ## Flags @@ -26,7 +26,7 @@ None # HELP windows_os_hostname Labelled system hostname information as provided by ComputerSystem.DNSHostName and ComputerSystem.Domain # TYPE windows_os_hostname gauge windows_os_hostname{domain="",fqdn="PC",hostname="PC"} 1 -# HELP windows_os_info Contains full product name & version in labels. Note that the "major_version" for Windows 11 is \\"10\\"; a build number greater than 22000 represents Windows 11. +# HELP windows_os_info Contains full product name & version in labels. Note that the "major_version" for Windows 11 is \\\"10\\\"; a build number greater than 22000 represents Windows 11. # TYPE windows_os_info gauge windows_os_info{build_number="19045",installation_type="Client",major_version="10",minor_version="0",product="Windows 10 Pro",revision="4842",version="10.0.19045"} 1 # HELP windows_os_install_time_timestamp_seconds Unix timestamp of OS installation time @@ -35,7 +35,7 @@ windows_os_install_time_timestamp_seconds 1.6725312e+09 ``` ## Useful queries -_This collector does not yet have useful queries, we would appreciate your help adding them!_ +_This collector does not yet have useful query examples, we would appreciate your help adding them!_ ## Alerting examples _This collector does not yet have alerting examples, we would appreciate your help adding them!_ diff --git a/internal/collector/dmi/dmi.go b/internal/collector/dmi/dmi.go new file mode 100644 index 00000000..617a6cf7 --- /dev/null +++ b/internal/collector/dmi/dmi.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package dmi + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/prometheus-community/windows_exporter/internal/headers/sysinfoapi" + "github.com/prometheus-community/windows_exporter/internal/mi" + "github.com/prometheus-community/windows_exporter/internal/types" + "github.com/prometheus/client_golang/prometheus" +) + +const Name = "dmi" + +type Config struct{} + +//nolint:gochecknoglobals +var ConfigDefaults = Config{} + +// A Collector is a Prometheus Collector for DMI (SMBIOS) metrics. +type Collector struct { + config Config + + dmiInfo *prometheus.Desc +} + +func New(config *Config) *Collector { + if config == nil { + config = &ConfigDefaults + } + + c := &Collector{ + config: *config, + } + + return c +} + +func NewWithFlags(_ *kingpin.Application) *Collector { + return &Collector{} +} + +func (c *Collector) GetName() string { + return Name +} + +func (c *Collector) Close() error { + return nil +} + +func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { + info, err := sysinfoapi.GetSMBIOSSystemInfo() + if err != nil { + return fmt.Errorf("SMBIOS system info unavailable: %w", err) + } + + // Sanitize label values: firmware strings may contain invalid UTF-8 bytes. + // Use "�" as replacement to match node_exporter's behavior. + cleanLabel := func(s string) string { + return strings.ToValidUTF8(strings.TrimSpace(s), "�") + } + + desc := prometheus.NewDesc( + prometheus.BuildFQName(types.Namespace, Name, "info"), + "A metric with a constant '1' value labeled by product_name, product_serial, "+ + "product_uuid, product_version, system_vendor from SMBIOS Type 1 (System Information).", + nil, + prometheus.Labels{ + "product_name": cleanLabel(info.ProductName), + "product_serial": cleanLabel(info.SerialNumber), + "product_uuid": strings.ToLower(cleanLabel(info.UUID)), + "product_version": cleanLabel(info.Version), + "system_vendor": cleanLabel(info.Manufacturer), + }, + ) + + // Validate the descriptor by attempting to create a metric. + // If label values are still invalid, log a warning and skip. + if _, err = prometheus.NewConstMetric(desc, prometheus.GaugeValue, 1.0); err != nil { + logger.Warn("SMBIOS labels produced an invalid metric, skipping dmi_info", slog.Any("err", err)) + + return nil + } + + c.dmiInfo = desc + + return nil +} + +// Collect sends the metric values for each metric +// to the provided prometheus Metric channel. +func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error { + if c.dmiInfo != nil { + ch <- prometheus.MustNewConstMetric( + c.dmiInfo, + prometheus.GaugeValue, + 1.0, + ) + } + + return nil +} diff --git a/internal/collector/dmi/dmi_test.go b/internal/collector/dmi/dmi_test.go new file mode 100644 index 00000000..637a6206 --- /dev/null +++ b/internal/collector/dmi/dmi_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package dmi_test + +import ( + "testing" + + "github.com/prometheus-community/windows_exporter/internal/collector/dmi" + "github.com/prometheus-community/windows_exporter/internal/utils/testutils" +) + +func BenchmarkCollector(b *testing.B) { + testutils.FuncBenchmarkCollector(b, dmi.Name, dmi.NewWithFlags) +} + +func TestCollector(t *testing.T) { + testutils.TestCollector(t, dmi.New, nil) +} diff --git a/internal/collector/os/os.go b/internal/collector/os/os.go index 0b346c50..f855f4a8 100644 --- a/internal/collector/os/os.go +++ b/internal/collector/os/os.go @@ -41,7 +41,7 @@ type Config struct{} //nolint:gochecknoglobals var ConfigDefaults = Config{} -// A Collector is a Prometheus Collector for WMI metrics. +// A Collector is a Prometheus Collector for OS metrics. type Collector struct { config Config diff --git a/internal/headers/sysinfoapi/smbios.go b/internal/headers/sysinfoapi/smbios.go new file mode 100644 index 00000000..3d0497cf --- /dev/null +++ b/internal/headers/sysinfoapi/smbios.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package sysinfoapi + +import ( + "fmt" + "unsafe" +) + +//nolint:gochecknoglobals +var procGetSystemFirmwareTable = kernel32.NewProc("GetSystemFirmwareTable") + +// SMBIOS table provider signature ('RSMB'). +const firmwareTableProviderSigRSMB uint32 = 0x52534D42 + +// GetSMBIOSSystemInfo retrieves the SMBIOS Type 1 (System Information) data +// using the GetSystemFirmwareTable Win32 API. No WMI dependency. +func GetSMBIOSSystemInfo() (*SMBIOSSystemInfo, error) { + data, err := getSystemFirmwareTable(firmwareTableProviderSigRSMB, 0) + if err != nil { + return nil, fmt.Errorf("GetSystemFirmwareTable: %w", err) + } + + return parseSMBIOSSystemInfo(data) +} + +// getSystemFirmwareTable wraps the GetSystemFirmwareTable Win32 API. +// https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getsystemfirmwaretable +func getSystemFirmwareTable(providerSig, tableID uint32) ([]byte, error) { + // First call: determine buffer size. + r1, _, err := procGetSystemFirmwareTable.Call( + uintptr(providerSig), + uintptr(tableID), + 0, + 0, + ) + + size := uint32(r1) + if size == 0 { + return nil, fmt.Errorf("failed to get firmware table size: %w", err) + } + + buf := make([]byte, size) + + r1, _, err = procGetSystemFirmwareTable.Call( + uintptr(providerSig), + uintptr(tableID), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(size), + ) + + written := uint32(r1) + if written == 0 { + return nil, fmt.Errorf("failed to get firmware table: %w", err) + } + + if written > size { + return nil, fmt.Errorf("firmware table size changed between calls (need %d, have %d)", written, size) + } + + return buf[:written], nil +} diff --git a/internal/headers/sysinfoapi/smbios_parse.go b/internal/headers/sysinfoapi/smbios_parse.go new file mode 100644 index 00000000..b5a90e62 --- /dev/null +++ b/internal/headers/sysinfoapi/smbios_parse.go @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sysinfoapi + +import ( + "encoding/binary" + "errors" + "fmt" +) + +// SMBIOSSystemInfo holds the fields extracted from the SMBIOS Type 1 +// (System Information) structure. +type SMBIOSSystemInfo struct { + Manufacturer string + ProductName string + Version string + SerialNumber string + UUID string +} + +// rawSMBIOSDataHeaderSize is the size of the RawSMBIOSData header returned +// by GetSystemFirmwareTable with the 'RSMB' provider: +// Used20CallingMethod (1) + SMBIOSMajorVersion (1) + SMBIOSMinorVersion (1) + +// DMARevision (1) + Length (4) = 8 bytes. +const rawSMBIOSDataHeaderSize = 8 + +// parseSMBIOSSystemInfo parses the raw RSMB firmware table and extracts +// the Type 1 (System Information) structure. +func parseSMBIOSSystemInfo(data []byte) (*SMBIOSSystemInfo, error) { + if len(data) < rawSMBIOSDataHeaderSize { + return nil, errors.New("SMBIOS data too short for header") + } + + smbiosMajor := data[1] + smbiosMinor := data[2] + + tableLen := binary.LittleEndian.Uint32(data[4:8]) + tableData := data[rawSMBIOSDataHeaderSize:] + + if uint32(len(tableData)) < tableLen { + return nil, errors.New("SMBIOS table data truncated") + } + + tableData = tableData[:tableLen] + + // Walk structures looking for Type 1. + offset := 0 + for offset < len(tableData) { + if offset+4 > len(tableData) { + break + } + + structType := tableData[offset] + structLen := int(tableData[offset+1]) + + if structLen < 4 || offset+structLen > len(tableData) { + break + } + + // Collect the unformatted (string) area that follows the formatted part. + // It is terminated by a double NUL (\0\0). + stringsStart := offset + structLen + stringsEnd := stringsStart + foundTerminator := false + + for stringsEnd < len(tableData)-1 { + if tableData[stringsEnd] == 0 && tableData[stringsEnd+1] == 0 { + stringsEnd += 2 + foundTerminator = true + + break + } + + stringsEnd++ + } + + if !foundTerminator { + return nil, errors.New("SMBIOS string area not properly terminated (missing double-NUL)") + } + + if structType == 1 && structLen >= 0x08 { + // Type 1: System Information + // Offsets (within the formatted area): + // 04h: Manufacturer (string index) + // 05h: Product Name (string index) + // 06h: Version (string index) + // 07h: Serial Number (string index) + // 08h-17h: UUID (16 bytes) — present only when structLen >= 0x19 + strs := extractStrings(tableData[stringsStart:stringsEnd]) + + info := &SMBIOSSystemInfo{ + Manufacturer: getStringByIndex(strs, tableData[offset+0x04]), + ProductName: getStringByIndex(strs, tableData[offset+0x05]), + Version: getStringByIndex(strs, tableData[offset+0x06]), + SerialNumber: getStringByIndex(strs, tableData[offset+0x07]), + } + + // UUID is only available in SMBIOS 2.1+ (structLen >= 0x19). + if structLen >= 0x19 { + // SMBIOS 2.6+ uses little-endian byte order for the first three UUID fields; + // earlier versions use network (big-endian) order throughout. + smbios26Plus := smbiosMajor > 2 || (smbiosMajor == 2 && smbiosMinor >= 6) + info.UUID = formatUUID(tableData[offset+0x08:offset+0x18], smbios26Plus) + } + + return info, nil + } + + offset = stringsEnd + } + + return nil, errors.New("SMBIOS Type 1 (System Information) structure not found") +} + +// extractStrings splits the unformatted string area into individual strings. +// Each string is NUL-terminated; the area ends with a double NUL. +// Empty strings are preserved by position so that 1-based indices remain correct. +func extractStrings(data []byte) []string { + var result []string + + start := 0 + + for i := range data { + if data[i] == 0 { + result = append(result, string(data[start:i])) + + start = i + 1 + } + } + + return result +} + +// getStringByIndex returns the 1-based string from the list, or empty if +// the index is 0 or out of range. +func getStringByIndex(strs []string, index byte) string { + if index == 0 || int(index) > len(strs) { + return "" + } + + return strs[index-1] +} + +// formatUUID formats the 16-byte SMBIOS UUID according to RFC 4122. +// When smbios26Plus is true, the first three fields are interpreted as +// little-endian (SMBIOS 2.6+ convention). When false, all fields are +// in network (big-endian) byte order (pre-2.6 convention). +func formatUUID(raw []byte, smbios26Plus bool) string { + if len(raw) < 16 { + return "" + } + + // Check for "not present" (all 0xFF) or "not settable" (all 0x00). + allFF := true + allZero := true + + for _, b := range raw { + if b != 0xFF { + allFF = false + } + + if b != 0x00 { + allZero = false + } + } + + if allFF || allZero { + return "" + } + + if smbios26Plus { + // SMBIOS 2.6+: first three components are little-endian. + // TimeLow (4 bytes LE), TimeMid (2 bytes LE), TimeHiAndVersion (2 bytes LE), + // ClockSeq (2 bytes BE), Node (6 bytes). + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + binary.LittleEndian.Uint32(raw[0:4]), + binary.LittleEndian.Uint16(raw[4:6]), + binary.LittleEndian.Uint16(raw[6:8]), + binary.BigEndian.Uint16(raw[8:10]), + raw[10:16], + ) + } + + // Pre-2.6: all fields in network byte order. + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + binary.BigEndian.Uint32(raw[0:4]), + binary.BigEndian.Uint16(raw[4:6]), + binary.BigEndian.Uint16(raw[6:8]), + binary.BigEndian.Uint16(raw[8:10]), + raw[10:16], + ) +} diff --git a/internal/headers/sysinfoapi/smbios_test.go b/internal/headers/sysinfoapi/smbios_test.go new file mode 100644 index 00000000..322bae1d --- /dev/null +++ b/internal/headers/sysinfoapi/smbios_test.go @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sysinfoapi + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormatUUID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw []byte + smbios26Plus bool + expected string + }{ + { + name: "SMBIOS 2.6+ UUID with LE byte swap", + // Stored as: 78 56 34 12 34 12 78 56 12 34 56 78 9a bc de f0 + // Expected: 12345678-1234-5678-1234-56789abcdef0 + raw: []byte{0x78, 0x56, 0x34, 0x12, 0x34, 0x12, 0x78, 0x56, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0}, + smbios26Plus: true, + expected: "12345678-1234-5678-1234-56789abcdef0", + }, + { + name: "pre-2.6 UUID in network byte order", + // All fields in big-endian (network) order. + raw: []byte{0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0}, + smbios26Plus: false, + expected: "12345678-1234-5678-1234-56789abcdef0", + }, + { + name: "all zeros - not settable", + raw: make([]byte, 16), + smbios26Plus: true, + expected: "", + }, + { + name: "all 0xFF - not present", + raw: []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, + smbios26Plus: true, + expected: "", + }, + { + name: "too short", + raw: []byte{0x01, 0x02}, + smbios26Plus: true, + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, formatUUID(tc.raw, tc.smbios26Plus)) + }) + } +} + +func TestExtractStrings(t *testing.T) { + t.Parallel() + + data := []byte("QEMU\x00Standard PC\x00v1.0\x00\x00") + result := extractStrings(data) + // The trailing double-NUL produces a final empty string entry; + // the first three entries correspond to the actual firmware strings. + require.GreaterOrEqual(t, len(result), 3) + assert.Equal(t, "QEMU", result[0]) + assert.Equal(t, "Standard PC", result[1]) + assert.Equal(t, "v1.0", result[2]) +} + +func TestExtractStringsEmpty(t *testing.T) { + t.Parallel() + + // Empty strings between NULs must be preserved by position. + data := []byte("first\x00\x00third\x00\x00") + result := extractStrings(data) + require.GreaterOrEqual(t, len(result), 3) + assert.Equal(t, "first", result[0]) + assert.Empty(t, result[1]) + assert.Equal(t, "third", result[2]) +} + +func TestGetStringByIndex(t *testing.T) { + t.Parallel() + + strs := []string{"first", "second", "third"} + assert.Empty(t, getStringByIndex(strs, 0)) + assert.Equal(t, "first", getStringByIndex(strs, 1)) + assert.Equal(t, "second", getStringByIndex(strs, 2)) + assert.Equal(t, "third", getStringByIndex(strs, 3)) + assert.Empty(t, getStringByIndex(strs, 4)) +} + +func TestParseSMBIOSSystemInfo(t *testing.T) { + t.Parallel() + + // Build a minimal RSMB blob with a Type 1 structure. + // Header: 8 bytes (rawSMBIOSData). + header := []byte{ + 0x00, // Used20CallingMethod + 0x03, // SMBIOSMajorVersion + 0x02, // SMBIOSMinorVersion + 0x00, // DMARevision + 0x00, 0x00, 0x00, 0x00, // Length (placeholder) + } + + // Type 1 formatted area: 0x19 bytes + type1 := make([]byte, 0x19) + type1[0] = 1 // Type + type1[1] = 0x19 // Length + // Handle (2 bytes) - don't care + type1[4] = 1 // Manufacturer string index + type1[5] = 2 // Product Name string index + type1[6] = 3 // Version string index + type1[7] = 4 // Serial Number string index + + // UUID at offset 0x08: 16 bytes + // Store 12345678-1234-5678-9abc-def012345678 in SMBIOS LE format + copy(type1[0x08:], []byte{0x78, 0x56, 0x34, 0x12, 0x34, 0x12, 0x78, 0x56, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78}) + + // Strings area + strings := []byte("QEMU\x00Standard PC (Q35 + ICH9, 2009)\x00pc-q35-9.2\x00SN-12345\x00\x00") + + // Combine + tableData := slices.Concat(type1, strings) + tableLen := len(tableData) + + // Fill in length in header + header[4] = byte(tableLen) + header[5] = byte(tableLen >> 8) + header[6] = byte(tableLen >> 16) + header[7] = byte(tableLen >> 24) + + data := slices.Concat(header, tableData) + + info, err := parseSMBIOSSystemInfo(data) + require.NoError(t, err) + assert.Equal(t, "QEMU", info.Manufacturer) + assert.Equal(t, "Standard PC (Q35 + ICH9, 2009)", info.ProductName) + assert.Equal(t, "pc-q35-9.2", info.Version) + assert.Equal(t, "SN-12345", info.SerialNumber) + assert.Equal(t, "12345678-1234-5678-9abc-def012345678", info.UUID) +} + +func TestParseSMBIOSSystemInfoPre26(t *testing.T) { + t.Parallel() + + // SMBIOS 2.5 header: UUID bytes in network (big-endian) order. + header := []byte{ + 0x00, // Used20CallingMethod + 0x02, // SMBIOSMajorVersion = 2 + 0x05, // SMBIOSMinorVersion = 5 (pre-2.6) + 0x00, // DMARevision + 0x00, 0x00, 0x00, 0x00, // Length (placeholder) + } + + type1 := make([]byte, 0x19) + type1[0] = 1 // Type + type1[1] = 0x19 // Length + type1[4] = 1 // Manufacturer string index + type1[5] = 2 // Product Name string index + type1[6] = 3 // Version string index + type1[7] = 4 // Serial Number string index + + // UUID in network byte order: 12345678-1234-5678-9abc-def012345678 + copy(type1[0x08:], []byte{0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78}) + + strs := []byte("QEMU\x00Standard PC\x00v1.0\x00SN-001\x00\x00") + + tableData := slices.Concat(type1, strs) + tableLen := len(tableData) + header[4] = byte(tableLen) + header[5] = byte(tableLen >> 8) + header[6] = byte(tableLen >> 16) + header[7] = byte(tableLen >> 24) + + data := slices.Concat(header, tableData) + + info, err := parseSMBIOSSystemInfo(data) + require.NoError(t, err) + assert.Equal(t, "12345678-1234-5678-9abc-def012345678", info.UUID) + assert.Equal(t, "QEMU", info.Manufacturer) +} + +func TestParseSMBIOSSystemInfoSMBIOS20(t *testing.T) { + t.Parallel() + + // SMBIOS 2.0 Type 1 structure: only 8 bytes (no UUID field). + header := []byte{ + 0x00, // Used20CallingMethod + 0x02, // SMBIOSMajorVersion = 2 + 0x00, // SMBIOSMinorVersion = 0 + 0x00, // DMARevision + 0x00, 0x00, 0x00, 0x00, // Length (placeholder) + } + + type1 := make([]byte, 0x08) + type1[0] = 1 // Type + type1[1] = 0x08 // Length (8 bytes, no UUID) + type1[4] = 1 // Manufacturer string index + type1[5] = 2 // Product Name string index + type1[6] = 3 // Version string index + type1[7] = 4 // Serial Number string index + + strs := []byte("OldBIOS\x00Legacy PC\x00v0.1\x00SN-OLD\x00\x00") + + tableData := slices.Concat(type1, strs) + tableLen := len(tableData) + header[4] = byte(tableLen) + header[5] = byte(tableLen >> 8) + header[6] = byte(tableLen >> 16) + header[7] = byte(tableLen >> 24) + + data := slices.Concat(header, tableData) + + info, err := parseSMBIOSSystemInfo(data) + require.NoError(t, err) + assert.Equal(t, "OldBIOS", info.Manufacturer) + assert.Equal(t, "Legacy PC", info.ProductName) + assert.Equal(t, "v0.1", info.Version) + assert.Equal(t, "SN-OLD", info.SerialNumber) + assert.Empty(t, info.UUID, "UUID should be empty for 8-byte SMBIOS 2.0 Type 1 structures") +} + +func TestParseSMBIOSSystemInfoWithType0Preceding(t *testing.T) { + t.Parallel() + + // Real firmware tables almost always have Type 0 (BIOS Information) before Type 1. + // Verify the walker correctly skips Type 0 and finds Type 1. + header := []byte{ + 0x00, // Used20CallingMethod + 0x03, // SMBIOSMajorVersion + 0x02, // SMBIOSMinorVersion + 0x00, // DMARevision + 0x00, 0x00, 0x00, 0x00, // Length (placeholder) + } + + // Type 0 (BIOS Information): 0x12 bytes formatted area. + type0 := make([]byte, 0x12) + type0[0] = 0 // Type 0 + type0[1] = 0x12 // Length + type0[4] = 1 // Vendor string index + type0[5] = 2 // BIOS Version string index + + type0Strs := []byte("BIOS Vendor\x00BIOS v1.0\x00\x00") + + // Type 1 (System Information): 0x19 bytes formatted area. + type1 := make([]byte, 0x19) + type1[0] = 1 // Type 1 + type1[1] = 0x19 // Length + type1[4] = 1 // Manufacturer string index + type1[5] = 2 // Product Name string index + type1[6] = 3 // Version string index + type1[7] = 4 // Serial Number string index + + copy(type1[0x08:], []byte{0x78, 0x56, 0x34, 0x12, 0x34, 0x12, 0x78, 0x56, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78}) + + type1Strs := []byte("Dell Inc.\x00PowerEdge R740\x00v2.0\x00SN-ABCDE\x00\x00") + + tableData := slices.Concat(type0, type0Strs, type1, type1Strs) + tableLen := len(tableData) + header[4] = byte(tableLen) + header[5] = byte(tableLen >> 8) + header[6] = byte(tableLen >> 16) + header[7] = byte(tableLen >> 24) + + data := slices.Concat(header, tableData) + + info, err := parseSMBIOSSystemInfo(data) + require.NoError(t, err) + assert.Equal(t, "Dell Inc.", info.Manufacturer) + assert.Equal(t, "PowerEdge R740", info.ProductName) + assert.Equal(t, "v2.0", info.Version) + assert.Equal(t, "SN-ABCDE", info.SerialNumber) + assert.Equal(t, "12345678-1234-5678-9abc-def012345678", info.UUID) +} + +func TestParseSMBIOSSystemInfoUnterminatedStrings(t *testing.T) { + t.Parallel() + + // Build a Type 1 structure whose string area lacks the double-NUL terminator. + header := []byte{ + 0x00, // Used20CallingMethod + 0x03, // SMBIOSMajorVersion + 0x02, // SMBIOSMinorVersion + 0x00, // DMARevision + 0x00, 0x00, 0x00, 0x00, // Length (placeholder) + } + + type1 := make([]byte, 0x19) + type1[0] = 1 // Type + type1[1] = 0x19 // Length + type1[4] = 1 // Manufacturer string index + + // String area with only a single NUL (missing the terminating double-NUL). + strs := []byte("QEMU\x00") + + tableData := slices.Concat(type1, strs) + tableLen := len(tableData) + header[4] = byte(tableLen) + header[5] = byte(tableLen >> 8) + header[6] = byte(tableLen >> 16) + header[7] = byte(tableLen >> 24) + + data := slices.Concat(header, tableData) + + _, err := parseSMBIOSSystemInfo(data) + require.Error(t, err) + assert.Contains(t, err.Error(), "not properly terminated") +} + +func FuzzParseSMBIOSSystemInfo(f *testing.F) { + // Seed with a valid RSMB blob. + header := []byte{ + 0x00, 0x03, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, + } + + type1 := make([]byte, 0x19) + type1[0] = 1 + type1[1] = 0x19 + type1[4] = 1 + type1[5] = 2 + type1[6] = 3 + type1[7] = 4 + + copy(type1[0x08:], []byte{0x78, 0x56, 0x34, 0x12, 0x34, 0x12, 0x78, 0x56, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78}) + + strs := []byte("QEMU\x00Standard PC\x00v1.0\x00SN-001\x00\x00") + tableData := slices.Concat(type1, strs) + tableLen := len(tableData) + header[4] = byte(tableLen) + header[5] = byte(tableLen >> 8) + header[6] = byte(tableLen >> 16) + header[7] = byte(tableLen >> 24) + + f.Add(slices.Concat(header, tableData)) + + // Also seed with an empty input and a truncated one. + f.Add([]byte{}) + f.Add(header[:4]) + + f.Fuzz(func(t *testing.T, data []byte) { + // Must not panic on any input. + _, _ = parseSMBIOSSystemInfo(data) + }) +} diff --git a/pkg/collector/collection.go b/pkg/collector/collection.go index 37076217..e8a6c5d6 100644 --- a/pkg/collector/collection.go +++ b/pkg/collector/collection.go @@ -38,6 +38,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/dfsr" "github.com/prometheus-community/windows_exporter/internal/collector/dhcp" "github.com/prometheus-community/windows_exporter/internal/collector/diskdrive" + "github.com/prometheus-community/windows_exporter/internal/collector/dmi" "github.com/prometheus-community/windows_exporter/internal/collector/dns" "github.com/prometheus-community/windows_exporter/internal/collector/exchange" "github.com/prometheus-community/windows_exporter/internal/collector/file" @@ -109,6 +110,7 @@ func NewWithConfig(config Config) *Collection { collectors[dfsr.Name] = dfsr.New(&config.DFSR) collectors[dhcp.Name] = dhcp.New(&config.Dhcp) collectors[diskdrive.Name] = diskdrive.New(&config.DiskDrive) + collectors[dmi.Name] = dmi.New(&config.DMI) collectors[dns.Name] = dns.New(&config.DNS) collectors[exchange.Name] = exchange.New(&config.Exchange) collectors[file.Name] = file.New(&config.File) diff --git a/pkg/collector/config.go b/pkg/collector/config.go index e5a26b7d..67cdad36 100644 --- a/pkg/collector/config.go +++ b/pkg/collector/config.go @@ -28,6 +28,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/dfsr" "github.com/prometheus-community/windows_exporter/internal/collector/dhcp" "github.com/prometheus-community/windows_exporter/internal/collector/diskdrive" + "github.com/prometheus-community/windows_exporter/internal/collector/dmi" "github.com/prometheus-community/windows_exporter/internal/collector/dns" "github.com/prometheus-community/windows_exporter/internal/collector/exchange" "github.com/prometheus-community/windows_exporter/internal/collector/file" @@ -79,6 +80,7 @@ type Config struct { DFSR dfsr.Config `yaml:"dfsr"` Dhcp dhcp.Config `yaml:"dhcp"` DiskDrive diskdrive.Config `yaml:"diskdrive"` + DMI dmi.Config `yaml:"dmi"` DNS dns.Config `yaml:"dns"` Exchange exchange.Config `yaml:"exchange"` File file.Config `yaml:"file"` @@ -134,6 +136,7 @@ var ConfigDefaults = Config{ DFSR: dfsr.ConfigDefaults, Dhcp: dhcp.ConfigDefaults, DiskDrive: diskdrive.ConfigDefaults, + DMI: dmi.ConfigDefaults, DNS: dns.ConfigDefaults, Exchange: exchange.ConfigDefaults, File: file.ConfigDefaults, diff --git a/pkg/collector/map.go b/pkg/collector/map.go index c18cc394..b590efde 100644 --- a/pkg/collector/map.go +++ b/pkg/collector/map.go @@ -32,6 +32,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/dfsr" "github.com/prometheus-community/windows_exporter/internal/collector/dhcp" "github.com/prometheus-community/windows_exporter/internal/collector/diskdrive" + "github.com/prometheus-community/windows_exporter/internal/collector/dmi" "github.com/prometheus-community/windows_exporter/internal/collector/dns" "github.com/prometheus-community/windows_exporter/internal/collector/exchange" "github.com/prometheus-community/windows_exporter/internal/collector/file" @@ -90,6 +91,7 @@ var BuildersWithFlags = map[string]BuilderWithFlags[Collector]{ dfsr.Name: NewBuilderWithFlags(dfsr.NewWithFlags), dhcp.Name: NewBuilderWithFlags(dhcp.NewWithFlags), diskdrive.Name: NewBuilderWithFlags(diskdrive.NewWithFlags), + dmi.Name: NewBuilderWithFlags(dmi.NewWithFlags), dns.Name: NewBuilderWithFlags(dns.NewWithFlags), exchange.Name: NewBuilderWithFlags(exchange.NewWithFlags), file.Name: NewBuilderWithFlags(file.NewWithFlags),