mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 10:09:05 +02:00
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
416 lines
13 KiB
Go
416 lines
13 KiB
Go
// 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 process
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
"unicode/utf8"
|
||
"unsafe"
|
||
|
||
"github.com/prometheus-community/windows_exporter/internal/mi"
|
||
"github.com/prometheus-community/windows_exporter/internal/types"
|
||
"github.com/stretchr/testify/require"
|
||
"golang.org/x/sys/windows"
|
||
)
|
||
|
||
// A command line as WAS starts IIS 10 worker processes.
|
||
const wasCommandLine = `c:\windows\system32\inetsrv\w3wp.exe -ap "DefaultAppPool" -v "v4.0" -l "webengine4.dll" -a \\.\pipe\iisipm5f5e2b1c-8d6a-4b2e-9a0c-3d1b2f4e6a7c -h "C:\inetpub\temp\apppools\DefaultAppPool\DefaultAppPool.config" -w "" -m 0 -t 20 -ta 0`
|
||
|
||
//nolint:gochecknoglobals
|
||
var commandLineTests = []struct {
|
||
name string
|
||
cmdLine string
|
||
args []string
|
||
}{
|
||
{"empty", ``, nil},
|
||
{"program only", `w3wp.exe`, []string{`w3wp.exe`}},
|
||
{"leading space", ` a b`, []string{``, `a`, `b`}},
|
||
{"whitespace only", " \t ", []string{``}},
|
||
{"trailing space", `a b `, []string{`a`, `b`}},
|
||
{"tabs", "a\tb\t\tc", []string{`a`, `b`, `c`}},
|
||
{"quoted program", `"C:\Program Files\w3wp.exe" -ap x`, []string{`C:\Program Files\w3wp.exe`, `-ap`, `x`}},
|
||
{"quoted program backslash", `"C:\dir\" x`, []string{`C:\dir\`, `x`}},
|
||
{"quoted program unterminated", `"C:\dir x`, []string{`C:\dir x`}},
|
||
{"quoted program followed by text", `"a"b c`, []string{`a`, `b`, `c`}},
|
||
{"program backslash quote", `a\"b c`, []string{`a\"b`, `c`}},
|
||
{"empty quoted argument", `a "" b`, []string{`a`, ``, `b`}},
|
||
{"trailing empty quoted argument", `a ""`, []string{`a`, ``}},
|
||
{"quoted spaces", `a "b c" d`, []string{`a`, `b c`, `d`}},
|
||
{"embedded quotes", `a b"c d"e`, []string{`a`, `bc de`}},
|
||
{"unterminated quote", `a "b c`, []string{`a`, `b c`}},
|
||
{"escaped quote", `a b\"c`, []string{`a`, `b"c`}},
|
||
{"escaped backslash before quote", `a "b\\" c`, []string{`a`, `b\`, `c`}},
|
||
{"three backslashes before quote", `a b\\\"c`, []string{`a`, `b\"c`}},
|
||
{"backslashes without quote", `a \\server\share\ b`, []string{`a`, `\\server\share\`, `b`}},
|
||
{"double quote in quoted part", `a "b""c" d`, []string{`a`, `b"c d`}},
|
||
{"three quotes in quoted part", `a "b"""c d`, []string{`a`, `b"c d`}},
|
||
{"control character ends program", "a\vb c", []string{`a`, `b`, `c`}},
|
||
{"control character after program", "a \vb", []string{`a`, "\vb"}},
|
||
{"control character after quoted program", "\"a\"\vb", []string{`a`, "\vb"}},
|
||
{"control character in argument", "a b\vc\nd", []string{`a`, "b\vc\nd"}},
|
||
{"unicode spaces", "a b c d", []string{"a b", "c d"}},
|
||
{"three quotes", `a """b c`, []string{`a`, `"b`, `c`}},
|
||
{"unicode", `a "Café Пул ✓ Ωμέγα" b`, []string{`a`, `Café Пул ✓ Ωμέγα`, `b`}},
|
||
{"was command line", wasCommandLine, []string{
|
||
`c:\windows\system32\inetsrv\w3wp.exe`, `-ap`, `DefaultAppPool`, `-v`, `v4.0`, `-l`, `webengine4.dll`,
|
||
`-a`, `\\.\pipe\iisipm5f5e2b1c-8d6a-4b2e-9a0c-3d1b2f4e6a7c`, `-h`, `C:\inetpub\temp\apppools\DefaultAppPool\DefaultAppPool.config`,
|
||
`-w`, ``, `-m`, `0`, `-t`, `20`, `-ta`, `0`,
|
||
}},
|
||
}
|
||
|
||
func TestSplitCommandLine(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for _, tc := range commandLineTests {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
require.Equal(t, tc.args, splitCommandLine(tc.cmdLine))
|
||
})
|
||
}
|
||
}
|
||
|
||
// splitCommandLine must split like CommandLineToArgvW, which is the reference implementation.
|
||
func TestSplitCommandLineMatchesCommandLineToArgvW(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for _, tc := range commandLineTests {
|
||
if tc.cmdLine == "" {
|
||
// CommandLineToArgvW returns the path of the current executable for an empty command line.
|
||
continue
|
||
}
|
||
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
requireCommandLineToArgvW(t, tc.cmdLine)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestAppPoolFromCommandLine(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for _, tc := range []struct {
|
||
name string
|
||
cmdLine string
|
||
pool string
|
||
ok bool
|
||
}{
|
||
{"was command line", wasCommandLine, "DefaultAppPool", true},
|
||
{"spaces", `w3wp.exe -ap "Classic .NET AppPool" -v "v2.0"`, "Classic .NET AppPool", true},
|
||
{"unicode", `w3wp.exe -ap "Café Пул ✓ Ωμέγα 🚀" -v "v4.0"`, "Café Пул ✓ Ωμέγα 🚀", true},
|
||
{"unquoted", `w3wp.exe -ap DefaultAppPool`, "DefaultAppPool", true},
|
||
{"tab separated", "w3wp.exe\t-ap\t\"Pool\"", "Pool", true},
|
||
{"upper case flag", `w3wp.exe -AP "Pool"`, "Pool", true},
|
||
{"not first argument", `w3wp.exe -debug -ap "Pool" -v "v4.0"`, "Pool", true},
|
||
{"first of duplicates", `w3wp.exe -ap "First" -ap "Second"`, "First", true},
|
||
{"quoted program with spaces", `"C:\Program Files\w3wp.exe" -ap "Pool"`, "Pool", true},
|
||
{"embedded quotes", `w3wp.exe -ap Default"App Pool"`, "DefaultApp Pool", true},
|
||
{"escaped quote", `w3wp.exe -ap "a\"b"`, `a"b`, true},
|
||
{"trailing backslash", `w3wp.exe -ap "a\\"`, `a\`, true},
|
||
{"literal backslash", `w3wp.exe -ap "a\b"`, `a\b`, true},
|
||
{"missing", `w3wp.exe -v "v4.0"`, "", false},
|
||
{"no arguments", `c:\windows\system32\inetsrv\w3wp.exe`, "", false},
|
||
{"empty command line", ``, "", false},
|
||
{"missing value", `w3wp.exe -v "v4.0" -ap`, "", false},
|
||
{"empty value", `w3wp.exe -ap "" -v "v4.0"`, "", false},
|
||
{"flag inside quotes", `w3wp.exe "-ap Pool"`, "", false},
|
||
{"flag prefix", `w3wp.exe -apx "Pool"`, "", false},
|
||
{"slash flag", `w3wp.exe /ap "Pool"`, "", false},
|
||
{"program named -ap", `-ap Pool`, "", false},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
pool, ok := appPoolFromCommandLine(tc.cmdLine)
|
||
require.Equal(t, tc.ok, ok)
|
||
require.Equal(t, tc.pool, pool)
|
||
})
|
||
}
|
||
}
|
||
|
||
func FuzzSplitCommandLine(f *testing.F) {
|
||
for _, tc := range commandLineTests {
|
||
f.Add(tc.cmdLine)
|
||
}
|
||
|
||
f.Add(`w3wp.exe -ap "a\\\"b" -ap`)
|
||
f.Add(`"" "" ""`)
|
||
f.Add(`a """"""" b`)
|
||
f.Add("a\v\r\nb\u00a0c\u3000d")
|
||
|
||
f.Fuzz(func(t *testing.T, cmdLine string) {
|
||
// CommandLineToArgvW takes NUL-terminated UTF-16 and returns the executable path for an empty command line.
|
||
if cmdLine == "" || strings.ContainsRune(cmdLine, 0) || !utf8.ValidString(cmdLine) {
|
||
t.Skip()
|
||
}
|
||
|
||
args := requireCommandLineToArgvW(t, cmdLine)
|
||
|
||
var (
|
||
want string
|
||
wantOK bool
|
||
)
|
||
|
||
for i := 1; i < len(args)-1; i++ {
|
||
if strings.EqualFold(args[i], "-ap") {
|
||
want, wantOK = args[i+1], args[i+1] != ""
|
||
|
||
break
|
||
}
|
||
}
|
||
|
||
pool, ok := appPoolFromCommandLine(cmdLine)
|
||
require.Equal(t, wantOK, ok)
|
||
require.Equal(t, want, pool)
|
||
})
|
||
}
|
||
|
||
func requireCommandLineToArgvW(t *testing.T, cmdLine string) []string {
|
||
t.Helper()
|
||
|
||
want, err := windows.DecomposeCommandLine(cmdLine)
|
||
require.NoError(t, err)
|
||
|
||
got := splitCommandLine(cmdLine)
|
||
if len(want) == 0 {
|
||
require.Empty(t, got, "command line %q", cmdLine)
|
||
} else {
|
||
require.Equal(t, want, got, "command line %q", cmdLine)
|
||
}
|
||
|
||
return got
|
||
}
|
||
|
||
// The command line is read from a process that never ran, with an argument
|
||
// list as WAS passes it, so no IIS installation is needed.
|
||
func TestWorkerProcessAppPool(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
executable, err := os.Executable()
|
||
require.NoError(t, err)
|
||
|
||
const pool = "Café Пул ✓ Ωμέγα 🚀"
|
||
|
||
cmdLine := `"` + executable + `" -ap "` + pool + `" -v "v4.0" -a \\.\pipe\iisipm -w "" -m 0`
|
||
|
||
appName, err := windows.UTF16PtrFromString(executable)
|
||
require.NoError(t, err)
|
||
|
||
cmdLineUTF16, err := windows.UTF16FromString(cmdLine)
|
||
require.NoError(t, err)
|
||
|
||
startupInfo := windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfo{}))}
|
||
|
||
var processInfo windows.ProcessInformation
|
||
|
||
// CREATE_SUSPENDED: the process parameters exist, but the test binary never runs.
|
||
require.NoError(t, windows.CreateProcess(appName, &cmdLineUTF16[0], nil, nil, false,
|
||
windows.CREATE_SUSPENDED|windows.CREATE_NO_WINDOW, nil, nil, &startupInfo, &processInfo))
|
||
|
||
t.Cleanup(func() {
|
||
require.NoError(t, windows.TerminateProcess(processInfo.Process, 1))
|
||
require.NoError(t, windows.CloseHandle(processInfo.Thread))
|
||
require.NoError(t, windows.CloseHandle(processInfo.Process))
|
||
})
|
||
|
||
pid := processInfo.ProcessId
|
||
|
||
got, err := queryProcessCommandLine(pid)
|
||
require.NoError(t, err)
|
||
require.Equal(t, cmdLine, got)
|
||
|
||
gotPool, err := workerProcessAppPool(pid)
|
||
require.NoError(t, err)
|
||
require.Equal(t, pool, gotPool)
|
||
}
|
||
|
||
func TestWorkerProcessAppPoolWithoutArgument(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
_, err := workerProcessAppPool(windows.GetCurrentProcessId())
|
||
require.ErrorIs(t, err, errNoAppPoolArgument)
|
||
}
|
||
|
||
func TestQueryProcessCommandLine(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got, err := queryProcessCommandLine(windows.GetCurrentProcessId())
|
||
require.NoError(t, err)
|
||
require.Equal(t, windows.UTF16PtrToString(windows.GetCommandLine()), got)
|
||
}
|
||
|
||
func TestResolveAppPools(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
logger := slog.New(slog.DiscardHandler)
|
||
|
||
commandLines := func(results map[uint32]error) func(uint32) (string, error) {
|
||
return func(pid uint32) (string, error) {
|
||
if err := results[pid]; err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return fmt.Sprintf("native-%d", pid), nil
|
||
}
|
||
}
|
||
|
||
wmiResult := []WorkerProcess{
|
||
{ProcessId: 4, AppPoolName: "wmi-4"},
|
||
{ProcessId: 8, AppPoolName: "wmi-8"},
|
||
{ProcessId: 12, AppPoolName: ""},
|
||
}
|
||
|
||
t.Run("command line only", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(nil), func() ([]WorkerProcess, error) {
|
||
t.Fatal("WMI must not be queried when every command line has the pool")
|
||
|
||
return nil, nil
|
||
})
|
||
require.NoError(t, err)
|
||
require.Equal(t, map[uint32]string{4: "native-4", 8: "native-8"}, pools)
|
||
})
|
||
|
||
t.Run("WMI fallback", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
calls := 0
|
||
pools, err := resolveAppPools(logger, []uint32{4, 8, 12, 16}, commandLines(map[uint32]error{
|
||
8: windows.ERROR_ACCESS_DENIED,
|
||
12: errNoAppPoolArgument,
|
||
16: windows.STATUS_INVALID_INFO_CLASS,
|
||
}), func() ([]WorkerProcess, error) {
|
||
calls++
|
||
|
||
return wmiResult, nil
|
||
})
|
||
require.NoError(t, err)
|
||
require.Equal(t, 1, calls)
|
||
// An empty WMI pool name and a process WMI doesn't know get no label.
|
||
require.Equal(t, map[uint32]string{4: "native-4", 8: "wmi-8"}, pools)
|
||
})
|
||
|
||
t.Run("exited process", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(map[uint32]error{
|
||
8: fmt.Errorf("OpenProcess: %w", windows.ERROR_INVALID_PARAMETER),
|
||
}), func() ([]WorkerProcess, error) {
|
||
t.Fatal("WMI must not be queried for exited processes")
|
||
|
||
return nil, nil
|
||
})
|
||
require.NoError(t, err)
|
||
require.Equal(t, map[uint32]string{4: "native-4"}, pools)
|
||
})
|
||
|
||
t.Run("without WMI", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(map[uint32]error{
|
||
8: windows.ERROR_ACCESS_DENIED,
|
||
}), nil)
|
||
require.NoError(t, err)
|
||
require.Equal(t, map[uint32]string{4: "native-4"}, pools)
|
||
})
|
||
|
||
t.Run("WMI error", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
wmiErr := windows.ERROR_TIMEOUT
|
||
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(map[uint32]error{
|
||
8: windows.ERROR_ACCESS_DENIED,
|
||
}), func() ([]WorkerProcess, error) {
|
||
return nil, wmiErr
|
||
})
|
||
require.ErrorIs(t, err, wmiErr)
|
||
require.Equal(t, map[uint32]string{4: "native-4"}, pools)
|
||
})
|
||
}
|
||
|
||
// Grafana Alloy builds and closes collectors repeatedly. Without
|
||
// root\WebAdministration, Build must succeed and use the command line only.
|
||
func TestBuildCloseWorkerProcess(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
miApp, err := mi.ApplicationInitialize()
|
||
require.NoError(t, err)
|
||
|
||
t.Cleanup(func() { require.NoError(t, miApp.Close()) })
|
||
|
||
miSession, err := miApp.NewSession(nil)
|
||
require.NoError(t, err)
|
||
|
||
t.Cleanup(func() { require.NoError(t, miSession.Close()) })
|
||
|
||
c := New(&Config{
|
||
ProcessInclude: regexp.MustCompile(`^(?:w3wp)$`),
|
||
ProcessExclude: types.RegExpEmpty,
|
||
EnableWorkerProcess: true,
|
||
})
|
||
|
||
logger := slog.New(slog.DiscardHandler)
|
||
|
||
for _, session := range []*mi.Session{nil, miSession, nil} {
|
||
require.NoError(t, c.Build(logger, session))
|
||
|
||
if session == nil {
|
||
require.Nil(t, c.miSession)
|
||
}
|
||
|
||
require.NoError(t, c.Close())
|
||
require.NoError(t, c.Close())
|
||
require.Nil(t, c.miSession)
|
||
}
|
||
}
|
||
|
||
// The command line source costs one OpenProcess and one NtQueryInformationProcess per worker process.
|
||
func BenchmarkWorkerProcessAppPool(b *testing.B) {
|
||
pid := windows.GetCurrentProcessId()
|
||
|
||
for b.Loop() {
|
||
cmdLine, err := queryProcessCommandLine(pid)
|
||
require.NoError(b, err)
|
||
|
||
appPoolFromCommandLine(cmdLine)
|
||
}
|
||
}
|
||
|
||
func BenchmarkAppPoolFromCommandLine(b *testing.B) {
|
||
for b.Loop() {
|
||
appPoolFromCommandLine(wasCommandLine)
|
||
}
|
||
}
|
||
|
||
// Errors must be the native Windows error, so callers can tell exited processes from denied access.
|
||
func TestQueryProcessCommandLineMissingProcess(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// Process IDs are multiples of four and far below this value.
|
||
_, err := queryProcessCommandLine(0xFFFFFFFC)
|
||
require.ErrorIs(t, err, windows.ERROR_INVALID_PARAMETER)
|
||
}
|