added Test-FileDownload.ps1
This commit is contained in:
@@ -16,6 +16,8 @@ If you're using an RMM tool, the scripts in the /rmm directory are all that's ne
|
|||||||
|
|
||||||
For DSA collection, paste [rmm/Get-DSAManifest.ps1](rmm/Get-DSAManifest.ps1) into the RMM caller, preserving its configured platform variables. This caller selects TLS 1.2 before downloading, stops on bootstrap failure, and dot-sources the downloaded tools in the caller's scope. Updating the repository alone will not change an existing RMM caller.
|
For DSA collection, paste [rmm/Get-DSAManifest.ps1](rmm/Get-DSAManifest.ps1) into the RMM caller, preserving its configured platform variables. This caller selects TLS 1.2 before downloading, stops on bootstrap failure, and dot-sources the downloaded tools in the caller's scope. Updating the repository alone will not change an existing RMM caller.
|
||||||
|
|
||||||
|
For download failures, use the standalone [Test-FileDownload.ps1](Test-FileDownload.ps1) diagnostic. See [the RMM caller, report guide, and shared caller audit](docs/FileDownload-Diagnostics.md). It compares the existing helper, BITS, and direct GET without changing production download behavior or running downloaded files.
|
||||||
|
|
||||||
## Uploading files
|
## Uploading files
|
||||||
|
|
||||||
After dot-sourcing `Tools.ps1`, scripts can upload a completed file to the Emberkom FileDrop:
|
After dot-sourcing `Tools.ps1`, scripts can upload a completed file to the Emberkom FileDrop:
|
||||||
|
|||||||
@@ -0,0 +1,540 @@
|
|||||||
|
#Requires -Version 2.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Compares download paths without changing the production downloader or TLS settings.
|
||||||
|
.DESCRIPTION
|
||||||
|
Run from the failing RMM caller, after its normal Tools.ps1 bootstrap, using the
|
||||||
|
same account and PowerShell executable. A standalone run skips the helper probe
|
||||||
|
when Download-File is not loaded. Never dot-sources a downloaded payload.
|
||||||
|
|
||||||
|
Each probe runs in a bounded child process. Only that run's BITS jobs and scratch
|
||||||
|
files are removed. Reports remain in a new subdirectory of OutputDirectory.
|
||||||
|
The source deliberately uses PowerShell 2 syntax and .NET 2-era APIs. Actual
|
||||||
|
PowerShell 2, 3, and 4 runtime validation is still required on legacy machines.
|
||||||
|
.PARAMETER URL
|
||||||
|
HTTP or HTTPS file URL. Downloads are never executed.
|
||||||
|
.PARAMETER OutputDirectory
|
||||||
|
Report parent directory; defaults to the account's temporary directory.
|
||||||
|
.PARAMETER TimeoutSeconds
|
||||||
|
Wall-clock limit for each probe, including download and hashing. Defaults to 120.
|
||||||
|
.EXAMPLE
|
||||||
|
& .\Test-FileDownload.ps1 -URL 'https://example.com/tool.exe'
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true, Position=0)][string]$URL,
|
||||||
|
[string]$OutputDirectory = [IO.Path]::GetTempPath(),
|
||||||
|
[ValidateRange(1,3600)][int]$TimeoutSeconds = 120
|
||||||
|
)
|
||||||
|
|
||||||
|
# All diagnostic state is local even when the caller dot-sources this script.
|
||||||
|
& {
|
||||||
|
param($FdUrlText, $FdOutputParent, $FdTimeout)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$FdUri = New-Object Uri $FdUrlText
|
||||||
|
if (!$FdUri.IsAbsoluteUri -or @('http','https') -notcontains $FdUri.Scheme -or $FdUri.UserInfo) {
|
||||||
|
throw 'URL must be an absolute HTTP(S) URL without embedded credentials.'
|
||||||
|
}
|
||||||
|
if ($PSVersionTable.PSVersion.Major -gt 5) { throw 'Run this diagnostic in Windows PowerShell 2-5.1, using the same shell as the RMM.' }
|
||||||
|
$FdRunId = [guid]::NewGuid().ToString('N')
|
||||||
|
$FdParent = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FdOutputParent)
|
||||||
|
$FdRoot = Join-Path $FdParent ('file-download-diagnostic-' + $FdRunId)
|
||||||
|
$null = New-Item -Path $FdRoot -ItemType Directory -ErrorAction Stop
|
||||||
|
$FdWork = Join-Path $FdRoot 'work'
|
||||||
|
$null = New-Item -Path $FdWork -ItemType Directory -ErrorAction Stop
|
||||||
|
$FdExe = [Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
|
||||||
|
if ([IO.Path]::GetFileName($FdExe) -ine 'powershell.exe') {
|
||||||
|
throw 'Run from powershell.exe rather than ISE or an embedded host so child probes can use the same executable safely.'
|
||||||
|
}
|
||||||
|
$FdExtension = [IO.Path]::GetExtension($FdUri.AbsolutePath)
|
||||||
|
if ($FdExtension -notmatch '^\.[A-Za-z0-9]{1,16}$') { $FdExtension = '.download' }
|
||||||
|
|
||||||
|
function Get-FdTextHash($Text) {
|
||||||
|
$FdHash = [Security.Cryptography.SHA256]::Create()
|
||||||
|
try { return [BitConverter]::ToString($FdHash.ComputeHash([Text.Encoding]::UTF8.GetBytes($Text))).Replace('-','') }
|
||||||
|
finally { $FdHash.Clear() }
|
||||||
|
}
|
||||||
|
function Export-FdXml($Value,$Path,$Depth) {
|
||||||
|
# PowerShell 2 lacks some LiteralPath parameter sets. A scoped drive keeps
|
||||||
|
# wildcard characters in the parent directory out of Export-Clixml's path.
|
||||||
|
$Drive = 'FdXml' + [guid]::NewGuid().ToString('N')
|
||||||
|
$null = New-PSDrive -Name $Drive -PSProvider FileSystem -Root ([IO.Path]::GetDirectoryName($Path)) -Scope Local
|
||||||
|
try { $Value | Export-Clixml -Path ($Drive + ':\' + [IO.Path]::GetFileName($Path)) -Depth $Depth }
|
||||||
|
finally { Remove-PSDrive -Name $Drive -Scope Local }
|
||||||
|
}
|
||||||
|
|
||||||
|
$FdCommands = @()
|
||||||
|
foreach ($FdName in @('Download-File','Get-AbsoluteURI','IsURLValid','Get-TempPath','Write-Error','Write-Output','Start-BitsTransfer')) {
|
||||||
|
$FdCommand = Get-Command -Name $FdName -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
$FdDefinition = $null
|
||||||
|
if ($FdCommand -and @('Function','Filter') -contains [string]$FdCommand.CommandType) { $FdDefinition = $FdCommand.Definition }
|
||||||
|
$FdCommands += New-Object PSObject -Property @{
|
||||||
|
Name=$FdName; CommandType=[string]$FdCommand.CommandType; ModuleName=$FdCommand.ModuleName
|
||||||
|
ModuleVersion=[string]$FdCommand.Module.Version; Definition=$FdDefinition
|
||||||
|
DefinitionSHA256=$(if ($null -ne $FdDefinition) { Get-FdTextHash $FdDefinition } else { $null })
|
||||||
|
ResolvedDefinition=$FdCommand.Definition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$FdContext = New-Object PSObject -Property @{
|
||||||
|
RunId=$FdRunId; URL=$FdUri.AbsoluteUri; Commands=$FdCommands
|
||||||
|
SecurityProtocol=[int][Net.ServicePointManager]::SecurityProtocol
|
||||||
|
CertificateCallbackPresent=($null -ne [Net.ServicePointManager]::ServerCertificateValidationCallback)
|
||||||
|
ProxyType=$(if ([Net.WebRequest]::DefaultWebProxy) { [Net.WebRequest]::DefaultWebProxy.GetType().FullName } else { 'None' })
|
||||||
|
ProxyAddress=[string]([Net.WebRequest]::DefaultWebProxy.Address)
|
||||||
|
PowerShellVersion=[string]$PSVersionTable.PSVersion; CLRVersion=[string][Environment]::Version
|
||||||
|
ProcessBits=([IntPtr]::Size * 8); Executable=$FdExe
|
||||||
|
Identity=[Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||||
|
WorkingDirectory=(Get-Location).Path; TimeoutSeconds=$FdTimeout; DestinationExtension=$FdExtension
|
||||||
|
}
|
||||||
|
$FdContextPath = Join-Path $FdWork 'context.clixml'
|
||||||
|
Export-FdXml $FdContext $FdContextPath 12
|
||||||
|
|
||||||
|
# This is diagnostic code, not code fetched from the URL. It is copied into each
|
||||||
|
# fresh child so leaked connections and preference changes cannot cross probes.
|
||||||
|
$FdWorker = {
|
||||||
|
param($InputPath)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
$InputData = Import-Clixml -Path ([Management.Automation.WildcardPattern]::Escape($InputPath))
|
||||||
|
$Context = Import-Clixml -Path ([Management.Automation.WildcardPattern]::Escape($InputData.ContextPath))
|
||||||
|
$Result = New-Object PSObject -Property @{
|
||||||
|
Name=$InputData.Name; Method=$InputData.Method; ExistingEmpty=$InputData.ExistingEmpty
|
||||||
|
Status='Running'; StartedUtc=[DateTime]::UtcNow.ToString('o'); ElapsedSeconds=0
|
||||||
|
Errors=@(); Notes=@(); Http=@(); Bits=@(); Snapshots=@(); HelperOutput=@(); HelperBitsSource=@(); Environment=$null
|
||||||
|
CleanupErrors=@(); ChildPowerShell=[string]$PSVersionTable.PSVersion
|
||||||
|
ChildCLR=[string][Environment]::Version; ChildIdentity=[Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||||
|
ChildProcessBits=([IntPtr]::Size * 8)
|
||||||
|
}
|
||||||
|
$Watch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
|
function Export-FdXml($Value,$Path,$Depth) {
|
||||||
|
$Drive = 'FdXml' + [guid]::NewGuid().ToString('N')
|
||||||
|
$null = New-PSDrive -Name $Drive -PSProvider FileSystem -Root ([IO.Path]::GetDirectoryName($Path)) -Scope Local
|
||||||
|
try { $Value | Export-Clixml -Path ($Drive + ':\' + [IO.Path]::GetFileName($Path)) -Depth $Depth }
|
||||||
|
finally { Remove-PSDrive -Name $Drive -Scope Local }
|
||||||
|
}
|
||||||
|
function Save-Checkpoint {
|
||||||
|
$Result.ElapsedSeconds = [Math]::Round($Watch.Elapsed.TotalSeconds,3)
|
||||||
|
# Replace only this probe's checkpoint; the parent reads it after child exit.
|
||||||
|
Export-FdXml $Result $InputData.ResultPath 16
|
||||||
|
}
|
||||||
|
function Get-ErrorDetail($Record) {
|
||||||
|
return New-Object PSObject -Property @{
|
||||||
|
Message=$Record.Exception.Message; Exception=$Record.Exception.ToString()
|
||||||
|
FullyQualifiedErrorId=$Record.FullyQualifiedErrorId
|
||||||
|
Category=[string]$Record.CategoryInfo; Position=$Record.InvocationInfo.PositionMessage
|
||||||
|
Record=($Record | Format-List * -Force | Out-String -Width 240)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Get-FileSnapshot($Label) {
|
||||||
|
$Snapshot = New-Object PSObject -Property @{Label=$Label; TimeUtc=[DateTime]::UtcNow.ToString('o'); Exists=$false; Length=$null; LengthAfterHash=$null; SHA256=$null; FirstBytes=$null; Error=$null}
|
||||||
|
$Stream = $null; $Hash = $null
|
||||||
|
try {
|
||||||
|
if (Test-Path -LiteralPath $InputData.Destination -PathType Leaf) {
|
||||||
|
$Snapshot.Exists = $true
|
||||||
|
$Snapshot.Length = (Get-Item -LiteralPath $InputData.Destination).Length
|
||||||
|
$Stream = [IO.File]::Open($InputData.Destination,[IO.FileMode]::Open,[IO.FileAccess]::Read,([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete))
|
||||||
|
$Prefix = New-Object byte[] 8
|
||||||
|
$Count = $Stream.Read($Prefix,0,$Prefix.Length)
|
||||||
|
if ($Count -gt 0) { $Snapshot.FirstBytes = [BitConverter]::ToString($Prefix,0,$Count) }
|
||||||
|
$Stream.Position = 0
|
||||||
|
$Hash = [Security.Cryptography.SHA256]::Create()
|
||||||
|
$Snapshot.SHA256 = [BitConverter]::ToString($Hash.ComputeHash($Stream)).Replace('-','')
|
||||||
|
$Snapshot.LengthAfterHash = (Get-Item -LiteralPath $InputData.Destination).Length
|
||||||
|
}
|
||||||
|
} catch { $Snapshot.Error = Get-ErrorDetail $_ }
|
||||||
|
finally { if ($Stream) { $Stream.Dispose() }; if ($Hash) { $Hash.Clear() } }
|
||||||
|
return $Snapshot
|
||||||
|
}
|
||||||
|
function Add-BitsSnapshot($Job, $Stage) {
|
||||||
|
$Result.Bits += New-Object PSObject -Property @{
|
||||||
|
Stage=$Stage; TimeUtc=[DateTime]::UtcNow.ToString('o'); JobId=[string]$Job.JobId
|
||||||
|
JobState=[string]$Job.JobState; BytesTotal=$Job.BytesTotal; BytesTransferred=$Job.BytesTransferred
|
||||||
|
FilesTotal=$Job.FilesTotal; FilesTransferred=$Job.FilesTransferred
|
||||||
|
ErrorCode=$Job.ErrorCode; ErrorContext=[string]$Job.ErrorContext; ErrorDescription=$Job.ErrorDescription
|
||||||
|
ProxyUsage=[string]$Job.ProxyUsage; OwnerAccount=$Job.OwnerAccount
|
||||||
|
}
|
||||||
|
Save-Checkpoint
|
||||||
|
}
|
||||||
|
function Remove-OwnedBitsJobs {
|
||||||
|
try {
|
||||||
|
Import-Module BitsTransfer -ErrorAction Stop
|
||||||
|
$Jobs = @(BitsTransfer\Get-BitsTransfer -ErrorAction Stop | Where-Object {
|
||||||
|
$_.DisplayName -eq $InputData.Tag -and $_.Description -eq $Context.RunId
|
||||||
|
})
|
||||||
|
foreach ($Job in $Jobs) {
|
||||||
|
Add-BitsSnapshot $Job 'Before cleanup'
|
||||||
|
BitsTransfer\Remove-BitsTransfer -BitsJob $Job -ErrorAction Stop
|
||||||
|
}
|
||||||
|
} catch { $Result.CleanupErrors += Get-ErrorDetail $_ }
|
||||||
|
}
|
||||||
|
function Add-HttpSnapshot($Response, $Method, $RequestUri) {
|
||||||
|
$Headers = @{}
|
||||||
|
foreach ($Header in @('Content-Length','Content-Range','Content-Type','Transfer-Encoding','Content-Encoding','Accept-Ranges','ETag','Last-Modified','Location','Via','Age','Server')) {
|
||||||
|
$Headers[$Header] = $Response.Headers[$Header]
|
||||||
|
}
|
||||||
|
$Result.Http += New-Object PSObject -Property @{
|
||||||
|
Method=$Method; RequestUri=$RequestUri; ResponseUri=$Response.ResponseUri.AbsoluteUri
|
||||||
|
Status=[int]$Response.StatusCode; ContentLength=$Response.ContentLength; Headers=$Headers
|
||||||
|
}
|
||||||
|
Save-Checkpoint
|
||||||
|
}
|
||||||
|
function Open-HttpResponse($Method, $UseRange) {
|
||||||
|
$CurrentUri = New-Object Uri $Context.URL
|
||||||
|
for ($Redirect = 0; $Redirect -le 10; $Redirect++) {
|
||||||
|
$Request = [Net.HttpWebRequest]::Create($CurrentUri)
|
||||||
|
$Request.Method = $Method
|
||||||
|
$Request.AllowAutoRedirect = $false
|
||||||
|
$Request.Timeout = $Context.TimeoutSeconds * 1000
|
||||||
|
$Request.ReadWriteTimeout = $Context.TimeoutSeconds * 1000
|
||||||
|
if ($UseRange) { $Request.AddRange(0,15) }
|
||||||
|
try { $Response = $Request.GetResponse() }
|
||||||
|
catch {
|
||||||
|
if ($_.Exception.Response) {
|
||||||
|
try { Add-HttpSnapshot $_.Exception.Response $Method $CurrentUri.AbsoluteUri }
|
||||||
|
finally { $_.Exception.Response.Close() }
|
||||||
|
}
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
Add-HttpSnapshot $Response $Method $CurrentUri.AbsoluteUri
|
||||||
|
if (@(301,302,303,307,308) -contains [int]$Response.StatusCode) {
|
||||||
|
try { $NextUri = New-Object Uri $CurrentUri, $Response.Headers['Location'] }
|
||||||
|
finally { $Response.Close() }
|
||||||
|
if (@('http','https') -notcontains $NextUri.Scheme -or $NextUri.UserInfo) { throw 'Unsupported redirect target.' }
|
||||||
|
if ($CurrentUri.Scheme -eq 'https' -and $NextUri.Scheme -eq 'http') { throw 'HTTPS-to-HTTP redirect recorded but not followed.' }
|
||||||
|
$CurrentUri = $NextUri
|
||||||
|
} else { return $Response }
|
||||||
|
}
|
||||||
|
throw 'HTTP redirect limit exceeded.'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Save-Checkpoint
|
||||||
|
# A child does not inherit this process-local .NET setting. Reproduce the
|
||||||
|
# caller's exact value, rather than selecting a new protocol or changing Windows.
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]$Context.SecurityProtocol
|
||||||
|
if ($InputData.Method -eq 'Cleanup') {
|
||||||
|
Remove-OwnedBitsJobs
|
||||||
|
$Result.Status = 'Complete'
|
||||||
|
} elseif ($InputData.Method -eq 'Environment') {
|
||||||
|
$Info = @{}
|
||||||
|
$Result.Environment = $Info
|
||||||
|
try { $Info['OS'] = Get-WmiObject -Class Win32_OperatingSystem -ErrorAction Stop | Select-Object Caption,Version,BuildNumber,OSArchitecture,ServicePackMajorVersion }
|
||||||
|
catch { $Info['OSError'] = Get-ErrorDetail $_ }
|
||||||
|
Save-Checkpoint
|
||||||
|
$Info['BitsService'] = Get-Service -Name BITS -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType
|
||||||
|
$Info['BitsModules'] = @(Get-Module -ListAvailable -Name BitsTransfer | Select-Object Name,Version,Path)
|
||||||
|
$BitsBinary = Join-Path $Env:WINDIR 'System32\qmgr.dll'
|
||||||
|
$NativeBitsBinary = Join-Path $Env:WINDIR 'Sysnative\qmgr.dll'
|
||||||
|
if (Test-Path -LiteralPath $NativeBitsBinary) { $BitsBinary = $NativeBitsBinary }
|
||||||
|
$Info['BitsBinaryPath'] = $BitsBinary
|
||||||
|
try { $Info['BitsBinaryVersion'] = [Diagnostics.FileVersionInfo]::GetVersionInfo($BitsBinary).FileVersion }
|
||||||
|
catch { $Info['BitsBinaryVersionError'] = Get-ErrorDetail $_ }
|
||||||
|
$Info['DotNetProxyType'] = $Context.ProxyType
|
||||||
|
$Info['CallerProxyAddress'] = $Context.ProxyAddress
|
||||||
|
$Info['DotNetSecurityProtocol'] = [string][Net.ServicePointManager]::SecurityProtocol
|
||||||
|
$Info['CertificateCallbackPresent'] = $Context.CertificateCallbackPresent
|
||||||
|
Save-Checkpoint
|
||||||
|
$Info['WinHttpProxy'] = (& "$Env:WINDIR\System32\netsh.exe" winhttp show proxy 2>&1 | Out-String)
|
||||||
|
$Info['WinHttpRegistryViews'] = @{}
|
||||||
|
foreach ($View in @('32','64')) {
|
||||||
|
# reg.exe supports explicit views without requiring the .NET 4 RegistryView API.
|
||||||
|
try { $Info['WinHttpRegistryViews'][$View] = (& "$Env:WINDIR\System32\reg.exe" query 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\WinHttp' /v DefaultSecureProtocols ("/reg:" + $View) 2>&1 | Out-String) }
|
||||||
|
catch { $Info['WinHttpRegistryViews'][$View] = $_.Exception.Message }
|
||||||
|
}
|
||||||
|
$Info['Registry'] = @()
|
||||||
|
foreach ($Key in @(
|
||||||
|
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings',
|
||||||
|
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\WinHttp',
|
||||||
|
'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Internet Settings\WinHttp',
|
||||||
|
'HKLM:\SOFTWARE\Microsoft\.NETFramework\v2.0.50727',
|
||||||
|
'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319',
|
||||||
|
'HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NETFramework\v2.0.50727',
|
||||||
|
'HKLM:\SOFTWARE\Wow6432Node\Microsoft\.NETFramework\v4.0.30319',
|
||||||
|
'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Client',
|
||||||
|
'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.1\Client',
|
||||||
|
'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'
|
||||||
|
)) {
|
||||||
|
$Values = Get-ItemProperty -LiteralPath $Key -ErrorAction SilentlyContinue
|
||||||
|
$Info['Registry'] += New-Object PSObject -Property @{
|
||||||
|
Key=$Key; Present=($null -ne $Values); Enabled=$Values.Enabled; DisabledByDefault=$Values.DisabledByDefault
|
||||||
|
DefaultSecureProtocols=$Values.DefaultSecureProtocols; SchUseStrongCrypto=$Values.SchUseStrongCrypto
|
||||||
|
SystemDefaultTlsVersions=$Values.SystemDefaultTlsVersions
|
||||||
|
ProxyEnable=$Values.ProxyEnable; ProxyServer=$Values.ProxyServer; AutoConfigURL=$Values.AutoConfigURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Save-Checkpoint
|
||||||
|
$Info['DotNetResolvedProxy'] = $(if ([Net.WebRequest]::DefaultWebProxy) { [Net.WebRequest]::DefaultWebProxy.GetProxy((New-Object Uri $Context.URL)).AbsoluteUri } else { 'None' })
|
||||||
|
$Result.Status = 'Complete'
|
||||||
|
} elseif ($InputData.Method -eq 'Headers') {
|
||||||
|
foreach ($HttpMethod in @('HEAD','Range')) {
|
||||||
|
$Response = $null
|
||||||
|
try {
|
||||||
|
if ($HttpMethod -eq 'HEAD') { $Response = Open-HttpResponse 'HEAD' $false }
|
||||||
|
else { $Response = Open-HttpResponse 'GET' $true }
|
||||||
|
} catch { $Result.Errors += Get-ErrorDetail $_ }
|
||||||
|
finally { if ($Response) { $Response.Close() } }
|
||||||
|
}
|
||||||
|
$Result.Status = $(if ($Result.Errors.Count) { 'Failed' } else { 'Complete' })
|
||||||
|
} else {
|
||||||
|
if ($InputData.ExistingEmpty) { [IO.File]::WriteAllBytes($InputData.Destination,(New-Object byte[] 0)) }
|
||||||
|
$Result.Snapshots += Get-FileSnapshot 'Before transfer'
|
||||||
|
Save-Checkpoint
|
||||||
|
if ($InputData.Method -eq 'Helper') {
|
||||||
|
$Helper = @($Context.Commands | Where-Object { $_.Name -eq 'Download-File' -and $_.Definition })
|
||||||
|
$BitsResolution = $Context.Commands | Where-Object { $_.Name -eq 'Start-BitsTransfer' }
|
||||||
|
if ($Helper.Count -eq 0) {
|
||||||
|
$Result.Status = 'Skipped'
|
||||||
|
$Result.Notes += 'Download-File was not a loaded function; no Tools.ps1 was fetched or sourced.'
|
||||||
|
} elseif ($BitsResolution.CommandType -ne 'Cmdlet' -or $BitsResolution.ModuleName -ne 'BitsTransfer') {
|
||||||
|
$Result.Status = 'Skipped'
|
||||||
|
$Result.Notes += 'Start-BitsTransfer is shadowed or unavailable. Definition recorded; helper not run because its jobs cannot be safely tagged.'
|
||||||
|
} else {
|
||||||
|
Import-Module BitsTransfer -ErrorAction Stop
|
||||||
|
foreach ($Command in $Context.Commands) {
|
||||||
|
if ($Command.Definition -and $Command.Name -ne 'Start-BitsTransfer') {
|
||||||
|
. ([scriptblock]::Create(('function global:{0} {{ {1} }}' -f $Command.Name,$Command.Definition)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$Global:LogFile = $InputData.LogPath
|
||||||
|
# Preserve all native BITS parameters; add only ownership tags so
|
||||||
|
# a timed-out synchronous job can be identified without touching others.
|
||||||
|
$Global:FdNativeBits = Get-Command BitsTransfer\Start-BitsTransfer
|
||||||
|
$Global:FdBitsTag = $InputData.Tag
|
||||||
|
$Global:FdBitsRun = $Context.RunId
|
||||||
|
$Global:FdWorkerResult = $Result
|
||||||
|
function global:Start-BitsTransfer {
|
||||||
|
[CmdletBinding()] param()
|
||||||
|
dynamicparam {
|
||||||
|
$Dictionary = New-Object Management.Automation.RuntimeDefinedParameterDictionary
|
||||||
|
foreach ($Parameter in $Global:FdNativeBits.Parameters.Values) {
|
||||||
|
if (@('Verbose','Debug','ErrorAction','WarningAction','InformationAction','ErrorVariable','WarningVariable','InformationVariable','OutVariable','OutBuffer','PipelineVariable','ProgressAction') -notcontains $Parameter.Name) {
|
||||||
|
$Attributes = New-Object 'Collections.ObjectModel.Collection[Attribute]'
|
||||||
|
foreach ($Attribute in $Parameter.Attributes) { $Attributes.Add($Attribute) }
|
||||||
|
$Dynamic = New-Object Management.Automation.RuntimeDefinedParameter $Parameter.Name,$Parameter.ParameterType,$Attributes
|
||||||
|
$Dictionary.Add($Parameter.Name,$Dynamic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $Dictionary
|
||||||
|
}
|
||||||
|
process {
|
||||||
|
$Global:FdWorkerResult.HelperBitsSource += @($PSBoundParameters['Source'])
|
||||||
|
$PSBoundParameters['DisplayName'] = $Global:FdBitsTag
|
||||||
|
$PSBoundParameters['Description'] = $Global:FdBitsRun
|
||||||
|
& $Global:FdNativeBits @PSBoundParameters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$Result.Notes += 'Loaded helper functions copied into a fresh process; BITS display name/description tagged for cleanup; helper logs redirected to diagnostic scratch.'
|
||||||
|
$Result.HelperOutput = @(Download-File -URL $Context.URL -File $InputData.Destination -ErrorAction Stop 2>&1 | ForEach-Object { [string]$_ })
|
||||||
|
}
|
||||||
|
} elseif ($InputData.Method -eq 'BitsSync') {
|
||||||
|
Import-Module BitsTransfer -ErrorAction Stop
|
||||||
|
BitsTransfer\Start-BitsTransfer -Source $Context.URL -Destination $InputData.Destination -DisplayName $InputData.Tag -Description $Context.RunId -ErrorAction Stop
|
||||||
|
} elseif ($InputData.Method -eq 'BitsJob') {
|
||||||
|
Import-Module BitsTransfer -ErrorAction Stop
|
||||||
|
$Job = BitsTransfer\Start-BitsTransfer -Source $Context.URL -Destination $InputData.Destination -DisplayName $InputData.Tag -Description $Context.RunId -Asynchronous -ErrorAction Stop
|
||||||
|
$PreviousState = ''
|
||||||
|
while ($true) {
|
||||||
|
$Job = BitsTransfer\Get-BitsTransfer -JobId $Job.JobId -ErrorAction Stop
|
||||||
|
if ([string]$Job.JobState -ne $PreviousState) { Add-BitsSnapshot $Job 'State change'; $PreviousState = [string]$Job.JobState }
|
||||||
|
if (@('Transferred','Error','TransientError','Suspended','Cancelled','Acknowledged') -contains [string]$Job.JobState) { break }
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
if ([string]$Job.JobState -ne 'Transferred') { throw ('BITS job ended probe in {0}: {1}' -f $Job.JobState,$Job.ErrorDescription) }
|
||||||
|
Add-BitsSnapshot $Job 'Before completion'
|
||||||
|
BitsTransfer\Complete-BitsTransfer -BitsJob $Job -ErrorAction Stop
|
||||||
|
} elseif ($InputData.Method -eq 'DotNetGet') {
|
||||||
|
$Response = $null; $InputStream = $null; $OutputStream = $null
|
||||||
|
try {
|
||||||
|
$Response = Open-HttpResponse 'GET' $false
|
||||||
|
$InputStream = $Response.GetResponseStream()
|
||||||
|
$OutputStream = [IO.File]::Open($InputData.Destination,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read)
|
||||||
|
$Buffer = New-Object byte[] 65536
|
||||||
|
$Received = [long]0
|
||||||
|
while (($Count = $InputStream.Read($Buffer,0,$Buffer.Length)) -gt 0) { $OutputStream.Write($Buffer,0,$Count); $Received += $Count }
|
||||||
|
if ($Response.ContentLength -ge 0 -and $Received -ne $Response.ContentLength) { throw "HTTP body length mismatch: expected $($Response.ContentLength), received $Received." }
|
||||||
|
} finally {
|
||||||
|
if ($OutputStream) { $OutputStream.Dispose() }
|
||||||
|
if ($InputStream) { $InputStream.Dispose() }
|
||||||
|
if ($Response) { $Response.Close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($Result.Status -ne 'Skipped') {
|
||||||
|
$Result.Snapshots += Get-FileSnapshot 'After transfer'
|
||||||
|
Save-Checkpoint
|
||||||
|
Start-Sleep -Milliseconds 1000
|
||||||
|
$Result.Snapshots += Get-FileSnapshot 'One second later'
|
||||||
|
$After = $Result.Snapshots[$Result.Snapshots.Count-1]
|
||||||
|
$Result.Status = $(if ($After.Error) { 'Failed' } elseif (!$After.Exists) { 'MissingFile' } elseif ($After.Length -eq 0) { 'EmptyFile' } else { 'Complete' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$Result.Status = 'Failed'
|
||||||
|
$Result.Errors += Get-ErrorDetail $_
|
||||||
|
if ($InputData.Destination) { $Result.Snapshots += Get-FileSnapshot 'After error' }
|
||||||
|
} finally {
|
||||||
|
Save-Checkpoint
|
||||||
|
if (@('Helper','BitsSync','BitsJob') -contains $InputData.Method) { Remove-OwnedBitsJobs }
|
||||||
|
Save-Checkpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$FdWorkerPath = Join-Path $FdWork 'worker.ps1'
|
||||||
|
[IO.File]::WriteAllText($FdWorkerPath,$FdWorker.ToString(),[Text.Encoding]::UTF8)
|
||||||
|
|
||||||
|
# Capture both child pipes concurrently without PowerShell callbacks on foreign
|
||||||
|
# threads. .NET file APIs also avoid Start-Process's wildcard path expansion.
|
||||||
|
if (!('EmberkomDownloadDiagnostic.ChildRunner' -as [type])) {
|
||||||
|
Add-Type -TypeDefinition @'
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
namespace EmberkomDownloadDiagnostic {
|
||||||
|
public class ChildResult { public bool TimedOut; public int ExitCode; }
|
||||||
|
public static class ChildRunner {
|
||||||
|
public static ChildResult Run(string exe,string args,string directory,string stdout,string stderr,int milliseconds) {
|
||||||
|
using (StreamWriter output=new StreamWriter(stdout))
|
||||||
|
using (StreamWriter error=new StreamWriter(stderr))
|
||||||
|
using (Process child=new Process()) {
|
||||||
|
child.StartInfo.FileName=exe; child.StartInfo.Arguments=args;
|
||||||
|
child.StartInfo.WorkingDirectory=directory;
|
||||||
|
child.StartInfo.UseShellExecute=false; child.StartInfo.CreateNoWindow=true;
|
||||||
|
child.StartInfo.WindowStyle=ProcessWindowStyle.Hidden;
|
||||||
|
child.StartInfo.RedirectStandardOutput=true; child.StartInfo.RedirectStandardError=true;
|
||||||
|
child.OutputDataReceived+=delegate(object sender,DataReceivedEventArgs e) { if(e.Data!=null) lock(output) { output.WriteLine(e.Data); } };
|
||||||
|
child.ErrorDataReceived+=delegate(object sender,DataReceivedEventArgs e) { if(e.Data!=null) lock(error) { error.WriteLine(e.Data); } };
|
||||||
|
child.Start(); child.BeginOutputReadLine(); child.BeginErrorReadLine();
|
||||||
|
ChildResult result=new ChildResult();
|
||||||
|
result.TimedOut=!child.WaitForExit(milliseconds);
|
||||||
|
if(result.TimedOut) {
|
||||||
|
try { child.Kill(); } catch(InvalidOperationException) { }
|
||||||
|
if(!child.WaitForExit(5000)) throw new TimeoutException("Diagnostic child did not exit after termination.");
|
||||||
|
}
|
||||||
|
child.WaitForExit(); // Process has exited; drain the asynchronous pipe callbacks.
|
||||||
|
result.ExitCode=child.ExitCode;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-FdWorker($Spec, $Limit) {
|
||||||
|
$InputFile = Join-Path $FdWork ($Spec.Name + '-input.clixml')
|
||||||
|
Export-FdXml $Spec $InputFile 8
|
||||||
|
$Code = '& ([scriptblock]::Create([IO.File]::ReadAllText(''' + $FdWorkerPath.Replace("'","''") + '''))) ''' + $InputFile.Replace("'","''") + ''''
|
||||||
|
$Encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Code))
|
||||||
|
$Arguments = '-NoProfile -NonInteractive '
|
||||||
|
if ($PSVersionTable.PSVersion.Major -eq 2) { $Arguments += '-Version 2.0 ' }
|
||||||
|
$Arguments += '-EncodedCommand ' + $Encoded
|
||||||
|
$Child = [EmberkomDownloadDiagnostic.ChildRunner]::Run($FdExe,$Arguments,$FdContext.WorkingDirectory,(Join-Path $FdWork ($Spec.Name + '-stdout.txt')),(Join-Path $FdWork ($Spec.Name + '-stderr.txt')),($Limit * 1000))
|
||||||
|
$TimedOut = $Child.TimedOut
|
||||||
|
$ExitCode = $Child.ExitCode
|
||||||
|
$Item = $null
|
||||||
|
if (Test-Path -LiteralPath $Spec.ResultPath) {
|
||||||
|
try { $Item = Import-Clixml -Path ([Management.Automation.WildcardPattern]::Escape($Spec.ResultPath)) } catch { $Item = $null }
|
||||||
|
}
|
||||||
|
if (!$Item) { $Item = New-Object PSObject -Property @{Name=$Spec.Name; Method=$Spec.Method; ExistingEmpty=$Spec.ExistingEmpty; Status='Failed'; Notes=@('Child did not produce a readable result.'); Errors=@(); Snapshots=@(); CleanupErrors=@(); ElapsedSeconds=$Limit} }
|
||||||
|
if ($TimedOut) { $Item.Status = 'TimedOut'; $Item.Notes += "Stopped after $Limit seconds; partial checkpoint retained." }
|
||||||
|
elseif (($null -ne $ExitCode -and $ExitCode -ne 0) -or $Item.Status -eq 'Running') { $Item.Status = 'Failed' }
|
||||||
|
foreach ($StreamName in @('stdout','stderr')) {
|
||||||
|
$StreamPath = Join-Path $FdWork ($Spec.Name + '-' + $StreamName + '.txt')
|
||||||
|
$StreamText = $(if (Test-Path -LiteralPath $StreamPath) { [IO.File]::ReadAllText($StreamPath) } else { '' })
|
||||||
|
Add-Member -InputObject $Item -MemberType NoteProperty -Name $StreamName -Value $StreamText
|
||||||
|
}
|
||||||
|
Add-Member -InputObject $Item -MemberType NoteProperty -Name ChildExitCode -Value $ExitCode
|
||||||
|
return $Item
|
||||||
|
}
|
||||||
|
|
||||||
|
$FdReport = New-Object PSObject -Property @{
|
||||||
|
SchemaVersion=1; RunId=$FdRunId; StartedUtc=[DateTime]::UtcNow.ToString('o'); Context=$FdContext
|
||||||
|
Probes=@(); Cleanup=@(); Findings=@(); ReportDirectory=$FdRoot
|
||||||
|
Limitations=@(
|
||||||
|
'Fresh child processes reproduce the caller TLS selection but not existing pooled connections, custom certificate callbacks, or custom in-memory proxy objects.',
|
||||||
|
'Registry paths reflect this process registry view; a 32-bit process may not expose the native 64-bit view.',
|
||||||
|
'Helper dependencies outside the captured functions/global LogFile are not reproduced.',
|
||||||
|
'Scratch files preserve the original URL extension when usable, but not the production path; path-specific security behavior may differ.',
|
||||||
|
'PowerShell 2-4 compatibility requires actual runtime validation; newer features elsewhere in Tools.ps1 are separate from this diagnostic.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
$FdReportXml = Join-Path $FdRoot 'report.clixml'
|
||||||
|
$FdReportText = Join-Path $FdRoot 'report.txt'
|
||||||
|
try {
|
||||||
|
$FdCases = @('Environment','Headers','Helper','BitsSync','BitsJob','DotNetGet')
|
||||||
|
foreach ($FdMethod in $FdCases) {
|
||||||
|
$FdVariants = @($false)
|
||||||
|
if (@('Helper','BitsSync','BitsJob','DotNetGet') -contains $FdMethod) { $FdVariants = @($false,$true) }
|
||||||
|
foreach ($FdExisting in $FdVariants) {
|
||||||
|
$FdName = $FdMethod + $(if ($FdExisting) { '-existing-empty' } else { '-new' })
|
||||||
|
$FdSpec = New-Object PSObject -Property @{
|
||||||
|
Name=$FdName; Method=$FdMethod; ExistingEmpty=$FdExisting; ContextPath=$FdContextPath
|
||||||
|
ResultPath=(Join-Path $FdWork ($FdName + '-result.clixml'))
|
||||||
|
Destination=(Join-Path $FdWork ($FdName + $FdExtension))
|
||||||
|
LogPath=(Join-Path $FdWork ($FdName + '-helper.log'))
|
||||||
|
Tag=('Emberkom-DownloadDiagnostic-' + $FdRunId + '-' + $FdName)
|
||||||
|
}
|
||||||
|
Write-Host ("Testing {0} (limit {1}s)..." -f $FdName,$FdTimeout)
|
||||||
|
$FdResult = Invoke-FdWorker $FdSpec $FdTimeout
|
||||||
|
if (Test-Path -LiteralPath $FdSpec.LogPath) { Add-Member -InputObject $FdResult -MemberType NoteProperty -Name HelperLog -Value ([IO.File]::ReadAllText($FdSpec.LogPath)) }
|
||||||
|
$FdReport.Probes += $FdResult
|
||||||
|
Export-FdXml $FdReport $FdReportXml 20
|
||||||
|
# A killed child cannot run finally. Always use a separate, bounded
|
||||||
|
# cleanup worker; filter by BOTH unpredictable run ID and exact tag.
|
||||||
|
if (@('Helper','BitsSync','BitsJob') -contains $FdMethod) {
|
||||||
|
$FdSpec.Method = 'Cleanup'
|
||||||
|
$FdSpec.Name += '-cleanup'
|
||||||
|
$FdSpec.ResultPath = Join-Path $FdWork ($FdSpec.Name + '-result.clixml')
|
||||||
|
$FdReport.Cleanup += Invoke-FdWorker $FdSpec 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$FdDownloads = @($FdReport.Probes | Where-Object { @('Helper','BitsSync','BitsJob','DotNetGet') -contains $_.Method -and $_.Status -ne 'Skipped' })
|
||||||
|
$FdBad = @($FdDownloads | Where-Object { $_.Status -ne 'Complete' })
|
||||||
|
$FdHashes = @($FdDownloads | Where-Object { $_.Status -eq 'Complete' } | ForEach-Object { $_.Snapshots[$_.Snapshots.Count-1].SHA256 } | Sort-Object -Unique)
|
||||||
|
if ($FdBad.Count -eq 0 -and $FdHashes.Count -eq 1) {
|
||||||
|
$FdReport.Findings += 'All tested download paths produced the same nonempty file. Failure not reproduced; no transport replacement is justified by this run.'
|
||||||
|
} else {
|
||||||
|
$FdReport.Findings += 'A failure or content difference was observed. Compare per-probe errors, HTTP responses, BITS byte counts, and file snapshots before choosing a shared fix.'
|
||||||
|
}
|
||||||
|
foreach ($FdProbe in $FdDownloads) {
|
||||||
|
$FdAfter = @($FdProbe.Snapshots | Where-Object { $_.Label -eq 'After transfer' -or $_.Label -eq 'One second later' })
|
||||||
|
if ($FdAfter.Count -eq 2 -and ($FdAfter[0].SHA256 -ne $FdAfter[1].SHA256 -or $FdAfter[0].Length -ne $FdAfter[1].Length)) {
|
||||||
|
$FdReport.Findings += "File changed after transfer in $($FdProbe.Name); investigate post-download modification."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
# This directory is created exclusively by this invocation. Delete only its
|
||||||
|
# immediate scratch files; never recurse or touch the production destination.
|
||||||
|
$FdResolvedWork = (Resolve-Path -LiteralPath $FdWork).Path
|
||||||
|
$FdExpectedWork = [IO.Path]::GetFullPath((Join-Path $FdRoot 'work'))
|
||||||
|
if ($FdResolvedWork -ne $FdExpectedWork -or (Get-Item -LiteralPath $FdResolvedWork).Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
||||||
|
$FdReport.Findings += 'Scratch path validation failed; cleanup skipped.'
|
||||||
|
} else {
|
||||||
|
foreach ($FdScratch in @(Get-ChildItem -LiteralPath $FdResolvedWork -Force)) {
|
||||||
|
if (!$FdScratch.PSIsContainer) {
|
||||||
|
try { Remove-Item -LiteralPath $FdScratch.FullName -Force -ErrorAction Stop }
|
||||||
|
catch { $FdReport.Findings += ('Could not remove scratch file: ' + $FdScratch.FullName + ': ' + $_.Exception.Message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (@(Get-ChildItem -LiteralPath $FdResolvedWork -Force).Count -eq 0) { Remove-Item -LiteralPath $FdResolvedWork -Force }
|
||||||
|
}
|
||||||
|
Export-FdXml $FdReport $FdReportXml 20
|
||||||
|
# A readable recursive rendering retains nested exception and HTTP details.
|
||||||
|
function Format-FdReport($Value, $Indent) {
|
||||||
|
if ($null -eq $Value) { return ($Indent + '<null>') }
|
||||||
|
if ($Value -is [string] -or $Value -is [ValueType]) { return ($Indent + [string]$Value) }
|
||||||
|
if ($Value -is [Collections.IDictionary]) {
|
||||||
|
foreach ($Key in @($Value.Keys | Sort-Object)) { $Indent + [string]$Key + ':'; Format-FdReport $Value[$Key] ($Indent + ' ') }
|
||||||
|
} elseif ($Value -is [Collections.IEnumerable]) {
|
||||||
|
foreach ($Entry in $Value) { Format-FdReport $Entry ($Indent + ' ') }
|
||||||
|
} else {
|
||||||
|
foreach ($Property in $Value.PSObject.Properties) {
|
||||||
|
if (@('NoteProperty','Property') -contains [string]$Property.MemberType) { $Indent + $Property.Name + ':'; Format-FdReport $Property.Value ($Indent + ' ') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$FdText = @(Format-FdReport $FdReport '') -join [Environment]::NewLine
|
||||||
|
[IO.File]::WriteAllText($FdReportText,$FdText,[Text.Encoding]::UTF8)
|
||||||
|
}
|
||||||
|
Write-Host ("Diagnostic reports: {0}" -f $FdRoot)
|
||||||
|
New-Object PSObject -Property @{TextReport=$FdReportText; XmlReport=$FdReportXml; Findings=$FdReport.Findings}
|
||||||
|
} $URL $OutputDirectory $TimeoutSeconds
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# File download diagnosis
|
||||||
|
|
||||||
|
`Test-FileDownload.ps1` compares the loaded `Download-File` helper, synchronous BITS, an inspectable BITS job, and a direct .NET GET. It makes no changes to `Tools.ps1`, collector behavior, Windows TLS configuration, or proxy configuration. Downloaded files are never executed.
|
||||||
|
|
||||||
|
## Run on the affected host
|
||||||
|
|
||||||
|
Use the same RMM account, PowerShell executable, and bootstrap as the failing job. Keep the existing Tools bootstrap unchanged and replace the final collector invocation with this block after publishing the diagnostic script to the repository:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$DiagnosticClient = New-Object Net.WebClient
|
||||||
|
try {
|
||||||
|
$DiagnosticText = $DiagnosticClient.DownloadString(
|
||||||
|
'https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Test-FileDownload.ps1'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
finally { $DiagnosticClient.Dispose() }
|
||||||
|
|
||||||
|
& ([scriptblock]::Create($DiagnosticText)) `
|
||||||
|
-URL 'https://xfer.emberkom.com/shares/tools/folders/9b9988fb-c53a-4833-a4ad-68d01a3021bb/files/dsa-collect-windows-amd64-exe/download/dsa-collect-windows-amd64.exe' `
|
||||||
|
-OutputDirectory "$Env:ProgramData\Emberkom\Output" `
|
||||||
|
-TimeoutSeconds 120
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not invoke it through `Download-File`, since that is the function being investigated. Alternatively, save the diagnostic on the affected host and call it directly with `& 'C:\path\Test-FileDownload.ps1' -URL '<download URL>'`. If Tools cannot load on a legacy engine, a standalone run still tests the other methods and records that the helper was unavailable.
|
||||||
|
|
||||||
|
Use `powershell.exe`, not ISE or an embedded PowerShell host. The diagnostic rejects other executables instead of guessing how to launch equivalent child processes.
|
||||||
|
|
||||||
|
The mandatory `-URL` can be any HTTP(S) file URL without embedded credentials. `-OutputDirectory` defaults to the account's temporary directory. Each invocation creates its own `file-download-diagnostic-<GUID>` subdirectory, containing `report.txt` and `report.clixml`. No reports are uploaded automatically. Retrieve `report.txt` from the affected host for analysis; the CLIXML file preserves structured details.
|
||||||
|
|
||||||
|
There are ten sequential probes: environment, HTTP headers/ranges, and two destination variants for each of four download paths. Eight probes download the payload, so use a small representative file. Each probe has a 120-second default wall-clock limit, followed by a separate cleanup attempt of at most 15 seconds for BITS-related probes. Allow about 22 minutes plus process startup/reporting overhead for the worst case, or specify a shorter timeout. Large downloads and hashing count toward the timeout.
|
||||||
|
|
||||||
|
## What the report captures
|
||||||
|
|
||||||
|
- OS, CLR and PowerShell versions, process bitness, executable, identity, BITS service/module information, and proxy/TLS settings. Missing registry values are recorded rather than invented as defaults.
|
||||||
|
- Definitions and SHA-256 hashes of the loaded helper functions, plus command resolution. The full Tools script is not executed by the diagnostic.
|
||||||
|
- HTTP redirect chains, status, content length, range support, and selected response headers. Cookies and authorization headers are not collected.
|
||||||
|
- Independent downloads to new destinations and existing empty destinations, byte counts, hashes, first bytes, timing, and full exceptions.
|
||||||
|
- BITS job state changes and byte counts before completion, and file snapshots immediately after transfer and one second later.
|
||||||
|
|
||||||
|
Child processes use the caller's PowerShell executable and account, with its .NET TLS selection reproduced in the child only. Existing pooled connections, custom in-memory proxy objects, and certificate callbacks are not copied. The helper probe copies the captured functions, redirects helper logging into diagnostic scratch, and adds ownership tags to BITS jobs. A shadowed/non-native `Start-BitsTransfer` is recorded and the helper probe is skipped rather than risking cleanup of unidentified jobs.
|
||||||
|
|
||||||
|
Scratch filenames retain the original URL's extension when usable, but the production destination is never touched. Path-specific security rules may therefore behave differently. The loaded helper's logging overrides are copied only into its child process.
|
||||||
|
|
||||||
|
Only jobs matching both the diagnostic's unique run ID and exact probe tag are removed. Scratch cleanup does not recurse or touch the production executable. Cleanup errors and unfinished probes remain visible in the report. Reports include local machine/account details and loaded source definitions; review them before sharing.
|
||||||
|
|
||||||
|
The code uses PowerShell 2 syntax and APIs available to legacy .NET, but local validation on PowerShell 5.1 does not prove execution on 2, 3, or 4. Run the diagnostic on actual legacy runtimes before declaring them supported. Full `Tools.ps1` loadability is a separate issue: it already contains newer syntax and commands, including `-in`/`-notin`, `::new()`, `ConvertFrom-Json`, and `Get-FileHash` outside the downloader.
|
||||||
|
|
||||||
|
## Interpret the comparison
|
||||||
|
|
||||||
|
| Observation | Next investigation |
|
||||||
|
|---|---|
|
||||||
|
| Only the helper fails | Compare `HelperBitsSource` with the original URL, the redirect chain, and URL-resolution requests. |
|
||||||
|
| BITS fails and direct GET succeeds | Compare service identity, WinHTTP/.NET proxy and TLS settings, HTTP behavior, and BITS error codes. |
|
||||||
|
| Both transports fail | Investigate endpoint responses, certificate trust, connectivity, and the affected machine's configuration. |
|
||||||
|
| BITS reports bytes transferred but the resulting file differs | Inspect completion errors, destination access, and post-download changes. |
|
||||||
|
| All paths produce the same nonempty hash | Failure was not reproduced. This run does not justify changing the shared transport. |
|
||||||
|
|
||||||
|
Neither a positive byte count nor matching hashes authenticates a publisher's executable. These are diagnostic comparisons, not a replacement for publisher-supplied integrity information.
|
||||||
|
|
||||||
|
## Shared caller audit
|
||||||
|
|
||||||
|
The audit found 24 active call sites across 20 files: 19 scripts plus five wrapper call sites in `Tools.ps1`. Commented-out calls and tests are excluded.
|
||||||
|
|
||||||
|
| Callers | Required behavior |
|
||||||
|
|---|---|
|
||||||
|
| `Run-Script`, `Source-PSScript`, `Uninstall-MicrosoftOffice` | Return one completed script path with its extension; allow immediate sourcing/execution. |
|
||||||
|
| `Install-MSI`, `Install-4KVideoDownloader`, `Install-LiquidFilesOutlookAgent`, `Install-SimpleInOut`, `Install-SpecsIntact`, `Install-Wireguard` | Return a completed installer path for immediate MSI invocation and later cleanup. |
|
||||||
|
| `Install-Nextcloud`, `Install-OpenVPN`, `Install-VLC` | Work inside a subexpression or via positional URL; return only the downloaded path. |
|
||||||
|
| `Install-AutodeskDesktopConnector`, `Install-Enscape`, `Install-ESETManagementAgent`, `Install-MicrosoftOffice365`, `Install-SketchUp` | Honor an explicit destination; handle redirects and potentially large installers; finish before installation. |
|
||||||
|
| `Fix-AutodeskProductLicensing`, `Test-InternetBandwidth`, `Get-LiquidFilesCLI` | Retain a usable ZIP filename and extension for extraction and derived working-directory names. |
|
||||||
|
| `Get-DSAManifest`, `Fix-UpdateLocalHosts`, `Remove-AllESETProducts`, `Install-LocalTools` | Honor fixed executable paths and complete replacement before execution. Caller-level caching remains separate. |
|
||||||
|
|
||||||
|
All concrete download URLs in this audit use HTTPS. The public wrappers also accept caller-provided HTTP(S) URLs. No audited caller consumes a BITS job object or explicitly controls background transfers/resume.
|
||||||
|
|
||||||
|
`Download-File` currently calls `Get-AbsoluteURI`, which first calls `IsURLValid`; both issue GET requests without disposing their responses. The final response URI is then passed to BITS. This is an observed implementation detail to investigate, not proof that BITS should be replaced.
|
||||||
|
|
||||||
|
Any subsequent shared fix must preserve URL binding, explicit/automatic filenames, synchronous completion, one success path on the output stream, redirects, large-file streaming, shell architectures, and `Run-Script` shared scope. It must also address validation and failure propagation before callers install or extract content. Production implementation and rollout follow the diagnostic findings; this change adds no fallback or collector-specific transport option.
|
||||||
|
|
||||||
|
## Local verification
|
||||||
|
|
||||||
|
Run `tests\Test-FileDownload.Tests.ps1` in both System32 and SysWOW64 Windows PowerShell. The suite serves synthetic payloads over loopback, tests both destination variants, redirects, chunked/empty/truncated/error responses, timeouts, hashing, and preservation of an unrelated BITS job. It requires access to the BITS service but performs no external downloads, installations, or uploads.
|
||||||
|
|
||||||
|
The affected RMM host and actual PowerShell 2-4 engines must still be tested separately. No local result substitutes for those environments.
|
||||||
|
|
||||||
|
Local verification on 2026-09-30 passed under both 32-bit and 64-bit PowerShell 5.1. A live 32-bit run against the AMD64 collector URL returned 3,455,488 bytes with SHA-256 `CC693EF2FEEEF05C99410B6F7A05AE2621EA0B89BA6D3E12C50B7F059D557F99` for every download method and destination variant. This is an observed comparison hash, not a publisher-provided trust anchor. The failure was not reproduced, so the current recommendation is to retain the shared transport until the affected RMM run supplies evidence.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#Requires -Version 5.0
|
||||||
|
# Local HTTP fixtures only. No external downloads, execution of payloads, or uploads.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
$Repo = Split-Path $PSScriptRoot -Parent
|
||||||
|
$Diagnostic = Join-Path $Repo 'Test-FileDownload.ps1'
|
||||||
|
$Tokens=$null; $Errors=$null
|
||||||
|
$Ast=[Management.Automation.Language.Parser]::ParseFile($Diagnostic,[ref]$Tokens,[ref]$Errors)
|
||||||
|
if ($Errors.Count) { throw ($Errors | Out-String) }
|
||||||
|
$WorkerAssignment=$Ast.Find({param($Node) $Node -is [Management.Automation.Language.AssignmentStatementAst] -and $Node.Left.Extent.Text -eq '$FdWorker'},$true)
|
||||||
|
$WorkerExpression=$WorkerAssignment.Find({param($Node) $Node -is [Management.Automation.Language.ScriptBlockExpressionAst]},$true)
|
||||||
|
$Worker=$WorkerExpression.ScriptBlock.GetScriptBlock()
|
||||||
|
$ToolsAst=[Management.Automation.Language.Parser]::ParseFile((Join-Path $Repo 'Tools.ps1'),[ref]$Tokens,[ref]$Errors)
|
||||||
|
foreach ($Name in @('Download-File','Get-AbsoluteURI','IsURLValid','Get-TempPath')) {
|
||||||
|
$Definition=$ToolsAst.Find({param($Node) $Node -is [Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq $Name},$true)
|
||||||
|
. ([scriptblock]::Create($Definition.Extent.Text))
|
||||||
|
}
|
||||||
|
Import-Module BitsTransfer
|
||||||
|
function Assert-Fd([bool]$Condition,[string]$Message) { if (!$Condition) { throw $Message } }
|
||||||
|
Add-Type -TypeDefinition @'
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
public sealed class FdHttpFixture : IDisposable {
|
||||||
|
private readonly TcpListener listener;
|
||||||
|
private volatile bool stopping;
|
||||||
|
public readonly byte[] Payload = new byte[131072];
|
||||||
|
public readonly string BaseUrl;
|
||||||
|
public FdHttpFixture() {
|
||||||
|
for (int i=0;i<Payload.Length;i++) Payload[i]=(byte)(i%251);
|
||||||
|
listener=new TcpListener(IPAddress.Loopback,0); listener.Start();
|
||||||
|
BaseUrl="http://127.0.0.1:"+((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
Thread t=new Thread(Accept); t.IsBackground=true; t.Start();
|
||||||
|
}
|
||||||
|
private void Accept() {
|
||||||
|
while(!stopping) {
|
||||||
|
try { TcpClient c=listener.AcceptTcpClient(); ThreadPool.QueueUserWorkItem(Serve,c); }
|
||||||
|
catch { if(stopping) return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void Serve(object state) {
|
||||||
|
using(TcpClient client=(TcpClient)state) {
|
||||||
|
try {
|
||||||
|
client.ReceiveTimeout=5000; client.SendTimeout=5000;
|
||||||
|
NetworkStream stream=client.GetStream();
|
||||||
|
StreamReader reader=new StreamReader(stream,Encoding.ASCII);
|
||||||
|
string first=reader.ReadLine(); if(first==null) return;
|
||||||
|
string[] parts=first.Split(' '); string method=parts[0],path=parts[1];
|
||||||
|
string range=null,line;
|
||||||
|
while(!String.IsNullOrEmpty(line=reader.ReadLine())) if(line.StartsWith("Range:",StringComparison.OrdinalIgnoreCase)) range=line.Substring(6).Trim();
|
||||||
|
if(path.StartsWith("/redirect")) { Send(stream,"302 Found","Location: /file.bin\r\nContent-Length: 0\r\n",null); return; }
|
||||||
|
if(path.StartsWith("/missing")) { Send(stream,"404 Not Found","Content-Length: 0\r\n",null); return; }
|
||||||
|
if(path.StartsWith("/empty")) { Send(stream,"200 OK","Content-Length: 0\r\n",null); return; }
|
||||||
|
if(path.StartsWith("/truncated")) { Send(stream,"200 OK","Content-Length: 100\r\n",Encoding.ASCII.GetBytes("short")); return; }
|
||||||
|
if(path.StartsWith("/chunked")) { Send(stream,"200 OK","Transfer-Encoding: chunked\r\n",Encoding.ASCII.GetBytes("5\r\nhello\r\n0\r\n\r\n")); return; }
|
||||||
|
int start=0,end=Payload.Length-1; string status="200 OK",extra="";
|
||||||
|
if(range!=null) {
|
||||||
|
string[] limits=range.Substring(6).Split('-'); start=Int32.Parse(limits[0]);
|
||||||
|
if(limits.Length>1 && limits[1].Length>0) end=Math.Min(end,Int32.Parse(limits[1]));
|
||||||
|
status="206 Partial Content"; extra="Content-Range: bytes "+start+"-"+end+"/"+Payload.Length+"\r\n";
|
||||||
|
}
|
||||||
|
byte[] body=new byte[end-start+1]; Buffer.BlockCopy(Payload,start,body,0,body.Length);
|
||||||
|
Send(stream,status,extra+"Content-Length: "+body.Length+"\r\n",method=="HEAD"?null:body);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private static void Send(NetworkStream stream,string status,string headers,byte[] body) {
|
||||||
|
byte[] header=Encoding.ASCII.GetBytes("HTTP/1.1 "+status+"\r\nConnection: close\r\nContent-Type: application/octet-stream\r\nAccept-Ranges: bytes\r\nLast-Modified: Mon, 01 Jan 2024 00:00:00 GMT\r\n"+headers+"\r\n");
|
||||||
|
stream.Write(header,0,header.Length); if(body!=null) stream.Write(body,0,body.Length);
|
||||||
|
}
|
||||||
|
public void Dispose() { stopping=true; listener.Stop(); }
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
$Fixture=New-Object FdHttpFixture
|
||||||
|
$Root=Join-Path ([IO.Path]::GetTempPath()) ('fd-diag-tests-' + [guid]::NewGuid().ToString('N'))
|
||||||
|
$null=New-Item -Path $Root -ItemType Directory
|
||||||
|
$SentinelJob=$null
|
||||||
|
try {
|
||||||
|
# Another application's BITS job must survive all diagnostic cleanups.
|
||||||
|
$SentinelJob=BitsTransfer\Start-BitsTransfer -Source ($Fixture.BaseUrl+'/file.bin') -Destination (Join-Path $Root 'unrelated.bin') -Suspended -Asynchronous -DisplayName ('fd-test-sentinel-'+[guid]::NewGuid()) -Description 'Unrelated test job'
|
||||||
|
$OriginalProtocol=[Net.ServicePointManager]::SecurityProtocol
|
||||||
|
$OriginalHelper=(Get-Command Download-File).Definition
|
||||||
|
$ReportPaths=& $Diagnostic -URL ($Fixture.BaseUrl+'/redirect.exe') -OutputDirectory (Join-Path $Root 'reports with spaces') -TimeoutSeconds 15
|
||||||
|
$Report=Import-Clixml -LiteralPath $ReportPaths.XmlReport
|
||||||
|
Assert-Fd ($Report.Probes.Count -eq 10) 'Expected metadata plus both destination variants for four download methods.'
|
||||||
|
Assert-Fd ($Report.Context.DestinationExtension -eq '.exe') 'Original file extension was not preserved.'
|
||||||
|
$Failures=@($Report.Probes | Where-Object {$_.Status -ne 'Complete'})
|
||||||
|
Assert-Fd ($Failures.Count -eq 0) ('Unexpected failures: '+($Failures | Select-Object Name,Status,@{Name='Message';Expression={$_.Errors.Message -join '; '}} | Format-List | Out-String))
|
||||||
|
$Downloads=@($Report.Probes | Where-Object {$_.Snapshots.Count -gt 0})
|
||||||
|
$Hasher=[Security.Cryptography.SHA256]::Create()
|
||||||
|
try { $ExpectedHash=[BitConverter]::ToString($Hasher.ComputeHash($Fixture.Payload)).Replace('-','') } finally {$Hasher.Clear()}
|
||||||
|
foreach ($Probe in $Downloads) {
|
||||||
|
$Snapshot=$Probe.Snapshots[-1]
|
||||||
|
Assert-Fd ($Snapshot.Length -eq $Fixture.Payload.Length -and $Snapshot.SHA256 -eq $ExpectedHash) ('Incorrect bytes: '+$Probe.Name)
|
||||||
|
Assert-Fd ($Probe.ChildProcessBits -eq [IntPtr]::Size*8) 'Child changed process architecture.'
|
||||||
|
}
|
||||||
|
Assert-Fd ((Get-Command Download-File).Definition -ceq $OriginalHelper) 'Diagnostic modified the caller helper.'
|
||||||
|
Assert-Fd ([Net.ServicePointManager]::SecurityProtocol -eq $OriginalProtocol) 'Diagnostic modified caller TLS.'
|
||||||
|
Assert-Fd (@($Report.Cleanup | Where-Object {$_.Status -ne 'Complete' -or $_.CleanupErrors.Count}).Count -eq 0) 'Cleanup reported an error.'
|
||||||
|
Assert-Fd (!(Test-Path -LiteralPath (Join-Path $Report.ReportDirectory 'work'))) 'Scratch files were not removed.'
|
||||||
|
Assert-Fd (Test-Path -LiteralPath $ReportPaths.TextReport) 'Readable report missing.'
|
||||||
|
Assert-Fd (@(($Report.Probes | Where-Object Method -eq 'Headers').Http | Where-Object Status -eq 302).Count -gt 0) 'Redirect chain missing.'
|
||||||
|
$null=BitsTransfer\Get-BitsTransfer -JobId $SentinelJob.JobId -ErrorAction Stop
|
||||||
|
Write-Host 'PASS: helper, sync BITS, inspectable BITS, and GET; redirects; existing-empty overwrite; hashes; isolation; cleanup.'
|
||||||
|
|
||||||
|
# Exercise response edge cases directly through the same diagnostic worker.
|
||||||
|
foreach ($Case in @('empty','chunked','truncated','missing')) {
|
||||||
|
$ContextPath=Join-Path $Root ($Case+'-context.xml')
|
||||||
|
@{URL=$Fixture.BaseUrl+'/'+$Case; SecurityProtocol=[int]$OriginalProtocol; TimeoutSeconds=5; RunId='test'} | Export-Clixml -Path $ContextPath
|
||||||
|
$SpecPath=Join-Path $Root ($Case+'-input.xml')
|
||||||
|
$ResultPath=Join-Path $Root ($Case+'-result.xml')
|
||||||
|
@{Name=$Case; Method='DotNetGet'; ExistingEmpty=$false; ContextPath=$ContextPath; ResultPath=$ResultPath; Destination=(Join-Path $Root ($Case+'.bin'))} | Export-Clixml -Path $SpecPath
|
||||||
|
& $Worker $SpecPath
|
||||||
|
$Edge=Import-Clixml -LiteralPath $ResultPath
|
||||||
|
if ($Case -eq 'empty') { Assert-Fd ($Edge.Status -eq 'EmptyFile' -and $Edge.Snapshots[-1].Length -eq 0) 'Empty body misclassified.' }
|
||||||
|
elseif ($Case -eq 'chunked') { Assert-Fd ($Edge.Status -eq 'Complete' -and $Edge.Snapshots[-1].Length -eq 5) 'Chunked response failed.' }
|
||||||
|
else { Assert-Fd ($Edge.Status -eq 'Failed' -and $Edge.Errors.Count -gt 0) ('Failure details missing: '+$Case) }
|
||||||
|
Write-Host "PASS: $Case response."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Download-File { [CmdletBinding()] param($URL,$File) Start-Sleep -Seconds 90 }
|
||||||
|
$Watch=[Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$TimeoutPaths=& $Diagnostic -URL ($Fixture.BaseUrl+'/file.bin') -OutputDirectory $Root -TimeoutSeconds 3
|
||||||
|
$TimeoutReport=Import-Clixml -LiteralPath $TimeoutPaths.XmlReport
|
||||||
|
$HelperTimeouts=@($TimeoutReport.Probes | Where-Object {$_.Method -eq 'Helper' -and $_.Status -eq 'TimedOut'})
|
||||||
|
Assert-Fd ($HelperTimeouts.Count -eq 2) 'Stalled helper was not bounded in both destination variants.'
|
||||||
|
Assert-Fd ($Watch.Elapsed.TotalSeconds -lt 100) 'Timeout run exceeded the expected total bound.'
|
||||||
|
Assert-Fd (!(Test-Path -LiteralPath (Join-Path $TimeoutReport.ReportDirectory 'work'))) 'Timed-out scratch files remain.'
|
||||||
|
$null=BitsTransfer\Get-BitsTransfer -JobId $SentinelJob.JobId -ErrorAction Stop
|
||||||
|
Write-Host 'PASS: stalled helper is stopped; partial report survives; unrelated BITS job survives.'
|
||||||
|
Write-Host "All Test-FileDownload tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size*8)-bit."
|
||||||
|
} finally {
|
||||||
|
$Fixture.Dispose()
|
||||||
|
if ($SentinelJob) { BitsTransfer\Remove-BitsTransfer -BitsJob $SentinelJob -ErrorAction SilentlyContinue }
|
||||||
|
$Resolved=(Resolve-Path -LiteralPath $Root).Path
|
||||||
|
$Temp=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')+'\'
|
||||||
|
if (!$Resolved.StartsWith($Temp,[StringComparison]::OrdinalIgnoreCase) -or (Split-Path $Resolved -Leaf) -notlike 'fd-diag-tests-*') { throw 'Unexpected cleanup path.' }
|
||||||
|
Remove-Item -LiteralPath $Resolved -Recurse -Force
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user