# SPDX-License-Identifier: GPL-3.0-or-later # # netdata-support-bundle - collect a diagnostic bundle for Netdata support tickets. # Windows counterpart of netdata-support-bundle. Same bundle layout, same MANIFEST # schema (netdata-support-bundle/v2), same sanitization rules. # # Standard captures redact secrets except the streaming API key in stream.conf # (netdata/netdata#23448). -IncludeSnmpDiagnostics adds raw SNMP files with neither # secret redaction nor PII obfuscation. Standard collected files # keep their original bytes - BOM, line terminators and a missing final newline # all survive redaction - and what was observed is recorded per file in # MANIFEST.json, so encoding faults stay diagnosable. # # What is collected and WHY each item is included is documented in # packaging/installer/SUPPORT-BUNDLE.md - read it before adding or changing # a collection item, and keep it in sync with this script. # # Usage (run in an elevated PowerShell): # powershell -ExecutionPolicy Bypass -File netdata-support-bundle.ps1 [-Output DIR] # [-SinceHours 24] [-NoObfuscate] [-IncludeSnmpDiagnostics] [-KeepStaging] [-SelfTest] # # Requires Windows PowerShell 5.1+ (ships with Windows Server 2016+) or # PowerShell 7. Output: netdata-support-bundle-.zip [CmdletBinding()] param( [string]$Output = $(if ($env:TEMP) { $env:TEMP } elseif ($env:TMPDIR) { $env:TMPDIR } else { [System.IO.Path]::GetTempPath() }), [int]$SinceHours = 24, [int]$TimeoutSeconds = 10, [switch]$NoObfuscate, [switch]$IncludeSnmpDiagnostics, [switch]$KeepStaging, [switch]$SelfTest, [switch]$Version ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'SilentlyContinue' $ToolVersion = '1.3.0' $script:SnmpFiles = 0 $script:SnmpStatus = 'not_requested' if ($Version) { Write-Output "netdata-support-bundle $ToolVersion"; exit 0 } $Obfuscate = -not $NoObfuscate $LogCap = 5MB $FileCap = 1MB $GlobalDeadline = (Get-Date).AddSeconds(240) # --- run at lowest priority: never compete with real workloads --------------- try { $proc = Get-Process -Id $PID $proc.PriorityClass = [System.Diagnostics.ProcessPriorityClass]::Idle } catch { Write-Verbose "could not lower process priority: $_" } $StartTime = Get-Date $Stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') $BundleName = "netdata-support-bundle-$Stamp-$PID" $TempBase = if ($env:TEMP) { $env:TEMP } elseif ($env:TMPDIR) { $env:TMPDIR } else { [System.IO.Path]::GetTempPath() } $Staging = Join-Path $TempBase ("netdata-support-bundle-staging-" + [System.IO.Path]::GetRandomFileName()) $Work = Join-Path $Staging $BundleName New-Item -ItemType Directory -Path $Work -Force | Out-Null $script:ManifestRows = New-Object System.Collections.ArrayList $script:PseudoMap = @{} # original -> pseudonym $script:IpCount = 0 $script:Ip6Count = 0 $script:FqdnCount = 0 $script:DomainCount = 0 $script:SeededHosts = @() # child/mirrored hostnames pre-seeded from the local API # byte-exact encoding preservation reads the whole file into one string; the # Windows perflib dump can be tens of MB, where fidelity is irrelevant (we # generated it) and the memory cost is not. Above this, use the line path. $script:ByteExactMax = 8MB function Show-Info([string]$msg) { Write-Host " [*] $msg" } # PS 5.1 Set-Content writes UTF-16LE by default; every bundle file must be UTF-8 $script:Utf8NoBom = New-Object System.Text.UTF8Encoding($false) # ISO-8859-1 maps bytes 0x00-0xFF one-to-one onto U+0000-U+00FF, so decoding and # re-encoding with it reproduces the ORIGINAL bytes exactly. The byte-exact # sanitize path uses it instead of UTF-8, whose replacement fallback would turn # every invalid sequence in a Latin-1 config into U+FFFD and corrupt the copy. # Every sanitizer pattern is ASCII, which this mapping leaves unchanged. $script:BytePreserving = [System.Text.Encoding]::GetEncoding(28591) function Write-Utf8([string]$path, [string]$text) { [System.IO.File]::WriteAllText($path, $text, $script:Utf8NoBom) } function Test-Deadline { return ((Get-Date) -gt $GlobalDeadline) } # Invoke-WebRequest wrapper: -UseBasicParsing is needed on Windows PowerShell 5.1 # (avoids the IE-engine dependency) but is a deprecated no-op on PowerShell 7+; # gate it on the major version so the same call works everywhere. function Invoke-LocalApi([string]$url, [int]$timeoutSec) { $iwrArgs = @{ Uri = $url; TimeoutSec = $timeoutSec } if ($PSVersionTable.PSVersion.Major -lt 6) { $iwrArgs["UseBasicParsing"] = $true } return Invoke-WebRequest @iwrArgs } # --- sanitizer ---------------------------------------------------------------- # pass 1 (always): credential-bearing key values, URL/DSN creds, JWT, # Bearer/Basic values, private keys, UUID section headers # pass 2 (default): emails, MACs, IPv4 pseudonyms, this host's names, # private-TLD FQDNs $SecretKeyWords = @( # substring-matched, so only unambiguous secret tokens belong here: # e.g. 'pat' is excluded because it matches 'path' (kept in sync with the .sh list) 'api key','apikey','token','password','passwd','pwd','secret','community', 'bearer','webhook','license key','auth','credential','cookie','passphrase', 'proxy user','proxy pass','username','dsn','private key','access key', 'session','recipient','account sid','priv key' ) $HostShort = $env:COMPUTERNAME $HostFqdn = try { [System.Net.Dns]::GetHostEntry('').HostName } catch { Write-Verbose "no FQDN: $_"; '' } $RunUser = $env:USERNAME if ($RunUser -in @('SYSTEM', 'Administrator') -or -not $RunUser -or $RunUser.Length -lt 3) { $RunUser = '' } # An Active Directory domain name is a corporate identifier, and the new # 09-permissions ACL captures print DOMAIN\user everywhere. Pseudonymize the # machine's OWN domain by exact value - the same approach used for the hostname # and the invoking user - rather than by pattern: a generic DOMAIN\user regex # cannot be told apart from a Windows path segment (C:\Users\Public). # POSIX has no counterpart: getfacl there yields local accounts only. $UserDomain = $env:USERDOMAIN if (-not $UserDomain -or $UserDomain -eq $HostShort -or $UserDomain.Length -lt 3) { $UserDomain = '' } $DnsDomain = '' try { $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue if ($cs -and $cs.PartOfDomain -and $cs.Domain) { $DnsDomain = [string]$cs.Domain } } catch { Write-Verbose "could not read the computer domain: $_" } if ($DnsDomain -in @('WORKGROUP', $HostShort, $HostFqdn) -or $DnsDomain.Length -lt 4) { $DnsDomain = '' } # set per file by Invoke-SanitizeLine; "stream" marks stream.conf, the one file # whose streaming API key is kept verbatim (netdata/netdata#23448) $script:SanCtx = '' function Test-SecretKey([string]$key) { $k = ($key -replace '[-_]', ' ').ToLower().Trim(' ', '#', "`t") foreach ($w in $SecretKeyWords) { if ($k.Contains($w)) { return $true } } return $false } function Test-DiagnosticKey([string]$key) { # keys that DESCRIBE secrets rather than hold them ("bearer token protection", # "netdata management api key file") end in a diagnostic noun; their values are # settings, not credentials. Exemption is KEY-based on purpose: a value like # "false" or "/root/x" attached to a real secret key must still be redacted. $k = ($key -replace '[-_]', ' ').ToLower().Trim(' ', '#', "`t") foreach ($w in @('file', 'path', 'dir', 'directory', 'protection', 'support', 'mode', 'level', 'port', 'timeout', 'cookies', 'secure', 'log', 'size', 'options')) { if ($k -eq $w -or $k.EndsWith(" $w")) { return $true } } return $false } function Test-KeyExempt([string]$key) { # the two reasons a key that LOOKS secret is left alone: it names or # describes a secret rather than holding one, or it is the streaming api key return ((Test-DiagnosticKey $key) -or (Test-StreamingApiKey $key)) } function Test-StreamingApiKey([string]$key) { # netdata/netdata#23448: the STREAMING api key is not treated as a secret - # support needs its value to tell whether a child and its parent agree. # Scoped to stream.conf ($script:SanCtx) and to an EXACT key match, so a # third-party "api key" in any other file, and any other secret key inside # stream.conf, is still redacted. Deliberately NOT applied to access logs, # where the same value also appears (stream-receiver-connection.c logs it, # and children send it as a "key=" query parameter). if ($script:SanCtx -ne 'stream') { return $false } $k = ($key -replace '[-_]', ' ').ToLower().Trim(' ', '#', "`t") return ($k -eq 'api key' -or $k -eq 'proxy api key') } function Get-Pseudonym([string]$orig, [string]$prefix) { if (-not $script:PseudoMap.ContainsKey($orig)) { # cap: past 4096 mappings, use a constant non-correlating placeholder if ($script:PseudoMap.Count -ge 4096) { return "redacted-$prefix" } if ($prefix -eq 'ip') { $script:IpCount++; $script:PseudoMap[$orig] = "ip-$($script:IpCount)" } elseif ($prefix -eq 'ip6') { $script:Ip6Count++; $script:PseudoMap[$orig] = "ip6-$($script:Ip6Count)" } elseif ($prefix -eq 'fqdn') { $script:FqdnCount++; $script:PseudoMap[$orig] = "private-host-$($script:FqdnCount)" } else { $script:PseudoMap[$orig] = 'redacted-host' } } return $script:PseudoMap[$orig] } function Invoke-RedactSecretLine([string]$line) { # pass 1 (always on): credential redaction # [] section headers are api keys (or machine GUIDs). In stream.conf # they are KEPT: support needs to see which key each section configures, and # collapsing them all to one placeholder also made per-key settings # unattributable. Everywhere else they are still withheld. if ($script:SanCtx -ne 'stream' -and $line -match '^\s*\[[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\]\s*$') { return '[REDACTED-KEY-SECTION]' } # ini/yaml/env key = value | key: value # (JSON-shaped lines are owned by the json rule below, which preserves quoting) # only plausible config keys: short, no sentence/shell punctuation, no path # separators (command lines with paths belong to the argv rule below) if ($line -notmatch '^\s*"' -and $line -match '^([^=:]{1,64})([=:])(.+)$' -and $Matches[1] -notmatch '["`;|()/]') { if ((Test-SecretKey $Matches[1]) -and $Matches[3].Trim() -ne '' -and -not (Test-DiagnosticKey $Matches[1]) -and -not (Test-StreamingApiKey $Matches[1])) { $line = $Matches[1] + $Matches[2] + ' [REDACTED]' } } # json "key": "value" pairs (possibly several per line) $line = [regex]::Replace($line, '"([^"]+)"\s*:\s*"(?:[^"\\]|\\.)*"', { param($m) if ((Test-SecretKey $m.Groups[1].Value) -and -not (Test-DiagnosticKey $m.Groups[1].Value)) { '"' + $m.Groups[1].Value + '": "[REDACTED]"' } else { $m.Value } }) # scalar (unquoted) JSON values under secret keys: "key": 12345 / true / null $line = [regex]::Replace($line, '"([^"]+)"\s*:\s*(-?\d[\d.eE+-]*|true|false|null)', { param($m) if ((Test-SecretKey $m.Groups[1].Value) -and -not (Test-DiagnosticKey $m.Groups[1].Value)) { '"' + $m.Groups[1].Value + '": "[REDACTED]"' } else { $m.Value } }) # NOTE (scope): nested JSON arrays/objects under secret keys are not # deep-parsed - see the matching note in netdata-support-bundle and the # "Redaction philosophy" section of SUPPORT-BUNDLE.md. Collected agent JSON # keeps credentials in string/scalar fields (handled above), not structures. # URL creds and Go-style DSN creds $line = $line -replace '://[^:/@\s]+:[^@\s]+@', '://[REDACTED]@' $line = $line -replace '\b[\w]+:[^@\s]+@(tcp|unix)\(', '[REDACTED]@$1(' # JWTs, HTTP auth header values $line = $line -replace 'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+', '[REDACTED-JWT]' # Bearer value must contain a digit: real tokens do, prose after "bearer" does not $line = $line -replace '[Bb]earer\s+[A-Za-z._~+/=-]*\d[A-Za-z0-9._~+/=-]*', 'Bearer [REDACTED]' $line = $line -replace '[Bb]asic\s+[A-Za-z0-9+/=]{8,}', 'Basic [REDACTED]' # secrets passed as URL query parameters (access-log request lines etc.) $line = $line -replace '([?&][\w.-]*(token|apikey|api_key|access_key|private_key|secret_key|password|passwd|secret|bearer|claim_token|claim_rooms|key|auth)=)[^&"\s]+', '$1[REDACTED]' # argv/env-style secrets mid-line (process command lines: -token=X, CLAIM_TOKEN=X), # incl. two-word keys ("api key = X"); diagnostic-noun keys are kept $line = [regex]::Replace($line, '(?i)(([\w.-]*(token|password|passwd|secret|apikey|api_key|community|bearer)|(api|license|auth|access) key|proxy (user|pass|password)) ?[=:] ?)([^&"\s\[]+)', { param($m) if ((Test-DiagnosticKey $m.Groups[2].Value) -or (Test-StreamingApiKey $m.Groups[2].Value)) { $m.Value } else { $m.Groups[1].Value + '[REDACTED]' } }) # NOTE: multiline PEM private-key blocks are handled by Invoke-SanitizeFile, # which tracks BEGIN/END state across lines and withholds the whole block. return $line } function Invoke-RedactDestinationLine([string]$line) { # stream.conf destination hosts are user infrastructure regardless of TLD. # Token syntax: [PROTOCOL:]HOST[%IFACE][:PORT][:SSL]. Runs BEFORE the IP # rules (pure-IP tokens are skipped and left to them). $eq = $line.IndexOf('=') $head = $line.Substring(0, $eq + 1) $rebuilt = foreach ($tok in ($line.Substring($eq + 1) -split '\s+')) { if (-not $tok) { continue } $work = $tok; $prefix = ''; $suffix = '' if ($work -match '^(tcp:|udp:|unix:)(.*)$') { $prefix = $Matches[1]; $work = $Matches[2] } if (-not $work.StartsWith('[') -and -not $work.StartsWith('/')) { $cut = $work.IndexOfAny([char[]]('%', ':')) if ($cut -ge 0) { $suffix = $work.Substring($cut); $work = $work.Substring(0, $cut) } if ($work.Length -ge 4 -and $work -notmatch '^(ip6?-\d+|private-host-\d+)$' -and $work -notmatch '^\d{1,3}(\.\d{1,3}){3}$' -and $work -notmatch '^[0-9A-Fa-f:]+$') { $work = Get-Pseudonym $work 'fqdn' } } $prefix + $work + $suffix } return $head + ' ' + ($rebuilt -join ' ') } function Invoke-ReplaceAddressText([string]$line) { # emails, MACs, IPv4, IPv6 $line = $line -replace '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}', '[EMAIL]' $line = $line -replace '\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b', '[MAC]' # IPv4 (keep loopback/wildcard/broadcast) $line = [regex]::Replace($line, '\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', { param($m) $ip = $m.Value if ($ip -like '127.*' -or $ip -eq '0.0.0.0' -or $ip -like '255.*') { return $ip } return (Get-Pseudonym $ip 'ip') }) # IPv6: hex-and-colon runs, validated to skip timestamps/:: tokens/loopback. # 6+ colons is always accepted: numeric-only uncompressed IPv6 has 7, # timestamps never have more than 2. $line = [regex]::Replace($line, '(? '$result'"; $fails++ } } # --- encoding fidelity (netdata/netdata#23448): a BOM, CRLF endings and a # --- missing final newline must all survive redaction, and a BOM must NOT # --- shift the ^-anchored rules (it used to defeat the section redaction) $encTmp = Join-Path $Staging 'enc-test.conf' $encText = "[11111111-2222-3333-4444-555555555555]`r`npassword = SENTINEL-ENC`r`nlast line without newline" [System.IO.File]::WriteAllBytes($encTmp, [byte[]](@(0xEF, 0xBB, 0xBF) + $script:Utf8NoBom.GetBytes($encText))) Invoke-SanitizeFile $encTmp $encOut = [System.IO.File]::ReadAllBytes($encTmp) $encStr = $script:Utf8NoBom.GetString($encOut) $encFails = @() if (-not ($encOut.Length -ge 3 -and $encOut[0] -eq 0xEF -and $encOut[1] -eq 0xBB -and $encOut[2] -eq 0xBF)) { $encFails += 'the UTF-8 BOM was stripped by sanitization' } if ($encStr.IndexOf('[REDACTED-KEY-SECTION]') -lt 0) { $encFails += 'a BOM defeated the [] section redaction' } if ($encStr.IndexOf('11111111-2222') -ge 0) { $encFails += 'the BOM-prefixed api key section survived' } if ($encStr.IndexOf('SENTINEL-ENC') -ge 0) { $encFails += 'a secret survived in the encoding vector' } if (@([regex]::Matches($encStr, "`r`n")).Count -lt 2) { $encFails += 'CRLF line endings were lost (including on the redacted line)' } if ($encOut[$encOut.Length - 1] -eq 10) { $encFails += 'a final newline was added to a source that had none' } if ($encFails.Count -gt 0) { foreach ($e in $encFails) { Write-Output "FAIL: $e"; $fails++ } } else { Write-Output 'ok: encoding preserved (BOM + CRLF + no trailing newline) and the BOM did not defeat redaction' } # multiline PEM block must be withheld end-to-end by the file-level sanitizer $pemTmp = Join-Path $Staging 'pem-test.txt' @('before line', '-----BEGIN RSA PRIVATE KEY-----', 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ', 'aGVsbG8gd29ybGQgdGhpcyBpcyBrZXkgbWF0ZXJpYWw=', '-----END RSA PRIVATE KEY-----', 'after line') | Out-String | ForEach-Object { Write-Utf8 $pemTmp $_ } Invoke-SanitizeFile $pemTmp $pem = Get-Content $pemTmp -Raw if ($pem -match 'MIIEvQ' -or $pem -match 'aGVsbG8' -or $pem -notmatch '\[REDACTED PRIVATE KEY BLOCK\]' -or $pem -notmatch 'before line' -or $pem -notmatch 'after line') { Write-Output 'FAIL: PEM block not fully withheld'; $fails++ } else { Write-Output 'ok: PEM block withheld (begin/body/end gone, surrounding lines kept)' } Remove-Item $Staging -Recurse -Force if ($fails -gt 0) { Write-Output "SELF TEST: $fails FAILURES"; exit 1 } Write-Output 'SELF TEST: ALL PASS' exit 0 } # --- manifest ------------------------------------------------------------------- function Add-Manifest([string]$rel, [string]$kind, [string]$origin, [string]$title, [bool]$raw = $false) { $full = Join-Path $Work $rel $bytes = 0 if (Test-Path $full) { $bytes = (Get-Item $full).Length } $row = [ordered]@{ path = $rel.Replace('\', '/'); kind = $kind; origin = $origin; title = $title bytes = [long]$bytes; pii_obfuscated = [bool]($Obfuscate -and -not $raw); sanitized = -not $raw } [void]$script:ManifestRows.Add($row) } # --- SNMP diagnostics ---------------------------------------------------------- function Add-SnmpNote([string]$message) { [System.IO.File]::AppendAllText((Join-Path $Work '06-state\snmp-diagnostics-status.txt'), "$message`n", $script:Utf8NoBom) } function Test-SnmpDirectory([string]$path) { try { $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop return ($item.PSIsContainer -and -not ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) } catch { return $false } } function Save-SnmpFile([string]$src, [string]$rel) { $full = Join-Path $Work ("06-state\snmp-diagnostics\" + $rel) $inputStream = $null; $outputStream = $null try { if (Test-Deadline) { throw 'deadline reached' } $item = Get-Item -LiteralPath $src -Force -ErrorAction Stop if ($item.PSIsContainer -or ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw 'source is not a regular file' } New-Item -ItemType Directory -Path (Split-Path $full) -Force -ErrorAction Stop | Out-Null # Delete sharing lets the Agent atomically replace a file while it is read. $sharing = [System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete $inputStream = [System.IO.File]::Open($src, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, $sharing) $outputStream = [System.IO.File]::Open("$full.tmp", [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) $buffer = New-Object byte[] 65536 $copyDeadline = (Get-Date).AddSeconds($TimeoutSeconds) while (($count = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { if ((Test-Deadline) -or (Get-Date) -gt $copyDeadline) { throw 'copy deadline reached' } $outputStream.Write($buffer, 0, $count) } $outputStream.Dispose(); $outputStream = $null $inputStream.Dispose(); $inputStream = $null [System.IO.File]::Move("$full.tmp", $full) $script:SnmpFiles++ Add-Manifest ("06-state\snmp-diagnostics\" + $rel) 'file' ("snmp/diagnostics/" + $rel.Replace('\', '/')) 'Original SNMP evidence (UNSANITIZED)' $true Add-SnmpNote ($rel.Replace('\', '/') + ': copied') return $true } catch { # Error text can contain machine-specific paths. Keep raw-copy status safe. $script:SnmpStatus = 'partial' Add-SnmpNote ($rel.Replace('\', '/') + ': unavailable or copy failed (withheld)') return $false } finally { if ($outputStream) { $outputStream.Dispose() } if ($inputStream) { $inputStream.Dispose() } if (Test-Path -LiteralPath "$full.tmp") { Remove-Item -LiteralPath "$full.tmp" -Force } } } function Get-SnmpRuns([string]$path) { # Match the fixed Agent-written index, not arbitrary JSON/path expressions. $uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' $pattern = '\A\s*\{\s*"current"\s*:\s*"(?' + $uuid + ')"(?:\s*,\s*"previous"\s*:\s*"(?' + $uuid + ')")?\s*\}\s*\z' $match = [regex]::Match([System.IO.File]::ReadAllText($path), $pattern) if (-not $match.Success) { throw 'invalid normal run index' } $current = $match.Groups['current'].Value $previous = $match.Groups['previous'].Value if ($current -eq $previous) { throw 'duplicate normal run identity' } $current if ($previous) { $previous } } function Save-SnmpDirectoryFiles([string]$root, [string]$relative, [string]$pattern) { $directory = Join-Path $root $relative if (-not (Test-SnmpDirectory $directory)) { $script:SnmpStatus = 'partial' Add-SnmpNote ($relative.Replace('\', '/') + ': directory unavailable or reparse point (withheld)') return } try { $files = @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop) foreach ($file in $files) { if ($file.Name -cmatch $pattern) { Save-SnmpFile $file.FullName (Join-Path $relative $file.Name) | Out-Null } } } catch { $script:SnmpStatus = 'partial' Add-SnmpNote ($relative.Replace('\', '/') + ': listing failed') } } function Collect-SnmpDiagnostics { New-Item -ItemType Directory -Path (Join-Path $Work '06-state') -Force | Out-Null Write-Utf8 (Join-Path $Work '06-state\snmp-diagnostics-status.txt') '' if (-not $IncludeSnmpDiagnostics) { Add-SnmpNote 'Not requested. Use -IncludeSnmpDiagnostics to include original, UNSANITIZED evidence.' } elseif (-not $LibDir -or -not (Test-Path -LiteralPath (Join-Path $LibDir 'snmp\diagnostics'))) { $script:SnmpStatus = 'unavailable' Add-SnmpNote 'No SNMP diagnostic directory found.' } else { $script:SnmpStatus = 'complete' $root = Join-Path $LibDir 'snmp\diagnostics' Add-SnmpNote 'Raw SNMP evidence requested: no secret redaction or PII obfuscation. Share privately.' if (-not (Test-SnmpDirectory (Join-Path $LibDir 'snmp')) -or -not (Test-SnmpDirectory $root)) { $script:SnmpStatus = 'partial' Add-SnmpNote 'Diagnostic directory unavailable or reparse point (withheld).' } else { $lifecycleMissing = $false if (Test-Path -LiteralPath (Join-Path $root 'lifecycle.zst')) { Save-SnmpFile (Join-Path $root 'lifecycle.zst') 'lifecycle.zst' | Out-Null } else { $lifecycleMissing = $true Add-SnmpNote 'lifecycle.zst: missing' } if (Test-Path -LiteralPath (Join-Path $root 'topology')) { Save-SnmpDirectoryFiles $root 'topology' '^checkpoint-[0-9]{20}\.zst$' } if (Test-Path -LiteralPath (Join-Path $root 'normal')) { if ((Test-SnmpDirectory (Join-Path $root 'normal')) -and (Save-SnmpFile (Join-Path $root 'normal\runs.json') 'normal\runs.json')) { try { $runs = @(Get-SnmpRuns (Join-Path $Work '06-state\snmp-diagnostics\normal\runs.json')) foreach ($run in $runs) { Save-SnmpDirectoryFiles $root ("normal\" + $run) '^device-[0-9]{20}\.zst$' } } catch { $script:SnmpStatus = 'partial' Add-SnmpNote 'normal/runs.json: invalid index; normal device files withheld.' } } else { $script:SnmpStatus = 'partial' Add-SnmpNote 'normal: directory or run index unavailable (device files withheld).' } } if ($script:SnmpFiles -gt 0 -and $lifecycleMissing) { $script:SnmpStatus = 'partial' } if ($script:SnmpFiles -eq 0 -and $script:SnmpStatus -eq 'complete') { $script:SnmpStatus = 'unavailable' Add-SnmpNote 'No completed SNMP diagnostic files found.' } } } Add-SnmpNote "Result: $script:SnmpStatus; complete files copied: $script:SnmpFiles." Add-Manifest '06-state\snmp-diagnostics-status.txt' 'file' 'generated' 'SNMP evidence collection status' } # --- end SNMP diagnostics ------------------------------------------------------ # --- collectors ------------------------------------------------------------------- # Launch a native netdata binary (netdata.exe / netdatacli.exe) DIRECTLY as a # foreground process. These MUST NOT go through Start-Job: they are MSYS2 # binaries whose runtime does not initialize inside a PowerShell background-job # runspace, so they exit non-zero with no output (an empty dump / empty stdout). # A foreground child with an inherited console works. Notes: # - System.Diagnostics.Process, not Start-Process: Start-Process -PassThru does # not expose a reliable .ExitCode on Windows PowerShell 5.1 (it reads back # empty even for `cmd /c exit 7`). # - CreateNoWindow stays $false: detaching from the console reproduces the same # empty-output failure. The bundle runs from a console PowerShell, so the # child inherits it and no extra window appears. # - both pipes are decoded as UTF-8 (see below) and drained asynchronously so a full buffer cannot deadlock the # wait, which is bounded by WaitForExit (kill on timeout). function Invoke-NativeProcess([string]$exe, [string[]]$argList, [int]$timeoutSec) { $r = [pscustomobject]@{ StdOut = ''; StdErr = ''; ExitCode = $null; TimedOut = $false; LaunchError = '' } $p = $null try { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $exe # PS 5.1 ProcessStartInfo has no ArgumentList; quote args containing spaces $psi.Arguments = ($argList | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } }) -join ' ' $psi.UseShellExecute = $false $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true # the MSYS2 binaries write UTF-8; without this .NET decodes the pipes # with the console code page and non-ASCII text (dyncfg names, labels) # is mangled before it is re-written as UTF-8 $psi.StandardOutputEncoding = New-Object System.Text.UTF8Encoding $false $psi.StandardErrorEncoding = New-Object System.Text.UTF8Encoding $false $psi.CreateNoWindow = $false $p = New-Object System.Diagnostics.Process $p.StartInfo = $psi [void]$p.Start() $outTask = $p.StandardOutput.ReadToEndAsync() $errTask = $p.StandardError.ReadToEndAsync() if ($p.WaitForExit($timeoutSec * 1000)) { $r.ExitCode = $p.ExitCode $r.StdOut = $outTask.Result $r.StdErr = $errTask.Result } else { try { $p.Kill() } catch { Write-Verbose "could not kill ${exe}: $_" } $r.TimedOut = $true } } catch { $r.LaunchError = "$_" } finally { if ($p) { $p.Dispose() } } return $r } function Save-Cmd([string]$rel, [string]$title, [scriptblock]$cmd, [string]$originText, [string]$nativeExe = '', [string[]]$nativeArgs = @(), [long]$cap = 0, [int]$timeoutFloor = 0) { # $cap mirrors the POSIX "collect_cmd --cap": journal/event-log captures are # documented at 5 MiB while command output defaults to 2 MiB if ($cap -le 0) { $cap = 2MB } # a capture that legitimately needs longer than the per-command default can # raise the floor; without it the job is killed and its WHOLE output is # replaced by "TIMEOUT after Ns", losing everything it had already gathered $timeout = if ($timeoutFloor -gt 0) { [Math]::Max($TimeoutSeconds, $timeoutFloor) } else { $TimeoutSeconds } if (Test-Deadline) { return } $full = Join-Path $Work $rel New-Item -ItemType Directory -Path (Split-Path $full) -Force | Out-Null $header = "# netdata-support-bundle v$ToolVersion | command: $originText | captured: $((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))" if ($nativeExe) { # MSYS2 binary: direct launch, never Start-Job (see Invoke-NativeProcess). # Text file, so merge stdout+stderr like the old `2>&1`; never silent. $np = Invoke-NativeProcess $nativeExe $nativeArgs $timeout if ($np.LaunchError) { $result = "ERROR: could not launch: $($np.LaunchError)" } elseif ($np.TimedOut) { $result = "TIMEOUT after ${timeout}s" } elseif ($np.ExitCode -ne 0) { $result = "ERROR: exit $($np.ExitCode)`r`n$($np.StdErr)$($np.StdOut)" } else { $result = "$($np.StdOut)$($np.StdErr)" } } else { try { $job = Start-Job -ScriptBlock $cmd $done = Wait-Job $job -Timeout $timeout if ($done) { $result = Receive-Job $job 2>&1 | Select-Object -First 50000 | Out-String } else { $result = "TIMEOUT after ${timeout}s" } Stop-Job $job -ErrorAction SilentlyContinue Remove-Job $job -Force } catch { $result = "ERROR: $_" } } if ($result.Length -gt $cap) { # truncate at a LINE boundary so a secret cannot straddle the cut $cut = $result.LastIndexOf("`n", [int]$cap) if ($cut -lt 0) { $result = '[content withheld: output exceeds the cap without a line break]' } else { $result = $result.Substring(0, $cut + 1) + ("### TRUNCATED at {0} bytes (line-aligned) ###" -f $cap) } } Write-Utf8 $full ($header + "`r`n" + $result) Invoke-SanitizeFile $full Add-Manifest $rel 'cmd' $originText $title } function Get-RawCmdOutput([scriptblock]$cmd, [string]$nativeExe, [string[]]$nativeArgs) { if ($nativeExe) { # MSYS2 binary: direct launch, never Start-Job (see Invoke-NativeProcess). # JSON output, so keep stdout ONLY (merging stderr would corrupt it); a # failed/empty run returns nothing. $np = Invoke-NativeProcess $nativeExe $nativeArgs $TimeoutSeconds if (-not $np.TimedOut -and -not $np.LaunchError -and $np.ExitCode -eq 0) { return $np.StdOut } return '' } $result = '' try { $job = Start-Job -ScriptBlock $cmd $done = Wait-Job $job -Timeout $TimeoutSeconds if ($done) { $result = Receive-Job $job 2>&1 | Select-Object -First 50000 | Out-String } Stop-Job $job -ErrorAction SilentlyContinue Remove-Job $job -Force } catch { Write-Verbose "collector failed: $_" } return $result } function Limit-RawCmdOutput([string]$result, [bool]$dropOverCap) { # a truncated JSON document is worse than none: fail closed # measure the UTF-8 bytes written, not UTF-16 code units (see Save-Api) if ([System.Text.Encoding]::UTF8.GetByteCount($result) -le 2MB) { return $result } if ($dropOverCap) { return '' } return '{"error":"output exceeded the cap and was withheld"}' } function Save-CmdRaw([string]$rel, [string]$title, [scriptblock]$cmd, [string]$originText, [string]$nativeExe = '', [string[]]$nativeArgs = @(), [switch]$dropOverCap) { # like Save-Cmd but with NO header/trailer, for commands whose output must # stay parseable (JSON). Provenance lives in MANIFEST.json only. The file is # removed if the command produced nothing, and with -dropOverCap also if the # output exceeds the cap, so a caller with a fallback source can still try it. if (Test-Deadline) { return } $full = Join-Path $Work $rel New-Item -ItemType Directory -Path (Split-Path $full) -Force | Out-Null $result = Get-RawCmdOutput $cmd $nativeExe $nativeArgs if ($result -and $result.Trim().Length -gt 0) { $result = Limit-RawCmdOutput $result $dropOverCap.IsPresent } if ($result -and $result.Trim().Length -gt 0) { Write-Utf8 $full $result Invoke-SanitizeFile $full Add-Manifest $rel 'cmd' $originText $title } elseif (Test-Path $full) { Remove-Item $full -Force } } function Save-File([string]$rel, [string]$title, [string]$src, [long]$cap = 0) { if (Test-Deadline) { return } if ($cap -eq 0) { $cap = $FileCap } if (-not (Test-Path $src -PathType Leaf)) { return } # stream.conf is the one file whose streaming API key is kept verbatim; the # context is keyed on the SOURCE name, never on the bundle path $ctx = if ((Split-Path -Leaf $src) -eq 'stream.conf') { 'stream' } else { '' } $full = Join-Path $Work $rel New-Item -ItemType Directory -Path (Split-Path $full) -Force | Out-Null $item = Get-Item $src -Force if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { # reparse points can silently retarget: fail closed rather than follow Write-Utf8 $full '[content withheld: source is a symlink/reparse point]' Add-Manifest $rel 'file' "$src (reparse point - withheld)" $title return } $size = $item.Length $origin = $src if ($size -gt $cap) { # keep the tail at a LINE boundary (drop the first, possibly partial, # line) so a secret can never straddle the cut and dodge the # line-based sanitizer; a tail with no line break at all is withheld $fs = [System.IO.File]::OpenRead($src) try { $slack = [Math]::Min($size, $cap + 4096) $fs.Seek(-$slack, [System.IO.SeekOrigin]::End) | Out-Null $buf = New-Object byte[] $slack $read = $fs.Read($buf, 0, $slack) $text = $script:Utf8NoBom.GetString($buf, 0, $read) $nl = $text.IndexOf("`n") if ($nl -lt 0) { Write-Utf8 $full '[content withheld: file tail exceeds the cap without a line break]' } else { Write-Utf8 $full $text.Substring($nl + 1) } } finally { $fs.Close() } $origin = "$src (last ~$cap of $size bytes, line-aligned)" } else { try { Copy-Item $src $full -Force -ErrorAction Stop } catch { Write-Utf8 $full "[content withheld: could not read source ($_)]" $origin = "$src (unreadable - withheld)" } } Invoke-SanitizeFile $full $true $ctx Add-Manifest $rel 'file' $origin $title } $NdPort = 19999 $CloudHost = 'app.netdata.cloud' # reachability probe target function Save-Api([string]$rel, [string]$title, [string]$urlPath) { if (Test-Deadline) { return } $full = Join-Path $Work $rel New-Item -ItemType Directory -Path (Split-Path $full) -Force | Out-Null try { $resp = Invoke-LocalApi "http://127.0.0.1:$NdPort$urlPath" $TimeoutSeconds $content = $resp.Content # a JSON body cut at any point is malformed, so overflow is withheld whole. # Measure the UTF-8 bytes actually written, not the string's UTF-16 code # units: a non-ASCII body can be several times longer on disk than in # characters and would otherwise sail past the cap. if ([System.Text.Encoding]::UTF8.GetByteCount($content) -gt 2MB) { $content = '{"error":"response exceeded the cap and was withheld"}' } Write-Utf8 $full $content Invoke-SanitizeFile $full Add-Manifest $rel 'api' $urlPath $title } catch { if (Test-Path $full) { Remove-Item $full -Force } } } # --- environment detection -------------------------------------------------------- $NetdataPrefix = 'C:\Program Files\Netdata' $ConfDir = Join-Path $NetdataPrefix 'etc\netdata' $LibDir = Join-Path $NetdataPrefix 'var\lib\netdata' $CacheDir = Join-Path $NetdataPrefix 'var\cache\netdata' $LogDir = Join-Path $NetdataPrefix 'var\log\netdata' $NetdataExe = Join-Path $NetdataPrefix 'usr\bin\netdata.exe' $NetdataCli = Join-Path $NetdataPrefix 'usr\bin\netdatacli.exe' $NetdataSvc = Get-Service -Name 'Netdata' -ErrorAction SilentlyContinue $NetdataProc = Get-Process -Name 'netdata' -ErrorAction SilentlyContinue | Select-Object -First 1 $NetdataProcessIds = @() if ($NetdataProc) { # Walk descendants so sockets opened by plugin processes remain in scope # without relying on a brittle fixed process-name list. $allProcesses = @(Get-CimInstance Win32_Process -Property ProcessId, ParentProcessId -ErrorAction SilentlyContinue) $ids = New-Object 'System.Collections.Generic.HashSet[int]' [void]$ids.Add([int]$NetdataProc.Id) $changed = $true while ($changed) { $changed = $false foreach ($child in $allProcesses) { if ($ids.Contains([int]$child.ParentProcessId) -and $ids.Add([int]$child.ProcessId)) { $changed = $true } } } $NetdataProcessIds = @($ids | Sort-Object) } $ApiOk = $false try { # /api/v3/info stays reachable under bearer protection (v1 is locked) foreach ($probe in @('/api/v3/info', '/api/v1/info')) { try { Invoke-LocalApi "http://127.0.0.1:$NdPort$probe" 3 | Out-Null; $ApiOk = $true; break } catch { Write-Verbose "probe $probe failed: $_" } } } catch { Write-Verbose "agent API not reachable: $_" } # pre-seed pseudonyms for child/mirrored hostnames, so a parent's children are # obfuscated consistently in every file (node_instances, stream configs, logs) if ($ApiOk -and $Obfuscate) { try { $names = @() $ni = (Invoke-LocalApi "http://127.0.0.1:$NdPort/api/v2/node_instances" $TimeoutSeconds).Content | ConvertFrom-Json if ($ni -and $ni.PSObject.Properties['nodes']) { $names += @($ni.nodes | ForEach-Object { $_.nm }) } $v1 = (Invoke-LocalApi "http://127.0.0.1:$NdPort/api/v1/info" $TimeoutSeconds).Content | ConvertFrom-Json if ($v1 -and $v1.PSObject.Properties['mirrored_hosts']) { $names += @($v1.mirrored_hosts) } foreach ($n in ($names | Sort-Object -Unique)) { if (-not $n -or $n.Length -lt 4 -or $n -eq 'localhost' -or $n -eq $HostShort -or $n -eq $HostFqdn) { continue } if (-not $script:PseudoMap.ContainsKey($n)) { if ($script:PseudoMap.Count -ge 4096) { break } # honor the map cap $script:FqdnCount++ $script:PseudoMap[$n] = "private-host-$($script:FqdnCount)" } } # longest first so overlapping names (host, host-2) replace correctly $script:SeededHosts = @($script:PseudoMap.Keys | Where-Object { $script:PseudoMap[$_] -like 'private-host*' } | Sort-Object { $_.Length } -Descending) } catch { Write-Verbose "child hostname pre-seed failed: $_" } } Show-Info "netdata-support-bundle $ToolVersion (Windows)" Show-Info ("service: {0} | process: {1} | api: {2}" -f ` $(if ($NetdataSvc) { $NetdataSvc.Status } else { 'not installed' }), ` $(if ($NetdataProc) { "pid $($NetdataProc.Id)" } else { 'not running' }), ` $(if ($ApiOk) { 'up' } else { 'unreachable' })) # ============================================================================ # 01-system # ============================================================================ Show-Info 'collecting: system' Save-Cmd '01-system\os-version.txt' 'OS version and build' { Get-CimInstance Win32_OperatingSystem | Format-List Caption, Version, BuildNumber, OSArchitecture, LastBootUpTime, TotalVisibleMemorySize, FreePhysicalMemory } 'Get-CimInstance Win32_OperatingSystem' Save-Cmd '01-system\computer-info.txt' 'Hardware, domain role, virtualization' { Get-CimInstance Win32_ComputerSystem | Format-List Manufacturer, Model, SystemType, NumberOfProcessors, NumberOfLogicalProcessors, TotalPhysicalMemory, DomainRole, HypervisorPresent } 'Get-CimInstance Win32_ComputerSystem' Save-Cmd '01-system\disk-usage.txt' 'Volume usage' { Get-CimInstance Win32_LogicalDisk | Format-Table DeviceID, @{n='SizeGB';e={[math]::Round($_.Size/1GB,1)}}, @{n='FreeGB';e={[math]::Round($_.FreeSpace/1GB,1)}}, FileSystem -AutoSize } 'Get-CimInstance Win32_LogicalDisk' Save-Cmd '01-system\clock-timesync.txt' 'Clock and time sync (drift breaks streaming/cloud)' { Get-Date -Format o; w32tm /query /status 2>&1 } 'Get-Date; w32tm /query /status' Save-Cmd '01-system\uptime.txt' 'System uptime' { (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime | Format-List Days, Hours, Minutes } 'uptime via Win32_OperatingSystem' # ============================================================================ # 02-install # ============================================================================ Show-Info 'collecting: install' # NOTE: never use Win32_Product here - querying it triggers MSI reconfiguration # of every installed package. The uninstall registry keys are the safe source. Save-Cmd '02-install\msi-info.txt' 'Installed Netdata MSI package info (from uninstall registry)' { foreach ($root in @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall')) { Get-ItemProperty "$root\*" -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like '*Netdata*' } | Format-List DisplayName, DisplayVersion, InstallDate, InstallLocation, Publisher } } 'registry uninstall keys (Netdata)' Save-Cmd '02-install\install-tree.txt' 'Install dir layout (top level)' { Get-ChildItem $using:NetdataPrefix | Format-Table Mode, LastWriteTime, Length, Name -AutoSize } "Get-ChildItem $NetdataPrefix (top level; -Depth is PS6+)" if (Test-Path (Join-Path $ConfDir '.install-type')) { Save-File '02-install\install-type.file.txt' 'Install type marker' (Join-Path $ConfDir '.install-type') } # ============================================================================ # 03-process # ============================================================================ Show-Info 'collecting: process' Save-Cmd '03-process\netdata-processes.txt' 'Netdata process tree with CPU/memory' { Get-Process | Where-Object { $_.ProcessName -match 'netdata|go.d|ebpf|windows.plugin' } | Format-Table Id, ProcessName, CPU, WorkingSet64, HandleCount, Threads -AutoSize } 'Get-Process (netdata family)' Save-Cmd '03-process\service-status.txt' 'Netdata service state and config' { Get-Service Netdata | Format-List *; (Get-CimInstance Win32_Service -Filter "Name='Netdata'") | Format-List StartMode, StartName, PathName, State, ExitCode } 'Get-Service Netdata + Win32_Service' # ============================================================================ # 04-config # ============================================================================ Show-Info 'collecting: config' if ($ApiOk) { Save-Api '04-config\effective-netdata.conf' 'EFFECTIVE running config (merged, annotated) - authoritative over on-disk file' '/netdata.conf' } if (Test-Path $ConfDir) { Save-Cmd '04-config\config-tree.txt' 'User config dir tree (files here = user-customized; ssl and key material excluded)' { Get-ChildItem $using:ConfDir -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch '[\\/]ssl([\\/]|$)' -and $_.Extension -notin @('.pem', '.key') } | Select-Object -First 2000 | Format-Table Mode, LastWriteTime, Length, FullName -AutoSize } "Get-ChildItem $ConfDir -Recurse (ssl/key material excluded)" Save-File '04-config\netdata.conf' 'On-disk main config' (Join-Path $ConfDir 'netdata.conf') Save-File '04-config\stream.conf' 'Streaming config (parent/child; the streaming api key is KEPT VERBATIM - see README.md)' (Join-Path $ConfDir 'stream.conf') Save-File '04-config\claim.conf' 'Cloud claim config (token redacted)' (Join-Path $ConfDir 'claim.conf') Save-File '04-config\go.d.conf' 'go.d orchestrator config' (Join-Path $ConfDir 'go.d.conf') # every user-customized collector/health config, keeping subdirectory # structure (go.d\sd\*.conf must not collide with go.d\*.conf); ssl dirs # and key material are never collected. Capped at 200 files. $script:ConfCollected = 0 foreach ($sub in @('go.d', 'health.d', 'python.d', 'charts.d', 'statsd.d')) { $subDir = Join-Path $ConfDir $sub if (-not (Test-Path $subDir)) { continue } Get-ChildItem $subDir -File -Recurse -Include '*.conf', '*.yml', '*.yaml' -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch '\\ssl\\' -and $_.Extension -notin @('.pem', '.key') } | ForEach-Object { if ($script:ConfCollected -ge 200) { return } $relSub = $_.FullName.Substring($subDir.Length).TrimStart('\') Save-File "04-config\$sub\$relSub" "User-customized $sub config (secrets redacted)" $_.FullName 256KB $script:ConfCollected++ } } } # ============================================================================ # 05-logs (Windows: Event Log is the primary destination) # ============================================================================ Show-Info "collecting: logs (last ${SinceHours}h, Event Log + files)" # Windows builds are ALWAYS compiled with ETW (CMakeLists.txt sets HAVE_ETW # unconditionally for OS_WINDOWS) and netdata-conf-logs.c then selects "etw" as # the default log method, so the daemon logs into five manifest-declared # Operational channels - Netdata/Daemon, /Collectors, /Access, /Health, /Aclk # (wevt_netdata_mc_generate.c). Querying only NetdataWEL therefore returned NO # daemon logs at all on a default install (netdata/netdata#23448). # # One merged file, ordered for triage: channel/provider state first, then Daemon # and Collectors (what you read first), and Access LAST with the smallest event # budget because it is by far the highest volume. Save-Cmd '05-logs\eventlog-netdata.txt' 'Netdata events from the Windows Event Log: ETW channels (Netdata/*) + NetdataWEL + Application, plus channel and provider registration state' { # capture $using: into locals once: the $using: modifier is resolved when the # job scriptblock is serialized and must not be relied on inside strings $sinceHours = [int]$using:SinceHours $since = (Get-Date).AddHours(-$sinceHours) '===== channel state (a disabled or full channel loses events) =====' Get-WinEvent -ListLog 'Netdata/*', 'NetdataWEL' -ErrorAction SilentlyContinue | Select-Object LogName, IsEnabled, RecordCount, FileSize | Format-Table -AutoSize # per-channel event budgets: Daemon/Collectors carry the diagnosis, Access is # request noise, so it gets the smallest share of the file $channels = @( @{ Log = 'Netdata/Daemon'; Max = 3000 } @{ Log = 'Netdata/Collectors'; Max = 3000 } @{ Log = 'Netdata/Health'; Max = 1000 } @{ Log = 'Netdata/Aclk'; Max = 1000 } @{ Log = 'NetdataWEL'; Max = 2000 } @{ Log = 'Application'; Max = 2000 } @{ Log = 'Netdata/Access'; Max = 500 } ) foreach ($ch in $channels) { "===== channel: $($ch.Log) (max $($ch.Max) events, last ${sinceHours}h) =====" $events = Get-WinEvent -FilterHashtable @{ LogName = $ch.Log; StartTime = $since } ` -MaxEvents $ch.Max -ErrorAction SilentlyContinue # Application is a shared channel: keep only Netdata's own records. The # dedicated channels are already Netdata-only, so filtering them would # drop records whose ProviderName resolves differently under ETW. if ($ch.Log -eq 'Application') { $events = $events | Where-Object { $_.ProviderName -match 'Netdata' } } if ($events) { $events | Select-Object TimeCreated, ProviderName, LevelDisplayName, Message | Format-List } else { '(no events in this window, or the channel does not exist on this system)' } } } "Get-WinEvent: Netdata/* ETW channels + NetdataWEL + Application (last ${SinceHours}h)" -cap $LogCap -timeoutFloor 30 if (Test-Path $LogDir) { Get-ChildItem $LogDir -File -Filter '*.log' | ForEach-Object { Save-File "05-logs\$($_.Name)" "Agent log file: $($_.Name)" $_.FullName $LogCap } } # ============================================================================ # 06-state # ============================================================================ Show-Info 'collecting: state' Collect-SnmpDiagnostics $StatusFile = Join-Path $LibDir 'status-netdata.json' if (Test-Path $StatusFile) { Save-File '06-state\status-file.json' 'Daemon status file: LAST EXIT/CRASH RECORD incl. fatal stack trace (read this first for crashes)' $StatusFile } if (Test-Path $LibDir) { # bearer_tokens FILENAMES are live API tokens - list only their count Save-Cmd '06-state\state-tree.txt' 'State dir listing (bearer token filenames withheld - they are live tokens)' { $items = Get-ChildItem $using:LibDir -Recurse -ErrorAction SilentlyContinue | Select-Object -First 2000 $tokenCount = @($items | Where-Object { $_.FullName -match '\\bearer_tokens\\' }).Count $items | Where-Object { $_.FullName -notmatch '\\bearer_tokens\\' } | Format-Table Mode, LastWriteTime, Length, FullName -AutoSize "[$tokenCount token file(s) - names withheld, they ARE the tokens]" } "Get-ChildItem $LibDir -Recurse (bearer_tokens contents withheld)" $CloudDir = Join-Path $LibDir 'cloud.d' if (Test-Path (Join-Path $CloudDir 'claimed_id')) { Save-File '06-state\claimed-id.txt' 'Cloud claim id (safe identifier; token/private.pem never collected)' (Join-Path $CloudDir 'claimed_id') } } if (Test-Path $CacheDir) { Save-Cmd '06-state\db-disk-usage.txt' 'Database disk usage per tier + sqlite sizes' { Get-ChildItem $using:CacheDir -Directory | ForEach-Object { $s = (Get-ChildItem $_.FullName -Recurse -File | Measure-Object Length -Sum).Sum; '{0} {1:N1} MB' -f $_.Name, ($s/1MB) }; Get-ChildItem $using:CacheDir -File -Filter '*.db*' | Format-Table Name, Length -AutoSize } "du of $CacheDir" } # ============================================================================ # 07-runtime # ============================================================================ # The dyncfg tree through the local command pipe: the agent answers it as a # member of its own space, which the web API needs and the bundle cannot be. # It does not need the web API, so it is tried whatever $ApiOk says - but at the # tree's own place in the order: sanitizing a large tree can take minutes, and # done first it would spend the deadline the smaller runtime captures need. # With -dropOverCap Save-CmdRaw leaves no file on any failure, so the API # fallback can use the same path. $DyncfgTreeTitle = 'Dynamic configuration tree: go.d/scripts.d job states (accepted/running/failed/disabled/orphan/incomplete/none), service discovery, vnodes, secret stores, alert prototypes - THE source of collector job state' function Save-DyncfgTreeCli { if ((Test-Path $NetdataCli) -and $NetdataProc) { # netdatacli.exe is MSYS2: native direct-launch, not Start-Job Save-CmdRaw '07-runtime\dyncfg-tree.json' $DyncfgTreeTitle $null 'netdatacli.exe dyncfg-tree' -nativeExe $NetdataCli -nativeArgs 'dyncfg-tree' -dropOverCap } } if ($ApiOk) { Show-Info 'collecting: runtime (agent is up)' Save-Api '07-runtime\info-v3.json' 'BEST SINGLE CALL: buildinfo, features, cloud status, per-tier retention' '/api/v3/info' Save-Api '07-runtime\info-v1.json' 'Agent info v1' '/api/v1/info' Save-Api '07-runtime\node-instances.json' 'Node instances: children, streaming state, db_size, metric counts' '/api/v2/node_instances' Save-Api '07-runtime\stream-info.json' 'Streaming diagnostics' '/api/v3/stream_info' Save-Api '07-runtime\aclk.json' 'Cloud/ACLK connection state' '/api/v1/aclk' Save-Api '07-runtime\alerts-active.json' 'Currently raised alerts' '/api/v3/alerts?options=active' Save-Api '07-runtime\alerts-all.json' 'All alert instances (summary)' '/api/v1/alarms?all' Save-Api '07-runtime\functions.json' 'Registered functions' '/api/v1/functions' Save-DyncfgTreeCli # Agents that predate `netdatacli dyncfg-tree` (tried just above), or a bundle run # by a user who cannot reach the command pipe, fall back to the API. $dyncfgPath = Join-Path $Work '07-runtime\dyncfg-tree.json' if (-not (Test-Path $dyncfgPath)) { Save-Api '07-runtime\dyncfg-tree.json' $DyncfgTreeTitle '/api/v3/config?action=tree' } # /api/v3/info answers without any access check, so $ApiOk can be true while # this endpoint - behind the dyncfg ACL - refuses us, and Save-Api deletes the # file on failure leaving no trace at all. The bundle cannot authenticate: # bearer token FILENAMES are themselves live tokens and are never collected. if (-not (Test-Path $dyncfgPath)) { # Every Save-Api in this block returns early once the deadline is past, # so 07-runtime may not exist yet and WriteAllText would throw. $dyncfgDir = Split-Path $dyncfgPath -Parent if (-not (Test-Path $dyncfgDir)) { New-Item -ItemType Directory -Path $dyncfgDir -Force | Out-Null } # State what was observed, not a cause: a refusal, a timeout, a transport # error and a deadline all reach this point identically. $marker = '{"error":"dyncfg tree was not collected","hint":"netdatacli dyncfg-tree was not usable (the agent predates it, the bundle could not reach the command pipe, or the call failed), so the bundle fell back to /api/v3/config, which only signed-in members of the agent''s space may list on current agents, and which a bearer-protected API refuses on older ones - a timeout, transport error, HTTP failure or an exceeded collection deadline is indistinguishable here. The bundle never collects bearer tokens and cannot authenticate. Run netdatacli.exe dyncfg-tree as an administrator, or ask for the collector job state separately.","means":"NOT evidence that no collector jobs exist"}' Write-Utf8 $dyncfgPath $marker Add-Manifest '07-runtime\dyncfg-tree.json' 'file' 'generated' 'Collector job state was unavailable (see hint in the file)' } Save-Api '07-runtime\ml-info.json' 'Machine learning status' '/api/v1/ml_info' Save-Api '07-runtime\self-cpu.csv' 'Netdata CPU last 10min (csv)' '/api/v1/data?chart=netdata.server_cpu&after=-600&points=60&format=csv' Save-Api '07-runtime\self-memory.csv' 'Netdata memory last 10min (csv)' '/api/v1/data?chart=netdata.memory&after=-600&points=60&format=csv' Save-Api '07-runtime\self-api-clients.csv' 'Netdata API clients last 10min (csv)' '/api/v1/data?chart=netdata.clients&after=-600&points=60&format=csv' } else { Show-Info 'agent API unreachable - skipping runtime section' $marker = Join-Path $Work '07-runtime' New-Item -ItemType Directory -Path $marker -Force | Out-Null Write-Utf8 (Join-Path $marker 'AGENT-WAS-DOWN.txt') "Agent API at 127.0.0.1:$NdPort was unreachable when this bundle was created. See 05-logs and 06-state\status-file.json for why." Add-Manifest '07-runtime\AGENT-WAS-DOWN.txt' 'file' 'generated' 'Marker: agent was not running' Save-DyncfgTreeCli } # Elevation state: the perflib dump needs an elevated session, and the summary # reports it too. Computed once here (after the self-test path has already # exited) so it is never evaluated on non-Windows self-test runs. $IsElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (Test-Path $NetdataExe) { # these MSYS2 netdata.exe invocations use the native direct-launch path, not # Start-Job (which yields empty output for MSYS2 binaries - see Invoke-NativeProcess) Save-Cmd '07-runtime\buildinfo.txt' 'netdata -W buildinfo (verbatim; works with daemon down)' $null 'netdata.exe -W buildinfo' -nativeExe $NetdataExe -nativeArgs '-W', 'buildinfo' Save-CmdRaw '07-runtime\buildinfo.json' 'netdata -W buildinfojson (machine-readable; no header so it parses as JSON)' $null 'netdata.exe -W buildinfojson' -nativeExe $NetdataExe -nativeArgs '-W', 'buildinfojson' Save-Cmd '07-runtime\cmakecache.txt' 'netdata -W cmakecache: authoritative build config (flags, plugins, paths) - superset of buildinfo' $null 'netdata.exe -W cmakecache' -nativeExe $NetdataExe -nativeArgs '-W', 'cmakecache' # Windows performance-library dump: counter/instance metadata used to # diagnose Windows collector issues (e.g. PerflibSMB). Written to a file via # -perflibfile (not a capped stdout capture) so a large dump is not # truncated; sanitized like any other file (instance names can include # share/host names). The dump reads HKEY_PERFORMANCE_DATA and needs an # elevated session, and enumerating every counter can exceed the default # per-command timeout, so perflib gets an elevation pre-check and a 30s # floor. A failure is never silent: a JSON marker records why (incl. the # exit code and captured stderr/stdout), mirroring the never-omit behaviour # of Save-Cmd. # # Launched via Invoke-NativeProcess (direct foreground child, never # Start-Job) since netdata.exe is an MSYS2 binary; the dump itself goes to # -perflibfile, so stdout is normally empty and stderr carries the # "written to" confirmation or the failure reason. $perfPath = Join-Path $Work '07-runtime\perflib.json' New-Item -ItemType Directory -Path (Split-Path $perfPath) -Force | Out-Null $perfOrigin = 'netdata.exe -W perflibdump -perflibfile' $perfTitle = 'Windows performance-library dump: counter/instance metadata for Windows collector diagnosis (e.g. PerflibSMB)' if (-not $IsElevated) { Write-Utf8 $perfPath (@{ error = 'perflib dump skipped: not running elevated'; hint = 'Re-run the bundle in an elevated (Run as Administrator) PowerShell to collect it.' } | ConvertTo-Json -Compress) } else { $perfTimeout = [Math]::Max($TimeoutSeconds, 30) $np = Invoke-NativeProcess $NetdataExe @('-W', 'perflibdump', '-perflibfile', $perfPath) $perfTimeout $perfExit = $np.ExitCode $perfReason = if ($np.LaunchError) { "could not launch netdata.exe: $($np.LaunchError)" } elseif ($np.TimedOut) { "TIMEOUT after ${perfTimeout}s" } else { '' } if (($perfExit -eq 0) -and (Test-Path $perfPath) -and ((Get-Item $perfPath).Length -gt 0)) { # a full counter dump is legitimately large (~60 MB on a typical host, # more on busy servers); it compresses ~10-20x in the zip. The ceiling # only guards against a pathological runaway. if ((Get-Item $perfPath).Length -gt 128MB) { Write-Utf8 $perfPath '{"error":"perflib dump exceeded 128 MB and was withheld"}' } } else { # never silently drop it: record the exit code and both captured streams if (-not $perfReason) { $perfReason = (@($np.StdErr, $np.StdOut) | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() }) -join ' | ' if (-not $perfReason) { $perfReason = 'perflib dump produced no output file and no error text' } } Write-Utf8 $perfPath (@{ error = 'perflib dump was not collected' exitcode = $perfExit reason = $perfReason hint = 'If this timed out, re-run with a larger -TimeoutSeconds. If it reported an access error, ensure the bundle runs in an elevated PowerShell. Requires a netdata.exe build new enough to support -perflibfile.' } | ConvertTo-Json -Compress) } } # PII obfuscation only: the dump is counter/instance metadata with no # credential fields, and the full per-key credential scan over a ~60 MB blob # would blow the global deadline and starve later sections. Invoke-SanitizeFile $perfPath $false Add-Manifest '07-runtime\perflib.json' 'cmd' $perfOrigin $perfTitle } if ((Test-Path $NetdataCli) -and $NetdataProc) { # netdatacli.exe is also MSYS2: native direct-launch, not Start-Job Save-CmdRaw '07-runtime\aclk-state.json' 'Cloud connectivity state (netdatacli aclk-state json; no header so it parses as JSON)' $null 'netdatacli.exe aclk-state json' -nativeExe $NetdataCli -nativeArgs 'aclk-state', 'json' } # ============================================================================ # 08-network # ============================================================================ Show-Info 'collecting: network' Save-Cmd '08-network\netdata-sockets.txt' 'All sockets owned by the Netdata process tree' { $pids = @($using:NetdataProcessIds) if (-not $pids) { 'unavailable: Netdata process not found'; return } $rows = @() try { $rows += @(Get-NetTCPConnection -ErrorAction Stop | Where-Object { $pids -contains $_.OwningProcess } | ForEach-Object { $p = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue [pscustomobject]@{ Protocol = 'TCP'; LocalAddress = $_.LocalAddress; LocalPort = $_.LocalPort; RemoteAddress = $_.RemoteAddress; RemotePort = $_.RemotePort; State = $_.State; OwningProcess = $_.OwningProcess; ProcessName = if ($p) { $p.ProcessName } else { '-' } } }) } catch { $rows += [pscustomobject]@{ Protocol = 'TCP'; State = "unavailable: $($_.Exception.Message)" } } try { $rows += @(Get-NetUDPEndpoint -ErrorAction Stop | Where-Object { $pids -contains $_.OwningProcess } | ForEach-Object { $p = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue [pscustomobject]@{ Protocol = 'UDP'; LocalAddress = $_.LocalAddress; LocalPort = $_.LocalPort; RemoteAddress = '*'; RemotePort = '*'; State = '-'; OwningProcess = $_.OwningProcess; ProcessName = if ($p) { $p.ProcessName } else { '-' } } }) } catch { $rows += [pscustomobject]@{ Protocol = 'UDP'; State = "unavailable: $($_.Exception.Message)" } } if ($rows.Count -eq 0) { 'No Netdata-owned TCP or UDP sockets found.' } else { $rows | Format-Table Protocol, LocalAddress, LocalPort, RemoteAddress, RemotePort, State, OwningProcess, ProcessName -AutoSize } } 'Get-NetTCPConnection/Get-NetUDPEndpoint (Netdata process tree)' Save-Cmd '08-network\dns-config.txt' 'DNS resolver config' { Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses } | Format-Table InterfaceAlias, ServerAddresses -AutoSize } 'Get-DnsClientServerAddress' Save-Cmd '08-network\proxy-config.txt' 'System proxy configuration' { netsh winhttp show proxy 2>&1; Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' | Format-List ProxyEnable, ProxyServer, AutoConfigURL } 'netsh winhttp show proxy + registry' Save-Cmd '08-network\cloud-connectivity.txt' 'Reachability of Netdata Cloud (TCP/TLS only, no data sent)' { Test-NetConnection -ComputerName $using:CloudHost -Port 443 -WarningAction SilentlyContinue | Format-List ComputerName, RemotePort, TcpTestSucceeded, PingSucceeded } "Test-NetConnection ${CloudHost}:443" # ============================================================================ # 09-permissions # ============================================================================ Show-Info 'collecting: permissions' $PluginsDir = Join-Path $NetdataPrefix 'usr\libexec\netdata\plugins.d' Save-Cmd '09-permissions\plugins-d.txt' 'plugins.d: ACLs, ownership, inheritance and integrity-level state for every plugin (a broken ACL here stops a collector dead)' { $d = $using:PluginsDir if (-not (Test-Path $d)) { "(plugins.d not found at $d)"; return } "===== $d =====" Get-Acl $d | Format-List Owner, Group, Access '-- contents --' Get-ChildItem $d -Force -ErrorAction SilentlyContinue | Select-Object Mode, LastWriteTime, Length, Name | Format-Table -AutoSize '-- per-file owner and access (non-inherited entries are the interesting ones) --' Get-ChildItem $d -File -Force -ErrorAction SilentlyContinue | Select-Object -First 120 | ForEach-Object { $acl = Get-Acl $_.FullName -ErrorAction SilentlyContinue if ($acl) { $explicit = @($acl.Access | Where-Object { -not $_.IsInherited }) "{0}`n owner={1}" -f $_.Name, $acl.Owner if ($explicit.Count -gt 0) { foreach ($a in $explicit) { " explicit: {0} {1} {2}" -f $a.IdentityReference, $a.AccessControlType, $a.FileSystemRights } } else { ' explicit: (none - all access inherited)' } } } '-- alternate data streams (a Zone.Identifier marks a file as downloaded/blocked) --' $ads = Get-ChildItem $d -File -Force -ErrorAction SilentlyContinue | Select-Object -First 120 | ForEach-Object { Get-Item $_.FullName -Stream * -ErrorAction SilentlyContinue } | Where-Object { $_.Stream -ne ':$DATA' } if ($ads) { $ads | Select-Object FileName, Stream, Length | Format-Table -AutoSize } else { '(no alternate data streams)' } } "Get-Acl + Get-ChildItem -Stream on $PluginsDir" $PermPaths = @($NetdataPrefix, $ConfDir, (Join-Path $ConfDir 'netdata.conf'), (Join-Path $ConfDir 'stream.conf'), (Join-Path $ConfDir 'ssl'), $LogDir, $LibDir, (Join-Path $LibDir 'cloud.d'), $CacheDir, $PluginsDir, $NetdataExe) | Where-Object { $_ } | Select-Object -Unique Save-Cmd '09-permissions\netdata-paths.txt' 'Ownership, ACLs, inheritance and integrity levels for netdata directories, configs and the binary' { foreach ($p in $using:PermPaths) { if (-not (Test-Path $p)) { continue } "===== $p =====" $acl = Get-Acl $p -ErrorAction SilentlyContinue if ($acl) { "owner: $($acl.Owner)" "group: $($acl.Group)" 'access:' $acl.Access | ForEach-Object { " {0} {1} {2} (inherited={3})" -f $_.IdentityReference, $_.AccessControlType, $_.FileSystemRights, $_.IsInherited } if ($acl.AreAccessRulesProtected) { 'NOTE: ACL inheritance is DISABLED on this path' } } else { '(could not read the ACL - insufficient privilege?)' } # mandatory integrity level, when one is explicitly set $il = (icacls $p 2>$null | Select-String -SimpleMatch 'Mandatory Label') -join '; ' if ($il) { "integrity: $il" } } } 'Get-Acl + icacls for netdata paths' # ============================================================================ # summary + manifest + README # ============================================================================ Show-Info 'writing summary and manifest' $AgentVersion = '' $infoV1 = Join-Path $Work '07-runtime\info-v1.json' if (Test-Path $infoV1) { try { $AgentVersion = (Get-Content $infoV1 -Raw | ConvertFrom-Json).version } catch { Write-Verbose "info-v1.json unparsable: $_" } } $CrashHint = '' $sfPath = Join-Path $Work '06-state\status-file.json' if (Test-Path $sfPath) { try { $sf = Get-Content $sfPath -Raw | ConvertFrom-Json if ($sf.agent -and $sf.agent.exit_reason) { $CrashHint = ($sf.agent.exit_reason -join ',') } } catch { Write-Verbose "status-file.json unparsable: $_" } } $RuntimeSecs = [int]((Get-Date) - $StartTime).TotalSeconds $StreamNote = '' if (Test-Path (Join-Path $Work '04-config\stream.conf')) { $StreamNote = 'streaming key: PRESENT VERBATIM in 04-config\stream.conf (by design - see README.md)' } $summary = @" NETDATA SUPPORT BUNDLE SUMMARY generated: $((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) tool version: $ToolVersion (windows) runtime seconds: $RuntimeSecs ran elevated: $IsElevated pii obfuscation: $(if ($Obfuscate) { 'on' } else { 'OFF' }) $StreamNote agent version: $(if ($AgentVersion) { $AgentVersion } else { 'unknown' }) service status: $(if ($NetdataSvc) { $NetdataSvc.Status } else { 'NOT INSTALLED' }) agent process: $(if ($NetdataProc) { "yes (pid $($NetdataProc.Id))" } else { 'NOT RUNNING' }) agent api: $(if ($ApiOk) { 'reachable' } else { 'UNREACHABLE' }) $(if ($CrashHint) { "last exit reason: $CrashHint <-- check 06-state\status-file.json" }) SNMP diagnostics: $script:SnmpStatus ($script:SnmpFiles raw files; UNSANITIZED when included) READ ORDER FOR TRIAGE: SNMP issues -> 06-state\snmp-diagnostics-status.txt, 06-state\snmp-diagnostics\ crashes/won't start -> 06-state\status-file.json, 05-logs\eventlog-netdata.txt collector issues -> 07-runtime\dyncfg-tree.json (job states), 04-config\go.d*, 05-logs\, 09-permissions\plugins-d.txt streaming issues -> 04-config\stream.conf, 07-runtime\node-instances.json, 01-system\clock-timesync.txt cloud/claiming -> 06-state\claimed-id.txt, 07-runtime\aclk.json, 08-network\ performance -> 03-process\netdata-processes.txt, 06-state\db-disk-usage.txt access denied -> 09-permissions\ (ACLs, ownership, inheritance, service account) "@ Write-Utf8 (Join-Path $Work 'summary.txt') $summary Add-Manifest 'summary.txt' 'file' 'generated' 'Human summary' $readme = @" # Netdata Support Bundle (Windows) Generated by ``netdata-support-bundle.ps1``. Standard captures are SANITIZED: secrets (tokens, api keys, passwords) are redacted; by default IPs, MACs, emails and hostnames are replaced with stable pseudonyms - consistent across all files. The pseudonym map stays on this machine, next to the zip - it is NOT in this bundle. **Streaming-key exception:** the **streaming API key** is kept VERBATIM in ``04-config\stream.conf`` (both ``api key`` values and ``[]`` section headers). It is the value support needs to tell whether a child and its parent agree, so it is not treated as a secret. If your threat model differs, remove or mask it before sending the bundle. The same key IS still redacted where it appears in the Access event-log channel. **Optional raw SNMP evidence:** ``06-state/snmp-diagnostics/``, when included, is UNSANITIZED: no secret redaction or PII obfuscation. It preserves device responses, inventory and metric values. Share this bundle through a restricted support channel, never a public issue. See ``06-state/snmp-diagnostics-status.txt`` for missing files or copy failures; the directory is not one simultaneous snapshot. Collected text files keep their original bytes: a byte-order mark, CRLF or CR line endings and a missing final newline all survive redaction, so an encoding fault in a config file is still visible here. Layout matches the POSIX bundle (see MANIFEST.json for every file): 01-system, 02-install, 03-process, 04-config, 05-logs (Windows Event Log: Netdata/* ETW channels + NetdataWEL), 06-state, 07-runtime, 08-network, 09-permissions (ACLs, ownership, inheritance, service account). Start with summary.txt. "@ Write-Utf8 (Join-Path $Work 'README.md') $readme Add-Manifest 'README.md' 'file' 'generated' 'Bundle documentation' # emit MANIFEST.json LAST so every file (incl. summary.txt and README.md) is indexed $manifest = [ordered]@{ schema = 'netdata-support-bundle/v2' tool_version = "$ToolVersion-windows" generated_utc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') runtime_seconds = $RuntimeSecs pii_obfuscated = [bool]($Obfuscate -and $script:SnmpFiles -eq 0) secrets_redacted = ($script:SnmpFiles -eq 0) snmp_diagnostics = [ordered]@{ requested = [bool]$IncludeSnmpDiagnostics; status = $script:SnmpStatus; files = $script:SnmpFiles } # the standard-capture exception to secrets_redacted (netdata/netdata#23448) streaming_api_key_redacted = $false agent_running = [bool]$NetdataProc agent_api_reachable = [bool]$ApiOk is_container = $false files = $script:ManifestRows } Write-Utf8 (Join-Path $Work 'MANIFEST.json') (($manifest | ConvertTo-Json -Depth 4) + "`n") # ============================================================================ # zip # ============================================================================ New-Item -ItemType Directory -Path $Output -Force | Out-Null $ZipPath = Join-Path $Output "$BundleName.zip" # build the zip inside the owner-only staging dir, then publish without # overwrite: Move-Item without -Force fails if the destination exists $StagingZip = Join-Path $Staging "$BundleName.zip" try { # Create mode streams entries; Compress-Archive uses Update mode and buffers # the complete evidence payload in memory until the archive is closed. Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop [System.IO.Compression.ZipFile]::CreateFromDirectory( $Work, $StagingZip, [System.IO.Compression.CompressionLevel]::Optimal, $true) Move-Item -Path $StagingZip -Destination $ZipPath -ErrorAction Stop } catch { # Write-Error would be swallowed by $ErrorActionPreference='SilentlyContinue' Show-Info "ERROR: could not create or publish $ZipPath ($_)" if (-not $KeepStaging) { Remove-Item $Staging -Recurse -Force -ErrorAction SilentlyContinue } exit 1 } $MapPath = Join-Path $Output "$BundleName.pseudonym-map.tsv" if ($Obfuscate -and $script:PseudoMap.Count -gt 0) { # same format as the POSIX script: typeoriginalpseudonym $mapText = ($script:PseudoMap.GetEnumerator() | ForEach-Object { $type = 'other' if ($_.Value -match '^ip6-\d+$') { $type = 'ip6' } elseif ($_.Value -match '^ip-\d+$') { $type = 'ip' } elseif ($_.Value -match '^private-host-\d+$') { $type = 'fqdn' } elseif ($_.Value -eq 'redacted-host') { $type = 'host' } elseif ($_.Value -eq 'redacted-user') { $type = 'user' } "$type`t$($_.Key)`t$($_.Value)" }) -join "`n" # build in the private staging dir, then move without -Force so a # pre-existing file or symlink in a shared output dir is never followed $mapStaged = Join-Path $Staging 'pseudonym-map.tsv' Write-Utf8 $mapStaged ($mapText + "`n") try { Move-Item -Path $mapStaged -Destination $MapPath -ErrorAction Stop } catch { Show-Info "could not publish pseudonym map to $MapPath ($_) - left in staging"; $MapPath = $mapStaged; $KeepStaging = $true } } if (-not $KeepStaging) { Remove-Item $Staging -Recurse -Force } Show-Info '' Show-Info ("done in {0}s" -f [int]((Get-Date) - $StartTime).TotalSeconds) Show-Info "bundle: $ZipPath" if ($Obfuscate -and $script:PseudoMap.Count -gt 0) { Show-Info "pseudonym map (KEEP PRIVATE, do not send): $MapPath" } Show-Info 'attach the bundle to your support ticket.'