<# 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 Installs, upgrades or uninstalls the Windows sysinfo client. .DESCRIPTION Run elevated from the unpacked release directory: powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -ClientName "Acme" -Seed https://camp.example.com/sysinfo-client.seed.json Install or upgrade: copies sysinfo_client.ps1, sysinfo_update.ps1, dsEdit.ps1, configure.ps1, lib, sensors and transports to %ProgramFiles%\camp\sysinfo_client (replacing the previous copy), locks that directory and %ProgramData%\camp\sysinfo_client to SYSTEM and Administrators, writes sysinfo_client.json only when none exists (template + seed + parameters + hardware identity; an existing config is never touched), registers the daily SYSTEM task \camp\sysinfo_client, checks the client's own ACL rule against every installed file, then runs the client once with -Test. Ends with 'RESULT: ok|fail ', as the Unix setup does; exit 0 on ok. Upgrade = unpack the new release and run this again, or run sysinfo_update.ps1 (installed beside the client), which does exactly that from the release zip. A missing client name does not block the install: it is asked for (not with -Quiet), and when still unknown the install completes with a warning. The client sends nothing until a name exists (use -Configure, a server.info with Owner:, or edit the config). Design: Documentation/Spec.md sections 7.2 and 7.7. Must stay Windows PowerShell 5.1 compatible and pure ASCII. Version 1.1.1 20261027 Claude Installs dsEdit.ps1 and configure.ps1 (bug 216, the config editor). -DesktopShortcut creates "CAMP sysinfo config" and "CAMP sysinfo update" shortcuts on the desktop of the user running the installer (-ShortcutAllUsers: the public desktop), flagged Run as administrator, because the program directory is unreadable to an unelevated user. An install or upgrade deletes \update_installed.sha256 (the updater's record of the installed release); sysinfo_update.ps1 writes a new one after it runs the installer. Version 1.1.0 20261025 Claude The previous program directory is renamed aside and restored if the copy fails (a locked directory no longer leaves a half-deleted client). Installs sysinfo_update.ps1; -UpdateUrl and -UpdateSha256Url set the config's update keys (new config, or with -Configure). Version 1.0.0 20261018 Claude Initial version (bug 211, stage W3); -ServerInfo installs a server.info from any path, or from beside the script; -Configure edits client name, location and tags after install; a missing client name warns instead of failing; prompts for the client name (and location) when none is known, also in -DryRun (found at M1) .PARAMETER ClientName clientName for a new config (overrides the seed). .PARAMETER Location location for a new config (overrides the seed). .PARAMETER Seed Path or http(s) URL of a JSON seed (site defaults and transports) merged over the template when a new config is written. .PARAMETER ServerInfo Path of a server.info file (host identity: Owner, Location, Serial, UUID, Tags) to install as %ProgramData%\camp\server.info, so it can be created anywhere convenient before the install (C:\, C:\temp, the desktop). The installer reads it from there, copies it into the locked camp directory and resets its permissions. Without this parameter a file named server.info beside install.ps1 is used if present; no other folder is searched, because anyone who can write to a folder the installer trusts could rename this host's reports. The source file is left in place. .PARAMETER UpdateUrl Release zip URL for sysinfo_update.ps1, stored as the config key update.url (overrides the seed). With -Configure it is the only change made to an existing config. .PARAMETER UpdateSha256Url Checksum file URL, stored as update.sha256_url (same rules as -UpdateUrl). .PARAMETER Configure Edits the client name, location and tags of an installed config without reinstalling (run elevated from the release directory). With -ClientName and/or -Location it applies them and asks nothing; otherwise it prompts, showing the current value (Enter keeps it, - clears location or tags; tags are comma separated). Takes effect at the next run. .PARAMETER DesktopShortcut Also creates "CAMP sysinfo config.lnk" (configure.ps1 -Pause, the config editor) and "CAMP sysinfo update.lnk" (sysinfo_update.ps1 -Pause) on the desktop of the user running the installer. Both are flagged Run as administrator (UAC prompt on double-click), since the program directory is locked to SYSTEM and Administrators and an unelevated user cannot even read the scripts. -Uninstall deletes them. .PARAMETER ShortcutAllUsers With -DesktopShortcut, create the shortcuts on the public desktop (every user) instead. .PARAMETER Uninstall Removes the scheduled task and the program directory; keeps the config directory unless -Purge. .PARAMETER Purge With -Uninstall, also removes %ProgramData%\camp\sysinfo_client. .PARAMETER DryRun Prints the actions (and the config that would be written) without changing anything. Allowed without elevation. .PARAMETER Quiet No prompts and no progress lines; only errors and the RESULT line. #> [CmdletBinding()] param( [string]$ClientName = '', [string]$Location = '', [string]$Seed = '', [string]$ServerInfo = '', [string]$UpdateUrl = '', [string]$UpdateSha256Url = '', [switch]$Configure, [switch]$DesktopShortcut, [switch]$ShortcutAllUsers, [switch]$Uninstall, [switch]$Purge, [switch]$DryRun, [switch]$Quiet ) $ErrorActionPreference = 'Stop' $script:InstallerVersion = '1.1.1' $script:SourceDir = $PSScriptRoot $script:TaskName = 'sysinfo_client' $script:TaskPath = '\camp\' $script:SidSystem = 'S-1-5-18' $script:SidAdministrators = 'S-1-5-32-544' $script:SidUsers = 'S-1-5-32-545' $script:ResultVersion = $script:InstallerVersion $script:NameMissing = $false ####################################################### # # Write-Result( $State, $Message ) # # Prints the RESULT line and exits (0 on ok). # ####################################################### function Write-Result { param([string]$State, [string]$Message) [Console]::Out.WriteLine("RESULT: $State $($script:ResultVersion) $Message") if ($State -eq 'ok') { exit 0 } exit 1 } ####################################################### # # Write-Step( $Message ) # # Progress line, suppressed by -Quiet; prefixed in -DryRun. # ####################################################### function Write-Step { param([string]$Message) if ($Quiet) { return } if ($DryRun) { $Message = "[dry run] $Message" } [Console]::Out.WriteLine($Message) } ####################################################### # # Invoke-Step( $Description, $Action ) # # Prints the step and runs it unless -DryRun. # ####################################################### function Invoke-Step { param([string]$Description, [scriptblock]$Action) Write-Step $Description if (-not $DryRun) { [void](& $Action) } } ####################################################### # # New-Sid( $Sid ) # ####################################################### function New-Sid { param([string]$Sid) return (New-Object System.Security.Principal.SecurityIdentifier $Sid) } ####################################################### # # Invoke-Icacls( $Arguments ) # # Runs icacls.exe quietly; throws on a non-zero exit. # ####################################################### function Invoke-Icacls { param([string[]]$Arguments) $icacls = [IO.Path]::Combine($env:SystemRoot, 'System32', 'icacls.exe') # native STDERR becomes error records under 2>&1, which Stop would make terminating $saved = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $output = & $icacls @Arguments 2>&1 | Out-String $code = $LASTEXITCODE } finally { $ErrorActionPreference = $saved } if ($code -ne 0) { throw "icacls $($Arguments -join ' ') failed: $($output.Trim())" } } ####################################################### # # Set-LockedAcl( $Path, $ReadSids ) # # Gives directory $Path a protected DACL (inheritance # off) owned by Administrators: SYSTEM and Administrators # FullControl, plus ReadAndExecute for $ReadSids, all # inherited by everything below. Then resets every child # to inherited-only ACEs and Administrators ownership, so # nothing below keeps an explicit ACE or an untrusted # owner (files created by an elevated admin may be owned # by the admin's own account). This is the ACL that # sysinfo_client.ps1's Get-SysinfoAclProblem accepts. # ####################################################### function Set-LockedAcl { param([string]$Path, [string[]]$ReadSids = @()) $inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) $acl.SetOwner((New-Sid $script:SidAdministrators)) $grants = New-Object System.Collections.ArrayList [void]$grants.Add(@($script:SidSystem, 'FullControl')) [void]$grants.Add(@($script:SidAdministrators, 'FullControl')) foreach ($sid in $ReadSids) { [void]$grants.Add(@($sid, 'ReadAndExecute')) } foreach ($grant in $grants) { $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule ( (New-Sid $grant[0]), [System.Security.AccessControl.FileSystemRights]$grant[1], $inherit, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow))) } Set-Acl -LiteralPath $Path -AclObject $acl if (@(Get-ChildItem -LiteralPath $Path -Force).Count -gt 0) { Invoke-Icacls @([IO.Path]::Combine($Path, '*'), '/reset', '/T', '/C', '/Q') } Invoke-Icacls @($Path, '/setowner', "*$($script:SidAdministrators)", '/T', '/C', '/Q') } ####################################################### # # Get-TreeAclProblems( $Path ) # # The client's ACL rule (Get-SysinfoAclProblem) applied to # $Path and everything below it; returns the problems. # ####################################################### function Get-TreeAclProblems { param([string]$Path) $problems = New-Object System.Collections.ArrayList $items = @((Get-Item -LiteralPath $Path -Force).FullName) $items += @(Get-ChildItem -LiteralPath $Path -Recurse -Force | ForEach-Object { $_.FullName }) foreach ($item in $items) { $problem = Get-SysinfoAclProblem $item if ($problem -ne '') { [void]$problems.Add($problem) } } return , $problems } ####################################################### # # Read-Seed( $Seed ) # # Seed JSON (file path or http/https URL) as a map. # ####################################################### function Read-Seed { param([string]$Seed) if ($Seed -match '^https?://') { try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { # not settable on this runtime } $web = New-Object System.Net.WebClient try { $text = (New-Object System.Text.UTF8Encoding $false).GetString($web.DownloadData($Seed)) } finally { $web.Dispose() } } else { if (-not (Test-Path -LiteralPath $Seed -PathType Leaf)) { throw "seed $Seed not found" } $text = [IO.File]::ReadAllText((Resolve-Path -LiteralPath $Seed).ProviderPath) } 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 "seed $Seed is not a JSON object" } return , $map } ####################################################### # # Get-CimText( $Class, $Property ) # # Trimmed first-instance property, '' on any failure. # ####################################################### function Get-CimText { param([string]$Class, [string]$Property) try { $instance = @(Get-CimInstance -ClassName $Class -ErrorAction Stop)[0] $value = $instance.$Property if ($null -eq $value) { return '' } return ([string]$value).Trim() } catch { return '' } } ####################################################### # # New-ClientConfig # # Template, then the seed's top-level keys, then the # parameters; serialNumber and UUID from the BIOS (blank # when a placeholder, Spec 1.3 rule P), hostname as the # client computes it, os_type windows. # ####################################################### function New-ClientConfig { $config = Read-SysinfoConfig ([IO.Path]::Combine($script:SourceDir, 'sysinfo_client.template.json')) if ($Seed -ne '') { $seedMap = Read-Seed $Seed foreach ($key in @($seedMap.Keys)) { $config[$key] = $seedMap[$key] } } if ($ClientName -ne '') { $config['clientName'] = $ClientName } if ($Location -ne '') { $config['location'] = $Location } Set-UpdateKeys $config $serial = Get-CimText 'Win32_BIOS' 'SerialNumber' if (Test-PlaceholderSerial $serial) { $serial = '' } $config['serialNumber'] = $serial $uuid = Get-CimText 'Win32_ComputerSystemProduct' 'UUID' if (Test-PlaceholderUUID $uuid) { $uuid = '' } $config['UUID'] = $uuid if ([string](Get-SysinfoValue $config 'hostname' '') -eq '') { $config['hostname'] = Get-SysinfoHostname } $config['os_type'] = 'windows' return , $config } ####################################################### # # Set-UpdateKeys( $Config ) # # Applies -UpdateUrl / -UpdateSha256Url to the config's # update map (created when absent; other update keys kept). # ####################################################### function Set-UpdateKeys { param($Config) if ($UpdateUrl -eq '' -and $UpdateSha256Url -eq '') { return } $update = Get-SysinfoValue $Config 'update' if (-not ($update -is [System.Collections.IDictionary])) { $update = New-SysinfoMap } if ($UpdateUrl -ne '') { $update['url'] = $UpdateUrl } if ($UpdateSha256Url -ne '') { $update['sha256_url'] = $UpdateSha256Url } $Config['update'] = $update } ####################################################### # # Get-TaskStart # # Start time for the daily trigger: the existing task's # time of day on upgrade (so re-registering does not move # it), else a random minute from 01:00 to 04:59. # ####################################################### function Get-TaskStart { param($ExistingTask) $today = (Get-Date).Date if ($null -ne $ExistingTask) { foreach ($trigger in @($ExistingTask.Triggers)) { $boundary = [datetime]::MinValue if ($null -ne $trigger.StartBoundary -and [datetime]::TryParse([string]$trigger.StartBoundary, [ref]$boundary)) { return $today.Add($boundary.TimeOfDay) } } } return $today.AddHours(1).AddMinutes((Get-Random -Minimum 0 -Maximum 240)) } ####################################################### # # Register-ClientTask( $BinDir ) # # Daily task run as SYSTEM, highest run level, catches up # after a missed start, runs on battery, no wake, 1 h # limit, one instance at a time. # ####################################################### function Register-ClientTask { param([string]$BinDir) $existing = Get-ScheduledTask -TaskPath $script:TaskPath -TaskName $script:TaskName -ErrorAction SilentlyContinue $start = Get-TaskStart $existing $powershell = [IO.Path]::Combine($env:SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') $clientPath = [IO.Path]::Combine($BinDir, 'sysinfo_client.ps1') $arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + $clientPath + '"' Invoke-Step ("Register task $($script:TaskPath)$($script:TaskName): daily at $($start.ToString('HH:mm')) as SYSTEM: `"$powershell`" $arguments") { $action = New-ScheduledTaskAction -Execute $powershell -Argument $arguments -WorkingDirectory $BinDir $trigger = New-ScheduledTaskTrigger -Daily -At $start $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -ExecutionTimeLimit (New-TimeSpan -Hours 1) -MultipleInstances IgnoreNew $settings.WakeToRun = $false Register-ScheduledTask -TaskPath $script:TaskPath -TaskName $script:TaskName -Action $action -Trigger $trigger ` -Principal $principal -Settings $settings -Description 'CAMP sysinfo client: daily inventory report' -Force } } ####################################################### # # Stop-ClientTask # # Stops the task if it is running (so its files can be # replaced or removed); returns the task or $null. # ####################################################### function Stop-ClientTask { $task = Get-ScheduledTask -TaskPath $script:TaskPath -TaskName $script:TaskName -ErrorAction SilentlyContinue if ($null -ne $task -and [string]$task.State -eq 'Running') { Invoke-Step "Stop running task $($script:TaskPath)$($script:TaskName)" { Stop-ScheduledTask -TaskPath $script:TaskPath -TaskName $script:TaskName } } return $task } ####################################################### # # Invoke-Uninstall( $Paths ) # ####################################################### function Invoke-Uninstall { param($Paths) $task = Stop-ClientTask if ($null -ne $task) { Invoke-Step "Remove task $($script:TaskPath)$($script:TaskName)" { Unregister-ScheduledTask -TaskPath $script:TaskPath -TaskName $script:TaskName -Confirm:$false } } foreach ($link in (Get-ShortcutPaths)) { if (Test-Path -LiteralPath $link) { Invoke-Step "Remove the desktop shortcut $link" { Remove-Item -LiteralPath $link -Force } } } if (Test-Path -LiteralPath $Paths['BinDir']) { Invoke-Step "Remove $($Paths['BinDir'])" { Remove-Item -LiteralPath $Paths['BinDir'] -Recurse -Force } } if (Test-Path -LiteralPath $Paths['BinParent']) { Invoke-Step "Remove $($Paths['BinParent']) if empty" { if (@(Get-ChildItem -LiteralPath $Paths['BinParent'] -Force).Count -eq 0) { Remove-Item -LiteralPath $Paths['BinParent'] -Force } } } if ($Purge) { if (Test-Path -LiteralPath $Paths['ConfDir']) { Invoke-Step "Remove $($Paths['ConfDir'])" { Remove-Item -LiteralPath $Paths['ConfDir'] -Recurse -Force } } } else { Write-Step "Kept $($Paths['ConfDir']) (use -Purge to remove it)" } Write-Result 'ok' 'uninstalled' } ####################################################### # # Invoke-ClientTest( $BinDir ) # # One 'sysinfo_client.ps1 -Test' run with the installed # config; '' when it exits 0 and writes valid JSON, else # the reason. # ####################################################### function Invoke-ClientTest { param([string]$BinDir) $powershell = [IO.Path]::Combine($env:SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') $clientPath = [IO.Path]::Combine($BinDir, 'sysinfo_client.ps1') $tempDir = $env:TEMP if (-not $tempDir) { $tempDir = [IO.Path]::GetTempPath() } $testPath = [IO.Path]::Combine($tempDir, 'sysinfo.testing.json') if (Test-Path -LiteralPath $testPath) { Remove-Item -LiteralPath $testPath -Force } # native STDERR becomes error records under 2>&1; they must not stop the installer $saved = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $output = & $powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $clientPath -Test 2>&1 | Out-String $code = $LASTEXITCODE } finally { $ErrorActionPreference = $saved } if ($code -ne 0) { return "test run exited $code`: $($output.Trim())" } if (-not (Test-Path -LiteralPath $testPath -PathType Leaf)) { return "test run wrote no $testPath" } try { $text = [IO.File]::ReadAllText($testPath) [void]($text | ConvertFrom-Json) } catch { return "test report $testPath is not valid JSON: $($_.Exception.Message)" } Write-Step "Test run ok, report in $testPath" return '' } ####################################################### # # Resolve-ServerInfoSource() # # The server.info to install: -ServerInfo, else server.info beside this # script, else ''. Fails when -ServerInfo names a missing file. # ####################################################### function Resolve-ServerInfoSource { if ($ServerInfo -ne '') { if (-not (Test-Path -LiteralPath $ServerInfo -PathType Leaf)) { Write-Result 'fail' "-ServerInfo file not found: $ServerInfo" } return [IO.Path]::GetFullPath($ServerInfo) } $beside = [IO.Path]::Combine($script:SourceDir, 'server.info') if (Test-Path -LiteralPath $beside -PathType Leaf) { return $beside } return '' } # the server.info in effect: the one about to be installed, else the installed one function Get-ServerInfoInUse { if ($script:ServerInfoSource) { return $script:ServerInfoSource } return (Find-SysinfoServerInfo '') } ####################################################### # # Test-ClientNameKnown( $Config ) # # True when the config, or an Owner in server.info, names the client. # ####################################################### function Test-ClientNameKnown { param($Config) if ([string](Get-SysinfoValue $Config 'clientName' '') -ne '') { return $true } $serverInfoPath = Get-ServerInfoInUse if ($serverInfoPath -eq '') { return $false } return ([string](Get-SysinfoValue (Read-SysinfoServerInfo $serverInfoPath) 'clientName' '') -ne '') } ####################################################### # # Invoke-Configure( $Paths ) # # Edits clientName, location, tags and the update keys # (-UpdateUrl, -UpdateSha256Url) in the installed config # (-Configure). Nothing else is touched. # ####################################################### function Invoke-Configure { param($Paths) $confDir = $Paths['ConfDir'] $configPath = [IO.Path]::Combine($confDir, 'sysinfo_client.json') if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { Write-Result 'fail' "no config at $configPath; run install.ps1 first" } try { $config = Read-SysinfoConfig $configPath } catch { Write-Result 'fail' "$configPath is unreadable: $($_.Exception.Message)" } if ($ClientName -ne '') { $config['clientName'] = $ClientName } if ($Location -ne '') { $config['location'] = $Location } Set-UpdateKeys $config if ($ClientName -eq '' -and $Location -eq '' -and $UpdateUrl -eq '' -and $UpdateSha256Url -eq '' -and -not $Quiet) { $current = [string](Get-SysinfoValue $config 'clientName' '') $answer = (Read-Host "Client name [$current] (Enter keeps it)").Trim() if ($answer -ne '') { $config['clientName'] = $answer } $current = [string](Get-SysinfoValue $config 'location' '') $answer = (Read-Host "Location [$current] (Enter keeps it, - clears it)").Trim() if ($answer -eq '-') { $config['location'] = '' } elseif ($answer -ne '') { $config['location'] = $answer } $currentTags = @(Get-SysinfoValue $config 'tags' @()) | Where-Object { $_ -ne $null -and [string]$_ -ne '' } $answer = (Read-Host "Tags, comma separated [$(@($currentTags) -join ', ')] (Enter keeps them, - clears them)").Trim() if ($answer -ne '') { $tags = New-Object System.Collections.ArrayList if ($answer -ne '-') { foreach ($tag in ($answer -split ',')) { if ($tag.Trim() -ne '') { [void]$tags.Add($tag.Trim()) } } } $config['tags'] = $tags } } $json = ConvertTo-SysinfoJson $config if ($DryRun) { if (-not $Quiet) { [Console]::Out.WriteLine($json) } Write-Result 'ok' 'dry run, nothing changed' } Write-Step "Write $configPath" Write-SysinfoFile $configPath $json Set-LockedAcl $confDir $problems = @(Get-TreeAclProblems $confDir) if ($problems.Count -gt 0) { Write-Result 'fail' "config directory fails the client's ACL check; first: $($problems[0])" } if (-not (Test-ClientNameKnown $config)) { Write-Result 'ok' 'configured; clientName is still not set, nothing will be sent until it is' } Write-Result 'ok' 'configured; takes effect at the next run' } ####################################################### # # Get-ShortcutNames() / Get-ShortcutPaths( $AllUsers ) # # The two desktop shortcuts. Created on the desktop of the user running the installer (an elevated # process reports the account it runs as), or the public desktop with -ShortcutAllUsers. With no # argument Get-ShortcutPaths lists both desktops, for -Uninstall. # ####################################################### function Get-ShortcutNames { return @('CAMP sysinfo config.lnk', 'CAMP sysinfo update.lnk') } function Get-ShortcutPaths { param($AllUsers = $null) $folders = New-Object System.Collections.ArrayList if ($null -eq $AllUsers -or -not $AllUsers) { [void]$folders.Add([Environment]::GetFolderPath('DesktopDirectory')) } if ($null -eq $AllUsers -or $AllUsers) { [void]$folders.Add([Environment]::GetFolderPath('CommonDesktopDirectory')) } $paths = New-Object System.Collections.ArrayList foreach ($folder in $folders) { if ($folder -eq '') { continue } foreach ($name in (Get-ShortcutNames)) { [void]$paths.Add([IO.Path]::Combine($folder, $name)) } } return , $paths.ToArray() } # Sets the "Run as administrator" flag (bit 0x20 of the byte at offset 0x15) of a saved .lnk function Set-ShortcutRunAs { param([string]$Link) $bytes = [IO.File]::ReadAllBytes($Link) if ($bytes.Length -lt 22) { throw "$Link is not a shortcut file" } $bytes[0x15] = $bytes[0x15] -bor 0x20 [IO.File]::WriteAllBytes($Link, $bytes) } ####################################################### # # New-DesktopShortcuts( $BinDir ) # # -DesktopShortcut: shortcuts that start configure.ps1 -Pause and sysinfo_update.ps1 -Pause, both # flagged Run as administrator so UAC appears before PowerShell starts (an unelevated user cannot # read the locked program directory, so a script could not elevate itself). # ####################################################### function New-DesktopShortcuts { param([string]$BinDir) $links = Get-ShortcutPaths ([bool]$ShortcutAllUsers) $scripts = @('configure.ps1', 'sysinfo_update.ps1') $descriptions = @('Edit the CAMP sysinfo client configuration', 'Update the CAMP sysinfo client') for ($i = 0; $i -lt $links.Count; $i++) { $link = $links[$i] Invoke-Step "Create the desktop shortcut $link" { $shell = New-Object -ComObject WScript.Shell $shortcut = $shell.CreateShortcut($link) $shortcut.TargetPath = [IO.Path]::Combine($env:SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') $shortcut.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "' + [IO.Path]::Combine($BinDir, $scripts[$i]) + '" -Pause' $shortcut.WorkingDirectory = $BinDir $shortcut.Description = $descriptions[$i] $shortcut.Save() Set-ShortcutRunAs $link } } } ####################################################### # # Invoke-Install( $Paths ) # ####################################################### function Invoke-Install { param($Paths) $binDir = $Paths['BinDir'] $confDir = $Paths['ConfDir'] $campData = $Paths['CampData'] $configPath = [IO.Path]::Combine($confDir, 'sysinfo_client.json') if ([IO.Path]::GetFullPath($script:SourceDir).TrimEnd('\') -ieq [IO.Path]::GetFullPath($binDir).TrimEnd('\')) { Write-Result 'fail' "run install.ps1 from the unpacked release, not from $binDir" } foreach ($name in @('sysinfo_client.ps1', 'sysinfo_update.ps1', 'dsEdit.ps1', 'configure.ps1', 'sysinfo_client.template.json', 'lib', 'sensors', 'transports')) { if (-not (Test-Path -LiteralPath ([IO.Path]::Combine($script:SourceDir, $name)))) { Write-Result 'fail' "release is incomplete: $name missing from $($script:SourceDir)" } } $script:ServerInfoSource = Resolve-ServerInfoSource if ($script:ServerInfoSource -ne '') { Write-Step "Using server.info from $($script:ServerInfoSource)" } # config first, so a bad seed or missing clientName fails before anything changes $newConfig = $null if (Test-Path -LiteralPath $configPath -PathType Leaf) { Write-Step "Keeping existing $configPath" if ($ClientName -ne '' -or $Location -ne '' -or $Seed -ne '') { [Console]::Error.WriteLine("Warning: -ClientName, -Location and -Seed apply only to a new config; $configPath was not changed (use -Configure to change the client name or location)") } try { $existingConfig = Read-SysinfoConfig $configPath if (-not (Test-ClientNameKnown $existingConfig)) { $script:NameMissing = $true } } catch { Write-Result 'fail' "existing $configPath is unreadable: $($_.Exception.Message)" } } else { try { $newConfig = New-ClientConfig } catch { Write-Result 'fail' "cannot build config: $($_.Exception.Message)" } if ([string](Get-SysinfoValue $newConfig 'clientName' '') -eq '') { $serverInfoPath = Get-ServerInfoInUse $owner = '' if ($serverInfoPath -ne '') { $owner = [string](Get-SysinfoValue (Read-SysinfoServerInfo $serverInfoPath) 'clientName' '') } if ($owner -eq '') { if (-not $Quiet) { $newConfig['clientName'] = (Read-Host 'Client name').Trim() } if (-not $Quiet -and [string](Get-SysinfoValue $newConfig 'location' '') -eq '') { $newConfig['location'] = (Read-Host 'Location (optional, Enter to skip)').Trim() } if ([string](Get-SysinfoValue $newConfig 'clientName' '') -eq '') { $script:NameMissing = $true [Console]::Error.WriteLine('Warning: no clientName; installing anyway. Nothing will be sent until it is set (install.ps1 -Configure, Owner in server.info, or edit the config).') } } } } $task = Stop-ClientTask # program directory: replaced as a whole, so no stale sensor survives an upgrade if (-not (Test-Path -LiteralPath $Paths['BinParent'])) { Invoke-Step "Create $($Paths['BinParent'])" { [void](New-Item -ItemType Directory -Path $Paths['BinParent']) } } # the previous copy is renamed aside, not deleted first: a rename that fails (a process has the # directory open) changes nothing, and a failed copy puts the old client back $oldDir = $binDir + '.old' if (Test-Path -LiteralPath $oldDir) { Invoke-Step "Remove stale $oldDir" { Remove-Item -LiteralPath $oldDir -Recurse -Force } } $movedAside = $false if (Test-Path -LiteralPath $binDir) { Invoke-Step "Move previous $binDir to $oldDir" { Rename-Item -LiteralPath $binDir -NewName ([IO.Path]::GetFileName($oldDir)) } $movedAside = -not $DryRun } try { Invoke-Step "Create $binDir" { [void](New-Item -ItemType Directory -Path $binDir) } Invoke-Step "Lock $binDir to SYSTEM and Administrators" { Set-LockedAcl $binDir } foreach ($name in @('sysinfo_client.ps1', 'sysinfo_update.ps1', 'dsEdit.ps1', 'configure.ps1', 'lib', 'sensors', 'transports')) { $source = [IO.Path]::Combine($script:SourceDir, $name) Invoke-Step "Copy $name to $binDir" { Copy-Item -LiteralPath $source -Destination $binDir -Recurse -Force } } Invoke-Step "Unblock files and reset ACLs under $binDir" { Get-ChildItem -LiteralPath $binDir -Recurse -File | Unblock-File Set-LockedAcl $binDir } } catch { $reason = $_.Exception.Message if ($movedAside) { try { if (Test-Path -LiteralPath $binDir) { Remove-Item -LiteralPath $binDir -Recurse -Force } Rename-Item -LiteralPath $oldDir -NewName ([IO.Path]::GetFileName($binDir)) $reason += '; the previous client was put back' } catch { $reason += "; the previous client is in $oldDir and could not be put back: $($_.Exception.Message)" } } Write-Result 'fail' $reason } if ($movedAside) { try { Remove-Item -LiteralPath $oldDir -Recurse -Force } catch { [Console]::Error.WriteLine("Warning: could not remove $oldDir ($($_.Exception.Message)); delete it by hand") } } # %ProgramData%\camp: ProgramData lets Users create files in new subdirectories, # which would let anyone plant a server.info that renames this host's reports if (-not (Test-Path -LiteralPath $campData)) { Invoke-Step "Create $campData" { [void](New-Item -ItemType Directory -Path $campData) } } Invoke-Step "Lock $campData (SYSTEM and Administrators full, Users read)" { $inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' $acl = New-Object System.Security.AccessControl.DirectorySecurity $acl.SetAccessRuleProtection($true, $false) $acl.SetOwner((New-Sid $script:SidAdministrators)) foreach ($grant in @(@($script:SidSystem, 'FullControl'), @($script:SidAdministrators, 'FullControl'), @($script:SidUsers, 'ReadAndExecute'))) { $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule ( (New-Sid $grant[0]), [System.Security.AccessControl.FileSystemRights]$grant[1], $inherit, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow))) } Set-Acl -LiteralPath $campData -AclObject $acl $serverInfo = [IO.Path]::Combine($campData, 'server.info') if ($script:ServerInfoSource -ne '') { Copy-Item -LiteralPath $script:ServerInfoSource -Destination $serverInfo -Force } if (Test-Path -LiteralPath $serverInfo -PathType Leaf) { Invoke-Icacls @($serverInfo, '/reset', '/Q') Invoke-Icacls @($serverInfo, '/setowner', "*$($script:SidAdministrators)", '/Q') } } # config directory if (-not (Test-Path -LiteralPath $confDir)) { Invoke-Step "Create $confDir" { [void](New-Item -ItemType Directory -Path $confDir) } } if ($null -ne $newConfig) { $json = ConvertTo-SysinfoJson $newConfig Invoke-Step "Write $configPath" { Write-SysinfoFile $configPath $json } if ($DryRun -and -not $Quiet) { [Console]::Out.WriteLine($json) } } Invoke-Step "Lock $confDir to SYSTEM and Administrators" { Set-LockedAcl $confDir } # update_installed.sha256 names the release sysinfo_update installed; this install may be a different # one, so the record goes (no record = not matched) and sysinfo_update writes a new one after it $recordFile = [IO.Path]::Combine($confDir, 'update_installed.sha256') if ((Test-Path -LiteralPath $recordFile) -or $DryRun) { Invoke-Step "Clear the recorded release checksum $recordFile" { Remove-Item -LiteralPath $recordFile -Force -ErrorAction SilentlyContinue } } Register-ClientTask $binDir if ($DesktopShortcut -or $ShortcutAllUsers) { New-DesktopShortcuts $binDir } if ($DryRun) { Write-Result 'ok' 'dry run, nothing changed' } # the client skips its ACL check in -Test, so apply it here to every installed file $problems = New-Object System.Collections.ArrayList foreach ($dir in @($binDir, $confDir)) { foreach ($problem in (Get-TreeAclProblems $dir)) { [void]$problems.Add($problem) } } if ($problems.Count -gt 0) { foreach ($problem in $problems) { [Console]::Error.WriteLine("ACL: $problem") } Write-Result 'fail' "installed files fail the client's ACL check ($($problems.Count)); first: $($problems[0])" } Write-Step 'ACL check passed on every installed file' $failure = Invoke-ClientTest $binDir if ($failure -ne '') { Write-Result 'fail' $failure } $verb = 'installed' if ($null -ne $task) { $verb = 'upgraded' } if ($script:NameMissing) { $verb += '; clientName is NOT set, nothing will be sent until it is (install.ps1 -Configure)' } Write-Result 'ok' $verb } ####################################################### # main ####################################################### try { if ($PSVersionTable.PSVersion -lt [version]'5.1') { Write-Result 'fail' "PowerShell 5.1 or later required (this is $($PSVersionTable.PSVersion))" } if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { Write-Result 'fail' 'Windows only' } if ($Purge -and -not $Uninstall) { Write-Result 'fail' '-Purge needs -Uninstall' } if ($Configure -and $Uninstall) { Write-Result 'fail' '-Configure and -Uninstall cannot be combined' } # the client's own functions (config, server.info, hostname, JSON, ACL rule) # and the sensor library's placeholder rules; dot-sourcing does not run the client $clientScript = [IO.Path]::Combine($script:SourceDir, 'sysinfo_client.ps1') if (-not (Test-Path -LiteralPath $clientScript -PathType Leaf)) { Write-Result 'fail' "sysinfo_client.ps1 not found in $($script:SourceDir)" } . $clientScript $ErrorActionPreference = 'Stop' $script:ResultVersion = $script:CLIENT_VERSION Import-Module ([IO.Path]::Combine($script:SourceDir, 'lib', 'SysinfoSensor.psm1')) -Force if (-not (Test-SysinfoElevated)) { if ($DryRun) { [Console]::Error.WriteLine('Warning: not elevated; a real run must be elevated') } else { Write-Result 'fail' 'must run elevated (Run as administrator)' } } $paths = @{ 'BinParent' = [IO.Path]::Combine($env:ProgramFiles, 'camp') 'BinDir' = [IO.Path]::Combine($env:ProgramFiles, 'camp', 'sysinfo_client') 'CampData' = [IO.Path]::Combine($env:ProgramData, 'camp') 'ConfDir' = [IO.Path]::Combine($env:ProgramData, 'camp', 'sysinfo_client') } if ($Uninstall) { Invoke-Uninstall $paths } if ($Configure) { Invoke-Configure $paths } Invoke-Install $paths } catch { Write-Result 'fail' $_.Exception.Message }