feat(scripts.d/nagios): auto-invoke Windows script files (.ps1, .bat, .cmd) through their interpreters (#22162)
Ilya Mashchenko committed
Apr 7, 2026 at 21:25 UTC
9b837d836791c73fd46791c7b380bf410c3d5312
5 files changed
+102
-11
src/go/plugin/scripts.d/collector/nagios/config_schema.json
+3
-3
@@ -258,15 +258,15 @@
258
},
259
"check_name": {
260
"ui:placeholder": "check_ping",
261
- "ui:help": "**Controls chart grouping and metric naming for performance data.**\n\nCharts appear under `Synthetic > Nagios > Perfdata > <check_name>` in the dashboard.\n\nIf omitted, Netdata derives it from the `plugin` basename (e.g. `/usr/lib/nagios/plugins/check_ping` → `check_ping`).\n\n### When to set it\n\n- When multiple jobs run the same plugin (e.g. `check_nrpe`) against different remote checks — set `check_name` to distinguish them (`check_disk`, `check_load`, etc.)\n- **Windows:** Always set it when using `powershell.exe` as `plugin` — otherwise all PowerShell jobs share `Perfdata/powershell`\n\n### Example metric names\n\nWith `check_name: check_memory` and a script that outputs `caches=2380912KB`:\n\n- `nagios.perfdata.check_memory.job.execution_state`\n- `nagios.perfdata.check_memory.bytes_caches`\n- `nagios.perfdata.check_memory.bytes_caches_threshold_state`"
261
+ "ui:help": "**Controls chart grouping and metric naming for performance data.**\n\nCharts appear under `Synthetic > Nagios > Perfdata > <check_name>` in the dashboard.\n\nIf omitted, Netdata derives it from the `plugin` basename (e.g. `/usr/lib/nagios/plugins/check_ping` → `check_ping`, `C:\\Netdata\\checks\\check_service.ps1` → `check_service`).\n\n### When to set it\n\n- When multiple jobs run the same plugin (e.g. `check_nrpe`) against different remote checks — set `check_name` to distinguish them (`check_disk`, `check_load`, etc.)\n\n### Example metric names\n\nWith `check_name: check_memory` and a script that outputs `caches=2380912KB`:\n\n- `nagios.perfdata.check_memory.job.execution_state`\n- `nagios.perfdata.check_memory.bytes_caches`\n- `nagios.perfdata.check_memory.bytes_caches_threshold_state`"
262
},
263
"plugin": {
264
"ui:placeholder": "/usr/lib/nagios/plugins/check_ping",
265
- "ui:help": "**Absolute path to the check command.**\n\n### Linux / macOS\n\nThe executable must meet these security requirements:\n- Owned by **root**\n- Not writable by group or others\n- All ancestor directories owned by root and not group/other-writable\n- Must be a regular, executable file\n- Symlinks are resolved before validation\n\nExample:\n```\n/usr/lib/nagios/plugins/check_ping\n```\n\n### Windows\n\nSince `.ps1` scripts cannot be executed directly, point `plugin` to `powershell.exe` and pass the script in `args`:\n```\nC:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\n```\nPath validation is not enforced on Windows — ensure scripts are stored in directories with appropriate ACLs.\n\n### Output format\n\nThe command should return exit code `0` (OK), `1` (WARNING), `2` (CRITICAL), or `3` (UNKNOWN) and may print performance data after `|`:\n```\nSTATUS TEXT | label=value;warn;crit;min;max\n```"
265
+ "ui:help": "**Absolute path to the check command.**\n\n### Linux / macOS\n\nThe executable must meet these security requirements:\n- Owned by **root**\n- Not writable by group or others\n- All ancestor directories owned by root and not group/other-writable\n- Must be a regular, executable file\n- Symlinks are resolved before validation\n\nExample:\n```\n/usr/lib/nagios/plugins/check_ping\n```\n\n### Windows\n\nPoint `plugin` directly to a `.ps1`, `.bat`, or `.cmd` script. Netdata automatically invokes `.ps1` scripts through `powershell.exe` and `.bat`/`.cmd` scripts through `cmd.exe`.\n\nExample:\n```\nC:\\Netdata\\checks\\check_service.ps1\n```\nPath validation is not enforced on Windows — ensure scripts are stored in directories with appropriate ACLs.\n\n### Output format\n\nThe command should return exit code `0` (OK), `1` (WARNING), `2` (CRITICAL), or `3` (UNKNOWN) and may print performance data after `|`:\n```\nSTATUS TEXT | label=value;warn;crit;min;max\n```"
266
},
267
"args": {
268
"ui:listFlavour": "list",
269
- "ui:help": "**Arguments passed to the check command after macro expansion.**\n\nYou can use Nagios-style macros like `$ARG1$`, `$HOSTADDRESS$`, `$_SERVICEDBHOST$` — they are expanded before execution.\n\n### Windows (PowerShell)\n\nWhen `plugin` is `powershell.exe`, pass the script path here:\n```\n-NoProfile\n-ExecutionPolicy\nBypass\n-File\nC:\\Netdata\\checks\\check_service.ps1\n```"
269
+ "ui:help": "**Arguments passed to the check command after macro expansion.**\n\nYou can use Nagios-style macros like `$ARG1$`, `$HOSTADDRESS$`, `$_SERVICEDBHOST$` — they are expanded before execution.\n\nOn Windows, when `plugin` points to a `.ps1`/`.bat`/`.cmd` script, these arguments are appended after the script path."
270
},
271
"arg_values": {
272
"ui:listFlavour": "list",
src/go/plugin/scripts.d/collector/nagios/init.go
+7
@@ -31,6 +31,13 @@ func (c *Collector) compileConfiguredJob() (compiledJob, error) {
31
return compiledJob{}, err
32
}
33
34
+ pluginPath, args, err := rewriteScriptCommand(job.config.Plugin, job.config.Args)
35
+ if err != nil {
36
+ return compiledJob{}, fmt.Errorf("job '%s': %w", job.config.Name, err)
37
+ }
38
+ job.config.Plugin = pluginPath
39
+ job.config.Args = args
40
+
41
validatedPath, err := c.validatePlugin(job.config.Plugin)
42
if err != nil {
43
return compiledJob{}, fmt.Errorf("job '%s': %w", job.config.Name, err)
src/go/plugin/scripts.d/collector/nagios/metadata.yaml
+7
-8
@@ -185,8 +185,9 @@ modules:
185
186
:::caution
187
188
- - **Linux/macOS:** Using an interpreter (e.g. `/bin/bash`) as `plugin` with a script path in `args` is **discouraged**. Netdata validates the interpreter binary but **cannot verify scripts passed via `args`**. A writable script in `args` is a privilege escalation vector. Instead, make scripts directly executable and point `plugin` to the script itself.
189
- - **Windows:** Since `.ps1` scripts cannot be executed directly, use `powershell.exe` as `plugin` and pass the script in `args`. Path validation is not enforced on Windows — ensure scripts are stored in directories with appropriate ACLs.
188
+ **Linux/macOS:** Using an interpreter (e.g. `/bin/bash`) as `plugin` with a script path in `args` is **discouraged**. Netdata validates the interpreter binary but **cannot verify scripts passed via `args`**. A writable script in `args` is a privilege escalation vector. Instead, make scripts directly executable and point `plugin` to the script itself.
189
+
190
+ **Windows:** Point `plugin` directly to a `.ps1`, `.bat`, or `.cmd` script — Netdata invokes the correct interpreter automatically. Path validation is not enforced on Windows — ensure scripts are stored in directories with appropriate ACLs.
191
192
:::
193
- title: Install check commands
@@ -416,7 +417,7 @@ modules:
417
check_interval: 1m
418
- name: Windows PowerShell check
419
description: |
419
- On Windows, `.ps1` scripts cannot be executed directly. Point `plugin` to `powershell.exe` and pass the script in `args`. Set `check_name` so that charts are grouped under the script identity instead of `powershell`.
420
+ On Windows, point `plugin` directly to a `.ps1` script. Netdata automatically invokes it through `powershell.exe` with `-NoProfile -ExecutionPolicy Bypass -File`. The `.bat` and `.cmd` scripts are also supported (invoked via `cmd.exe /c`).
421
422
**1. Create the script** (e.g., `C:\Netdata\checks\check_service.ps1`):
423
@@ -451,9 +452,7 @@ modules:
452
config: |
453
jobs:
454
- name: service_health_win
454
- check_name: check_service
455
- plugin: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
456
- args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\Netdata\\checks\\check_service.ps1"]
455
+ plugin: C:\Netdata\checks\check_service.ps1
456
timeout: 10s
457
check_interval: 1m
458
- name: Remote check via NRPE
@@ -530,9 +529,9 @@ modules:
529
- name: Check works as root but fails under Netdata
530
description: |
531
The Netdata Agent runs as the `netdata` user. If a check needs to read protected files, access SNMP, or connect to local sockets, it must be accessible to the `netdata` user. Test as that user first: `sudo -u netdata /path/to/check`. Common fixes include adding the `netdata` user to the required system group or using `sudo` with a specific NOPASSWD rule for the check command.
533
- - name: Windows checks need an executable entry point
532
+ - name: Windows script support
533
description: |
535
- The collector runs the command named in `plugin` directly. On Windows, `.ps1` scripts cannot be executed directly — point `plugin` to the absolute path of `powershell.exe` and pass the script in `args`. Always set `check_name` when using an interpreter so charts are grouped under the script identity instead of `powershell`. Ensure scripts are stored in directories with appropriate ACLs.
534
+ On Windows, point `plugin` directly to a `.ps1`, `.bat`, or `.cmd` script. Netdata automatically invokes `.ps1` scripts through `powershell.exe` and `.bat`/`.cmd` scripts through `cmd.exe`. Ensure scripts are stored in directories with appropriate ACLs.
535
alerts:
536
- name: nagios_job_execution_state_warn
537
metric: nagios.job.execution_state
src/go/plugin/scripts.d/collector/nagios/script_rewrite_stub.go
new
+11
@@ -0,0 +1,11 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+//go:build !windows
4
+
5
+package nagios
6
+
7
+// rewriteScriptCommand is a no-op on non-Windows platforms.
8
+// On Linux/macOS, scripts should be directly executable (chmod +x).
9
+func rewriteScriptCommand(pluginPath string, args []string) (string, []string, error) {
10
+ return pluginPath, args, nil
11
+}
src/go/plugin/scripts.d/collector/nagios/script_rewrite_windows.go
new
+74
@@ -0,0 +1,74 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+//go:build windows
4
+
5
+package nagios
6
+
7
+import (
8
+ "fmt"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+)
13
+
14
+// rewriteScriptCommand detects Windows script files (.ps1, .bat, .cmd)
15
+// and rewrites the command to invoke them through the appropriate interpreter.
16
+// This allows users to set plugin directly to a script path without manually
17
+// configuring the interpreter.
18
+func rewriteScriptCommand(pluginPath string, args []string) (string, []string, error) {
19
+ lower := strings.ToLower(pluginPath)
20
+
21
+ switch {
22
+ case strings.HasSuffix(lower, ".ps1"):
23
+ psPath, err := findPowerShell()
24
+ if err != nil {
25
+ return "", nil, fmt.Errorf("plugin '%s' is a PowerShell script but powershell.exe was not found: %w", pluginPath, err)
26
+ }
27
+ newArgs := make([]string, 0, 5+len(args))
28
+ newArgs = append(newArgs, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", pluginPath)
29
+ newArgs = append(newArgs, args...)
30
+ return psPath, newArgs, nil
31
+
32
+ case strings.HasSuffix(lower, ".bat"), strings.HasSuffix(lower, ".cmd"):
33
+ cmdPath, err := findCmd()
34
+ if err != nil {
35
+ return "", nil, fmt.Errorf("plugin '%s' is a batch script but cmd.exe was not found: %w", pluginPath, err)
36
+ }
37
+ newArgs := make([]string, 0, 2+len(args))
38
+ newArgs = append(newArgs, "/c", pluginPath)
39
+ newArgs = append(newArgs, args...)
40
+ return cmdPath, newArgs, nil
41
+ }
42
+
43
+ return pluginPath, args, nil
44
+}
45
+
46
+func findPowerShell() (string, error) {
47
+ sysRoot := os.Getenv("SystemRoot")
48
+ if sysRoot == "" {
49
+ sysRoot = `C:\Windows`
50
+ }
51
+ path := filepath.Join(sysRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
52
+ if _, err := os.Stat(path); err != nil {
53
+ return "", fmt.Errorf("powershell.exe not found at %s: %w", path, err)
54
+ }
55
+ return path, nil
56
+}
57
+
58
+func findCmd() (string, error) {
59
+ // ComSpec is the standard Windows env var pointing to cmd.exe.
60
+ if comSpec := os.Getenv("ComSpec"); comSpec != "" {
61
+ if _, err := os.Stat(comSpec); err == nil {
62
+ return comSpec, nil
63
+ }
64
+ }
65
+ sysRoot := os.Getenv("SystemRoot")
66
+ if sysRoot == "" {
67
+ sysRoot = `C:\Windows`
68
+ }
69
+ path := filepath.Join(sysRoot, "System32", "cmd.exe")
70
+ if _, err := os.Stat(path); err != nil {
71
+ return "", fmt.Errorf("cmd.exe not found at %s: %w", path, err)
72
+ }
73
+ return path, nil
74
+}