process: read the IIS application pool from the w3wp command line (#2638)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-10 15:44:24 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e58e4763e2
commit 4ca7dc8957
8 changed files with 1248 additions and 46 deletions
+20
View File
@@ -0,0 +1,20 @@
# process collector
IIS application pool names (`--collector.process.iis`) come from two sources.
- Primary: the `-ap "<pool>"` argument of each `w3wp` command line, read with
`NtQueryInformationProcess(ProcessCommandLineInformation)`. It needs only
`PROCESS_QUERY_LIMITED_INFORMATION` and Windows 8.1 or Server 2012 R2.
- Fallback: `WorkerProcess` in `root\WebAdministration`, used only when it
answered during `Build` and a command line can't be read or has no `-ap`.
It needs the optional IIS Management Scripts and Tools feature
(`Web-Scripting-Tools`), and asks WAS through RSCA on every query.
- `splitCommandLine` follows `CommandLineToArgvW`. `FuzzSplitCommandLine`
compares it with `windows.DecomposeCommandLine`; run it after parser changes
with `go test -run '^$' -fuzz FuzzSplitCommandLine ./internal/collector/process/`.
- Without IIS, `TestCollectorWorkerProcessCommandLine` starts a suspended copy
of the test binary named `w3wp.exe` to check the published label. The IIS
tests compare against WMI when available and against the
`IIS APPPOOL\<pool>` process owner.
- Don't create or change IIS application pools on a development machine to
test edge cases; use suspended processes with crafted command lines.
+276
View File
@@ -0,0 +1,276 @@
// 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 (
"errors"
"fmt"
"strings"
"unicode/utf16"
"unsafe"
"golang.org/x/sys/windows"
)
// workerProcessName is the perflib instance name of IIS worker processes (w3wp.exe).
const workerProcessName = "w3wp"
// errNoAppPoolArgument reports a w3wp command line without a non-empty -ap argument.
var errNoAppPoolArgument = errors.New("command line has no -ap argument")
// isWorkerProcess reports whether the perflib process name (without instance suffix) is an IIS worker process.
func isWorkerProcess(name string) bool {
return strings.EqualFold(name, workerProcessName)
}
// workerProcessAppPool returns the application pool of the IIS worker process pid.
//
// The Windows Process Activation Service (WAS) starts every worker process as
// `w3wp.exe -ap "<pool>" ...`. The pool name is read from the command line of
// the process. It opens the process with PROCESS_QUERY_LIMITED_INFORMATION only.
func workerProcessAppPool(pid uint32) (string, error) {
cmdLine, err := queryProcessCommandLine(pid)
if err != nil {
return "", err
}
pool, ok := appPoolFromCommandLine(cmdLine)
if !ok {
return "", errNoAppPoolArgument
}
return pool, nil
}
// queryProcessCommandLine returns the command line of process pid.
//
// It uses NtQueryInformationProcess(ProcessCommandLineInformation), which is
// available since Windows 8.1 and Windows Server 2012 R2. Earlier versions
// return STATUS_INVALID_INFO_CLASS. Unlike reading the PEB, it needs only
// PROCESS_QUERY_LIMITED_INFORMATION, not PROCESS_VM_READ.
func queryProcessCommandLine(pid uint32) (string, error) {
hProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return "", fmt.Errorf("OpenProcess: %w", err)
}
defer func() {
_ = windows.CloseHandle(hProcess)
}()
return processCommandLine(hProcess)
}
// unicodeStringHeader mirrors UNICODE_STRING. Buffer is kept as an address
// because it points into the buffer returned by the kernel.
type unicodeStringHeader struct {
Length uint16
MaximumLength uint16
Buffer uintptr
}
func processCommandLine(hProcess windows.Handle) (string, error) {
// UNICODE_STRING header plus a typical w3wp command line.
size := uint32(1024)
var err error
for range 4 {
// []uint64 keeps the UNICODE_STRING header pointer-aligned.
buf := make([]uint64, (size+7)/8)
bufSize := uint32(len(buf) * 8)
retLen := uint32(0)
err = windows.NtQueryInformationProcess(hProcess, windows.ProcessCommandLineInformation, unsafe.Pointer(&buf[0]), bufSize, &retLen)
if err == nil {
return decodeUnicodeString(buf)
}
if !errors.Is(err, windows.STATUS_INFO_LENGTH_MISMATCH) &&
!errors.Is(err, windows.STATUS_BUFFER_TOO_SMALL) &&
!errors.Is(err, windows.STATUS_BUFFER_OVERFLOW) {
break
}
if retLen <= bufSize {
break
}
size = retLen
}
return "", fmt.Errorf("NtQueryInformationProcess(ProcessCommandLineInformation): %w", err)
}
// decodeUnicodeString decodes the UNICODE_STRING that the kernel placed at the
// start of buf. Its Buffer must point into buf.
func decodeUnicodeString(buf []uint64) (string, error) {
base := unsafe.Pointer(&buf[0])
header := (*unicodeStringHeader)(base)
if header.Length == 0 {
return "", nil
}
bufSize := uintptr(len(buf)) * 8
offset := header.Buffer - uintptr(base)
if header.Buffer < uintptr(base) || offset < unsafe.Sizeof(*header) || offset > bufSize ||
uintptr(header.Length) > bufSize-offset || header.Length%2 != 0 {
return "", fmt.Errorf("UNICODE_STRING with length %d at offset %d does not fit the %d byte buffer", header.Length, offset, bufSize)
}
data := unsafe.Slice((*uint16)(unsafe.Add(base, offset)), header.Length/2)
return string(utf16.Decode(data)), nil
}
// appPoolFromCommandLine returns the value of the first -ap argument of a w3wp
// command line. Arguments are split with the CommandLineToArgvW rules.
//
// Application pool names can't contain '"' or '\', so the quoting WAS applies
// is unambiguous for every valid pool name.
func appPoolFromCommandLine(cmdLine string) (string, bool) {
args := splitCommandLine(cmdLine)
// args[0] is the program name.
for i := 1; i < len(args)-1; i++ {
if strings.EqualFold(args[i], "-ap") {
return args[i+1], args[i+1] != ""
}
}
return "", false
}
func isCommandLineSpace(c byte) bool {
return c == ' ' || c == '\t'
}
// splitCommandLine splits a command line like CommandLineToArgvW does.
//
// The program name ends at the next space or control character, which is
// consumed, or, if it starts with a double quote, at the next double quote;
// backslashes are literal in it. In the other arguments:
// - spaces and tabs outside double quotes separate arguments,
// - 2n backslashes followed by a double quote produce n backslashes, and the
// double quote starts or ends a quoted part,
// - 2n+1 backslashes followed by a double quote produce n backslashes and a
// literal double quote,
// - backslashes not followed by a double quote are literal,
// - inside a quoted part, every third consecutive double quote is literal.
//
// The rules only treat ASCII bytes specially, so the input is processed as
// UTF-8 bytes without decoding runes.
func splitCommandLine(cmdLine string) []string {
if cmdLine == "" {
return nil
}
var (
args []string
i int
)
if cmdLine[0] == '"' {
end := strings.IndexByte(cmdLine[1:], '"')
if end < 0 {
return []string{cmdLine[1:]}
}
args = append(args, cmdLine[1:end+1])
i = end + 2
} else {
// Any control character or space ends an unquoted program name, and is consumed.
for i < len(cmdLine) && cmdLine[i] > ' ' {
i++
}
args = append(args, cmdLine[:i])
i++
}
for i < len(cmdLine) && isCommandLineSpace(cmdLine[i]) {
i++
}
var (
arg []byte
inArg bool
quotes int
backslashes int
)
for i < len(cmdLine) {
c := cmdLine[i]
switch {
case isCommandLineSpace(c) && quotes == 0:
args = append(args, string(arg))
arg = arg[:0]
inArg = false
backslashes = 0
for i < len(cmdLine) && isCommandLineSpace(cmdLine[i]) {
i++
}
continue
case c == '\\':
arg = append(arg, c)
backslashes++
case c == '"':
if backslashes%2 == 0 {
// 2n backslashes and a quote: n backslashes, the quote toggles quoting.
arg = arg[:len(arg)-backslashes/2]
quotes++
} else {
// 2n+1 backslashes and a quote: n backslashes and a literal quote.
arg = append(arg[:len(arg)-backslashes/2-1], '"')
}
backslashes = 0
for i+1 < len(cmdLine) && cmdLine[i+1] == '"' {
i++
quotes++
if quotes == 3 {
arg = append(arg, '"')
quotes = 0
}
}
if quotes == 2 {
quotes = 0
}
default:
arg = append(arg, c)
backslashes = 0
}
inArg = true
i++
}
if inArg {
args = append(args, string(arg))
}
return args
}
+416
View File
@@ -0,0 +1,416 @@
// 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,
CounterVersion: 1,
})
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)
}
+24
View File
@@ -0,0 +1,24 @@
// 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
// WorkerProcessAppPool exposes the command line source to the IIS tests, which
// must live in process_test because testutils imports the collector registry.
//
//nolint:gochecknoglobals
var WorkerProcessAppPool = workerProcessAppPool
+43 -19
View File
@@ -64,6 +64,8 @@ type Collector struct {
logger *slog.Logger
// miSession is set when root\WebAdministration answered during Build. It's
// the fallback for worker processes whose command line can't be read.
miSession *mi.Session
workerProcessMIQueryQuery mi.Query
@@ -133,7 +135,7 @@ func NewWithFlags(app *kingpin.Application) *Collector {
app.Flag(
"collector.process.iis",
"Enable IIS collectWorker process name queries. May cause the collector to leak memory.",
"Append the IIS application pool name to the process name of IIS worker processes (w3wp).",
).Default(strconv.FormatBool(c.config.EnableWorkerProcess)).BoolVar(&c.config.EnableWorkerProcess)
app.Flag(
@@ -183,6 +185,9 @@ func (c *Collector) Close() error {
c.workerWG.Wait()
}
// The session belongs to the caller of Build.
c.miSession = nil
return nil
}
@@ -329,31 +334,50 @@ func (c *Collector) Build(logger *slog.Logger, miSession *mi.Session) error {
nil,
)
c.miSession = nil
if c.config.EnableWorkerProcess {
if miSession == nil {
return errors.New("miSession is nil")
}
miQuery, err := mi.NewQuery("SELECT AppPoolName, ProcessId FROM WorkerProcess")
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
}
c.workerProcessMIQueryQuery = miQuery
c.miSession = miSession
var workerProcesses []WorkerProcess
if err = c.miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, c.workerProcessMIQueryQuery, 0); err != nil {
c.config.EnableWorkerProcess = false
return fmt.Errorf("WMI query for collector.process.iis failed: %w", err)
if err := c.buildWorkerProcessWMI(miSession); err != nil {
return err
}
}
return nil
}
// buildWorkerProcessWMI enables the root\WebAdministration fallback for application pool names
// if the IIS WMI provider is installed. Without it, only the w3wp command line is used.
func (c *Collector) buildWorkerProcessWMI(miSession *mi.Session) error {
if miSession == nil {
c.logger.LogAttrs(context.Background(), slog.LevelDebug,
"No MI session, reading IIS application pool names from the w3wp command line only",
)
return nil
}
miQuery, err := mi.NewQuery("SELECT AppPoolName, ProcessId FROM WorkerProcess")
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
}
var workerProcesses []WorkerProcess
if err = miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, miQuery, 0); err != nil {
c.logger.LogAttrs(context.Background(), slog.LevelDebug,
`root\WebAdministration isn't available, reading IIS application pool names from the w3wp command line only`,
slog.Any("err", err),
)
return nil
}
c.workerProcessMIQueryQuery = miQuery
c.miSession = miSession
return nil
}
func (c *Collector) Collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
return c.collect(ch, maxScrapeDuration)
}
@@ -0,0 +1,351 @@
// 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_test
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"time"
"unsafe"
"github.com/prometheus-community/windows_exporter/internal/collector/process"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/types"
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// appPoolIdentityPrefix is the domain of ApplicationPoolIdentity virtual accounts,
// whose names are the application pool names.
const appPoolIdentityPrefix = `IIS APPPOOL\`
// The application pool read from each w3wp command line must match
// WorkerProcess.AppPoolName and the ApplicationPoolIdentity of the process.
func TestWorkerProcessAppPoolIIS(t *testing.T) {
pids := requireWorkerProcesses(t)
appPools := make(map[uint32]string, len(pids))
for _, pid := range pids {
appPool, err := process.WorkerProcessAppPool(pid)
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
// The worker process exited, for example after its idle timeout.
continue
}
if errors.Is(err, windows.ERROR_ACCESS_DENIED) && !iisRequired() {
t.Skipf("worker process %d: %v; run the test elevated", pid, err)
}
require.NoError(t, err, "worker process %d", pid)
require.NotEmpty(t, appPool)
appPools[pid] = appPool
if owner := processOwner(t, pid); strings.HasPrefix(owner, appPoolIdentityPrefix) {
require.Equal(t, strings.TrimPrefix(owner, appPoolIdentityPrefix), appPool, "worker process %d", pid)
}
}
workerProcesses, err := queryWorkerProcesses(t)
if err != nil {
t.Logf(`root\WebAdministration isn't available, skipping the WMI comparison: %v`, err)
return
}
for _, wp := range workerProcesses {
if appPool, ok := appPools[uint32(wp.ProcessId)]; ok {
require.Equal(t, wp.AppPoolName, appPool, "worker process %d", wp.ProcessId)
}
}
}
// The collector must publish the application pool in the process label.
func TestCollectorIISAppPool(t *testing.T) {
requireWorkerProcesses(t)
families := testutils.TestCollector(t, process.New, &process.Config{
ProcessInclude: regexp.MustCompile(`^(?:w3wp)$`),
ProcessExclude: types.RegExpEmpty,
EnableWorkerProcess: true,
CounterVersion: 1,
})
require.Contains(t, families, "windows_process_info")
var labeled int
for _, metric := range families["windows_process_info"].GetMetric() {
labels := make(map[string]string)
for _, label := range metric.GetLabel() {
labels[label.GetName()] = label.GetValue()
}
owner := labels["owner"]
if !strings.HasPrefix(owner, appPoolIdentityPrefix) {
continue
}
require.Equal(t, "w3wp_"+strings.TrimPrefix(owner, appPoolIdentityPrefix), labels["process"], "process_id %s", labels["process_id"])
labeled++
}
if iisRequired() {
require.Positive(t, labeled, "no worker process runs as an ApplicationPoolIdentity")
}
}
// A suspended copy of the test binary named w3wp.exe, started with a WAS
// command line, must get the pool in its process label without IIS.
func TestCollectorWorkerProcessCommandLine(t *testing.T) {
executable, err := os.Executable()
require.NoError(t, err)
content, err := os.ReadFile(executable)
require.NoError(t, err)
workerProcess := filepath.Join(t.TempDir(), "w3wp.exe")
//nolint:gosec // The destination is a fixed filename in a test-owned temporary directory.
require.NoError(t, os.WriteFile(workerProcess, content, 0o600))
const appPool = "Test Pool Ωμέγα 🚀"
pid := startSuspended(t, workerProcess, `"`+workerProcess+`" -ap "`+appPool+`" -v "v4.0" -w "" -m 0`)
families := testutils.TestCollector(t, process.New, &process.Config{
ProcessInclude: regexp.MustCompile(`^(?:w3wp)$`),
ProcessExclude: types.RegExpEmpty,
EnableWorkerProcess: true,
CounterVersion: 1,
})
if _, ok := families["windows_process_info"]; !ok {
t.Skip("the Process counter set has no w3wp instance")
}
for _, metric := range families["windows_process_info"].GetMetric() {
labels := make(map[string]string)
for _, label := range metric.GetLabel() {
labels[label.GetName()] = label.GetValue()
}
if labels["process_id"] == strconv.FormatUint(uint64(pid), 10) {
require.Equal(t, "w3wp_"+appPool, labels["process"])
return
}
}
t.Fatalf("windows_process_info has no series for process %d", pid)
}
// startSuspended starts a process that never runs and returns its ID.
func startSuspended(t *testing.T, path, cmdLine string) uint32 {
t.Helper()
appName, err := windows.UTF16PtrFromString(path)
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
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))
_, _ = windows.WaitForSingleObject(processInfo.Process, windows.INFINITE)
require.NoError(t, windows.CloseHandle(processInfo.Thread))
require.NoError(t, windows.CloseHandle(processInfo.Process))
})
return processInfo.ProcessId
}
// iisRequired reports whether CI provisioned IIS, so a missing worker process is a failure.
func iisRequired() bool {
return slices.Contains(strings.Split(os.Getenv("WINDOWS_EXPORTER_TEST_COLLECTORS"), ","), "iis")
}
// requireWorkerProcesses returns the IIS worker processes. If W3SVC runs
// without one, it requests the local default site to start one.
func requireWorkerProcesses(t *testing.T) []uint32 {
t.Helper()
if pids := workerProcessIDs(t); len(pids) > 0 {
return pids
}
if !serviceRunning(t, "W3SVC") {
if iisRequired() {
t.Fatal("W3SVC isn't running")
}
t.Skip("IIS isn't running")
}
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1/", nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
if err == nil {
_ = resp.Body.Close()
} else {
t.Logf("request to start a worker process failed: %v", err)
}
pids := workerProcessIDs(t)
if len(pids) == 0 {
if iisRequired() {
t.Fatal("no IIS worker process is running")
}
t.Skip("no IIS worker process is running")
}
return pids
}
func workerProcessIDs(t *testing.T) []uint32 {
t.Helper()
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
require.NoError(t, err)
defer func() { _ = windows.CloseHandle(snapshot) }()
var (
pids []uint32
entry = windows.ProcessEntry32{Size: uint32(unsafe.Sizeof(windows.ProcessEntry32{}))}
)
for err = windows.Process32First(snapshot, &entry); err == nil; err = windows.Process32Next(snapshot, &entry) {
if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), "w3wp.exe") {
pids = append(pids, entry.ProcessID)
}
}
require.ErrorIs(t, err, windows.ERROR_NO_MORE_FILES)
return pids
}
func serviceRunning(t *testing.T, name string) bool {
t.Helper()
scm, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT)
require.NoError(t, err)
defer func() { _ = windows.CloseServiceHandle(scm) }()
serviceName, err := windows.UTF16PtrFromString(name)
require.NoError(t, err)
h, err := windows.OpenService(scm, serviceName, windows.SERVICE_QUERY_STATUS)
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
return false
}
require.NoError(t, err)
defer func() { _ = windows.CloseServiceHandle(h) }()
var status windows.SERVICE_STATUS
require.NoError(t, windows.QueryServiceStatus(h, &status))
return status.CurrentState == windows.SERVICE_RUNNING
}
// processOwner returns the DOMAIN\user owner of pid, or "" if it can't be read.
func processOwner(t *testing.T, pid uint32) string {
t.Helper()
hProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return ""
}
defer func() { _ = windows.CloseHandle(hProcess) }()
var token windows.Token
if err := windows.OpenProcessToken(hProcess, windows.TOKEN_QUERY, &token); err != nil {
return ""
}
defer func() { _ = token.Close() }()
user, err := token.GetTokenUser()
require.NoError(t, err)
account, domain, _, err := user.User.Sid.LookupAccount("")
if err != nil {
t.Logf("worker process %d: LookupAccountSid: %v", pid, err)
return ""
}
return domain + `\` + account
}
func queryWorkerProcesses(t *testing.T) ([]process.WorkerProcess, error) {
t.Helper()
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()) })
query, err := mi.NewQuery("SELECT AppPoolName, ProcessId FROM WorkerProcess")
require.NoError(t, err)
var workerProcesses []process.WorkerProcess
if err := miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, query, 0); err != nil {
return nil, err
}
for _, wp := range workerProcesses {
t.Logf("WorkerProcess %s: %q", strconv.FormatUint(wp.ProcessId, 10), wp.AppPoolName)
}
return workerProcesses, nil
}
+103 -23
View File
@@ -19,6 +19,7 @@ package process
import (
"context"
"errors"
"fmt"
"log/slog"
"runtime/debug"
@@ -29,14 +30,15 @@ import (
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/sys/windows"
)
type processWorkerRequest struct {
ch chan<- prometheus.Metric
name string
appPoolName string
performanceCounterValues perfDataCounterValues
waitGroup *sync.WaitGroup
workerProcesses []WorkerProcess
}
func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
@@ -45,16 +47,9 @@ func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.
return fmt.Errorf("failed to collect metrics: %w", err)
}
err = nil
requests := make([]processWorkerRequest, 0, len(c.perfDataObject))
var workerProcesses []WorkerProcess
if c.config.EnableWorkerProcess {
if err = c.miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, c.workerProcessMIQueryQuery, maxScrapeDuration); err != nil {
err = fmt.Errorf("WMI query for collector.process.iis failed: %w", err)
}
}
wg := &sync.WaitGroup{}
var workerProcessIDs []uint32
for _, process := range c.perfDataObject {
// Duplicate processes are suffixed #, and an index number. Remove those.
@@ -77,15 +72,32 @@ func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.
continue
}
wg.Add(1)
if c.config.EnableWorkerProcess && isWorkerProcess(name) {
workerProcessIDs = append(workerProcessIDs, uint32(process.ProcessID))
}
c.workerCh <- processWorkerRequest{
requests = append(requests, processWorkerRequest{
ch: ch,
name: name,
performanceCounterValues: process,
workerProcesses: workerProcesses,
waitGroup: wg,
}
})
}
var appPools map[uint32]string
if len(workerProcessIDs) > 0 {
appPools, err = resolveAppPools(c.logger, workerProcessIDs, workerProcessAppPool, c.workerProcessWMIQuery(maxScrapeDuration))
}
wg := &sync.WaitGroup{}
for _, req := range requests {
req.appPoolName = appPools[uint32(req.performanceCounterValues.ProcessID)]
req.waitGroup = wg
wg.Add(1)
c.workerCh <- req
}
wg.Wait()
@@ -93,6 +105,80 @@ func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.
return err
}
// workerProcessWMIQuery returns the root\WebAdministration query, or nil if it was unavailable during Build.
func (c *Collector) workerProcessWMIQuery(maxScrapeDuration time.Duration) func() ([]WorkerProcess, error) {
miSession, miQuery := c.miSession, c.workerProcessMIQueryQuery
if miSession == nil {
return nil
}
return func() ([]WorkerProcess, error) {
var workerProcesses []WorkerProcess
err := miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, miQuery, maxScrapeDuration)
return workerProcesses, err
}
}
// resolveAppPools maps IIS worker process IDs to application pool names.
//
// The command line of the worker process is the primary source: it needs no
// optional feature and no WMI round trip. WMI is queried at most once, for the
// worker processes whose command line couldn't be read or has no -ap argument,
// and only if queryWMI isn't nil. Unresolved processes have no entry.
func resolveAppPools(
logger *slog.Logger,
pids []uint32,
readCommandLine func(pid uint32) (string, error),
queryWMI func() ([]WorkerProcess, error),
) (map[uint32]string, error) {
appPools := make(map[uint32]string, len(pids))
var unresolved []uint32
for _, pid := range pids {
appPool, err := readCommandLine(pid)
if err == nil {
appPools[pid] = appPool
continue
}
logger.LogAttrs(context.Background(), slog.LevelDebug, "Failed to read the IIS application pool from the worker process command line",
slog.Uint64("pid", uint64(pid)),
slog.Any("err", err),
)
// OpenProcess fails with ERROR_INVALID_PARAMETER for processes that exited after the
// performance counter snapshot. WAS no longer reports them either.
if !errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
unresolved = append(unresolved, pid)
}
}
if len(unresolved) == 0 || queryWMI == nil {
return appPools, nil
}
workerProcesses, err := queryWMI()
if err != nil {
return appPools, fmt.Errorf("WMI query for collector.process.iis failed: %w", err)
}
for _, pid := range unresolved {
for _, wp := range workerProcesses {
if wp.ProcessId == uint64(pid) && wp.AppPoolName != "" {
appPools[pid] = wp.AppPoolName
break
}
}
}
return appPools, nil
}
func (c *Collector) collectWorker(requests <-chan processWorkerRequest) {
defer c.workerWG.Done()
@@ -115,14 +201,8 @@ func (c *Collector) collectWorker(requests <-chan processWorkerRequest) {
pid := uint64(data.ProcessID)
parentPID := strconv.FormatUint(uint64(data.CreatingProcessID), 10)
if c.config.EnableWorkerProcess {
for _, wp := range req.workerProcesses {
if wp.ProcessId == pid {
name = strings.Join([]string{name, wp.AppPoolName}, "_")
break
}
}
if req.appPoolName != "" {
name = name + "_" + req.appPoolName
}
cmdLine, processOwner, processGroupID, err := c.getProcessInformation(uint32(pid))