From 100a27e8239d186c5a1ac4ed124b7d8c4c2a960b Mon Sep 17 00:00:00 2001 From: julesroussel3 <53919374+julesroussel3@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:28:27 -0500 Subject: [PATCH] feat(inputs.win_wmi): add Windows Management Instrumentation (WMI) input plugin (#11250) --- go.mod | 2 +- plugins/inputs/all/win_wmi.go | 5 + plugins/inputs/win_wmi/README.md | 298 +++++++++++++++++++ plugins/inputs/win_wmi/sample.conf | 13 + plugins/inputs/win_wmi/win_wmi.go | 225 ++++++++++++++ plugins/inputs/win_wmi/win_wmi_notwindows.go | 4 + plugins/inputs/win_wmi/win_wmi_test.go | 75 +++++ 7 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 plugins/inputs/all/win_wmi.go create mode 100644 plugins/inputs/win_wmi/README.md create mode 100644 plugins/inputs/win_wmi/sample.conf create mode 100644 plugins/inputs/win_wmi/win_wmi.go create mode 100644 plugins/inputs/win_wmi/win_wmi_notwindows.go create mode 100644 plugins/inputs/win_wmi/win_wmi_test.go diff --git a/go.mod b/go.mod index 67d876e62..643695326 100644 --- a/go.mod +++ b/go.mod @@ -69,6 +69,7 @@ require ( github.com/fatih/color v1.13.0 github.com/go-ldap/ldap/v3 v3.4.4 github.com/go-logfmt/logfmt v0.6.0 + github.com/go-ole/go-ole v1.2.6 github.com/go-redis/redis/v7 v7.4.1 github.com/go-redis/redis/v8 v8.11.5 github.com/go-sql-driver/mysql v1.6.0 @@ -275,7 +276,6 @@ require ( github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-macaroon-bakery/macaroonpb v1.0.0 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/swag v0.22.1 // indirect diff --git a/plugins/inputs/all/win_wmi.go b/plugins/inputs/all/win_wmi.go new file mode 100644 index 000000000..f37daf301 --- /dev/null +++ b/plugins/inputs/all/win_wmi.go @@ -0,0 +1,5 @@ +//go:build !custom || inputs || inputs.win_wmi + +package all + +import _ "github.com/influxdata/telegraf/plugins/inputs/win_wmi" // register plugin diff --git a/plugins/inputs/win_wmi/README.md b/plugins/inputs/win_wmi/README.md new file mode 100644 index 000000000..2e93c0f2b --- /dev/null +++ b/plugins/inputs/win_wmi/README.md @@ -0,0 +1,298 @@ +# Windows Management Instrumentation Input Plugin + +This document presents the input plugin to read WMI classes on Windows +operating systems. With the win_wmi plugin, it is possible to +capture and filter virtually any configuration or metric value exposed +through the Windows Management Instrumentation ([WMI][WMIdoc]) +service. At minimum, the telegraf service user must have permission +to [read][ACL] the WMI namespace that is being queried. + +[ACL]: https://learn.microsoft.com/en-us/windows/win32/wmisdk/access-to-wmi-namespaces +[WMIdoc]: https://learn.microsoft.com/en-us/windows/win32/wmisdk/wmi-start-page + +## Global configuration options + +In addition to the plugin-specific configuration settings, plugins support +additional global and plugin configuration settings. These settings are used to +modify metrics, tags, and field or create aliases and configure ordering, etc. +See the [CONFIGURATION.md][CONFIGURATION.md] for more details. + +[CONFIGURATION.md]: ../../../docs/CONFIGURATION.md + +## Configuration + +```toml @sample.conf +# Input plugin to query Windows Management Instrumentation +[[inputs.win_wmi]] + # specifies a prefix to attach to the measurement name + name_prefix = "win_wmi_" + [[inputs.win_wmi.query]] + # a string representing the WMI namespace to be queried + namespace = "root\\cimv2" + # a string representing the WMI class to be queried + class_name = "Win32_Volume" + # an array of strings representing the properties of the WMI class to be queried + properties = ["Name", "Capacity", "FreeSpace"] + # a string specifying a WHERE clause to use as a filter for the WQL + filter = 'NOT Name LIKE "\\\\?\\%"' + # WMI class properties which should be considered tags instead of fields + tag_properties = ["Name"] +``` + +### namespace + +A string representing the WMI namespace to be queried. For example, +`root\\cimv2`. + +### class_name + +A string representing the WMI class to be queried. For example, +`Win32_Processor`. + +### properties + +An array of strings representing the properties of the WMI class to be queried. + +### filter + +A string specifying a WHERE clause to use as a filter for the WMI Query +Language (WQL). See [WHERE Clause][WHERE] for more information. + +[WHERE]: https://learn.microsoft.com/en-us/windows/win32/wmisdk/where-clause?source=recommendations + +### tag_properties + +Properties which should be considered tags instead of fields. + +## Metrics + +By default, a WMI class property's value is used as a metric field. If a class +property's value is specified in `tag_properties`, then the value is +instead included with the metric as a tag. + +## Troubleshooting + +### Errors + +If you are getting an error about an invalid WMI namespace, class, or property, +use the `Get-WmiObject` or `Get-CimInstance` PowerShell commands in order to +verify their validity. For example: + +```powershell +Get-WmiObject -Namespace root\cimv2 -Class Win32_Volume -Property Capacity, FreeSpace, Name -Filter 'NOT Name LIKE "\\\\?\\%"' +``` + +```powershell +Get-CimInstance -Namespace root\cimv2 -ClassName Win32_Volume -Property Capacity, FreeSpace, Name -Filter 'NOT Name LIKE "\\\\?\\%"' +``` + +### Data types + +Some WMI classes will return the incorrect data type for a field. In those +cases, it is necessary to use a processor to convert the data type. For +example, the Capacity and FreeSpace properties of the Win32_Volume class must +be converted to integers: + +```toml +[[processors.converter]] + namepass = ["win_wmi_Win32_Volume"] + [processors.converter.fields] + integer = ["Capacity", "FreeSpace"] +``` + +## Example Output + +### Physical Memory + +This query provides metrics for the speed and capacity of each physical memory +device, along with tags describing the manufacturer, part number, and device +locator of each device. + +```toml +[[inputs.win_wmi]] + name_prefix = "win_wmi_" + [[inputs.win_wmi.query]] + namespace = "root\\cimv2" + class_name = "Win32_PhysicalMemory" + properties = [ + "Name", + "Capacity", + "DeviceLocator", + "Manufacturer", + "PartNumber", + "Speed", + ] + tag_properties = ["Name","DeviceLocator","Manufacturer","PartNumber"] +``` + +Example Output: + +```text +win_wmi_Win32_PhysicalMemory,DeviceLocator=DIMM1,Manufacturer=80AD000080AD,Name=Physical\ Memory,PartNumber=HMA82GU6DJR8N-XN\ \ \ \ ,host=foo Capacity=17179869184i,Speed=3200i 1654269272000000000 +``` + +### Processor + +This query provides metrics for the number of cores in each physical processor. +Since the Name property of the WMI class is included by default, the metrics +will also contain a tag value describing the model of each CPU. + +```toml +[[inputs.win_wmi]] + name_prefix = "win_wmi_" + [[inputs.win_wmi.query]] + namespace = "root\\cimv2" + class_name = "Win32_Processor" + properties = ["Name","NumberOfCores"] + tag_properties = ["Name"] +``` + +Example Output: + +```text +win_wmi_Win32_Processor,Name=Intel(R)\ Core(TM)\ i9-10900\ CPU\ @\ 2.80GHz,host=foo NumberOfCores=10i 1654269272000000000 +``` + +### Computer System + +This query provides metrics for the number of socketted processors, number of +logical cores on each processor, and the total physical memory in the computer. +The metrics include tag values for the domain, manufacturer, and model of the +computer. + +```toml +[[inputs.win_wmi]] + name_prefix = "win_wmi_" + [[inputs.win_wmi.query]] + namespace = "root\\cimv2" + class_name = "Win32_ComputerSystem" + properties = [ + "Name", + "Domain", + "Manufacturer", + "Model", + "NumberOfLogicalProcessors", + "NumberOfProcessors", + "TotalPhysicalMemory" + ] + tag_properties = ["Name","Domain","Manufacturer","Model"] +``` + +Example Output: + +```text +win_wmi_Win32_ComputerSystem,Domain=company.com,Manufacturer=Lenovo,Model=X1\ Carbon,Name=FOO,host=foo NumberOfLogicalProcessors=20i,NumberOfProcessors=1i,TotalPhysicalMemory=34083926016i 1654269272000000000 +``` + +### Operating System + +This query provides metrics for the paging file's free space, the operating +system's free virtual memory, the operating system SKU installed on the +computer, and the Windows product type. The OS architecture is included as a +tagged value to describe whether the installation is 32-bit or 64-bit. + +```toml +[[inputs.win_wmi]] + name_prefix = "win_wmi_" + [[inputs.win_wmi.query]] + class_name = "Win32_OperatingSystem" + namespace = "root\\cimv2" + properties = [ + "Name", + "Caption", + "FreeSpaceInPagingFiles", + "FreeVirtualMemory", + "OperatingSystemSKU", + "OSArchitecture", + "ProductType" + ] + tag_properties = ["Name","Caption","OSArchitecture"] +``` + +Example Output: + +```text +win_wmi_Win32_OperatingSystem,Caption=Microsoft\ Windows\ 10\ Enterprise,InstallationType=Client,Name=Microsoft\ Windows\ 10\ Enterprise|C:\WINDOWS|\Device\Harddisk0\Partition3,OSArchitecture=64-bit,host=foo FreeSpaceInPagingFiles=5203244i,FreeVirtualMemory=16194496i,OperatingSystemSKU=4i,ProductType=1i 1654269272000000000 +``` + +### Failover Clusters + +This query provides a boolean metric describing whether Dynamic Quorum is +enabled for the cluster. The tag values for the metric also include the name of +the Windows Server Failover Cluster and the type of Quorum in use. + +```toml +[[inputs.win_wmi]] + name_prefix = "win_wmi_" + [[inputs.win_wmi.query]] + namespace = "root\\mscluster" + class_name = "MSCluster_Cluster" + properties = [ + "Name", + "QuorumType", + "DynamicQuorumEnabled" + ] + tag_properties = ["Name","QuorumType"] +``` + +Example Output: + +```text +win_wmi_MSCluster_Cluster,Name=testcluster1,QuorumType=Node\ and\ File\ Share\ Majority,host=testnode1 DynamicQuorumEnabled=1i 1671553260000000000 +``` + +### Bitlocker + +This query provides a list of volumes which are eligible for bitlocker +encryption and their compliance status. Because the MBAM_Volume class does not +include a Name property, the ExcludeNameKey configuration is included. The +VolumeName property is included in the metric as a tagged value. + +```toml +[[inputs.win_wmi]] + name_prefix = "win_wmi_" + [[inputs.win_wmi.query]] + namespace = "root\\Microsoft\\MBAM" + class_name = "MBAM_Volume" + properties = [ + "Compliant", + "VolumeName" + ] + tag_properties = ["VolumeName"] +``` + +Example Output: + +```text +win_wmi_MBAM_Volume,VolumeName=C:,host=foo Compliant=1i 1654269272000000000 +``` + +### SQL Server + +This query provides metrics which contain tags describing the version and SKU +of SQL Server. These properties are useful for creating a dashboard of your SQL +Server inventory, which includes the patch level and edition of SQL Server that +is installed. + +```toml +[[inputs.win_wmi]] + name_prefix = "win_wmi_" + [[inputs.win_wmi.query]] + namespace = "Root\\Microsoft\\SqlServer\\ComputerManagement15" + class_name = "SqlServiceAdvancedProperty" + properties = [ + "PropertyName", + "ServiceName", + "PropertyStrValue", + "SqlServiceType" + ] + filter = "ServiceName LIKE 'MSSQLSERVER' AND SqlServiceType = 1 AND (PropertyName LIKE 'FILEVERSION' OR PropertyName LIKE 'SKUNAME')" + tag_properties = ["PropertyName","ServiceName","PropertyStrValue"] +``` + +Example Output: + +```text +win_wmi_SqlServiceAdvancedProperty,PropertyName=FILEVERSION,PropertyStrValue=2019.150.4178.1,ServiceName=MSSQLSERVER,host=foo,sqlinstance=foo SqlServiceType=1i 1654269272000000000 +win_wmi_SqlServiceAdvancedProperty,PropertyName=SKUNAME,PropertyStrValue=Developer\ Edition\ (64-bit),ServiceName=MSSQLSERVER,host=foo,sqlinstance=foo SqlServiceType=1i 1654269272000000000 +``` diff --git a/plugins/inputs/win_wmi/sample.conf b/plugins/inputs/win_wmi/sample.conf new file mode 100644 index 000000000..bfbffdf75 --- /dev/null +++ b/plugins/inputs/win_wmi/sample.conf @@ -0,0 +1,13 @@ +# Input plugin to query Windows Management Instrumentation +[[inputs.win_wmi]] + [[inputs.win_wmi.query]] + # a string representing the WMI namespace to be queried + namespace = "root\\cimv2" + # a string representing the WMI class to be queried + class_name = "Win32_Volume" + # an array of strings representing the properties of the WMI class to be queried + properties = ["Name", "Capacity", "FreeSpace"] + # a string specifying a WHERE clause to use as a filter for the WQL + filter = 'NOT Name LIKE "\\\\?\\%"' + # WMI class properties which should be considered tags instead of fields + tag_properties = ["Name"] diff --git a/plugins/inputs/win_wmi/win_wmi.go b/plugins/inputs/win_wmi/win_wmi.go new file mode 100644 index 000000000..1fe5d42c2 --- /dev/null +++ b/plugins/inputs/win_wmi/win_wmi.go @@ -0,0 +1,225 @@ +//go:build windows +// +build windows + +package win_wmi + +import ( + _ "embed" + "fmt" + "runtime" + "strings" + "sync" + + ole "github.com/go-ole/go-ole" + "github.com/go-ole/go-ole/oleutil" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/filter" + "github.com/influxdata/telegraf/internal" + "github.com/influxdata/telegraf/plugins/inputs" +) + +//go:embed sample.conf +var sampleConfig string + +// Query struct +type Query struct { + query string + Namespace string `toml:"namespace"` + ClassName string `toml:"class_name"` + Properties []string `toml:"properties"` + Filter string `toml:"filter"` + TagPropertiesInclude []string `toml:"tag_properties"` + tagFilter filter.Filter +} + +// Wmi struct +type Wmi struct { + Queries []Query `toml:"query"` + Log telegraf.Logger +} + +// S_FALSE is returned by CoInitializeEx if it was already called on this thread. +const sFalse = 0x00000001 + +func oleInt64(item *ole.IDispatch, prop string) (int64, error) { + v, err := oleutil.GetProperty(item, prop) + if err != nil { + return 0, err + } + defer v.Clear() + + return v.Val, nil +} + +// Init function +func (s *Wmi) Init() error { + return compileInputs(s) +} + +// SampleConfig function +func (s *Wmi) SampleConfig() string { + return sampleConfig +} + +func compileInputs(s *Wmi) error { + buildWqlStatements(s) + return compileTagFilters(s) +} + +func compileTagFilters(s *Wmi) error { + for i, q := range s.Queries { + var err error + s.Queries[i].tagFilter, err = compileTagFilter(q) + if err != nil { + return err + } + } + return nil +} + +func compileTagFilter(q Query) (filter.Filter, error) { + tagFilter, err := filter.NewIncludeExcludeFilterDefaults(q.TagPropertiesInclude, nil, false, false) + if err != nil { + return nil, fmt.Errorf("creating tag filter failed: %w", err) + } + return tagFilter, nil +} + +// build a WMI query from input configuration +func buildWqlStatements(s *Wmi) { + for i, q := range s.Queries { + wql := fmt.Sprintf("SELECT %s FROM %s", strings.Join(q.Properties, ", "), q.ClassName) + if len(q.Filter) > 0 { + wql = fmt.Sprintf("%s WHERE %s", wql, q.Filter) + } + s.Queries[i].query = wql + } +} + +func (q *Query) doQuery(acc telegraf.Accumulator) error { + // The only way to run WMI queries in parallel while being thread-safe is to + // ensure the CoInitialize[Ex]() call is bound to its current OS thread. + // Otherwise, attempting to initialize and run parallel queries across + // goroutines will result in protected memory errors. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + tags := map[string]string{} + fields := map[string]interface{}{} + + // init COM + if err := ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED); err != nil { + oleCode := err.(*ole.OleError).Code() + if oleCode != ole.S_OK && oleCode != sFalse { + return err + } + } + defer ole.CoUninitialize() + + unknown, err := oleutil.CreateObject("WbemScripting.SWbemLocator") + if err != nil { + return err + } + if unknown == nil { + return fmt.Errorf("failed to create WbemScripting.SWbemLocator, maybe WMI is broken") + } + defer unknown.Release() + + wmi, err := unknown.QueryInterface(ole.IID_IDispatch) + if err != nil { + return fmt.Errorf("failed to QueryInterface: %w", err) + } + defer wmi.Release() + + // service is a SWbemServices + serviceRaw, err := oleutil.CallMethod(wmi, "ConnectServer", nil, q.Namespace) + if err != nil { + return fmt.Errorf("failed calling method ConnectServer: %w", err) + } + service := serviceRaw.ToIDispatch() + defer serviceRaw.Clear() + + // result is a SWBemObjectSet + resultRaw, err := oleutil.CallMethod(service, "ExecQuery", q.query) + if err != nil { + return fmt.Errorf("failed calling method ExecQuery for query %s: %w", q.query, err) + } + result := resultRaw.ToIDispatch() + defer resultRaw.Clear() + + count, err := oleInt64(result, "Count") + if err != nil { + return fmt.Errorf("failed getting Count: %w", err) + } + + for i := int64(0); i < count; i++ { + // item is a SWbemObject + itemRaw, err := oleutil.CallMethod(result, "ItemIndex", i) + if err != nil { + return fmt.Errorf("failed calling method ItemIndex: %w", err) + } + + item := itemRaw.ToIDispatch() + defer item.Release() + + for _, wmiProperty := range q.Properties { + prop, err := oleutil.GetProperty(item, wmiProperty) + if err != nil { + return fmt.Errorf("failed GetProperty: %w", err) + } + defer prop.Clear() + + if q.tagFilter.Match(wmiProperty) { + valStr, err := internal.ToString(prop.Value()) + if err != nil { + return fmt.Errorf("converting property %q failed: %w", wmiProperty, err) + } + tags[wmiProperty] = valStr + } else { + var fieldValue interface{} + switch v := prop.Value().(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + fieldValue = v + case string: + fieldValue = v + case bool: + fieldValue = v + case []byte: + fieldValue = string(v) + case fmt.Stringer: + fieldValue = v.String() + case nil: + fieldValue = nil + default: + return fmt.Errorf("property %q of type \"%T\" unsupported", wmiProperty, v) + } + fields[wmiProperty] = fieldValue + } + } + acc.AddFields(q.ClassName, fields, tags) + } + return nil +} + +// Gather function +func (s *Wmi) Gather(acc telegraf.Accumulator) error { + var wg sync.WaitGroup + for _, query := range s.Queries { + wg.Add(1) + go func(q Query) { + defer wg.Done() + err := q.doQuery(acc) + if err != nil { + acc.AddError(err) + } + }(query) + } + wg.Wait() + + return nil +} + +func init() { + inputs.Add("win_wmi", func() telegraf.Input { return &Wmi{} }) +} diff --git a/plugins/inputs/win_wmi/win_wmi_notwindows.go b/plugins/inputs/win_wmi/win_wmi_notwindows.go new file mode 100644 index 000000000..67e373fe9 --- /dev/null +++ b/plugins/inputs/win_wmi/win_wmi_notwindows.go @@ -0,0 +1,4 @@ +//go:build !windows +// +build !windows + +package win_wmi diff --git a/plugins/inputs/win_wmi/win_wmi_test.go b/plugins/inputs/win_wmi/win_wmi_test.go new file mode 100644 index 000000000..2eebb4db6 --- /dev/null +++ b/plugins/inputs/win_wmi/win_wmi_test.go @@ -0,0 +1,75 @@ +//go:build windows +// +build windows + +package win_wmi + +import ( + "fmt" + "os" + "regexp" + "testing" + + "github.com/influxdata/telegraf/testutil" + "github.com/stretchr/testify/require" +) + +// initialize test data +var sysDrive = fmt.Sprintf(`%s\`, os.Getenv("SystemDrive")) // C:\ + +// include Name as a tag, FreeSpace as a field, and Purpose as a known-null class property +var testQuery Query = Query{ + Namespace: "ROOT\\cimv2", + ClassName: "Win32_Volume", + Properties: []string{"Name", "FreeSpace", "Purpose"}, + Filter: fmt.Sprintf(`NOT Name LIKE "\\\\?\\%%" AND Name LIKE "%s"`, regexp.QuoteMeta(sysDrive)), + TagPropertiesInclude: []string{"Name"}, + tagFilter: nil, // this is filled in by CompileInputs() +} +var expectedWql = fmt.Sprintf( + `SELECT Name, FreeSpace, Purpose FROM Win32_Volume WHERE NOT Name LIKE "\\\\?\\%%" AND Name LIKE "%s"`, + regexp.QuoteMeta(sysDrive)) + +// test buildWqlStatements +func TestWmi_buildWqlStatements(t *testing.T) { + var logger = new(testutil.Logger) + plugin := Wmi{Queries: []Query{testQuery}, Log: logger} + require.NoError(t, compileInputs(&plugin)) + require.Equal(t, expectedWql, plugin.Queries[0].query) +} + +// test DoQuery +func TestWmi_DoQuery(t *testing.T) { + var logger = new(testutil.Logger) + var acc = new(testutil.Accumulator) + plugin := Wmi{Queries: []Query{testQuery}, Log: logger} + require.NoError(t, compileInputs(&plugin)) + for _, q := range plugin.Queries { + require.NoError(t, q.doQuery(acc)) + } + // no errors in accumulator + require.Empty(t, acc.Errors) + // Only one metric was returned (because we filtered for SystemDrive) + require.Len(t, acc.Metrics, 1) + // Name property collected and is the SystemDrive + require.Equal(t, sysDrive, acc.Metrics[0].Tags["Name"]) + // FreeSpace property was collected as a field + require.NotEmpty(t, acc.Metrics[0].Fields["FreeSpace"]) +} + +// test Init function +func TestWmi_Init(t *testing.T) { + var logger = new(testutil.Logger) + plugin := Wmi{Queries: []Query{testQuery}, Log: logger} + require.NoError(t, plugin.Init()) +} + +// test Gather function +func TestWmi_Gather(t *testing.T) { + var logger = new(testutil.Logger) + var acc = new(testutil.Accumulator) + plugin := Wmi{Queries: []Query{testQuery}, Log: logger} + plugin.Init() + require.NoError(t, plugin.Gather(acc)) + // no errors in accumulator + require.Empty(t, acc.Errors) +}