Compare commits

..
10 Commits
98 changed files with 2251 additions and 20 deletions
+37 -15
View File
@@ -2,15 +2,18 @@
<#
.SYNOPSIS
Downloads and runs the DSA manifest collector.
Downloads and runs the DSA manifest collector, with optional upload and cleanup.
.DESCRIPTION
Tools.ps1 must be dot-sourced by the caller first. Set $DSAManifestSource
in the caller's scope before using Run-Script -LivePSScript Get-DSAManifest.
The output path is generated in $Global:OutputDirectory with the filename
dsa-manifest-HOSTNAME-yyyyMMddHHmmss.csv, using the computer name and current local date and time.
Set $Zip to 'true', '$true', 'yes', 'y', or '1' to include --zip.
Use 'false', '$false', 'no', 'n', or '0' to disable ZIP; an empty value also disables it.
Values are case-insensitive and surrounding whitespace is ignored.
$Zip enables ZIP compression; $Upload sends the result to the FileDrop;
$DeleteAfterUpload removes the local result only after a confirmed upload.
All three accept 'true', '$true', 'yes', 'y', or '1' to enable, and
'false', '$false', 'no', 'n', or '0' to disable. Empty values disable the option.
Values are case-insensitive and surrounding whitespace is ignored. When
$Upload is false, the local CSV or ZIP is retained regardless of $DeleteAfterUpload.
#>
If ( [string]::IsNullOrWhiteSpace([string]$DSAManifestSource) ) {
@@ -18,16 +21,12 @@ If ( [string]::IsNullOrWhiteSpace([string]$DSAManifestSource) ) {
}
# Normalize RMM text values without treating every nonempty string as true.
$DsaZipValue = ([string]$Zip).Trim().ToLowerInvariant()
If ( $DsaZipValue -in @('true', '$true', 'yes', 'y', '1') ) {
$DsaZipEnabled = $true
}
ElseIf ( $DsaZipValue -in @('', 'false', '$false', 'no', 'n', '0') ) {
$DsaZipEnabled = $false
}
Else {
Throw 'Invalid $Zip value. Use true, $true, yes, y, 1, false, $false, no, n, or 0.'
$DsaOptions = @{
Zip = ConvertTo-RmmBoolean -Value $Zip -VariableName 'Zip'
Upload = ConvertTo-RmmBoolean -Value $Upload -VariableName 'Upload'
DeleteAfterUpload = ConvertTo-RmmBoolean -Value $DeleteAfterUpload -VariableName 'DeleteAfterUpload'
}
$DsaZipEnabled = $DsaOptions.Zip
$DSAManifestOutput = Join-Path -Path $Global:OutputDirectory -ChildPath (
'dsa-manifest-{0}-{1}.csv' -f $Env:COMPUTERNAME, (Get-Date -Format 'yyyyMMddHHmmss')
@@ -46,13 +45,16 @@ $DsaExecutablePath = Join-Path -Path $Global:ToolsDirectory -ChildPath 'dsa-coll
New-Item -Path $Global:ToolsDirectory -ItemType Directory -Force -ErrorAction Stop | Out-Null
Write-Output "Downloading dsa-collect.exe to ${DsaExecutablePath}"
$DsaDownloadedFile = Download-File -URL $DsaDownloadUrl -File $DsaExecutablePath -ErrorAction Stop
$DsaDownloadedFile = Download-FileDirectly -URL $DsaDownloadUrl -File $DsaExecutablePath -ErrorAction Stop
# Download-File can return nothing on failure. Do not run a stale executable.
# Require a returned path and a nonempty download before starting the collector.
If ( [string]::IsNullOrWhiteSpace([string]$DsaDownloadedFile) -or
!(Test-Path -LiteralPath $DsaExecutablePath -PathType Leaf) ) {
Throw "Failed to download dsa-collect.exe to ${DsaExecutablePath}"
}
If ( (Get-Item -LiteralPath $DsaExecutablePath -ErrorAction Stop).Length -eq 0 ) {
Throw "The downloaded dsa-collect.exe is 0 bytes: ${DsaExecutablePath}. The collector was not started. Check the download response and client TLS/BITS settings."
}
$DsaArguments = @('--source', [string]$DSAManifestSource, '--output', [string]$DSAManifestOutput)
If ( $DsaZipEnabled ) {
@@ -65,6 +67,7 @@ $DsaCommandLine = ($DsaArguments | ForEach-Object {
'"{0}"' -f ($_ -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1')
}) -join ' '
$DsaCollectionTime = Get-Date
Write-Output "Running dsa-collect.exe (ZIP compression: ${DsaZipEnabled})"
$DsaProcess = Start-Process -FilePath $DsaExecutablePath -ArgumentList $DsaCommandLine `
-Wait -PassThru -WindowStyle Hidden -ErrorAction Stop
@@ -80,3 +83,22 @@ If ( !(Test-Path -LiteralPath $DsaResultPath -PathType Leaf) ) {
Throw "dsa-collect.exe did not create the expected output: ${DsaResultPath}"
}
Write-Output "DSA manifest created: ${DsaResultPath}"
If ( $DsaOptions.Upload ) {
Write-Output "Uploading DSA manifest: ${DsaResultPath}"
$DsaUploadReceipt = Upload-File -Path $DsaResultPath `
-Subject "DSA manifest file uploaded from ${Env:COMPUTERNAME}" `
-Message "DSA collection performed on ${DSAManifestSource} at $($DsaCollectionTime.ToString('yyyy-MM-dd HH:mm:ss zzz'))" `
-ErrorAction Stop
If ( [string]::IsNullOrWhiteSpace([string]$DsaUploadReceipt.AttachmentId) -or
[string]::IsNullOrWhiteSpace([string]$DsaUploadReceipt.Status) ) {
Throw 'The upload did not return a confirmation receipt. The local manifest has been retained.'
}
Write-Output "DSA manifest uploaded: ${DsaResultPath}"
If ( $DsaOptions.DeleteAfterUpload ) {
Remove-Item -LiteralPath $DsaResultPath -Force -ErrorAction Stop
Write-Output "Deleted uploaded DSA manifest: ${DsaResultPath}"
}
$DsaUploadReceipt
}
+60 -2
View File
@@ -1,9 +1,67 @@
# Management Scripts
Most scripts here require the Tools.ps1 script. You can dot source that script like this:
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
```powershell
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
```
On older Windows PowerShell/.NET configurations, select TLS 1.2 **before** the first HTTPS download. The TLS setting inside `Tools.ps1` cannot help download `Tools.ps1` itself. See [Microsoft's PowerShell TLS guidance](https://learn.microsoft.com/en-us/security/engineering/solving-tls1-problem#update-windows-powershell-scripts-or-related-registry-settings).
You can then run any of the scripts using the Run-Script function:
Run-Script -LivePSScript Script-Name
If you're using an RMM tool, the scripts in the /rmm directory are all that's needed to hook the main script in this directory.
If you're using an RMM tool, the scripts in the /rmm directory are all that's needed to hook the main script in this directory.
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.
`Get-DSAManifest.ps1` uses `Download-FileDirectly`, which passes the original URL to BITS without preliminary `Get-AbsoluteURI` or `IsURLValid` requests. It accepts the same URL and optional `-File` arguments as `Download-File`, waits for completion, and throws on BITS errors. If `-File` is omitted, its temporary filename comes from the original URL; supply `-File` for extensionless URLs or URLs with query strings. Existing `Download-File` callers retain their previous behavior. Publish `Tools.ps1` together with `Get-DSAManifest.ps1` so the new helper is available.
## Converting RMM boolean variables
After dot-sourcing `Tools.ps1`, use `ConvertTo-RmmBoolean` with any RMM variable:
```powershell
$RestartEnabled = ConvertTo-RmmBoolean -Value $Restart -VariableName 'Restart'
If ( $RestartEnabled ) {
# Perform the requested restart.
}
# The variable name is optional; positional and pipeline input also work.
$ZipEnabled = ConvertTo-RmmBoolean $Zip
$UploadEnabled = $Upload | ConvertTo-RmmBoolean
```
Each input returns a `System.Boolean`: `true`, `$true`, `yes`, `y`, and `1` become `$true`; `false`, `$false`, `no`, `n`, `0`, blank strings, and `$null` become `$false`. Case and surrounding whitespace are ignored, and native boolean values also work. Other values throw an error; `-VariableName` identifies the input in that error. The function returns the converted value without changing the original variable; assign the result wherever needed.
`Get-DSAManifest.ps1` uses this helper for its three options. Publish it together with the updated `Tools.ps1` so the helper is available to the RMM caller.
## Uploading files
After dot-sourcing `Tools.ps1`, scripts can upload a completed file to the Emberkom FileDrop:
```powershell
Upload-File -Path 'C:\ProgramData\Emberkom\Output\report.zip'
```
`Get-DSAManifest.ps1` uploads its completed CSV or ZIP when `$Upload` is enabled. If `$DeleteAfterUpload` is also enabled, it deletes that same local file only after a confirmed successful upload. Disabled or failed uploads retain the file. `$Zip`, `$Upload`, and `$DeleteAfterUpload` all accept `true`, `$true`, `yes`, `y`, or `1`; `false`, `$false`, `no`, `n`, `0`, or an empty value disable the option. Casing and surrounding whitespace are ignored. The notification subject identifies the computer, and its message includes the source path and collection start time (local time with UTC offset).
The default destination is `https://xfer.emberkom.com/filedrop/cmd`. The sender defaults to the computer's hostname plus its primary DNS suffix, followed by `@emberkom.com` (for example, `pc01.example.com@emberkom.com`). Without a DNS suffix, it uses the hostname. Override these with `-FileDropUrl` and `-From`; `-Subject` and `-Message` are also optional.
`Upload-File` uses the [LiquidFiles FileDrop API](https://docs.liquidfiles.com/api/v4.3/filedrop/) to obtain a temporary token, upload the file, and submit the message that triggers FileDrop delivery and its configured email notification. It returns a receipt with the path, destination, sender, attachment ID, byte count, and server status. It retains the local file and throws on failure.
Uploads use [binary chunks](https://docs.liquidfiles.com/api/v4.3/attachments/chunks.html) with a default 10 MiB buffer, so large files do not need to fit in memory. The FileDrop's size and extension restrictions are checked before upload. `-ChunkSizeMB`, `-RetryCount`, and `-TimeoutSeconds` control chunk size, retries, and each request's timeout; `-Verbose` displays transfer details.
Allow the RMM job to run for the entire upload. The function waits for completion and retries failed chunks within that run; it does not resume across process restarts. LiquidFiles tokens expire after 24 hours. Final submission is attempted once: if its response is lost, check the FileDrop before rerunning to avoid duplicate notifications.
Offline tests (no uploads or notifications):
```powershell
powershell.exe -NoProfile -File .\tests\Upload-File.Tests.ps1
powershell.exe -NoProfile -File .\tests\Get-DSAManifest.Tests.ps1
powershell.exe -NoProfile -File .\tests\Download-File.Tests.ps1
powershell.exe -NoProfile -File .\tests\ConvertTo-RmmBoolean.Tests.ps1
```
+540
View File
@@ -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
+274 -3
View File
@@ -180,6 +180,45 @@ Function Get-Version {
Return [version]$Return.Trim('.')
}
# Convert an RMM text variable into an actual Boolean value.
Function ConvertTo-RmmBoolean {
<#
.SYNOPSIS
Converts an RMM text value to a System.Boolean.
.DESCRIPTION
Accepts true, $true, yes, y, or 1; false, $false, no, n, or 0.
Matching ignores case and surrounding whitespace. Null and empty values
return false. Unrecognized values throw instead of silently enabling an option.
.PARAMETER Value
The value to convert. Native Boolean values are also accepted.
.PARAMETER VariableName
Optional RMM variable name, without the leading $, for error messages.
.EXAMPLE
$ZipEnabled = ConvertTo-RmmBoolean -Value $Zip -VariableName 'Zip'
.EXAMPLE
ConvertTo-RmmBoolean ' No '
#>
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory=$true,Position=0,ValueFromPipeline=$true)]
[AllowNull()]
[AllowEmptyString()]
[string]$Value,
[string]$VariableName = 'Value'
)
process {
$NormalizedValue = ([string]$Value).Trim().ToLowerInvariant()
If ( $NormalizedValue -in @('true', '$true', 'yes', 'y', '1') ) {
Return $true
}
If ( $NormalizedValue -in @('', 'false', '$false', 'no', 'n', '0') ) {
Return $false
}
Throw ('Invalid ${0} value. Use true, $true, yes, y, 1, false, $false, no, n, or 0.' -f $VariableName)
}
}
# Determines whether a URL is valid
Function IsURLValid {
param([Parameter(Mandatory=$true,ValueFromPipeline=$true)][string]$URL)
@@ -203,12 +242,244 @@ Function Download-File {
$URI = Get-AbsoluteURI -URL $URL
If ( $null -eq $URI ) { Return }
If ( [string]::IsNullOrEmpty($File) ) { $File = '{0}{1}' -f (Get-TempPath), (Split-Path $URI -Leaf) }
#Try { (New-Object System.Net.WebClient).DownloadFile($URI,$File) }
Try { Start-BitsTransfer -Source $URI -Destination $File }
Catch { Write-Error $_.Exception.Message ; Return }
# BITS errors must stop the caller instead of returning a failed download's path.
Try { Start-BitsTransfer -Source $URI -Destination $File -ErrorAction Stop }
Catch { Throw "BITS download failed from ${URI} to ${File}: $($_.Exception.Message)" }
Return $File
}
# Downloads from the original URL; BITS handles redirects without preliminary web requests.
# Without -File, the temporary filename comes from the original URL, not its redirect target.
Function Download-FileDirectly {
param(
[Parameter(Mandatory=$true,ValueFromPipeline=$true)][string]$URL,
[Parameter(Mandatory=$false,ValueFromPipeline=$false)][string]$File
)
If ( [string]::IsNullOrEmpty($File) ) { $File = '{0}{1}' -f (Get-TempPath), (Split-Path $URL -Leaf) }
Try { Start-BitsTransfer -Source $URL -Destination $File -ErrorAction Stop }
Catch { Throw "BITS download failed from ${URL} to ${File}: $($_.Exception.Message)" }
Return $File
}
# Upload a file to a LiquidFiles Filedrop and submit its notification message.
Function Upload-File {
<#
.SYNOPSIS
Uploads a file to the Emberkom LiquidFiles Filedrop.
.DESCRIPTION
Uses the LiquidFiles 3.7+ JSON API with bounded binary chunks, including in
32-bit Windows PowerShell 5. The local file is retained. Returns a receipt
only after the Filedrop accepts the final message for delivery.
Uploads run synchronously. Allow enough time in the RMM for the entire
transfer; the temporary Filedrop API key expires after 24 hours. Failed
chunks are retried within this call, but restarting the script starts a
new transfer. Final message submission is not retried automatically,
because a lost response could otherwise cause duplicate notifications.
.PARAMETER Path
Literal path of one completed file. Also accepts -File or a pipeline path.
.PARAMETER From
Optional sender override. Defaults to the computer's fully qualified host
name at emberkom.com (or its host name when no DNS suffix is configured).
.PARAMETER ChunkSizeMB
Maximum upload buffer size in MiB; defaults to 10 regardless of file size.
.PARAMETER TimeoutSeconds
Timeout for each HTTP request, not for the entire transfer.
.EXAMPLE
Upload-File -Path $DsaResultPath
.EXAMPLE
Upload-File -File 'C:\Reports\report.zip' -Subject 'Diagnostic report' -Verbose
.LINK
https://docs.liquidfiles.com/api/v4.3/filedrop/
.LINK
https://docs.liquidfiles.com/api/v4.3/attachments/chunks.html
#>
[CmdletBinding()]
param(
[Parameter(Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
[Alias('File','FullName')][ValidateNotNullOrEmpty()][string]$Path,
[ValidateNotNullOrEmpty()][string]$FileDropUrl = 'https://xfer.emberkom.com/filedrop/cmd',
[string]$From,
[string]$Subject,
[string]$Message,
[ValidateRange(1,100)][int]$ChunkSizeMB = 10,
[ValidateRange(1,3600)][int]$TimeoutSeconds = 900,
[ValidateRange(0,10)][int]$RetryCount = 3
)
PROCESS {
$UploadUri = [uri]$FileDropUrl
If ( !$UploadUri.IsAbsoluteUri -or $UploadUri.Scheme -ne 'https' -or
$UploadUri.UserInfo -or $UploadUri.Query -or $UploadUri.Fragment ) {
Throw 'FileDropUrl must be an absolute HTTPS Filedrop URL without credentials, query, or fragment.'
}
$UploadBaseUrl = $UploadUri.AbsoluteUri.TrimEnd('/')
$UploadFile = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
If ( $UploadFile -isnot [System.IO.FileInfo] ) { Throw 'Upload-File requires a file, not a directory.' }
$UploadHostInfo = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties()
$UploadHostName = $UploadHostInfo.HostName
If ( [string]::IsNullOrWhiteSpace($UploadHostName) ) { $UploadHostName = [Environment]::MachineName }
$UploadDnsSuffix = $UploadHostInfo.DomainName.Trim('.')
If ( $UploadDnsSuffix -and !$UploadHostName.EndsWith(".${UploadDnsSuffix}", [StringComparison]::OrdinalIgnoreCase) ) {
$UploadHostName = "${UploadHostName}.${UploadDnsSuffix}"
}
$UploadSender = $From
If ( [string]::IsNullOrWhiteSpace($UploadSender) ) { $UploadSender = $UploadHostName.ToLowerInvariant() + '@emberkom.com' }
Try { $UploadSender = ([System.Net.Mail.MailAddress]::new($UploadSender)).Address }
Catch { Throw 'From must be a valid sender email address.' }
$UploadSubject = $Subject
If ( [string]::IsNullOrWhiteSpace($UploadSubject) ) { $UploadSubject = "File upload from ${UploadHostName}: $($UploadFile.Name)" }
$UploadMessage = $Message
If ( [string]::IsNullOrWhiteSpace($UploadMessage) ) { $UploadMessage = "Uploaded by ${UploadHostName}." }
Add-Type -AssemblyName System.Net.Http -ErrorAction Stop
$UploadClient = $null
$UploadHandler = $null
$UploadStream = $null
$UploadApiKey = $null
# Keep the same HTTP client (and cookies) for every chunk in this session.
Function Invoke-FileDropRequest {
param(
[string]$Method,
[string]$Uri,
[byte[]]$Data,
[int]$Count = 0,
[string]$ContentType,
[int]$Retries = $RetryCount
)
For ( $UploadAttempt = 0; $UploadAttempt -le $Retries; $UploadAttempt++ ) {
$UploadRequest = $null
$UploadResponse = $null
$UploadStatus = 0
Try {
$UploadRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method), $Uri)
If ( $Method -eq 'POST' ) {
$UploadRequest.Content = [System.Net.Http.ByteArrayContent]::new($Data, 0, $Count)
If ( $ContentType ) {
$UploadRequest.Content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse($ContentType)
}
}
$UploadResponse = $UploadClient.SendAsync($UploadRequest).GetAwaiter().GetResult()
$UploadStatus = [int]$UploadResponse.StatusCode
$UploadResponseText = $UploadResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult()
If ( !$UploadResponse.IsSuccessStatusCode ) {
# Do not include authentication tokens in errors or RMM logs.
If ( $UploadApiKey ) { $UploadResponseText = $UploadResponseText.Replace($UploadApiKey, '[redacted]') }
If ( $UploadResponseText.Length -gt 1000 ) { $UploadResponseText = $UploadResponseText.Substring(0, 1000) }
Throw "Filedrop returned HTTP ${UploadStatus}: ${UploadResponseText}"
}
If ( [string]::IsNullOrWhiteSpace($UploadResponseText) ) { Return $null }
$UploadResponseData = ConvertFrom-Json -InputObject $UploadResponseText -ErrorAction Stop
If ( $UploadResponseData.errors ) { Throw ('Filedrop rejected the request: ' + ($UploadResponseData.errors -join '; ')) }
Return $UploadResponseData
}
Catch {
$UploadCanRetry = $UploadStatus -eq 0 -or $UploadStatus -in @(408,429,500,502,503,504)
If ( !$UploadCanRetry -or $UploadAttempt -ge $Retries ) { Throw }
Write-Verbose "Retrying Filedrop request after a connection error or HTTP ${UploadStatus}."
}
Finally {
If ( $null -ne $UploadResponse ) { $UploadResponse.Dispose() }
If ( $null -ne $UploadRequest ) { $UploadRequest.Dispose() }
}
Start-Sleep -Seconds ([Math]::Min(30, [Math]::Pow(2, $UploadAttempt + 1)))
}
}
Try {
# Deny writes while reading so retried chunks always contain the same bytes.
$UploadStream = [System.IO.File]::Open($UploadFile.FullName, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
[long]$UploadLength = $UploadStream.Length
$UploadHandler = [System.Net.Http.HttpClientHandler]::new()
$UploadHandler.AllowAutoRedirect = $false
$UploadClient = New-Object -TypeName System.Net.Http.HttpClient -ArgumentList $UploadHandler
$UploadClient.Timeout = [TimeSpan]::FromSeconds($TimeoutSeconds)
$UploadClient.DefaultRequestHeaders.Accept.ParseAdd('application/json')
$UploadInfo = (Invoke-FileDropRequest -Method GET -Uri $UploadBaseUrl).filedrop
$UploadApiKey = [string]$UploadInfo.api_key
If ( [string]::IsNullOrWhiteSpace($UploadApiKey) ) {
Throw 'The Filedrop did not provide an API key. Check its URL, password, and email-validation requirements.'
}
If ( $UploadInfo.max_upload_size -gt 0 -and $UploadLength -gt ([long]$UploadInfo.max_upload_size * 1MB) ) {
Throw "The file exceeds the Filedrop limit of $($UploadInfo.max_upload_size) MiB."
}
$UploadExtension = $UploadFile.Extension.TrimStart('.').ToLowerInvariant()
$UploadPermitted = $UploadInfo.permitted_extensions
If ( !$UploadPermitted ) { $UploadPermitted = $UploadInfo.limited_extensions }
$UploadAllowedExtensions = @(([string]$UploadPermitted -split '[,\s]+') | ForEach-Object { $_.TrimStart('.') } | Where-Object { $_ })
$UploadBlockedExtensions = @(([string]$UploadInfo.blocked_extensions -split '[,\s]+') | ForEach-Object { $_.TrimStart('.') } | Where-Object { $_ })
If ( ($UploadAllowedExtensions.Count -gt 0 -and $UploadExtension -notin $UploadAllowedExtensions) -or
$UploadExtension -in $UploadBlockedExtensions ) {
Throw "The Filedrop does not permit the file extension '$UploadExtension'."
}
$UploadAuth = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($UploadApiKey + ':x'))
$UploadClient.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Basic', $UploadAuth)
[int]$UploadChunkSize = $ChunkSizeMB * 1MB
[long]$UploadChunks = [Math]::Max(1, [Math]::Ceiling($UploadLength / [double]$UploadChunkSize))
$UploadBuffer = [byte[]]::new([int][Math]::Min($UploadChunkSize, [Math]::Max(1, $UploadLength)))
$UploadEncodedName = [uri]::EscapeDataString($UploadFile.Name)
$UploadAttachment = $null
Write-Verbose "Uploading $($UploadFile.Name) ($UploadLength bytes) in $UploadChunks chunk(s) as ${UploadSender}."
For ( [long]$UploadChunk = 0; $UploadChunk -lt $UploadChunks; $UploadChunk++ ) {
[int]$UploadBytesNeeded = [Math]::Min($UploadChunkSize, $UploadLength - $UploadStream.Position)
[int]$UploadBytesRead = 0
While ( $UploadBytesRead -lt $UploadBytesNeeded ) {
$UploadRead = $UploadStream.Read($UploadBuffer, $UploadBytesRead, $UploadBytesNeeded - $UploadBytesRead)
If ( $UploadRead -eq 0 ) { Throw 'The local file ended before all expected bytes were read.' }
$UploadBytesRead += $UploadRead
}
$UploadChunkUrl = "${UploadBaseUrl}/attachments/upload?filename=${UploadEncodedName}&chunk=${UploadChunk}&chunks=${UploadChunks}"
$UploadChunkResult = Invoke-FileDropRequest -Method POST -Uri $UploadChunkUrl -Data $UploadBuffer -Count $UploadBytesRead
If ( $UploadChunkResult.attachment.id ) { $UploadAttachment = $UploadChunkResult.attachment }
Write-Progress -Activity "Uploading $($UploadFile.Name)" -Status "Chunk $($UploadChunk + 1) of ${UploadChunks}" `
-PercentComplete ([int](100 * ($UploadChunk + 1) / $UploadChunks))
}
If ( !$UploadAttachment.id ) { Throw 'The upload did not return an attachment ID; the Filedrop message was not submitted.' }
If ( $null -ne $UploadAttachment.size -and [long]$UploadAttachment.size -ne $UploadLength ) {
Throw 'The uploaded attachment size does not match the local file; the Filedrop message was not submitted.'
}
If ( $UploadAttachment.content_blocked ) { Throw 'LiquidFiles blocked this attachment; the Filedrop message was not submitted.' }
$UploadBody = @{ message = @{
from = $UploadSender
subject = $UploadSubject
message = $UploadMessage
attachments = @([string]$UploadAttachment.id)
} } | ConvertTo-Json -Depth 4 -Compress
$UploadBodyBytes = [Text.Encoding]::UTF8.GetBytes($UploadBody)
Try {
$UploadReceipt = Invoke-FileDropRequest -Method POST -Uri $UploadBaseUrl -Data $UploadBodyBytes `
-Count $UploadBodyBytes.Length -ContentType 'application/json; charset=utf-8' -Retries 0
If ( [string]::IsNullOrWhiteSpace([string]$UploadReceipt.message.status) ) {
Throw 'The server did not return a Filedrop submission confirmation.'
}
}
Catch {
Throw "Filedrop submission was not confirmed. Check the Filedrop before retrying to avoid duplicate notifications. $($_.Exception.Message)"
}
[pscustomobject]@{
Path = $UploadFile.FullName
FileDropUrl = $UploadBaseUrl
From = $UploadSender
AttachmentId = [string]$UploadAttachment.id
Size = $UploadLength
Status = [string]$UploadReceipt.message.status
}
}
Finally {
Write-Progress -Activity "Uploading $($UploadFile.Name)" -Completed
If ( $null -ne $UploadStream ) { $UploadStream.Dispose() }
If ( $null -ne $UploadClient ) { $UploadClient.Dispose() }
ElseIf ( $null -ne $UploadHandler ) { $UploadHandler.Dispose() }
$UploadApiKey = $null
}
}
}
# Install a program from a provided path
Function Install-Program {
param(
+85
View File
@@ -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,5 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
# $CleanupPath and $DaysWithNoModification must be set by caller
Run-Script -LivePSScript Cleanup-RevitTempFilesOnFileServerCalcOnly
+1
View File
@@ -0,0 +1 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Cleanup-SystemTempFiles.ps1')))
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Create-BatteryReport
+9
View File
@@ -0,0 +1,9 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
# Create the Enberkom Administrator user
New-LocalAccount -Username 'ek-admin' -Password 'C0d3R3d@!!' -FullName 'Emberkom Administrator' -Description 'Used by Emberkom to administer this endpoint' -MakeAdmin -Hide
# Delete the provisioning user that may have been created via PPKG
If ( $(Get-LocalUser -Name "ek-admin") ) {
Run-Script -LivePSScript Remove-ProvisioningAdminUser
}
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Create-NewTicketFromAppCrash-KERNELBASEdll
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Create-NewTicketFromBSODAnalysis
@@ -0,0 +1,3 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
$VisionURL = 'https://dbia.deltekfirst.com/DBIAClient/DeltekVision.application'
Run-Script -LivePSScript Install-DeltekVision -Arguments $VisionURL
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Enable-HyperVFeatures
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-AutodeskProductLicensing
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-ClearOutlookAutoCompleteCache
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-ClearQuickAccess
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-DellOptimizer
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-DisableAllOneDriveNotifications
+6
View File
@@ -0,0 +1,6 @@
@echo off
title Disable Sleep While Plugged-In
powercfg /x -hibernate-timeout-ac 0
powercfg /x -disk-timeout-ac 0
powercfg /x -monitor-timeout-ac 0
powercfg /x -standby-timeout-ac 0
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-DisableWindowsTelemetry
@@ -0,0 +1,12 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
# Set the edition of the ECB agent.
# Valid options are 'desktop' 'server' or 'vm'
$Edition = '{[Edition]}'
# Set the MSP360 username and password
$MSP360Username = '{[MSP360_Username]}'
$MSP360Password = '{[MSP360_Password]}'
# Run the installation script
Run-Script -LivePSScript Fix-EmberkomCloudBackupProductEdition
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-EnableGPUScheduling
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-EnableSystemRestore
+11
View File
@@ -0,0 +1,11 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
# Set the TDR level
# Note: 1 = Disable, 3 = Recover (default)
[int]$TdrLevel = '{[TDR_Level]}'
# Set TDR delay
# Note: 2 = Default
[int]$TdrDelay = '{[TDR_Delay]}'
Run-Script -LivePSScript Fix-GPUTimeoutDetectionResponse
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-GroupPolicyUpdate
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-HighCPUUsage
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-HighMemoryUsage
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-MicrosoftTeams
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-OfficeModernAuth
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-OutlookUnblockAttachmentTypes
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-RebootIntoSafeMode
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-ReinstallAllAppXPackages
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-Service-QuickBooksDB31
+9
View File
@@ -0,0 +1,9 @@
@echo off
rem Remove registry properties that add the Splashtop PDF Printer to the system
REG DELETE "HKEY_LOCAL_MACHINE\SOFTWARE\Splashtop Inc.\Splashtop Remote Server" /f /v PrinterINFa
REG DELETE "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Splashtop Inc.\Splashtop Remote Server" /f /v PrinterINFa
rem Restart the SplashtopRemoteService
net stop SplashtopRemoteService
net start SplashtopRemoteService
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-SplashtopService
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-SystemPrintQueue
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-UpdateLocalHosts
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-WindowsHealth
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-WindowsManagementInsturmentationWMI
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Fix-WindowsUpdate
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Get-BackupCapacityPlanningData
+23
View File
@@ -0,0 +1,23 @@
#Requires -Version 5.0
# Syncro supplies DSAManifestSource, Zip, Upload, and DeleteAfterUpload.
# Preserve those caller variables for the repository script.
# This must run before downloading Tools.ps1, including on Server 2016.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$RmmToolsUrl = 'https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1'
$RmmToolsClient = New-Object Net.WebClient
Try {
$RmmToolsText = $RmmToolsClient.DownloadString($RmmToolsUrl)
$RmmToolsScript = [Scriptblock]::Create($RmmToolsText)
}
Catch {
Throw "Unable to download or parse Tools.ps1 from ${RmmToolsUrl}: $($_.Exception.Message)"
}
Finally {
$RmmToolsClient.Dispose()
}
# Dot-source in this scope so the RMM input variables remain accessible.
. $RmmToolsScript
Run-Script -LivePSScript Get-DSAManifest
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Get-DisplayInformation
+3
View File
@@ -0,0 +1,3 @@
. ([ScriptBlock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
$DriveLetter = '{[DriveLetter]}'
Get-FileSizes -Path "${DriveLetter}:\" -Recurse -IncludeHidden -Largest 50 -Units 'GB'
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Get-InstalledSoftware
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Get-UserLogonActivity
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Get-WindowsAppXPackages
@@ -0,0 +1,4 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Create-NetworkDrive -DriveLetter O: -Path "\\nexus\Office Drive" -Label "Office Drive"
Create-NetworkDrive -DriveLetter P: -Path "\\nexus\CWolfe Drive" -Label "Design Drive"
Create-NetworkDrive -DriveLetter Z: -Path "\\nexus\Accounting Drive" -Label "Accounting Drive"
+32
View File
@@ -0,0 +1,32 @@
# The following section is by j.mcbride from https://community.syncromsp.com/t/powershell-7-2/7425/5
# Check for required PowerShell version (7+)
if (!($PSVersionTable.PSVersion.Major -ge 7)) {
try {
# Install PowerShell 7 if missing
if (!(Test-Path "$env:SystemDrive\Program Files\PowerShell\7")) {
Write-Output 'Installing PowerShell version 7...'
Invoke-Expression "& { $(Invoke-RestMethod https://aka.ms/install-powershell.ps1) } -UseMSI -Quiet"
}
# Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User')
# Restart script in PowerShell 7
pwsh -File "`"$PSCommandPath`"" @PSBoundParameters
}
catch {
Write-Output 'PowerShell 7 was not installed. Update PowerShell and try again.'
throw $Error
}
finally { exit $LASTEXITCODE }
}
# Input the actual script below this line
# The rest of this script is by Emberkom
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
$HuduAPIKey = ''
$HuduBaseDomain = 'https://emberkom.huducloud.com'
$CompanyName = $CName
Run-Script -LivePSScript Hudu-DocumentADDS
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Install-4KVideoDownloader
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Install-AdobeConnect
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Install-AmazonCorretto
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Install-AutodeskDesktopConnector
+3
View File
@@ -0,0 +1,3 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
# $VisionURL = ''
Run-Script -LivePSScript Install-DeltekVision -Arguments $VisionURL
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Install-DisplayLink
+12
View File
@@ -0,0 +1,12 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
# Set the edition of the ECB agent.
# Valid options are 'desktop' 'server' or 'vm'
$Edition = '{[Edition]}'
# Set the MSP360 username and password
$MSP360Username = '{[MSP360_Username]}'
$MSP360Password = '{[MSP360_Password]}'
# Run the installation script
Run-Script -LivePSScript Install-EmberkomCloudBackup
+3
View File
@@ -0,0 +1,3 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Install-Enscape
+6
View File
@@ -0,0 +1,6 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
# Set the host to connect to
$LFHost = '{[LF_Base_URL]}'
Run-Script -LivePSScript Install-LiquidFilesOutlookAgent
+5
View File
@@ -0,0 +1,5 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
# RMM provider must supply the value for $COMPANY variable
Run-Script -LivePSScript Install-MicrosoftOffice365
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Install-Nextcloud
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Install-OpenVPN
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Install-SimpleInOut
+6
View File
@@ -0,0 +1,6 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
## Version of SketchUp to install
$InstallVersion = '{[Version]}'
Run-Script -LivePSScript Install-SketchUp
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Install-SpecsIntact
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Install-Wireguard
@@ -0,0 +1,247 @@
# Settings
$UserPrincipalName = "admin@constructtechservices.com"
$EnableExternalTagging = $true
$ExternalTaggingAllowedDomains = @{Add="emberkom.com", "ef-capital.com", "elysionconstruction.com", "elysioncreations.com", "tesseractrentals.com", "arcadiapropertyservices.com"}
$BypassSenderDomains = @(
"emberkom.com",
"notify.emberkom.com",
"ef-capital.com",
"elysionconstruction.com",
"elysioncreations.com",
#"constructtechservices.com",
"tesseractrentals.com",
"arcadiapropertyservices.com"
)
$SetImpersonationRule = $true
$SetSuspiciousEmailRule = $true
$SetGeneralExternalEmailRule = $true
# Connect to EOL
Connect-ExchangeOnline -UserPrincipalName $UserPrincipalName
# Enable/disable external tagging for Outlook clients
If ( (Get-ExternalInOutlook).Enabled -ne $EnableExternalTagging ) {
If ( $EnableExternalTagging ) {
Write-Output "Enabling external email tagging"
} Else {
Write-Output "Disabling external email tagging"
}
If ( $EnableExternalTagging -and $ExternalTaggingAllowedDomains ) {
Set-ExternalInOutlook -Enabled $EnableExternalTagging -AllowList $ExternalTaggingAllowedDomains
} Else {
Set-ExternalInOutlook -Enabled $EnableExternalTagging
}
}
# Impersonation Rule
If ( $SetImpersonationRule ) {
$ImpersonationEmailDisclaimerTitle = "Warning:"
$ImpersonationEmailDisclaimerText = "This appears to be a fradulent email attempting to impersonate someone inside your organization. Do not click on links or open attachments unless you are certain this email is safe."
$ImpersonationRuleName = "Impersonation Warning"
$ImpersonationDisclaimer = '<table border=0 cellspacing=0 cellpadding=0 align="left" width="100%">
<tr>
<td style="background:#dc3232;padding:5pt 2pt 5pt 2pt"></td>
<td width="100%" cellpadding="7px 6px 7px 15px" style="background:#fff8e5;padding:5pt 4pt 5pt 12pt;word-wrap:break-word">
<div style="color:#222222;">
<span style="color:#222; font-weight:bold;">' + $ImpersonationEmailDisclaimerTitle + ' </span>' + $ImpersonationEmailDisclaimerText + '
</div>
</td>
</tr>
</table>
<br/>'
# Set the transport rule for user impersonation
$DisplayNames = (Get-EXOMailbox -ResultSize Unlimited -RecipientTypeDetails UserMailbox).DisplayName
$ImpersonationTransportRule = Get-TransportRule | Where-Object { $_.Name -eq $ImpersonationRuleName }
If ( $ImpersonationTransportRule ) {
Write-Output "Updating transport rule: ${ImpersonationRuleName}"
Set-TransportRule -Identity $ImpersonationRuleName `
-FromScope NotInOrganization `
-SentToScope InOrganization `
-HeaderMatchesMessageHeader From `
-HeaderMatchesPatterns $DisplayNames `
-ExceptIfSenderDomainIs $BypassSenderDomains `
-ApplyHtmlDisclaimerLocation Prepend `
-ApplyHtmlDisclaimerText $ImpersonationDisclaimer `
-ApplyHtmlDisclaimerFallbackAction Wrap
Write-Output "Transport rule updated"
} Else {
Write-Output "Creating transport rule: ${ImpersonationRuleName}"
New-TransportRule -Name $ImpersonationRuleName `
-FromScope NotInOrganization `
-SentToScope InOrganization `
-HeaderMatchesMessageHeader From `
-HeaderMatchesPatterns $DisplayNames `
-ExceptIfSenderDomainIs $BypassSenderDomains `
-ApplyHtmlDisclaimerLocation Prepend `
-ApplyHtmlDisclaimerText $ImpersonationDisclaimer `
-ApplyHtmlDisclaimerFallbackAction Wrap
Write-Output "Transport rule created"
}
}
# Suspicious Email Rule
If ( $SetSuspiciousEmailRule ) {
$SuspiciousEmailDisclaimerTitle = "Caution:"
$SuspiciousEmailDisclaimerText = "This is a suspicious email from outside your organization. Please be cautious when clicking links or opening attachments."
$SuspiciousEmailRuleName = "Suspicious Email Warning"
$SuspiciousEmailDisclaimer = '<table border=0 cellspacing=0 cellpadding=0 align="left" width="100%">
<tr>
<td style="background:#ffb900;padding:5pt 2pt 5pt 2pt"></td>
<td width="100%" cellpadding="7px 6px 7px 15px" style="background:#fff8e5;padding:5pt 4pt 5pt 12pt;word-wrap:break-word">
<div style="color:#222222;">
<span style="color:#222; font-weight:bold;">' + $SuspiciousEmailDisclaimerTitle + ' </span>' + $SuspiciousEmailDisclaimerText + '
</div>
</td>
</tr>
</table>
<br />'
# This list is copied from: https://github.com/SwiftOnSecurity/PhishingRegex/blob/master/PhishingRegex.txt
$SuspiciousEmailPatterns = @(
'blocked\ your?\ online',
'suspicious\ activit',
'updated?\ your\ account\ record',
'Securely\ \S{3,4}\ one(\ )?drive',
'Securely\ \S{3,4}\ drop(\ )?box',
'Securely\ \S{3,4}\ Google\ Drive',
'sign\ in\S{0,7}(with\ )?\ your\ email\ address',
'Verify\ your\ ID\s',
'dear\ \w{3,8}(\ banking)?\ user',
'chase\S{0,10}\.html"',
'\b(?<=https?://)(www\.)?icloud(?!\.com)',
'(?<![\x00\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4A\x4B\x4C\x4D\x4E\x4F\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5A])appie\W',
'/GoogleDrive/',
'/googledocs?/',
'/Dropfile/',
'limit\ (and\ suspend\ )?your\ account',
'\b(?<=https?://)(?!www\.paypal\.com/)\S{0,40}pa?y\S{0,2}al(?!\S*\.com/)',
'sitey\.me',
'myfreesites\.net',
'/uploadfile/',
'/\S{0,3}outloo\S{0,2}k\S{1,3}\W',
'\b(?<=https?://webmail\.)\S{0,40}webmail\w{0,3}(?!/[0-9])(?!\S{0,40}\.com/)',
'owaportal',
'outlook\W365',
'/office\S{0,3}365/',
'-icloud\Wcom',
'pyapal',
'/docu\S{0,3}sign\S{1,4}/',
'/helpdesk/',
'pay\Sa\S{0,2}login',
'/natwest/',
'/dro?pbo?x/',
'%20paypal',
'\.invoice\.php',
'security-?err',
'/newdropbox/',
'/www/amazon',
'simplefileupload',
'security-?warning',
'-(un)?b?locked',
'//helpdesk(?!\.)',
'\.my-free\.website',
'mail-?update',
'\.yolasite\.com',
'//webmail(?!\.)',
'\.freetemplate\.site',
'\.sitey\.me',
'\.ezweb123\.com',
'\.tripod\.com',
'\.myfreesites\.net',
'mailowa',
'-icloud',
'icloud-',
'contabo\.net',
'\.xyz/',
'ownership\ validation\ (has\ )?expired',
'icloudcom',
'\w\.jar(?=\b)',
'/https?/www/',
'\.000webhost(app)?\.com',
'is\.gd/',
'\.weebly\.com',
'\.wix\.com',
'tiny\.cc/',
'\.joburg',
'\.top/'
)
# Set the transport rule for suspicious emails
$SuspiciousEmailRule = Get-TransportRule | Where-Object { $_.Name -eq $SuspiciousEmailRuleName }
If ( $SuspiciousEmailRule ) {
Write-Output "Updating transport rule: ${SuspiciousEmailRuleName}"
Set-TransportRule -Identity $SuspiciousEmailRuleName `
-FromScope NotInOrganization `
-SentToScope InOrganization `
-SubjectOrBodyMatchesPatterns $SuspiciousEmailPatterns `
-ExceptIfSenderDomainIs $BypassSenderDomains `
-ApplyHtmlDisclaimerLocation Prepend `
-ApplyHtmlDisclaimerText $SuspiciousEmailDisclaimer `
-ApplyHtmlDisclaimerFallbackAction Wrap
Write-Output "Transport rule updated"
} Else {
Write-Output "Creating transport rule: ${SuspiciousEmailRuleName}"
New-TransportRule -Name $SuspiciousEmailRuleName `
-FromScope NotInOrganization `
-SentToScope InOrganization `
-SubjectOrBodyMatchesPatterns $SuspiciousEmailPatterns `
-ExceptIfSenderDomainIs $BypassSenderDomains `
-ApplyHtmlDisclaimerLocation Prepend `
-ApplyHtmlDisclaimerText $SuspiciousEmailDisclaimer `
-ApplyHtmlDisclaimerFallbackAction Wrap
Write-Output "Transport rule created"
}
}
# General External Email Rule
If ( $SetGeneralExternalEmailRule ) {
$GeneralExternalEmailDisclaimerTitle = "Notice:"
$GeneralExternalEmailDisclaimerText = "This email came from outside your organization. Please be sure you know the recipient or were expecting this email before clicking on links or opening attachments."
$GeneralExternalEmailRuleName = "External Email Warning"
$GeneralExternalEmailDisclaimer = '<table border=0 cellspacing=0 cellpadding=0 align="left" width="100%">
<tr>
<td style="background:#00A0d2;padding:5pt 2pt 5pt 2pt"></td>
<td width="100%" cellpadding="7px 6px 7px 15px" style="background:#e5f5fa;padding:5pt 4pt 5pt 12pt;word-wrap:break-word">
<div style="color:#222222;">
<span style="color:#222; font-weight:bold;">' + $GeneralExternalEmailDisclaimerTitle + ' </span>' + $GeneralExternalEmailDisclaimerText + '
</div>
</td>
</tr>
</table>
<br/>'
# Set the transport rule for all external emails
$GeneralExternalEmailRule = Get-TransportRule | Where-Object { $_.Name -eq $GeneralExternalEmailRuleName }
If ( $GeneralExternalEmailRule ) {
Write-Output "Updating transport rule: ${GeneralExternalEmailRuleName}"
Set-TransportRule -Identity $GeneralExternalEmailRuleName `
-FromScope NotInOrganization `
-SentToScope InOrganization `
-ExceptIfSenderDomainIs $BypassSenderDomains `
-ApplyHtmlDisclaimerLocation Prepend `
-ApplyHtmlDisclaimerText $GeneralExternalEmailDisclaimer `
-ApplyHtmlDisclaimerFallbackAction Wrap
Write-Output "Transport rule updated"
} Else {
Write-Output "Creating transport rule: ${GeneralExternalEmailRuleName}"
New-TransportRule -Name $GeneralExternalEmailRuleName `
-FromScope NotInOrganization `
-SentToScope InOrganization `
-ExceptIfSenderDomainIs $BypassSenderDomains `
-ApplyHtmlDisclaimerLocation Prepend `
-ApplyHtmlDisclaimerText $GeneralExternalEmailDisclaimer `
-ApplyHtmlDisclaimerFallbackAction Wrap
Write-Output "Transport rule created"
}
}
# Close connection to EOL
Disconnect-ExchangeOnline -Confirm:$false | Out-Null
+8
View File
@@ -0,0 +1,8 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
$Username = '{[Username]}'
$Password = '{[Password]}'
$FullName = '{[FullName]}'
$Description = '{[Description]}'
If ( '{[MakeAdmin]}' -eq 'true' ) { $MakeAdmin = $true } Else { $MakeAdmin = $false }
If ( '{[Hide]}' -eq 'true' ) { $Hide = $true } Else { $Hide = $false }
New-LocalUser -Username "${Username}" -Password "${Password}" -FullName "${FullName}" -Description "${Description}" -MakeAdmin:$MakeAdmin -Hide:$Hide
+6
View File
@@ -0,0 +1,6 @@
@ECHO OFF
REM Atera agent reinstallation script
powershell -ExecutionPolicy Bypass -InputFormat None -NonInteractive -Command "((New-Object System.Net.WebClient).DownloadFile('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Install-AteraAgent.ps1','Install-AteraAgent.ps1')); ./Install-AteraAgent.ps1 "-FORCE""
DEL /F /Q Install-AteraAgent.ps1
:END
EXIT /B
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Remove-AllESETProducts
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Remove-DeltekVision
+9
View File
@@ -0,0 +1,9 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
## These are RMM variables
$Path = '{[Path]}'
$OlderThanDays = '{[OlderThanDays]}'
Try { $OlderThanDays = [int]$OlderThanDays }
Catch { LogErr $_.Exception.Message }
Run-Script -LivePSScript Remove-OldFiles
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Remove-OldRevitLocals
+4
View File
@@ -0,0 +1,4 @@
# Note: this script came from https://adamtheautomator.com/powershell-delete-user-profile/
$UserToDelete = '{[UserToDelete]}'
Get-CimInstance -Class Win32_UserProfile | Where-Object { $_.LocalPath.split('\')[-1] -eq "${UserToDelete}" } | Remove-CimInstance
+5
View File
@@ -0,0 +1,5 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
$MaxUptimeHours = '{[MaxUptimeHours]}'
Restart-EndpointOnUptime -MaxUptime $(New-TimeSpan -Hours [int]$MaxUptimeHours)
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Set-DefaultAdministratorCredentials
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Set-EventLogs
+10
View File
@@ -0,0 +1,10 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
# RMM variable $EnableOrDisable must be set at runtime!
# Possible values are: 'enable' or '1' to enable profile prompt, 'disable' or '0' to disable profile prompt
Switch ($EnableOrDisable.ToLower().Trim()) {
{ ($_ -eq 'enable') -or ($_ -eq 1) } { Set-OutlookProfilePrompt -Enable }
{ ($_ -eq 'disable') -or ($_ -eq 0) } { Set-OutlookProfilePrompt -Disable }
default { Microsoft.Powershell.Utility\Write-Error "Please enter ""enable"" or ""1"" to enable the profile prompt, or ""disable"" or ""0"" to disable the profile prompt" }
}
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Start-Windows10UpdateAssistant
+6
View File
@@ -0,0 +1,6 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
## This is an RMM script variable
$ProcessName = '{[ProcessName]}'
Run-Script -LivePSScript 'Stop-Process'
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Test-InternetBandwidth
+1
View File
@@ -0,0 +1 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Track-InstalledSoftware.ps1')))
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Uninstall-AdobeFlash
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Uninstall-AutodeskDesktopApp
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Uninstall-EmberkomCloudBackup
+5
View File
@@ -0,0 +1,5 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
$APP_NAME = '{[AppName]}'
Run-Script -LivePSScript Uninstall-MSI
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Uninstall-McAfeeSecurityProducts
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Uninstall-MicrosoftOffice
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Uninstall-NCentralComponents
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Uninstall-TeamViewer
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Uninstall-UnwantedDefaultApps
+2
View File
@@ -0,0 +1,2 @@
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
Run-Script -LivePSScript Update-Wireguard
+45
View File
@@ -0,0 +1,45 @@
#Requires -Version 5.0
# The following variables need to be set in the RMM platform
# [string]$PathToFile
# [string]$DeleteAfterUpload
# [string]$MessageSubject
# [string]$MessageBody
# This must run before downloading Tools.ps1, including on Server 2016.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$RmmToolsUrl = 'https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1'
$RmmToolsClient = New-Object Net.WebClient
Try {
$RmmToolsText = $RmmToolsClient.DownloadString($RmmToolsUrl)
$RmmToolsScript = [Scriptblock]::Create($RmmToolsText)
}
Catch {
Throw "Unable to download or parse Tools.ps1 from ${RmmToolsUrl}: $($_.Exception.Message)"
}
Finally {
$RmmToolsClient.Dispose()
}
# Dot-source in this scope so the RMM input variables remain accessible.
. $RmmToolsScript
If ( !(Test-Path -LiteralPath $PathToFile -PathType Leaf) ) { Write-Error "The specified path does not exist: ${PathToFile}" -ErrorAction Stop }
$Delete = ConvertTo-RmmBoolean -Value $DeleteAfterUpload
Write-Output "Uploading file: ${PathToFile}"
$UploadReceipt = Upload-File -Path $PathToFile `
-Subject "${MessageSubject} [${Env:COMPUTERNAME}]" `
-Message "The file ""$(Split-Path -Path $PathToFile -Leaf)"" has been uploaded on $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss zzz'))`n`n${MessageBody}" `
-ErrorAction Stop
If ( [string]::IsNullOrWhiteSpace([string]$UploadReceipt.AttachmentId) -or
[string]::IsNullOrWhiteSpace([string]$UploadReceipt.Status) ) {
Throw 'The upload did not return a confirmation receipt. The local manifest has been retained.'
}
Write-Output "File uploaded: ${PathToFile}"
If ( $Delete ) {
Remove-Item -LiteralPath $PathToFile -Force -ErrorAction Stop
Write-Output "Deleted uploaded file: ${PathToFile}"
}
$UploadReceipt
+69
View File
@@ -0,0 +1,69 @@
#Requires -Version 5.0
# Offline tests: load only the helper to avoid Tools.ps1 setup and logging.
$ErrorActionPreference = 'Stop'
$BooleanToolsPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Tools.ps1'
$BooleanTokens = $null
$BooleanParseErrors = $null
$BooleanAst = [Management.Automation.Language.Parser]::ParseFile($BooleanToolsPath, [ref]$BooleanTokens, [ref]$BooleanParseErrors)
If ($BooleanParseErrors.Count) { Throw ($BooleanParseErrors | Out-String) }
$BooleanDefinition = $BooleanAst.Find({
param($Node)
$Node -is [Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq 'ConvertTo-RmmBoolean'
}, $true)
If (!$BooleanDefinition) { Throw 'Missing helper: ConvertTo-RmmBoolean' }
. ([scriptblock]::Create($BooleanDefinition.Extent.Text))
# Tools.ps1 overrides Write-Output with a string parameter. Boolean results must
# bypass that wrapper so $false remains a Boolean, without log text or side effects.
Function Write-Output { Throw 'Conversion must not use the string-based logging wrapper.' }
$BooleanCases = @(
@{Values=@('true', '$true', 'yes', 'y', '1', $true, 1); Expected=$true}
@{Values=@('false', '$false', 'no', 'n', '0', '', $null, $false, 0); Expected=$false}
)
Foreach ($Case in $BooleanCases) {
Foreach ($Value in $Case.Values) {
$Variants = @($Value)
If ($Value -is [string]) { $Variants += " `t$($Value.ToUpperInvariant())`r`n " }
Foreach ($Variant in $Variants) {
$Result = @(ConvertTo-RmmBoolean -Value $Variant)
If ($Result.Count -ne 1 -or $Result[0] -isnot [bool] -or $Result[0] -ne $Case.Expected) {
Throw "Input '$Variant' must return exactly one Boolean with value $($Case.Expected)."
}
}
}
}
Write-Host 'PASS: accepted text, case, whitespace, null, native booleans, and numeric 0/1 return one Boolean.'
$RmmInput = ' no '
$Result = ConvertTo-RmmBoolean $RmmInput
If ($Result -isnot [bool] -or $Result -ne $false -or $RmmInput -cne ' no ') {
Throw 'Positional conversion must return false without changing the original input.'
}
$Result = @(' yes ', 'false', '', $null, '$TRUE', '0' | ConvertTo-RmmBoolean)
$Expected = @($true, $false, $false, $false, $true, $false)
If ($Result.Count -ne $Expected.Count) { Throw 'Pipeline conversion must return one value for every input.' }
For ($Index = 0; $Index -lt $Expected.Count; $Index++) {
If ($Result[$Index] -isnot [bool] -or $Result[$Index] -ne $Expected[$Index]) {
Throw "Pipeline conversion returned the wrong type or value at index $Index."
}
}
Write-Host 'PASS: positional input, preserved caller value, and multiple pipeline inputs.'
# RMM scripts may use Continue and omit -ErrorAction. Invalid input must still stop.
$ErrorActionPreference = 'Continue'
Foreach ($Invalid in @('maybe', 'on', 'off', '2', 'true false', '$null')) {
Foreach ($Parameters in @(@{}, @{VariableName='Restart'})) {
$Caught = $null
$Returned = @()
Try { ConvertTo-RmmBoolean -Value $Invalid @Parameters | ForEach-Object { $Returned += $_ } }
Catch { $Caught = $_ }
$Name = If ($Parameters.ContainsKey('VariableName')) { 'Restart' } Else { 'Value' }
If ($null -eq $Caught -or !$Caught.Exception.Message.StartsWith(('Invalid ${0} value.' -f $Name))) {
Throw "Invalid input '$Invalid' must throw an error identifying $Name."
}
If ($Returned.Count -ne 0) { Throw 'Invalid input must not return a value.' }
}
}
Write-Host 'PASS: invalid input throws with default or caller-supplied variable name.'
Write-Host "All ConvertTo-RmmBoolean tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size * 8)-bit."
+95
View File
@@ -0,0 +1,95 @@
#Requires -Version 5.0
# Offline regression tests. BITS and URL resolution are mocked.
$ErrorActionPreference = 'Stop'
$DownloadToolsPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Tools.ps1'
$DownloadTokens = $null
$DownloadParseErrors = $null
$DownloadAst = [Management.Automation.Language.Parser]::ParseFile($DownloadToolsPath, [ref]$DownloadTokens, [ref]$DownloadParseErrors)
If ($DownloadParseErrors.Count) { Throw ($DownloadParseErrors | Out-String) }
Foreach ($DownloadFunctionName in @('Download-File','Download-FileDirectly')) {
$DownloadDefinition = $DownloadAst.Find({
param($Node)
$Node -is [Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq $DownloadFunctionName
}, $true)
If (!$DownloadDefinition) { Throw "Missing helper: $DownloadFunctionName" }
. ([scriptblock]::Create($DownloadDefinition.Extent.Text))
}
Function Get-AbsoluteURI {
param($URL)
If ($script:DownloadRejectPreflight) { Throw 'Direct download must not resolve the URL first.' }
Return $URL
}
Function IsURLValid { Throw 'Direct download must not make a preliminary validation request.' }
Function Get-TempPath { Return ($script:DownloadTestRoot + '\') }
Function Start-BitsTransfer {
[CmdletBinding()]
param($Source, $Destination)
$script:DownloadRecordedSource = $Source
If ($script:DownloadTestMode -eq 'success') {
[IO.File]::WriteAllText($Destination, 'Synthetic download.')
Return
}
[IO.File]::WriteAllBytes($Destination, [byte[]]@())
If ($script:DownloadTestMode -eq 'terminating-error') { Throw 'Synthetic BITS connection failure.' }
Microsoft.PowerShell.Utility\Write-Error 'Synthetic BITS TLS failure.'
}
$DownloadTestRoot = Join-Path ([IO.Path]::GetTempPath()) ('download-file-tests-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -Path $DownloadTestRoot -ItemType Directory
Try {
Foreach ($DownloadFunctionName in @('Download-File','Download-FileDirectly')) {
$script:DownloadRejectPreflight = ($DownloadFunctionName -eq 'Download-FileDirectly')
$DownloadTestFile = Join-Path $DownloadTestRoot 'collector $test.exe'
[IO.File]::WriteAllBytes($DownloadTestFile, [byte[]]@())
$script:DownloadTestMode = 'success'
$DownloadTestUrl = 'https://example.invalid/collector.exe?token=original'
$DownloadResult = @(& $DownloadFunctionName -URL $DownloadTestUrl -File $DownloadTestFile)
If ($DownloadResult.Count -ne 1 -or $DownloadResult[0] -cne $DownloadTestFile -or
[IO.File]::ReadAllText($DownloadResult[0]) -cne 'Synthetic download.' -or
$script:DownloadRecordedSource -cne $DownloadTestUrl) {
Throw 'Successful download must pass the source URL intact, replace an empty file, and return exactly one completed path.'
}
Write-Host "PASS: ${DownloadFunctionName}: URL forwarding, empty destination overwrite, and one completed path."
$DownloadAutomaticFile = Join-Path $DownloadTestRoot 'collector.msi'
Foreach ($DownloadBinding in @('positional','pipeline')) {
$DownloadResult = @(If ($DownloadBinding -eq 'positional') {
& $DownloadFunctionName 'https://example.invalid/collector.msi'
} Else {
'https://example.invalid/collector.msi' | & $DownloadFunctionName
})
If ($DownloadResult.Count -ne 1 -or $DownloadResult[0] -cne $DownloadAutomaticFile -or
[IO.File]::ReadAllText($DownloadAutomaticFile) -cne 'Synthetic download.') {
Throw "Automatic filename or $DownloadBinding URL binding failed."
}
}
Write-Host "PASS: ${DownloadFunctionName}: positional/pipeline URL and automatic filename with extension."
Foreach ($script:DownloadTestMode in @('nonterminating-error','terminating-error')) {
# Match an RMM that uses the normal Continue preference and does not pass -ErrorAction.
$ErrorActionPreference = 'Continue'
$DownloadCaught = $null
$DownloadReturnedPaths = @()
Try {
& $DownloadFunctionName -URL 'https://example.invalid/collector.exe' -File $DownloadTestFile |
ForEach-Object { $DownloadReturnedPaths += $_ }
}
Catch { $DownloadCaught = $_ }
Finally { $ErrorActionPreference = 'Stop' }
If ($null -eq $DownloadCaught -or $DownloadCaught.Exception.Message -notlike '*Synthetic BITS*failure*') {
Throw 'Failed transfer must throw and preserve the BITS error.'
}
If ($DownloadReturnedPaths.Count -ne 0) { Throw 'Failed transfer returned a success path.' }
Write-Host "PASS: ${DownloadFunctionName}: $script:DownloadTestMode stops the caller and preserves the transfer error."
}
}
Write-Host "All download helper tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size * 8)-bit."
}
Finally {
$DownloadResolvedRoot = (Resolve-Path -LiteralPath $DownloadTestRoot).Path
$DownloadTempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
If (!$DownloadResolvedRoot.StartsWith($DownloadTempRoot, [StringComparison]::OrdinalIgnoreCase) -or
(Split-Path $DownloadResolvedRoot -Leaf) -notlike 'download-file-tests-*') { Throw 'Unexpected cleanup directory.' }
Remove-Item -LiteralPath $DownloadResolvedRoot -Recurse -Force
}
+160
View File
@@ -0,0 +1,160 @@
#Requires -Version 5.0
# Offline tests: collector and upload calls are mocked; only temporary files are deleted.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$DsaScriptUnderTest = Join-Path (Split-Path $PSScriptRoot -Parent) 'Get-DSAManifest.ps1'
$DsaTokens = $null
$DsaErrors = $null
$null = [Management.Automation.Language.Parser]::ParseFile($DsaScriptUnderTest, [ref]$DsaTokens, [ref]$DsaErrors)
If ($DsaErrors.Count) { Throw ($DsaErrors | Out-String) }
# Load only the real conversion helper, without running Tools.ps1 setup.
$DsaToolsPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Tools.ps1'
$DsaToolsAst = [Management.Automation.Language.Parser]::ParseFile($DsaToolsPath, [ref]$DsaTokens, [ref]$DsaErrors)
If ($DsaErrors.Count) { Throw ($DsaErrors | Out-String) }
$DsaBooleanDefinition = $DsaToolsAst.Find({
param($Node)
$Node -is [Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq 'ConvertTo-RmmBoolean'
}, $true)
If (!$DsaBooleanDefinition) { Throw 'Missing helper: ConvertTo-RmmBoolean' }
. ([scriptblock]::Create($DsaBooleanDefinition.Extent.Text))
Function Assert-Dsa { param([bool]$Condition, [string]$Description) If (!$Condition) { Throw $Description } }
Function Download-File { Throw 'DSA must use Download-FileDirectly.' }
Function Download-FileDirectly {
[CmdletBinding()]
param($URL, $File)
$script:DsaDownloadCalls++
If ($script:DsaTestMode -eq 'empty-download') {
[IO.File]::WriteAllBytes($File, [byte[]]@())
Return $File
}
If ($script:DsaTestMode -eq 'download-failure') { Throw 'Simulated BITS failure.' }
[IO.File]::WriteAllText($File, 'Mock executable; never executed.')
Return $File
}
Function Start-Process {
[CmdletBinding()]
param($FilePath, $ArgumentList, [switch]$Wait, [switch]$PassThru, $WindowStyle)
$script:DsaProcessCalls++
$DsaMatch = [regex]::Match($ArgumentList, '"--output"\s+"([^"]+)"')
If (!$DsaMatch.Success) { Throw 'Collector output argument missing.' }
$script:DsaCsvPath = $DsaMatch.Groups[1].Value
$script:DsaGeneratedPath = $DsaCsvPath
If ($ArgumentList -match '"--zip"') { $script:DsaGeneratedPath = [IO.Path]::ChangeExtension($DsaCsvPath, '.zip') }
If (!$DsaGeneratedPath.StartsWith($script:DsaTestRoot + '\', [StringComparison]::OrdinalIgnoreCase)) {
Throw 'Mock collector output is outside the test directory.'
}
If ($script:DsaTestMode -eq 'collector-failure') { Return [pscustomobject]@{ExitCode=7} }
If ($script:DsaTestMode -ne 'missing-output') {
[IO.File]::WriteAllText($DsaGeneratedPath, 'Synthetic manifest.')
If ($DsaGeneratedPath -ne $DsaCsvPath) {
# A same-basename CSV must not be deleted when the uploaded result is ZIP.
[IO.File]::WriteAllText($DsaCsvPath, 'Unrelated CSV sentinel.')
}
}
Return [pscustomobject]@{ExitCode=0}
}
Function Upload-File {
[CmdletBinding()]
param($Path, $Subject, $Message)
Assert-Dsa (Test-Path -LiteralPath $Path -PathType Leaf) 'File was deleted before upload.'
$script:DsaRecordedUploads += [pscustomobject]@{Path=$Path; Subject=$Subject; Message=$Message}
If ($script:DsaTestMode -eq 'upload-failure') { Throw 'Simulated upload failure.' }
If ($script:DsaTestMode -eq 'no-receipt') { Return }
If ($script:DsaTestMode -eq 'incomplete-receipt') { Return [pscustomobject]@{AttachmentId='test-attachment'} }
Return [pscustomobject]@{Path=$Path; AttachmentId='test-attachment'; Status='Filedrop message sent successfully'}
}
Function Invoke-DsaCase {
param([bool]$ZipExpected, [bool]$UploadExpected, [bool]$DeleteExpected, [string]$Mode='success', [hashtable]$Inputs)
$Zip = If ($ZipExpected) { ' YeS ' } Else { ' n ' }
$Upload = If ($UploadExpected) { ' $TrUe ' } Else { ' 0 ' }
$DeleteAfterUpload = If ($DeleteExpected) { ' 1 ' } Else { ' $FaLsE ' }
If ($null -ne $Inputs) {
$Zip = $Inputs.Zip
$Upload = $Inputs.Upload
$DeleteAfterUpload = $Inputs.DeleteAfterUpload
}
$DSAManifestSource = 'C:\Synthetic Source'
$Global:ToolsDirectory = Join-Path $script:DsaTestRoot 'Tools'
$Global:OutputDirectory = Join-Path $script:DsaTestRoot ([guid]::NewGuid().ToString('N'))
$script:DsaTestMode = $Mode
$script:DsaRecordedUploads = @()
$script:DsaDownloadCalls = 0
$script:DsaProcessCalls = 0
$script:DsaGeneratedPath = $null
$DsaCaught = $null
Try { . $DsaScriptUnderTest | Out-Null } Catch { $DsaCaught = $_ }
If ($Mode -eq 'invalid-input') {
Assert-Dsa ($null -ne $DsaCaught -and $DsaCaught.Exception.Message -like 'Invalid $* value*') 'Invalid flag was not rejected.'
Assert-Dsa ($script:DsaDownloadCalls -eq 0) 'Invalid flag must fail before downloading.'
Return
}
Assert-Dsa ($script:DsaDownloadCalls -eq 1) 'Expected one direct download.'
If ($Mode -in @('empty-download','download-failure')) {
Assert-Dsa ($null -ne $DsaCaught) 'Download failure must stop collection.'
Assert-Dsa ($script:DsaProcessCalls -eq 0 -and $script:DsaRecordedUploads.Count -eq 0) 'Download failure must prevent execution and upload.'
If ($Mode -eq 'empty-download') {
Assert-Dsa ($DsaCaught.Exception.Message -like '*0 bytes*') 'Empty download error must explain the failure.'
}
Return
}
If ($Mode -in @('collector-failure','missing-output')) {
Assert-Dsa ($null -ne $DsaCaught -and $script:DsaRecordedUploads.Count -eq 0) 'Collection failure must prevent upload.'
Return
}
If ($Mode -eq 'success') { Assert-Dsa ($null -eq $DsaCaught) "Unexpected failure: ${DsaCaught}" }
Else { Assert-Dsa ($null -ne $DsaCaught) 'Expected upload/receipt failure.' }
$DsaExpectedExtension = If ($ZipExpected) { '.zip' } Else { '.csv' }
Assert-Dsa ([IO.Path]::GetExtension($script:DsaGeneratedPath) -eq $DsaExpectedExtension) 'Incorrect collected file type.'
$DsaExpectedCalls = If ($UploadExpected) { 1 } Else { 0 }
Assert-Dsa ($script:DsaRecordedUploads.Count -eq $DsaExpectedCalls) 'Unexpected upload call count.'
If ($UploadExpected) {
Assert-Dsa ($DsaRecordedUploads[0].Path -eq $script:DsaGeneratedPath) 'Upload targeted the wrong file.'
Assert-Dsa ($DsaRecordedUploads[0].Subject -ceq "DSA manifest file uploaded from ${Env:COMPUTERNAME}") 'Subject changed.'
$DsaExpectedMessage = "DSA collection performed on ${DSAManifestSource} at $($DsaCollectionTime.ToString('yyyy-MM-dd HH:mm:ss zzz'))"
Assert-Dsa ($DsaRecordedUploads[0].Message -ceq $DsaExpectedMessage) 'Message changed.'
}
$DsaShouldRetain = !($UploadExpected -and $DeleteExpected -and $Mode -eq 'success')
Assert-Dsa ((Test-Path -LiteralPath $script:DsaGeneratedPath) -eq $DsaShouldRetain) 'Incorrect deletion or retention behavior.'
If ($ZipExpected) { Assert-Dsa (Test-Path -LiteralPath $script:DsaCsvPath) 'Cleanup incorrectly deleted the CSV neighbor.' }
}
$script:DsaTestRoot = Join-Path ([IO.Path]::GetTempPath()) ('dsa-options-tests-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -Path $script:DsaTestRoot -ItemType Directory
$DsaOriginalToolsDirectory = $Global:ToolsDirectory
$DsaOriginalOutputDirectory = $Global:OutputDirectory
Try {
Foreach ($ZipExpected in @($false,$true)) {
Foreach ($UploadExpected in @($false,$true)) {
Foreach ($DeleteExpected in @($false,$true)) {
Invoke-DsaCase $ZipExpected $UploadExpected $DeleteExpected
Write-Host "PASS: ZIP=$ZipExpected Upload=$UploadExpected DeleteAfterUpload=$DeleteExpected"
}
}
Foreach ($Mode in @('upload-failure','no-receipt','incomplete-receipt','collector-failure','missing-output','empty-download','download-failure')) {
Invoke-DsaCase $ZipExpected $true $true $Mode
Write-Host "PASS: ZIP=$ZipExpected $Mode prevents deletion."
}
}
Invoke-DsaCase $false $false $false -Inputs @{}
Foreach ($Name in @('Zip','Upload','DeleteAfterUpload')) {
$Inputs = @{Zip='no'; Upload='no'; DeleteAfterUpload='no'}
$Inputs[$Name] = 'maybe'
Invoke-DsaCase $false $false $false 'invalid-input' $Inputs
}
Write-Host "PASS: empty options default to false; invalid options fail early."
Write-Host "All Get-DSAManifest tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size * 8)-bit."
}
Finally {
$Global:ToolsDirectory = $DsaOriginalToolsDirectory
$Global:OutputDirectory = $DsaOriginalOutputDirectory
$DsaResolvedRoot = (Resolve-Path -LiteralPath $script:DsaTestRoot).Path
$DsaTempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
If (!$DsaResolvedRoot.StartsWith($DsaTempRoot, [StringComparison]::OrdinalIgnoreCase) -or
(Split-Path $DsaResolvedRoot -Leaf) -notlike 'dsa-options-tests-*') { Throw 'Unexpected cleanup directory.' }
Remove-Item -LiteralPath $DsaResolvedRoot -Recurse -Force
}
+142
View File
@@ -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
}
+174
View File
@@ -0,0 +1,174 @@
#Requires -Version 5.0
# Offline protocol tests. No network requests, production setup, or notifications.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
Add-Type -AssemblyName System.Net.Http
$ToolsPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Tools.ps1'
$ParseTokens = $null
$ParseErrors = $null
$ToolsAst = [System.Management.Automation.Language.Parser]::ParseFile($ToolsPath, [ref]$ParseTokens, [ref]$ParseErrors)
If ( $ParseErrors.Count ) { Throw ($ParseErrors | Out-String) }
$Definition = $ToolsAst.Find({
param($Node)
$Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq 'Upload-File'
}, $true)
. ([scriptblock]::Create($Definition.Extent.Text))
If ( !('FileDropTestHandler' -as [type]) ) {
Add-Type -ReferencedAssemblies System.Net.Http -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
public class FileDropTestHandler : HttpMessageHandler {
public string Mode;
public int InfoRequests, UploadRequests, Submissions;
public string Submission, Filename;
public long ReceivedSize;
public List<byte[]> Parts = new List<byte[]>();
public Dictionary<int, int> Attempts = new Dictionary<int, int>();
public FileDropTestHandler(string mode) { Mode = mode; }
private HttpResponseMessage Json(int status, string body) {
return new HttpResponseMessage((HttpStatusCode)status) {
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) {
if (request.Method == HttpMethod.Get) {
InfoRequests++;
if (request.Headers.Authorization != null) throw new Exception("Unexpected authentication on initial request.");
if (Mode == "info-error") return Task.FromResult(Json(401, "{\"errors\":[\"Authentication required\"]}"));
string limit = Mode == "too-large" ? "1" : "102400";
string extensions = Mode == "extension" ? "pdf" : "zip";
return Task.FromResult(Json(200, "{\"filedrop\":{\"api_key\":\"test-token\",\"max_upload_size\":" + limit +
",\"permitted_extensions\":\"" + extensions + "\",\"blocked_extensions\":\"\"}}"));
}
if (request.Headers.Authorization == null || request.Headers.Authorization.ToString() != "Basic dGVzdC10b2tlbjp4")
throw new Exception("Incorrect FileDrop authentication.");
byte[] data = request.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
if (request.RequestUri.AbsolutePath.EndsWith("/attachments/upload")) {
UploadRequests++;
if (request.Content.Headers.ContentType != null) throw new Exception("Binary upload must allow server content detection.");
var query = new Dictionary<string, string>();
foreach (string part in request.RequestUri.Query.TrimStart('?').Split('&')) {
string[] pair = part.Split(new char[] { '=' }, 2);
query[pair[0]] = Uri.UnescapeDataString(pair[1]);
}
int chunk = Int32.Parse(query["chunk"]), chunks = Int32.Parse(query["chunks"]);
Filename = query["filename"];
if (!Attempts.ContainsKey(chunk)) Attempts[chunk] = 0;
Attempts[chunk]++;
if (Mode == "permanent") return Task.FromResult(Json(413, "{\"errors\":[\"Rejected\"]}"));
if (Mode == "json-error") return Task.FromResult(Json(200, "{\"errors\":[\"Rejected\"]}"));
if ((Mode == "retry" && chunk == 1 && Attempts[chunk] == 1) || Mode == "exhausted")
return Task.FromResult(Json(503, "{\"errors\":[\"Temporarily unavailable\"]}"));
if (Mode == "lost-chunk" && chunk == 1 && Attempts[chunk] == 1)
throw new HttpRequestException("Simulated connection loss.");
if (chunk != Parts.Count) throw new Exception("Unexpected chunk ordering.");
Parts.Add(data);
ReceivedSize += data.Length;
if (chunk + 1 != chunks || Mode == "no-id") return Task.FromResult(Json(200, "{\"chunk\":true}"));
long size = ReceivedSize + (Mode == "bad-size" ? 1 : 0);
return Task.FromResult(Json(200, "{\"attachment\":{\"id\":\"test-attachment\",\"size\":" + size +
",\"content_blocked\":" + (Mode == "blocked" ? "true" : "false") + "}}"));
}
Submissions++;
Submission = Encoding.UTF8.GetString(data);
if (request.Content.Headers.ContentType.MediaType != "application/json") throw new Exception("Submission must be JSON.");
if (Mode == "submission-failure") return Task.FromResult(Json(503, "{\"errors\":[\"Unavailable\"]}"));
if (Mode == "submission-lost") throw new HttpRequestException("Simulated lost submission response.");
if (Mode == "no-confirmation") return Task.FromResult(Json(200, "{}"));
return Task.FromResult(Json(200, "{\"message\":{\"status\":\"Filedrop message sent successfully\"}}"));
}
}
'@
}
# Replace only the HTTP transport, keeping production request/response handling.
Function New-Object {
param([string]$TypeName, [object[]]$ArgumentList)
If ( $TypeName -ne 'System.Net.Http.HttpClient' ) { Throw "Unexpected object: ${TypeName}" }
$ArgumentList[0].Dispose()
Return [System.Net.Http.HttpClient]::new($script:TestHandler)
}
Function Start-Sleep { param($Seconds) } # Keep retry tests fast.
Function Assert-True { param([bool]$Condition, [string]$Description) If (!$Condition) { Throw $Description } }
$TestRoot = Join-Path ([IO.Path]::GetTempPath()) ('filedrop-tests-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -Path $TestRoot -ItemType Directory
Try {
# Spaces, URL metacharacters, and a Unicode name; more than two full chunks.
$TestFile = Join-Path $TestRoot ('report [1] & ' + [char]0xE9 + '.zip')
$TestBytes = [byte[]]::new(2MB + 37)
[Random]::new(73).NextBytes($TestBytes)
[IO.File]::WriteAllBytes($TestFile, $TestBytes)
$TestMessage = 'Machine diagnostics "quoted" ' + [char]0xE9
Foreach ($Mode in @('success', 'retry', 'lost-chunk')) {
$script:TestHandler = [FileDropTestHandler]::new($Mode)
$Result = @(Upload-File -Path $TestFile -ChunkSizeMB 1 -Message $TestMessage)
Assert-True ($Result.Count -eq 1 -and $Result[0].AttachmentId -eq 'test-attachment') 'Expected one structured receipt.'
Assert-True ($TestHandler.Parts.Count -eq 3 -and $TestHandler.Submissions -eq 1) 'Expected three chunks and one submission.'
Assert-True ($TestHandler.Filename -ceq [IO.Path]::GetFileName($TestFile)) 'Filename did not round trip through URL encoding.'
$Rebuilt = [IO.MemoryStream]::new()
Try {
Foreach ($Part in $TestHandler.Parts) { $Rebuilt.Write($Part, 0, $Part.Length) }
$Hash = [Security.Cryptography.SHA256]::Create()
Try {
$ExpectedHash = [Convert]::ToBase64String($Hash.ComputeHash($TestBytes))
$ActualHash = [Convert]::ToBase64String($Hash.ComputeHash($Rebuilt.ToArray()))
Assert-True ($ExpectedHash -eq $ActualHash) 'Reassembled upload does not match the source.'
} Finally { $Hash.Dispose() }
} Finally { $Rebuilt.Dispose() }
$Sent = ($TestHandler.Submission | ConvertFrom-Json).message
Assert-True ($Sent.message -ceq $TestMessage) 'Submission text was corrupted.'
Assert-True ($Sent.attachments.Count -eq 1 -and $Sent.attachments[0] -eq 'test-attachment') 'Incorrect attachment list.'
$HostInfo = [Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties()
$ExpectedHost = $HostInfo.HostName
If ($HostInfo.DomainName -and !$ExpectedHost.EndsWith('.' + $HostInfo.DomainName, [StringComparison]::OrdinalIgnoreCase)) {
$ExpectedHost += '.' + $HostInfo.DomainName
}
Assert-True ($Sent.from -eq ($ExpectedHost.ToLowerInvariant() + '@emberkom.com')) 'Incorrect default sender.'
If ($Mode -ne 'success') { Assert-True ($TestHandler.Attempts[1] -eq 2) 'Failed chunk was not retried.' }
Write-Host "PASS: $Mode; exact bytes, filename, sender, metadata, receipt."
}
Foreach ($Mode in @('info-error','too-large','extension','permanent','json-error','exhausted','no-id','bad-size','blocked',
'submission-failure','submission-lost','no-confirmation')) {
$script:TestHandler = [FileDropTestHandler]::new($Mode)
$Caught = $false
Try { $null = Upload-File -Path $TestFile -ChunkSizeMB 1 -RetryCount 1 }
Catch { $Caught = $true }
Assert-True $Caught "Expected a terminating error for ${Mode}."
If ($Mode -in @('info-error','too-large','extension')) { Assert-True ($TestHandler.UploadRequests -eq 0) 'Validation must precede upload.' }
If ($Mode -in @('permanent','json-error')) { Assert-True ($TestHandler.UploadRequests -eq 1) 'Permanent error must not retry.' }
If ($Mode -eq 'exhausted') { Assert-True ($TestHandler.UploadRequests -eq 2) 'Retry limit was not enforced.' }
$ExpectedSubmissions = If ($Mode -in @('submission-failure','submission-lost','no-confirmation')) { 1 } Else { 0 }
Assert-True ($TestHandler.Submissions -eq $ExpectedSubmissions) 'Submission was retried or sent despite upload failure.'
Write-Host "PASS: $Mode; correct failure and submission behavior."
}
$EmptyFile = Join-Path $TestRoot 'empty.zip'
[IO.File]::WriteAllBytes($EmptyFile, [byte[]]@())
$script:TestHandler = [FileDropTestHandler]::new('success')
$EmptyResult = Upload-File -File $EmptyFile -From 'override@example.com'
Assert-True ($EmptyResult.Size -eq 0 -and $EmptyResult.From -eq 'override@example.com') 'Empty file or sender override failed.'
Assert-True ($TestHandler.UploadRequests -eq 1) 'Empty file must still upload once.'
# Confirm the source can be opened exclusively after failure and success.
$CheckStream = [IO.File]::Open($TestFile, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
$CheckStream.Dispose()
Write-Host "PASS: empty file, sender override, and file handles released."
Write-Host "All Upload-File tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size * 8)-bit."
}
Finally {
$ResolvedTestRoot = (Resolve-Path -LiteralPath $TestRoot).Path
$ResolvedTempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
If (!$ResolvedTestRoot.StartsWith($ResolvedTempRoot, [StringComparison]::OrdinalIgnoreCase) -or
(Split-Path $ResolvedTestRoot -Leaf) -notlike 'filedrop-tests-*') { Throw 'Unexpected cleanup directory.' }
Remove-Item -LiteralPath $ResolvedTestRoot -Recurse -Force
}