<# Copyright (c) 2026, R. W. Rodolico All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #> <# .SYNOPSIS sysinfo client for Windows: runs the sensors, builds the report, sends it. .DESCRIPTION Windows port of the Unix sysinfo_client (Documentation/Spec.md; Windows design in Documentation/Spec.md section 7). Runs every sensor in moduleDirs as a child PowerShell process, parses their tab-delimited output into the report, and hands the JSON report to the transports in priority order. Must run elevated (SYSTEM or Administrator) unless -Test. Can be dot-sourced (by t\windows\run_tests.ps1) without running. Must stay Windows PowerShell 5.1 compatible and pure ASCII. Version 1.1.0 20261025 Claude Version number raised with the release that adds sysinfo_update.ps1 (the updater compares it); no other change. Version 1.0.0 20261018 Claude Initial version (bug 211, stage W1): config/server.info loading, sensor discovery with ACL check, asynchronous sensor execution with tree-kill timeout, parser port of Spec 2.4, JSON report (no BOM), transports. A successful send deletes an older unsent.json (found at M1). -Test no longer needs a clientName (a real run still refuses without one). .PARAMETER ConfigFile Config file to use instead of searching. .PARAMETER Test Write the report to $env:TEMP\sysinfo.testing.json; call no transport; skip the elevation and ACL checks. .PARAMETER LogLevel 0-5; overrides the config 'log level'. .PARAMETER ServerInfoFile server.info path to use instead of the default (for tests). #> [CmdletBinding()] param( [string]$ConfigFile = '', [switch]$Test, [switch]$Version, [switch]$Help, [int]$LogLevel = -1, [string]$ServerInfoFile = '' ) Set-StrictMode -Version 2.0 $script:CLIENT_VERSION = '1.1.0' $script:DATA_VERSION = 'v3.8.0' $script:PACKAGE_NAME = 'sysinfo-client-windows' $script:OnWindows = ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) $script:BinDir = $PSScriptRoot $script:ConfDir = '' if ($env:ProgramData) { $script:ConfDir = [IO.Path]::Combine($env:ProgramData, 'camp', 'sysinfo_client') } $script:Utf8NoBom = New-Object System.Text.UTF8Encoding $false $script:LogState = @{ 'Level' = 1; 'Path' = ''; 'Echo' = $false } # owner and trusted ACE identities for sensor and transport files: # SYSTEM, BUILTIN\Administrators, NT SERVICE\TrustedInstaller $script:TrustedSids = @( 'S-1-5-18', 'S-1-5-32-544', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' ) # any of these rights granted to another identity fails the ACL check: # WriteData/CreateFiles 0x2, AppendData/CreateDirectories 0x4, # WriteExtendedAttributes 0x10, DeleteSubdirectoriesAndFiles 0x40, # WriteAttributes 0x100, Delete 0x10000, ChangePermissions 0x40000, # TakeOwnership 0x80000, GENERIC_ALL 0x10000000, GENERIC_WRITE 0x40000000 # (Write, Modify and FullControl are combinations of these; the generic bits # appear in raw ACEs that were never mapped to file rights) $script:WriteRightsMask = [int64]0x500D0156 # config keys never copied into system (as Unix initReport) $script:SystemExcludedKeys = @('moduleDirs', 'scriptDirs', 'transports', 'logging', 'sensorTimeout', 'update', 'testing', 'outputType', 'dataType', 'periodic') # server.info key (lower case) => config key, Spec 3.3 $script:ServerInfoMap = [ordered]@{ 'owner' = 'clientName' 'hostname' = 'hostname' 'serial' = 'serialNumber' 'uuid' = 'UUID' 'location' = 'location' 'os_type' = 'os_type' 'tags' = 'tags' } $script:SensorNamePattern = '^[A-Za-z0-9][A-Za-z0-9_]*\.ps1$' $script:TransportNamePattern = '^[A-Za-z0-9][A-Za-z0-9_.-]*$' ####################################################### # # New-SysinfoMap # # Returns an empty insertion-ordered, case-sensitive map # ([ordered]@{} is case-insensitive; Perl hash keys and # CAMP's PHP arrays are not). # ####################################################### function New-SysinfoMap { return , (New-Object System.Collections.Specialized.OrderedDictionary ([StringComparer]::Ordinal)) } ####################################################### # # ConvertTo-SysinfoMap( $Value ) # # Recursively converts ConvertFrom-Json output (PS 5.1 has # no -AsHashtable) into maps and ArrayLists, so one-element # lists stay lists when written back out. # ####################################################### function ConvertTo-SysinfoMap { param([AllowNull()][object]$Value) if ($null -eq $Value) { return $null } if ($Value -is [string]) { return $Value } if ($Value.GetType().FullName -eq 'System.Management.Automation.PSCustomObject') { $map = New-SysinfoMap foreach ($property in $Value.PSObject.Properties) { $map[$property.Name] = ConvertTo-SysinfoMap $property.Value } return , $map } if ($Value -is [System.Collections.IDictionary]) { $map = New-SysinfoMap foreach ($key in $Value.Keys) { $map[[string]$key] = ConvertTo-SysinfoMap $Value[$key] } return , $map } if ($Value -is [System.Collections.IEnumerable]) { $list = New-Object System.Collections.ArrayList foreach ($item in $Value) { [void]$list.Add((ConvertTo-SysinfoMap $item)) } return , $list } return $Value } ####################################################### # # Get-SysinfoValue( $Map, $Key, $Default ) # # Map lookup returning $Default when the key is absent or # its value is null. # ####################################################### function Get-SysinfoValue { param($Map, [string]$Key, $Default = $null) if ($null -ne $Map -and $Map.Contains($Key) -and $null -ne $Map[$Key]) { return $Map[$Key] } return $Default } ####################################################### # # Test-SysinfoTruthy( $Value ) # # Perl-style truth: false for null, '', '0', 0 and $false. # ####################################################### function Test-SysinfoTruthy { param([AllowNull()][object]$Value) if ($null -eq $Value) { return $false } if ($Value -is [bool]) { return $Value } $text = [string]$Value return -not ($text -eq '' -or $text -eq '0') } ####################################################### # # Write-SysinfoFile( $Path, $Text ) # # Writes UTF-8 without a BOM (PHP json_decode rejects a # BOM; PS 5.1 Out-File -Encoding utf8 writes one). # ####################################################### function Write-SysinfoFile { param([string]$Path, [string]$Text) [IO.File]::WriteAllText($Path, $Text, $script:Utf8NoBom) } ####################################################### # # Initialize-SysinfoLog( $Config, $CliLevel, $Echo ) # # Sets log level and file from config 'logging' (log level, # log path), truncating the file when over 1 MB. # ####################################################### function Initialize-SysinfoLog { param($Config, [int]$CliLevel, [bool]$Echo) $logging = Get-SysinfoValue $Config 'logging' $level = 1 $configured = Get-SysinfoValue $logging 'log level' if ($null -ne $configured) { $parsed = 0 if ([int]::TryParse([string]$configured, [ref]$parsed)) { $level = $parsed } } if ($CliLevel -ge 0) { $level = $CliLevel } $path = '' if ($script:ConfDir) { $path = [IO.Path]::Combine($script:ConfDir, 'sysinfo_client.log') } $configuredPath = [string](Get-SysinfoValue $logging 'log path' '') if ($configuredPath -ne '') { if (Test-Path -LiteralPath $configuredPath -PathType Container) { $path = [IO.Path]::Combine($configuredPath, 'sysinfo_client.log') } else { $path = $configuredPath } } $script:LogState['Level'] = $level $script:LogState['Path'] = $path $script:LogState['Echo'] = $Echo if ($path -ne '') { try { $info = New-Object System.IO.FileInfo $path if ($info.Exists -and $info.Length -gt 1MB) { Write-SysinfoFile $path '' } } catch { $script:LogState['Path'] = '' } } } ####################################################### # # Write-SysinfoLog( $Level, $Message ) # # Appends one line to the log when $Level is at or below # the configured level; echoes to STDERR in test mode. # ####################################################### function Write-SysinfoLog { param([int]$Level, [string]$Message) if ($Level -gt $script:LogState['Level']) { return } $line = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + " [$Level] " + $Message if ($script:LogState['Echo']) { [Console]::Error.WriteLine($line) } if ($script:LogState['Path'] -ne '') { try { [IO.File]::AppendAllText($script:LogState['Path'], $line + "`r`n", $script:Utf8NoBom) } catch { # logging must never stop the report } } } ####################################################### # # Find-SysinfoConfig( $ConfigFile ) # # Returns the config path: -ConfigFile, then # \sysinfo_client.json, then the script directory; # '' if none exists. # ####################################################### function Find-SysinfoConfig { param([string]$ConfigFile) if ($ConfigFile -ne '') { if (Test-Path -LiteralPath $ConfigFile -PathType Leaf) { return (Resolve-Path -LiteralPath $ConfigFile).ProviderPath } return '' } $candidates = New-Object System.Collections.ArrayList if ($script:ConfDir) { [void]$candidates.Add([IO.Path]::Combine($script:ConfDir, 'sysinfo_client.json')) } [void]$candidates.Add([IO.Path]::Combine($script:BinDir, 'sysinfo_client.json')) foreach ($candidate in $candidates) { if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } } return '' } ####################################################### # # Read-SysinfoConfig( $Path ) # # Reads a JSON config into a map. Throws on bad JSON or a # top level that is not an object. # ####################################################### function Read-SysinfoConfig { param([string]$Path) $text = [IO.File]::ReadAllText($Path) if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) } $map = ConvertTo-SysinfoMap ($text | ConvertFrom-Json) if (-not ($map -is [System.Collections.IDictionary])) { throw "$Path does not contain a JSON object" } return , $map } ####################################################### # # Find-SysinfoServerInfo( $ServerInfoFile ) # # Returns the server.info path in use, or '' if none. # ####################################################### function Find-SysinfoServerInfo { param([string]$ServerInfoFile) $path = $ServerInfoFile if ($path -eq '' -and $env:ProgramData) { $path = [IO.Path]::Combine($env:ProgramData, 'camp', 'server.info') } if ($path -ne '' -and (Test-Path -LiteralPath $path -PathType Leaf)) { return $path } return '' } ####################################################### # # Read-SysinfoServerInfo( $Path ) # # Spec 3.3: 'Key: Value' lines, split on the first colon, # trimmed, keys case-insensitive, '#' and blank lines and # unknown keys ignored, empty values dropped. Returns a map # of config key => value (tags as a list). # ####################################################### function Read-SysinfoServerInfo { param([string]$Path) $values = New-SysinfoMap foreach ($line in [IO.File]::ReadAllLines($Path)) { $trimmed = $line.Trim() if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue } $colon = $trimmed.IndexOf(':') if ($colon -lt 1) { continue } $key = $trimmed.Substring(0, $colon).Trim().ToLowerInvariant() $value = $trimmed.Substring($colon + 1).Trim() if (-not $script:ServerInfoMap.Contains($key)) { continue } if ($value -eq '') { continue } $configKey = $script:ServerInfoMap[$key] if ($configKey -eq 'tags') { $tags = New-Object System.Collections.ArrayList foreach ($tag in $value.Split(',')) { $tag = $tag.Trim() if ($tag -ne '') { [void]$tags.Add($tag) } } if ($tags.Count -eq 0) { continue } $values[$configKey] = $tags } else { $values[$configKey] = $value } } return , $values } ####################################################### # # Get-SysinfoHostname # # Lower-cased FQDN; the machine name if DNS fails. # ####################################################### function Get-SysinfoHostname { try { $name = [System.Net.Dns]::GetHostEntry('').HostName if ($name) { return $name.ToLowerInvariant() } } catch { # fall through } return [System.Environment]::MachineName.ToLowerInvariant() } ####################################################### # # Test-SysinfoElevated # # True when running as SYSTEM or an elevated Administrator. # ####################################################### function Test-SysinfoElevated { if (-not $script:OnWindows) { return $false } $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() if ($identity.User.Value -eq 'S-1-5-18') { return $true } $principal = New-Object System.Security.Principal.WindowsPrincipal $identity return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } ####################################################### # # Get-SysinfoAclProblem( $Path ) # # '' when $Path is owned by a trusted identity and no # allow ACE that applies to it grants write-type rights # to anyone else; otherwise the reason. # ####################################################### function Get-SysinfoAclProblem { param([string]$Path) $sidType = [System.Security.Principal.SecurityIdentifier] try { $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop $owner = $acl.GetOwner($sidType).Value } catch { return "cannot read ACL of ${Path}: $($_.Exception.Message)" } if ($script:TrustedSids -notcontains $owner) { return "$Path is owned by untrusted $owner" } foreach ($rule in $acl.GetAccessRules($true, $true, $sidType)) { if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue } # inherit-only ACEs (e.g. CREATOR OWNER) do not apply to the object itself if (($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue } $sid = $rule.IdentityReference.Value if ($script:TrustedSids -contains $sid) { continue } $mask = [int64][int]$rule.FileSystemRights if (($mask -band $script:WriteRightsMask) -ne 0) { return ("$Path grants write rights (0x{0:X8}) to $sid" -f ([int]$rule.FileSystemRights)) } } return '' } ####################################################### # # Test-SysinfoAcl( $Paths ) # # Replaces the Unix root/0700 check: '' when every path # passes Get-SysinfoAclProblem, else the first reason. # ####################################################### function Test-SysinfoAcl { param([string[]]$Paths) foreach ($path in $Paths) { if ($null -eq $path -or $path -eq '') { continue } $problem = Get-SysinfoAclProblem $path if ($problem -ne '') { return $problem } } return '' } ####################################################### # # Get-SysinfoDirectories( $Dirs, $Default, $What ) # # Existing directories from a config list (order kept, # missing ones logged); $Default if none exist. # ####################################################### function Get-SysinfoDirectories { param($Dirs, [string]$Default, [string]$What) $found = New-Object System.Collections.ArrayList if ($null -ne $Dirs) { if ($Dirs -is [string]) { $Dirs = @($Dirs) } foreach ($dir in $Dirs) { $dir = [string]$dir if ($dir -ne '' -and (Test-Path -LiteralPath $dir -PathType Container)) { [void]$found.Add((Resolve-Path -LiteralPath $dir).ProviderPath.TrimEnd('\', '/')) } else { Write-SysinfoLog 1 "$What directory missing, skipping: $dir" } } } if ($found.Count -eq 0 -and (Test-Path -LiteralPath $Default -PathType Container)) { [void]$found.Add($Default) } return , $found } ####################################################### # # Get-SysinfoSensorList( $Dirs, $SkipAcl ) # # Spec 2.1 for Windows: files matching the sensor name # pattern, sorted per directory, first name wins. Unless # $SkipAcl, each sensor's file, directory, the directory's # parent and the ..\lib\SysinfoSensor.psm1 it imports must # pass the ACL check; failures carry PermissionError. # ####################################################### function Get-SysinfoSensorList { param($Dirs, [bool]$SkipAcl) $sensors = New-Object System.Collections.ArrayList $seen = @{} foreach ($dir in $Dirs) { $dirProblem = '' if (-not $SkipAcl) { $parent = [IO.Path]::GetDirectoryName($dir) $lib = [IO.Path]::GetFullPath([IO.Path]::Combine($dir, '..', 'lib', 'SysinfoSensor.psm1')) $paths = @($dir, $parent) if (Test-Path -LiteralPath $lib -PathType Leaf) { $paths += @($lib, [IO.Path]::GetDirectoryName($lib)) } $dirProblem = Test-SysinfoAcl $paths } $files = @(Get-ChildItem -LiteralPath $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -cmatch $script:SensorNamePattern } | Sort-Object -Property Name) foreach ($file in $files) { $name = [IO.Path]::GetFileNameWithoutExtension($file.Name) if ($seen.ContainsKey($name)) { continue } $seen[$name] = $true $problem = $dirProblem if (-not $SkipAcl -and $problem -eq '') { $problem = Test-SysinfoAcl @($file.FullName) } [void]$sensors.Add(@{ 'Name' = $name; 'Path' = $file.FullName; 'Dir' = $dir; 'PermissionError' = $problem }) } } return , $sensors } ####################################################### # # ConvertTo-SysinfoArgument( $Text ) # # Quotes one argument for a Windows command line (the # rules .NET and the C runtime parse): backslashes before # a quote or the closing quote are doubled, so a directory # ending in '\' does not escape the closing quote. # ####################################################### function ConvertTo-SysinfoArgument { param([string]$Text) $builder = New-Object System.Text.StringBuilder [void]$builder.Append('"') $backslashes = 0 foreach ($char in $Text.ToCharArray()) { if ($char -eq '\') { $backslashes++ continue } if ($char -eq '"') { [void]$builder.Append([char]'\', $backslashes * 2 + 1) } elseif ($backslashes -gt 0) { [void]$builder.Append([char]'\', $backslashes) } $backslashes = 0 [void]$builder.Append($char) } if ($backslashes -gt 0) { [void]$builder.Append([char]'\', $backslashes * 2) } [void]$builder.Append('"') return $builder.ToString() } ####################################################### # # Get-SysinfoHostPath # # The PowerShell executable running this script (sensors # run under the same host), falling back to $PSHOME. # ####################################################### function Get-SysinfoHostPath { $path = (Get-Process -Id $PID).Path if ($path -and ([IO.Path]::GetFileName($path) -match '^(powershell|pwsh)(\.exe)?$')) { return $path } foreach ($name in @('powershell.exe', 'pwsh.exe', 'pwsh')) { $candidate = [IO.Path]::Combine($PSHOME, $name) if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } } return $path } ####################################################### # # Stop-SysinfoProcessTree( $Process ) # # Kills the process and its children. On Windows uses # taskkill /T /F (Process.Kill() on .NET Framework kills # only the one process); elsewhere (tests) Kill(true). # ####################################################### function Stop-SysinfoProcessTree { param([System.Diagnostics.Process]$Process) if ($script:OnWindows) { $taskkill = [IO.Path]::Combine($env:SystemRoot, 'System32', 'taskkill.exe') & $taskkill /T /F /PID $Process.Id 2>&1 | Out-Null } try { if (-not $Process.HasExited) { try { $Process.Kill($true) } catch { $Process.Kill() } } } catch { # already gone } } ####################################################### # # Invoke-SysinfoSensorProcess( $Path, $Dir, $Timeout, $SensorEnv ) # # Spec 2.2 for Windows: runs one sensor as # -NoProfile -NonInteractive -ExecutionPolicy Bypass # -File "" "\" # with STDOUT/STDERR read asynchronously (a sensor filling # one pipe while the other is read synchronously would # deadlock), STDIN closed, SYSINFO_* in the environment. # Returns TimedOut, ExitCode, Stdout, Stderr, Seconds. # ####################################################### function Invoke-SysinfoSensorProcess { param([string]$Path, [string]$Dir, [int]$Timeout, $SensorEnv) $dirArgument = $Dir.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar $info = New-Object System.Diagnostics.ProcessStartInfo $info.FileName = Get-SysinfoHostPath $info.Arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File ' + (ConvertTo-SysinfoArgument $Path) + ' ' + (ConvertTo-SysinfoArgument $dirArgument) $info.WorkingDirectory = $Dir $info.UseShellExecute = $false $info.CreateNoWindow = $true $info.RedirectStandardInput = $true $info.RedirectStandardOutput = $true $info.RedirectStandardError = $true $info.StandardOutputEncoding = $script:Utf8NoBom $info.StandardErrorEncoding = $script:Utf8NoBom foreach ($key in @($SensorEnv.Keys)) { $value = $SensorEnv[$key] if ($null -eq $value) { if ($info.EnvironmentVariables.ContainsKey($key)) { $info.EnvironmentVariables.Remove($key) } } else { $info.EnvironmentVariables[$key] = [string]$value } } $result = @{ 'TimedOut' = $false; 'ExitCode' = $null; 'Stdout' = ''; 'Stderr' = ''; 'Seconds' = 0.0 } $clock = [System.Diagnostics.Stopwatch]::StartNew() $process = New-Object System.Diagnostics.Process $process.StartInfo = $info try { [void]$process.Start() } catch { $result['ExitCode'] = 4 $result['Stderr'] = "could not start sensor: $($_.Exception.Message)" return $result } try { $process.StandardInput.Close() $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() if ($process.WaitForExit($Timeout * 1000)) { $result['ExitCode'] = $process.ExitCode } else { $result['TimedOut'] = $true Stop-SysinfoProcessTree $process [void]$process.WaitForExit(5000) } # bounded: a grandchild still holding a pipe open must not hang the run if ($stdoutTask.Wait(5000)) { $result['Stdout'] = $stdoutTask.Result } if ($stderrTask.Wait(5000)) { $result['Stderr'] = $stderrTask.Result } } finally { $clock.Stop() $process.Dispose() } $result['Seconds'] = [math]::Round($clock.Elapsed.TotalSeconds, 1) return $result } ####################################################### # # Get-SysinfoFirstLine( $Text ) # # First non-empty trimmed line, at most 200 characters. # ####################################################### function Get-SysinfoFirstLine { param([AllowNull()][string]$Text) if ($null -eq $Text) { return '' } foreach ($line in ($Text -split "`r?`n")) { $trimmed = $line.Trim() if ($trimmed -ne '') { if ($trimmed.Length -gt 200) { $trimmed = $trimmed.Substring(0, 200) } return $trimmed } } return '' } ####################################################### # # Add-SysinfoFacts( $Report, $Text ) # # Port of Unix tabDelimitedToHash (Spec 2.4): one fact per # line, split on / *\t */, last field the value, the rest # the key path. Rejects (counted, first kept): fewer than # 2 fields, an empty key segment, descending through a # scalar, replacing a map with a scalar. A later scalar # replaces an earlier one. Values kept as raw strings. # Returns Rejected and FirstRejected. # ####################################################### function Add-SysinfoFacts { param($Report, [AllowNull()][string]$Text) $rejected = 0 $firstRejected = '' if ($null -eq $Text -or $Text -notmatch '\S') { return @{ 'Rejected' = 0; 'FirstRejected' = '' } } foreach ($rawLine in $Text.Split("`n")) { $line = $rawLine.TrimEnd("`r") if ($line -notmatch '\S') { continue } $fields = @($line -split ' *\t *') $bad = $fields.Count -lt 2 if (-not $bad) { for ($i = 0; $i -lt $fields.Count - 1; $i++) { if ($fields[$i] -eq '') { $bad = $true; break } } } if (-not $bad) { $value = $fields[$fields.Count - 1] $cursor = $Report for ($i = 0; $i -lt $fields.Count - 1; $i++) { $key = [string]$fields[$i] if ($i -eq $fields.Count - 2) { if ($cursor.Contains($key) -and $cursor[$key] -is [System.Collections.IDictionary]) { $bad = $true; break } $cursor[$key] = $value break } if ($cursor.Contains($key)) { if (-not ($cursor[$key] -is [System.Collections.IDictionary])) { $bad = $true; break } } else { $cursor[$key] = New-SysinfoMap } $cursor = $cursor[$key] } } if ($bad) { $rejected++ if ($firstRejected -eq '') { $firstRejected = $line } } } return @{ 'Rejected' = $rejected; 'FirstRejected' = $firstRejected } } ####################################################### # # Invoke-SysinfoSensors( $Report, $Config, $SensorEnv, $SkipAcl ) # # Runs every discovered sensor, merges exit-0 output into # the report and records sysinfo.sensors. (Spec 1.5; # exit-code mapping in unix/sensors/Documentation/README.md). # ####################################################### function Invoke-SysinfoSensors { param($Report, $Config, $SensorEnv, [bool]$SkipAcl) if (-not $Report['sysinfo'].Contains('sensors')) { $Report['sysinfo']['sensors'] = New-SysinfoMap } $statusFor = @{ 1 = 'wrong_os'; 2 = 'not_applicable'; 3 = 'not_due' } $timeout = 120 $configuredTimeout = 0 if ([int]::TryParse([string](Get-SysinfoValue $Config 'sensorTimeout' ''), [ref]$configuredTimeout) -and $configuredTimeout -gt 0) { $timeout = $configuredTimeout } $dirs = Get-SysinfoDirectories (Get-SysinfoValue $Config 'moduleDirs') ([IO.Path]::Combine($script:BinDir, 'sensors')) 'Sensor' foreach ($sensor in (Get-SysinfoSensorList $dirs $SkipAcl)) { $status = New-SysinfoMap $status['seconds'] = 0.0 $Report['sysinfo']['sensors'][$sensor['Name']] = $status if ($sensor['PermissionError'] -ne '') { $status['status'] = 'skipped_permission' $message = $sensor['PermissionError'] if ($message.Length -gt 200) { $message = $message.Substring(0, 200) } $status['message'] = $message Write-SysinfoLog 0 "Sensor $($sensor['Name']) skipped: $($sensor['PermissionError'])" continue } Write-SysinfoLog 3 "Running sensor $($sensor['Path'])" $run = Invoke-SysinfoSensorProcess $sensor['Path'] $sensor['Dir'] $timeout $SensorEnv $status['seconds'] = $run['Seconds'] if ($run['TimedOut']) { $status['status'] = 'timeout' $message = Get-SysinfoFirstLine $run['Stderr'] if ($message -ne '') { $status['message'] = $message } Write-SysinfoLog 1 "Sensor $($sensor['Name']) timed out after $timeout s" continue } $status['exit_code'] = $run['ExitCode'] if ($run['ExitCode'] -eq 0) { $parsed = Add-SysinfoFacts $Report $run['Stdout'] $status['status'] = 'ok' if ($parsed['Rejected'] -gt 0) { $status['rejected_lines'] = $parsed['Rejected'] $message = $parsed['FirstRejected'] if ($message.Length -gt 200) { $message = $message.Substring(0, 200) } if ($message -ne '') { $status['message'] = $message } } } else { $mapped = $statusFor[[int]$run['ExitCode']] if ($null -eq $mapped) { $mapped = 'error' } $status['status'] = $mapped $message = Get-SysinfoFirstLine $run['Stderr'] if ($message -ne '') { $status['message'] = $message } } Write-SysinfoLog 2 "Sensor $($sensor['Name']): $($status['status']) in $($run['Seconds']) s" } } ####################################################### # # New-SysinfoReport( $Config, $ConfigPath, $ServerInfoPath ) # # The client-written parts of the report (Spec 1.2 report # and system rows, Spec 1.5), as Unix initReport. # ####################################################### function New-SysinfoReport { param($Config, [string]$ConfigPath, [string]$ServerInfoPath) $report = New-SysinfoMap $report['report'] = New-SysinfoMap $report['report']['version'] = $script:DATA_VERSION $report['report']['date'] = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) $report['report']['client'] = [string](Get-SysinfoValue $Config 'clientName' '') $report['system'] = New-SysinfoMap $report['system']['hostname'] = [string](Get-SysinfoValue $Config 'hostname' '') $report['system']['serial'] = [string](Get-SysinfoValue $Config 'serialNumber' '') $report['system']['UUID'] = [string](Get-SysinfoValue $Config 'UUID' '') foreach ($key in @($Config.Keys)) { if ($script:SystemExcludedKeys -ccontains $key) { continue } $report['system'][$key] = $Config[$key] } $report['software'] = New-SysinfoMap $report['software'][$script:PACKAGE_NAME] = New-SysinfoMap $report['software'][$script:PACKAGE_NAME]['version'] = $script:CLIENT_VERSION $report['sysinfo'] = New-SysinfoMap $report['sysinfo']['client_version'] = $script:CLIENT_VERSION $report['sysinfo']['data_version'] = $script:DATA_VERSION $report['sysinfo']['config_file'] = $ConfigPath if ($ServerInfoPath -ne '') { $report['sysinfo']['server_info_file'] = $ServerInfoPath } return , $report } ####################################################### # # ConvertTo-SysinfoJson( $Report ) # # -Depth 20: the default (2) silently stringifies deeper # levels. -InputObject so nothing is unrolled. # ####################################################### function ConvertTo-SysinfoJson { param($Report) return (ConvertTo-Json -InputObject $Report -Depth 20) } ####################################################### # # Expand-SysinfoPlaceholders( $Value, $Config, $ReportDate ) # # Port of Unix substituteTransportPlaceholders: literal # token replacement (Spec 3.5), no Invoke-Expression. # ####################################################### function Expand-SysinfoPlaceholders { param([AllowNull()][string]$Value, $Config, [string]$ReportDate) if ($null -eq $Value) { return $Value } $hostname = [string](Get-SysinfoValue $Config 'hostname' '') $clientName = [string](Get-SysinfoValue $Config 'clientName' '') $serial = [string](Get-SysinfoValue $Config 'serialNumber' '') $replace = [ordered]@{ '$configuration{''hostname''}' = $hostname '$configuration{''clientName''}' = $clientName '$configuration{''serialNumber''}' = $serial '$config{''hostname''}' = $hostname '$config{''clientName''}' = $clientName '$config{''serialNumber''}' = $serial '$reportDate' = $ReportDate '$hostname' = $hostname } foreach ($token in $replace.Keys) { $Value = $Value.Replace($token, $replace[$token]) } return $Value } ####################################################### # # ConvertTo-SysinfoPriority( $Key ) # # Numeric sort key for a transports map key (non-numeric # keys sort as 0, as Perl <=>). # ####################################################### function ConvertTo-SysinfoPriority { param([string]$Key) $number = 0.0 if ([double]::TryParse($Key, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$number)) { return $number } return 0.0 } ####################################################### # # Send-SysinfoResults( $Globals, $Transports, $Message, $ScriptDirs, $Config, $SkipAcl ) # # Port of Unix sendResults (Spec 3.5): transports in # numeric key order; each sendScript resolved as # .ps1 in $ScriptDirs, ACL-checked unless $SkipAcl, # dot-sourced inside a fresh script block (so every load # gets a clean Send-SysinfoReport) and called with the # merged, substituted params. True on the first return # of 1; false when all fail. # ####################################################### function Send-SysinfoResults { param($Globals, $Transports, [string]$Message, $ScriptDirs, $Config, [bool]$SkipAcl) if (-not ($Transports -is [System.Collections.IDictionary])) { Write-SysinfoLog 0 'No transports configured' return $false } $invoke = { param($ScriptPath, $TransportParams, $TransportReport) . $ScriptPath if (-not (Test-Path -LiteralPath 'Function:\Send-SysinfoReport')) { throw 'script does not define Send-SysinfoReport' } Send-SysinfoReport $TransportParams $TransportReport } $priorities = @($Transports.Keys | Sort-Object -Property { ConvertTo-SysinfoPriority $_ }) foreach ($priority in $priorities) { $transport = $Transports[$priority] if (-not ($transport -is [System.Collections.IDictionary])) { continue } $scriptName = [string](Get-SysinfoValue $transport 'sendScript' '') if ($scriptName -eq '') { continue } if ($scriptName -notmatch $script:TransportNamePattern) { Write-SysinfoLog 0 "Transport priority ${priority}: invalid sendScript name $scriptName" continue } if (-not $scriptName.EndsWith('.ps1', [StringComparison]::OrdinalIgnoreCase)) { $scriptName += '.ps1' } $scriptPath = '' foreach ($dir in $ScriptDirs) { $candidate = [IO.Path]::Combine($dir, $scriptName) if (Test-Path -LiteralPath $candidate -PathType Leaf) { $scriptPath = $candidate; break } } if ($scriptPath -eq '') { Write-SysinfoLog 1 "Could not find transport script $scriptName at priority $priority" continue } if (-not $SkipAcl) { $problem = Test-SysinfoAcl @($scriptPath, [IO.Path]::GetDirectoryName($scriptPath)) if ($problem -ne '') { Write-SysinfoLog 0 "Transport priority $priority skipped: $problem" continue } } $params = New-SysinfoMap foreach ($key in @($transport.Keys)) { $params[$key] = $transport[$key] } foreach ($key in @($Globals.Keys)) { $params[$key] = $Globals[$key] } foreach ($key in @($params.Keys)) { if ($params[$key] -is [string]) { $params[$key] = Expand-SysinfoPlaceholders $params[$key] $Config $Globals['report date'] } } try { $output = @(& $invoke $scriptPath $params $Message) } catch { Write-SysinfoLog 0 "Transport priority $priority failed in ${scriptPath}: $($_.Exception.Message)" continue } $return = $null if ($output.Count -gt 0) { $return = $output[$output.Count - 1] } $code = 0 $isInt = ($null -ne $return) -and [int]::TryParse([string]$return, [ref]$code) $shown = 'undef' if ($null -ne $return) { $shown = [string]$return } Write-SysinfoLog 1 "Transport priority $priority returned $shown" if ($isInt -and $code -eq 1) { return $true } } Write-SysinfoLog 0 'All transports failed' return $false } ####################################################### # # Show-SysinfoHelp # ####################################################### function Show-SysinfoHelp { $name = [IO.Path]::GetFileName($PSCommandPath) [Console]::Out.WriteLine(@" $name $($script:CLIENT_VERSION) (data $($script:DATA_VERSION)) Usage: $name [-ConfigFile ] [-Test] [-LogLevel <0-5>] [-Version] [-Help] -ConfigFile config file (default \camp\sysinfo_client\sysinfo_client.json, then the script directory) -Test write the report to `$env:TEMP\sysinfo.testing.json; send nothing; skip the elevation and permission checks -LogLevel 0 (errors) to 5 (everything); overrides the config 'log level' -Version print the version and exit -Help print this help and exit Must run elevated (SYSTEM or Administrator) unless -Test. "@) } ####################################################### # # Get-SysinfoUnsentPath() # # unsent.json beside the config (the temp directory when there is no # config directory). # ####################################################### function Get-SysinfoUnsentPath { $unsentDir = $script:ConfDir if (-not $unsentDir -or -not (Test-Path -LiteralPath $unsentDir -PathType Container)) { $unsentDir = [IO.Path]::GetTempPath() } return [IO.Path]::Combine($unsentDir, 'unsent.json') } ####################################################### # # Remove-SysinfoUnsent() # # A successful send makes an older unsent.json stale, so it is deleted. # ####################################################### function Remove-SysinfoUnsent { $unsentPath = Get-SysinfoUnsentPath if (-not (Test-Path -LiteralPath $unsentPath -PathType Leaf)) { return } try { Remove-Item -LiteralPath $unsentPath -Force Write-SysinfoLog 1 "Removed old unsent report $unsentPath" } catch { Write-SysinfoLog 0 "Cannot remove old unsent report ${unsentPath}: $($_.Exception.Message)" } } ####################################################### # # Invoke-SysinfoClient # # Main program; returns the process exit code. # ####################################################### function Invoke-SysinfoClient { $name = [IO.Path]::GetFileName($PSCommandPath) if ($Help) { Show-SysinfoHelp; return 0 } if ($Version) { [Console]::Out.WriteLine("$name $($script:CLIENT_VERSION) (data $($script:DATA_VERSION))") return 0 } if ($LogLevel -gt 5) { [Console]::Error.WriteLine('-LogLevel must be 0 to 5') return 1 } if (-not $Test -and -not (Test-SysinfoElevated)) { [Console]::Error.WriteLine("$name must run elevated (SYSTEM or Administrator) unless -Test") return 1 } $configPath = Find-SysinfoConfig $ConfigFile if ($configPath -eq '') { [Console]::Error.WriteLine("$name : no configuration file found") return 1 } try { $config = Read-SysinfoConfig $configPath } catch { [Console]::Error.WriteLine("$name : cannot read ${configPath}: $($_.Exception.Message)") return 1 } Initialize-SysinfoLog $config $LogLevel ([bool]$Test) Write-SysinfoLog 1 "$name $($script:CLIENT_VERSION) starting, config $configPath" # values as in the config file, before server.info (SYSINFO_CONFIGURED_*) $configuredUUID = [string](Get-SysinfoValue $config 'UUID' '') $configuredSerial = [string](Get-SysinfoValue $config 'serialNumber' '') $serverInfoPath = Find-SysinfoServerInfo $ServerInfoFile $serverInfo = New-SysinfoMap if ($serverInfoPath -ne '') { try { $serverInfo = Read-SysinfoServerInfo $serverInfoPath } catch { Write-SysinfoLog 0 "Cannot read ${serverInfoPath}: $($_.Exception.Message)" } foreach ($key in @($serverInfo.Keys)) { $config[$key] = $serverInfo[$key] } } if ([string](Get-SysinfoValue $config 'hostname' '') -eq '') { $config['hostname'] = Get-SysinfoHostname } if ([string](Get-SysinfoValue $config 'clientName' '') -eq '') { if (-not $Test) { Write-SysinfoLog 0 'No clientName defined; nothing sent (run install.ps1 -Configure, or set Owner in server.info)' [Console]::Error.WriteLine("$name : no clientName defined (run install.ps1 -Configure, or set Owner in server.info)") return 1 } Write-SysinfoLog 1 'Warning: no clientName defined; -Test continues, a real run would send nothing' } $testing = [bool]$Test -or (Test-SysinfoTruthy (Get-SysinfoValue $config 'TESTING')) $skipChecks = [bool]$Test $report = New-SysinfoReport $config $configPath $serverInfoPath $sensorEnv = @{ 'SYSINFO_CONFIGURED_UUID' = $configuredUUID 'SYSINFO_CONFIGURED_SERIAL' = $configuredSerial 'SYSINFO_SERVERINFO_UUID' = (Get-SysinfoValue $serverInfo 'UUID') 'SYSINFO_SERVERINFO_SERIAL' = (Get-SysinfoValue $serverInfo 'serialNumber') 'SYSINFO_PERIODIC' = $null } Invoke-SysinfoSensors $report $config $sensorEnv $skipChecks # a sensor line must not replace the client's own version entry if (-not ($report['software'] -is [System.Collections.IDictionary])) { $report['software'] = New-SysinfoMap } $report['software'][$script:PACKAGE_NAME] = New-SysinfoMap $report['software'][$script:PACKAGE_NAME]['version'] = $script:CLIENT_VERSION $json = ConvertTo-SysinfoJson $report if ($testing) { $tempDir = $env:TEMP if (-not $tempDir) { $tempDir = [IO.Path]::GetTempPath() } $testPath = [IO.Path]::Combine($tempDir, 'sysinfo.testing.json') Write-SysinfoFile $testPath $json Write-SysinfoLog 1 "Test report written to $testPath" [Console]::Out.WriteLine("Test report written to $testPath") return 0 } $scriptDirs = Get-SysinfoDirectories (Get-SysinfoValue $config 'scriptDirs') ([IO.Path]::Combine($script:BinDir, 'transports')) 'Transport' $globals = [ordered]@{ 'upload_type' = 'sysinfo' 'data version' = $script:DATA_VERSION 'report date' = $report['report']['date'] 'client name' = [string](Get-SysinfoValue $config 'clientName' '') 'host name' = [string](Get-SysinfoValue $config 'hostname' '') 'serial number' = [string](Get-SysinfoValue $config 'serialNumber' '') 'UUID' = [string](Get-SysinfoValue $config 'UUID' '') 'fileType' = 'json' } if (Send-SysinfoResults $globals (Get-SysinfoValue $config 'transports') $json $scriptDirs $config $skipChecks) { Write-SysinfoLog 1 'Report sent' Remove-SysinfoUnsent return 0 } $unsentPath = Get-SysinfoUnsentPath try { Write-SysinfoFile $unsentPath $json Write-SysinfoLog 0 "Report saved to $unsentPath" } catch { Write-SysinfoLog 0 "Cannot save report to ${unsentPath}: $($_.Exception.Message)" } return 1 } if ($MyInvocation.InvocationName -ne '.') { # last value only, so stray pipeline output cannot become the exit code $exitCode = @(Invoke-SysinfoClient) exit ([int]$exitCode[$exitCode.Count - 1]) }